char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somfjson: remove members without leaving spurious whitespace75a6060

main
16.5 KiB328 linesraw
1mod common;
2
3#[test]
4fn fjson_parses_all_roots_and_always_prints_readable_fjson() {
5    let output = common::run(
6        r#"
7        import {parse, stringify} from 'avril:fjson';
8        const value = parse("// title\nname: 'fJSON'\noptions: {enabled: true\ntargets: ['linux', 'macos']}\n");
9        if (value.name !== 'fJSON' || !value.options.enabled) throw Error('Parse');
10        const text = stringify(value);
11        if (!text.startsWith('name: "fJSON"\noptions: {\n  enabled: true\n')) throw Error(text);
12        if (JSON.stringify(parse(text)) !== JSON.stringify(value)) throw Error('Round trip');
13        for (const source of ['null','false','42','"😀"','[1, 2,]','{}']) {
14            const parsed = parse(source);
15            if (JSON.stringify(parse(stringify(parsed))) !== JSON.stringify(parsed)) throw Error(source);
16        }
17        const proto = parse('__proto__: {safe: true}');
18        if (!Object.hasOwn(proto, '__proto__') || Object.getPrototypeOf(proto) !== Object.prototype
19            || proto.__proto__.safe !== true) throw Error('Prototype');
20        console.log('fjson');
21    "#,
22        &[],
23    );
24    assert_eq!(output.stdout, b"fjson\n");
25}
26
27#[test]
28fn fjson_conversion_errors_are_normal_javascript_errors() {
29    common::run(
30        r#"
31        import {parse, stringify} from 'avril:fjson';
32        for (const source of ['[1 2]','1 2','a: 1\na: 2','a: 1e9999']) {
33            try {parse(source); throw Error('Accepted invalid source');}
34            catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('<fjson>:')) throw e;}
35        }
36        for (const value of [undefined, Symbol('x'), () => {}, 1n]) {
37            try {stringify(value); throw Error('Accepted invalid value');}
38            catch(e) {if (!(e instanceof TypeError)) throw e;}
39        }
40        const cycle = {}; cycle.self = cycle;
41        try {stringify(cycle); throw Error('Accepted cycle');}
42        catch(e) {if (!(e instanceof TypeError)) throw e;}
43        const value = {toJSON() {return {result: [NaN, Infinity, undefined]};}};
44        if (stringify(value) !== 'result: [null, null, null]') throw Error('JSON conversion');
45        const marker = {};
46        try {stringify({get value() {throw marker;}}); throw Error('Lost exception');}
47        catch(e) {if (e !== marker) throw e;}
48    "#,
49        &[],
50    );
51}
52
53#[test]
54fn fjson_rejects_unpaired_surrogates_without_lossy_replacement() {
55    common::run(
56        r#"
57        import {parse, stringify} from 'avril:fjson';
58        for (const invalid of ['\ud800', '\udfff', '\ud800a', '\ud800\ud800', '\udfff\ud800']) {
59            for (const source of [`'${invalid}'`, `"${invalid}": 1`, `// ${invalid}\ntrue`]) {
60                try {parse(source); throw Error('Accepted unpaired surrogate');}
61                catch(e) {
62                    if (!(e instanceof SyntaxError) || !e.message.includes('Unpaired Unicode surrogate')
63                        || !e.message.includes('<fjson>:')) throw e;
64                }
65            }
66            for (const value of [invalid, {value: invalid}, {[invalid]: true}]) {
67                try {stringify(value); throw Error('Serialized unpaired surrogate');}
68                catch(e) {if (!(e instanceof TypeError) || !e.message.includes('surrogate')) throw e;}
69            }
70        }
71        for (const source of ['"\\ud800"', '"\\udfff"', '"\\ud800\\u0041"']) {
72            try {parse(source); throw Error('Accepted escaped unpaired surrogate');}
73            catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('surrogate')) throw e;}
74        }
75        const source = "label: '😀'\r\ntext: '\ud800'";
76        try {parse(source); throw Error('Accepted invalid Unicode');}
77        catch(e) {if (!e.message.startsWith('<fjson>:2:8: Unpaired Unicode surrogate')) throw e;}
78        for (const valid of ['😀', '\ufffd', '\ud800\udc00']) {
79            if (parse(`'${valid}'`) !== valid || parse(stringify(valid)) !== valid) throw Error('Lost valid Unicode');
80        }
81    "#,
82        &[],
83    );
84}
85
86#[test]
87fn fjson_depth_errors_are_typed_and_successful_prints_round_trip() {
88    common::run(
89        r#"
90        import {parse, stringify} from 'avril:fjson';
91        let value = null;
92        for (let depth = 0; depth < 129; depth++) value = [value];
93        const text = stringify(value);
94        if (JSON.stringify(parse(text)) !== JSON.stringify(value)) throw Error('Depth round trip');
95        value = [value];
96        try {stringify(value); throw Error('Serialized excessive nesting');}
97        catch(e) {if (!(e instanceof RangeError) || !e.message.includes('fJSON nesting')) throw e;}
98        try {parse(JSON.stringify(value)); throw Error('Parsed excessive nesting');}
99        catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('fJSON nesting')) throw e;}
100    "#,
101        &[],
102    );
103}
104
105#[test]
106fn fjson_apply_merges_leaves_without_rewriting_untouched_source() {
107    common::run(
108        r#"
109        import {apply, parse} from 'avril:fjson';
110        const source = "// heading\r\noptions: {\r\n\tenabled: /* choice */ false // reason\r\n\tname: 'old'\r\n\tkeep: [1, /* inside */ 2,]\r\n}\r\nother: 1e3\r\n";
111        const updated = apply(source, {options: {enabled: true, name: "it's new"}});
112        const expected = source.replace('false', 'true').replace("'old'", "'it\\'s new'");
113        if (updated !== expected) throw Error(updated);
114        if (apply(source) !== source || apply(source, {}, {options: {keep: [1, 2], name: 'old'}}) !== source) {
115            throw Error('Reformatted unchanged values');
116        }
117        const added = apply(updated, {options: {added: {active: true}}});
118        if (!added.includes('\tadded: {\r\n\t  active: true\r\n\t}')) throw Error(added);
119        if (!added.includes('\tkeep: [1, /* inside */ 2,]') || !added.endsWith('other: 1e3\r\n')) throw Error(added);
120        if (parse(added).options.added.active !== true) throw Error('Insertion');
121        for (const empty of ['', '// heading', '{/* empty */}']) {
122            if (parse(apply(empty, {options: {enabled: true}})).options.enabled !== true) throw Error(empty);
123        }
124        for (const newline of ['\n', '\r\n', '\r']) {
125            const original = `options: {${newline}\tkeep: 'same'${newline}}${newline}`;
126            const edited = apply(original, {options: {new: 'value'}});
127            if (!edited.includes(`\tkeep: 'same'${newline}`) || !edited.includes(`\tnew: "value"${newline}`)) {
128                throw Error(edited);
129            }
130            if (newline !== '\n' && edited.split(newline).join('').includes('\n')) throw Error('Changed line endings');
131        }
132    "#,
133        &[],
134    );
135}
136
137#[test]
138fn fjson_apply_deletes_resets_and_applies_overrides_left_to_right() {
139    common::run(
140        r#"
141        import {apply, parse} from 'avril:fjson';
142        const source = "options: /* before */ {enabled: false, keep: 1 /* inside */} // after\nlist: [1, 2]\nscalar: false\n";
143        const merged = parse(apply(source, {options: {enabled: true}}));
144        if (!merged.options.enabled || merged.options.keep !== 1) throw Error('Lost sibling');
145        const reset = apply(source, {options: undefined}, {options: {enabled: true}});
146        const value = parse(reset);
147        if (JSON.stringify(value.options) !== '{"enabled":true}') throw Error(reset);
148        for (const comment of ['/* before */', '// after']) if (!reset.includes(comment)) throw Error(reset);
149        if (reset.includes('/* inside */')) throw Error(reset);
150        const removed = parse(apply(source, {options: {enabled: undefined}, missing: undefined}));
151        if (Object.hasOwn(removed.options, 'enabled') || removed.options.keep !== 1) throw Error('Deletion');
152        const changed = parse(apply(source,
153            {list: [3], scalar: {a: 1}, options: null},
154            {scalar: {b: 2}, options: {new: true}},
155        ));
156        if (JSON.stringify(changed.list) !== '[3]' || JSON.stringify(changed.scalar) !== '{"a":1,"b":2}'
157            || JSON.stringify(changed.options) !== '{"new":true}') throw Error(JSON.stringify(changed));
158        if (apply(source, {options: {}}) !== source) throw Error('Empty merge cleared object');
159        const empty = parse(apply('a: 1', {a: {}, b: {missing: undefined}}));
160        if (JSON.stringify(empty) !== '{"a":{},"b":{}}') throw Error('Empty objects');
161        const shared = {value: 1};
162        const repeated = parse(apply('', {a: shared, b: shared}));
163        if (repeated.a.value !== 1 || repeated.b.value !== 1) throw Error('Shared reference');
164    "#,
165        &[],
166    );
167}
168
169#[test]
170fn fjson_apply_removing_members_leaves_no_whitespace_only_lines() {
171    common::run(
172        r#"
173        import {apply, parse} from 'avril:fjson';
174        const source = "tasks: {\n  a: {\n    command: \"x\",\n  },\n  b: {\n    command: \"y\",\n  },\n},\n";
175        if (apply(source, {tasks: {a: undefined}}) !==
176            "tasks: {\n  b: {\n    command: \"y\",\n  },\n},\n") throw Error('Remove first');
177        if (apply(source, {tasks: {b: undefined}}) !==
178            "tasks: {\n  a: {\n    command: \"x\",\n  },\n},\n") throw Error('Remove last');
179        let current = source;
180        for (let round = 0; round < 3; round++) {
181            current = apply(current,
182                {tasks: {a: undefined, b: undefined}},
183                {tasks: {a: {command: `x${round}`}, b: {command: 'y'}}});
184        }
185        if (/^[ \t]+$/m.test(current)) throw Error(current);
186        if (parse(current).tasks.a.command !== 'x2') throw Error('Regeneration');
187        const inline = apply('tasks: { a: 1, b: 2 }, // done\n', {tasks: {a: undefined}});
188        if (parse(inline).tasks.b !== 2 || !inline.includes('// done') || inline.includes('\n\n')) {
189            throw Error(inline);
190        }
191    "#,
192        &[],
193    );
194}
195
196#[test]
197fn fjson_apply_uses_own_keys_and_json_conversion_for_replacements() {
198    common::run(
199        r#"
200        import {apply, parse} from 'avril:fjson';
201        const overrides = Object.create({inherited: 1});
202        Object.defineProperty(overrides, 'hidden', {value: 2});
203        overrides[Symbol('ignored')] = 3;
204        overrides.fn = () => {}; overrides.symbol = Symbol('ignored');
205        overrides.values = [undefined, NaN, Infinity, {toJSON() {return 'converted';}}];
206        overrides.nullable = null;
207        const value = parse(apply('', overrides));
208        if (JSON.stringify(value) !== '{"values":[null,null,null,"converted"],"nullable":null}') {
209            throw Error(JSON.stringify(value));
210        }
211        const special = Object.create(null);
212        special.__proto__ = {safe: true}; special['a.b'] = {'日本語': '😀'};
213        const result = parse(apply('__proto__: {kept: 1}', special));
214        if (!Object.hasOwn(result, '__proto__') || result.__proto__.kept !== 1 || !result.__proto__.safe
215            || Object.getPrototypeOf(result) !== Object.prototype || result['a.b']['日本語'] !== '😀'
216            || Object.prototype.safe !== undefined) throw Error('Keys');
217        const indexed = parse(apply('', {0: 'zero', nested: {1: 'one'}}));
218        if (indexed['0'] !== 'zero' || indexed.nested['1'] !== 'one') throw Error('Index keys');
219        const omitted = []; omitted.toJSON = () => undefined;
220        if (apply('value: true', {value: omitted}) !== 'value: true') throw Error('Unsupported JSON root');
221        const converted = () => {}; converted.toJSON = () => 'converted';
222        if (parse(apply('', {value: converted})).value !== 'converted') throw Error('Function toJSON');
223        const marker = {};
224        const direct = {value: true, toJSON() {throw marker;}};
225        if (parse(apply('', direct)).value !== true) throw Error('Converted overrides');
226        const failedArray = []; failedArray.toJSON = () => {throw marker;};
227        for (const overrides of [
228            {get value() {throw marker;}},
229            new Proxy({}, {ownKeys() {throw marker;}}),
230            {value: failedArray},
231        ]) {
232            try {apply('', overrides); throw Error('Lost exception');}
233            catch(e) {if (e !== marker) throw e;}
234        }
235    "#,
236        &[],
237    );
238}
239
240#[test]
241fn fjson_apply_reports_source_conversion_and_depth_errors() {
242    common::run(
243        r#"
244        import {apply, parse} from 'avril:fjson';
245        for (const source of ['[]', 'null', 'a:', 'a: 1\na: 2', 'a: 1e9999', "// \ud800\na: 1"]) {
246            try {apply(source); throw Error('Accepted invalid source');}
247            catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('<fjson>:')) throw e;}
248        }
249        const cyclic = {}; cyclic.self = cyclic;
250        for (const overrides of [null, [], 1, undefined, () => {}, Symbol('x'), 1n, cyclic, {value: 1n}, {value: '\ud800'}, {'\udfff': 1}]) {
251            try {apply('', overrides); throw Error('Accepted invalid overrides');}
252            catch(e) {if (!(e instanceof TypeError)) throw e;}
253        }
254        const arrayCycle = []; arrayCycle.push(arrayCycle);
255        try {apply('', {value: arrayCycle}); throw Error('Accepted cyclic array');}
256        catch(e) {if (!(e instanceof TypeError)) throw e;}
257        let object = {leaf: true};
258        for (let depth = 0; depth < 128; depth++) object = {child: object};
259        const text = apply('', object);
260        if (JSON.stringify(parse(text)) !== JSON.stringify(object)) throw Error('Object depth round trip');
261        object = {child: object};
262        try {apply('', object); throw Error('Accepted excessive object nesting');}
263        catch(e) {if (!(e instanceof RangeError)) throw e;}
264        let array = null;
265        for (let depth = 0; depth < 128; depth++) array = [array];
266        const arrays = apply('', {value: array});
267        if (JSON.stringify(parse(arrays).value) !== JSON.stringify(array)) throw Error('Array depth round trip');
268        array = [array];
269        try {apply('', {value: array}); throw Error('Accepted excessive array nesting');}
270        catch(e) {if (!(e instanceof RangeError)) throw e;}
271    "#,
272        &[],
273    );
274}
275
276#[test]
277fn fjson_is_permissionless_private_and_survives_poisoned_globals() {
278    let dir = tempfile::tempdir().unwrap();
279    let path = dir.path().join("main.js");
280    std::fs::write(&path, r#"
281        import {apply, parse, stringify} from 'avril:fjson';
282        import {pledge} from 'avril:std';
283        const parseError = SyntaxError, typeError = TypeError, rangeError = RangeError;
284        if (Symbol.for('__avril.fjson') in globalThis) throw Error('Registry');
285        const poison = () => {throw Error('Poisoned')};
286        JSON.parse = poison; JSON.stringify = poison;
287        globalThis.JSON = null; globalThis.SyntaxError = poison; globalThis.TypeError = poison;
288        globalThis.RangeError = poison;
289        Object.prototype.Err = 'poison'; Array.prototype[Symbol.iterator] = poison;
290        Symbol.for = poison; Object.keys = poison; Array.isArray = poison;
291        Set.prototype.has = poison; Set.prototype.add = poison; Set.prototype.delete = poison;
292        pledge({net: false, read: false, write: false, jsExec: false});
293        if ((await import('avril:fjson')).parse !== parse) throw Error('Lost module');
294        if (parse(stringify({answer: 42})).answer !== 42) throw Error('Poisoned internals');
295        const patched = parse(apply('options: {keep: 1, old: true}', {options: {old: undefined, answer: 42}}));
296        if (patched.options.keep !== 1 || patched.options.answer !== 42 || patched.options.old !== undefined) {
297            throw Error('Poisoned apply');
298        }
299        try {apply('a:'); throw Error('Accepted invalid source');}
300        catch(e) {if (!(e instanceof parseError)) throw e;}
301        try {apply('', {value: '\ud800'}); throw Error('Applied invalid Unicode');}
302        catch(e) {if (!(e instanceof typeError)) throw e;}
303        try {parse('a:'); throw Error('Accepted invalid source');}
304        catch(e) {if (!(e instanceof parseError)) throw e;}
305        try {parse("'\ud800'"); throw Error('Lost Unicode validation');}
306        catch(e) {if (!(e instanceof parseError)) throw e;}
307        try {stringify('\ud800'); throw Error('Serialized invalid Unicode');}
308        catch(e) {if (!(e instanceof typeError)) throw e;}
309        let nested = null;
310        for (let depth = 0; depth < 130; depth++) nested = [nested];
311        try {stringify(nested); throw Error('Serialized excessive nesting');}
312        catch(e) {if (!(e instanceof rangeError)) throw e;}
313        try {await import('ext:fjson/main.js'); throw Error('Escaped');}
314        catch(e) {if (!e.message.includes('private')) throw e;}
315        console.log('isolated');
316    "#).unwrap();
317    let output = std::process::Command::new(env!("CARGO_BIN_EXE_avril"))
318        .args(["run", "-0"])
319        .arg(path)
320        .output()
321        .unwrap();
322    assert!(
323        output.status.success(),
324        "{}",
325        String::from_utf8_lossy(&output.stderr)
326    );
327    assert_eq!(output.stdout, b"isolated\n");
328}