mod common; #[test] fn fjson_parses_all_roots_and_always_prints_readable_fjson() { let output = common::run( r#" import {parse, stringify} from 'avril:fjson'; const value = parse("// title\nname: 'fJSON'\noptions: {enabled: true\ntargets: ['linux', 'macos']}\n"); if (value.name !== 'fJSON' || !value.options.enabled) throw Error('Parse'); const text = stringify(value); if (!text.startsWith('name: "fJSON"\noptions: {\n enabled: true\n')) throw Error(text); if (JSON.stringify(parse(text)) !== JSON.stringify(value)) throw Error('Round trip'); for (const source of ['null','false','42','"😀"','[1, 2,]','{}']) { const parsed = parse(source); if (JSON.stringify(parse(stringify(parsed))) !== JSON.stringify(parsed)) throw Error(source); } const proto = parse('__proto__: {safe: true}'); if (!Object.hasOwn(proto, '__proto__') || Object.getPrototypeOf(proto) !== Object.prototype || proto.__proto__.safe !== true) throw Error('Prototype'); console.log('fjson'); "#, &[], ); assert_eq!(output.stdout, b"fjson\n"); } #[test] fn fjson_conversion_errors_are_normal_javascript_errors() { common::run( r#" import {parse, stringify} from 'avril:fjson'; for (const source of ['[1 2]','1 2','a: 1\na: 2','a: 1e9999']) { try {parse(source); throw Error('Accepted invalid source');} catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes(':')) throw e;} } for (const value of [undefined, Symbol('x'), () => {}, 1n]) { try {stringify(value); throw Error('Accepted invalid value');} catch(e) {if (!(e instanceof TypeError)) throw e;} } const cycle = {}; cycle.self = cycle; try {stringify(cycle); throw Error('Accepted cycle');} catch(e) {if (!(e instanceof TypeError)) throw e;} const value = {toJSON() {return {result: [NaN, Infinity, undefined]};}}; if (stringify(value) !== 'result: [null, null, null]') throw Error('JSON conversion'); const marker = {}; try {stringify({get value() {throw marker;}}); throw Error('Lost exception');} catch(e) {if (e !== marker) throw e;} "#, &[], ); } #[test] fn fjson_rejects_unpaired_surrogates_without_lossy_replacement() { common::run( r#" import {parse, stringify} from 'avril:fjson'; for (const invalid of ['\ud800', '\udfff', '\ud800a', '\ud800\ud800', '\udfff\ud800']) { for (const source of [`'${invalid}'`, `"${invalid}": 1`, `// ${invalid}\ntrue`]) { try {parse(source); throw Error('Accepted unpaired surrogate');} catch(e) { if (!(e instanceof SyntaxError) || !e.message.includes('Unpaired Unicode surrogate') || !e.message.includes(':')) throw e; } } for (const value of [invalid, {value: invalid}, {[invalid]: true}]) { try {stringify(value); throw Error('Serialized unpaired surrogate');} catch(e) {if (!(e instanceof TypeError) || !e.message.includes('surrogate')) throw e;} } } for (const source of ['"\\ud800"', '"\\udfff"', '"\\ud800\\u0041"']) { try {parse(source); throw Error('Accepted escaped unpaired surrogate');} catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('surrogate')) throw e;} } const source = "label: '😀'\r\ntext: '\ud800'"; try {parse(source); throw Error('Accepted invalid Unicode');} catch(e) {if (!e.message.startsWith(':2:8: Unpaired Unicode surrogate')) throw e;} for (const valid of ['😀', '\ufffd', '\ud800\udc00']) { if (parse(`'${valid}'`) !== valid || parse(stringify(valid)) !== valid) throw Error('Lost valid Unicode'); } "#, &[], ); } #[test] fn fjson_depth_errors_are_typed_and_successful_prints_round_trip() { common::run( r#" import {parse, stringify} from 'avril:fjson'; let value = null; for (let depth = 0; depth < 129; depth++) value = [value]; const text = stringify(value); if (JSON.stringify(parse(text)) !== JSON.stringify(value)) throw Error('Depth round trip'); value = [value]; try {stringify(value); throw Error('Serialized excessive nesting');} catch(e) {if (!(e instanceof RangeError) || !e.message.includes('fJSON nesting')) throw e;} try {parse(JSON.stringify(value)); throw Error('Parsed excessive nesting');} catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('fJSON nesting')) throw e;} "#, &[], ); } #[test] fn fjson_apply_merges_leaves_without_rewriting_untouched_source() { common::run( r#" import {apply, parse} from 'avril:fjson'; 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"; const updated = apply(source, {options: {enabled: true, name: "it's new"}}); const expected = source.replace('false', 'true').replace("'old'", "'it\\'s new'"); if (updated !== expected) throw Error(updated); if (apply(source) !== source || apply(source, {}, {options: {keep: [1, 2], name: 'old'}}) !== source) { throw Error('Reformatted unchanged values'); } const added = apply(updated, {options: {added: {active: true}}}); if (!added.includes('\tadded: {\r\n\t active: true\r\n\t}')) throw Error(added); if (!added.includes('\tkeep: [1, /* inside */ 2,]') || !added.endsWith('other: 1e3\r\n')) throw Error(added); if (parse(added).options.added.active !== true) throw Error('Insertion'); for (const empty of ['', '// heading', '{/* empty */}']) { if (parse(apply(empty, {options: {enabled: true}})).options.enabled !== true) throw Error(empty); } for (const newline of ['\n', '\r\n', '\r']) { const original = `options: {${newline}\tkeep: 'same'${newline}}${newline}`; const edited = apply(original, {options: {new: 'value'}}); if (!edited.includes(`\tkeep: 'same'${newline}`) || !edited.includes(`\tnew: "value"${newline}`)) { throw Error(edited); } if (newline !== '\n' && edited.split(newline).join('').includes('\n')) throw Error('Changed line endings'); } "#, &[], ); } #[test] fn fjson_apply_deletes_resets_and_applies_overrides_left_to_right() { common::run( r#" import {apply, parse} from 'avril:fjson'; const source = "options: /* before */ {enabled: false, keep: 1 /* inside */} // after\nlist: [1, 2]\nscalar: false\n"; const merged = parse(apply(source, {options: {enabled: true}})); if (!merged.options.enabled || merged.options.keep !== 1) throw Error('Lost sibling'); const reset = apply(source, {options: undefined}, {options: {enabled: true}}); const value = parse(reset); if (JSON.stringify(value.options) !== '{"enabled":true}') throw Error(reset); for (const comment of ['/* before */', '// after']) if (!reset.includes(comment)) throw Error(reset); if (reset.includes('/* inside */')) throw Error(reset); const removed = parse(apply(source, {options: {enabled: undefined}, missing: undefined})); if (Object.hasOwn(removed.options, 'enabled') || removed.options.keep !== 1) throw Error('Deletion'); const changed = parse(apply(source, {list: [3], scalar: {a: 1}, options: null}, {scalar: {b: 2}, options: {new: true}}, )); if (JSON.stringify(changed.list) !== '[3]' || JSON.stringify(changed.scalar) !== '{"a":1,"b":2}' || JSON.stringify(changed.options) !== '{"new":true}') throw Error(JSON.stringify(changed)); if (apply(source, {options: {}}) !== source) throw Error('Empty merge cleared object'); const empty = parse(apply('a: 1', {a: {}, b: {missing: undefined}})); if (JSON.stringify(empty) !== '{"a":{},"b":{}}') throw Error('Empty objects'); const shared = {value: 1}; const repeated = parse(apply('', {a: shared, b: shared})); if (repeated.a.value !== 1 || repeated.b.value !== 1) throw Error('Shared reference'); "#, &[], ); } #[test] fn fjson_apply_removing_members_leaves_no_whitespace_only_lines() { common::run( r#" import {apply, parse} from 'avril:fjson'; const source = "tasks: {\n a: {\n command: \"x\",\n },\n b: {\n command: \"y\",\n },\n},\n"; if (apply(source, {tasks: {a: undefined}}) !== "tasks: {\n b: {\n command: \"y\",\n },\n},\n") throw Error('Remove first'); if (apply(source, {tasks: {b: undefined}}) !== "tasks: {\n a: {\n command: \"x\",\n },\n},\n") throw Error('Remove last'); let current = source; for (let round = 0; round < 3; round++) { current = apply(current, {tasks: {a: undefined, b: undefined}}, {tasks: {a: {command: `x${round}`}, b: {command: 'y'}}}); } if (/^[ \t]+$/m.test(current)) throw Error(current); if (parse(current).tasks.a.command !== 'x2') throw Error('Regeneration'); const inline = apply('tasks: { a: 1, b: 2 }, // done\n', {tasks: {a: undefined}}); if (parse(inline).tasks.b !== 2 || !inline.includes('// done') || inline.includes('\n\n')) { throw Error(inline); } "#, &[], ); } #[test] fn fjson_apply_uses_own_keys_and_json_conversion_for_replacements() { common::run( r#" import {apply, parse} from 'avril:fjson'; const overrides = Object.create({inherited: 1}); Object.defineProperty(overrides, 'hidden', {value: 2}); overrides[Symbol('ignored')] = 3; overrides.fn = () => {}; overrides.symbol = Symbol('ignored'); overrides.values = [undefined, NaN, Infinity, {toJSON() {return 'converted';}}]; overrides.nullable = null; const value = parse(apply('', overrides)); if (JSON.stringify(value) !== '{"values":[null,null,null,"converted"],"nullable":null}') { throw Error(JSON.stringify(value)); } const special = Object.create(null); special.__proto__ = {safe: true}; special['a.b'] = {'日本語': '😀'}; const result = parse(apply('__proto__: {kept: 1}', special)); if (!Object.hasOwn(result, '__proto__') || result.__proto__.kept !== 1 || !result.__proto__.safe || Object.getPrototypeOf(result) !== Object.prototype || result['a.b']['日本語'] !== '😀' || Object.prototype.safe !== undefined) throw Error('Keys'); const indexed = parse(apply('', {0: 'zero', nested: {1: 'one'}})); if (indexed['0'] !== 'zero' || indexed.nested['1'] !== 'one') throw Error('Index keys'); const omitted = []; omitted.toJSON = () => undefined; if (apply('value: true', {value: omitted}) !== 'value: true') throw Error('Unsupported JSON root'); const converted = () => {}; converted.toJSON = () => 'converted'; if (parse(apply('', {value: converted})).value !== 'converted') throw Error('Function toJSON'); const marker = {}; const direct = {value: true, toJSON() {throw marker;}}; if (parse(apply('', direct)).value !== true) throw Error('Converted overrides'); const failedArray = []; failedArray.toJSON = () => {throw marker;}; for (const overrides of [ {get value() {throw marker;}}, new Proxy({}, {ownKeys() {throw marker;}}), {value: failedArray}, ]) { try {apply('', overrides); throw Error('Lost exception');} catch(e) {if (e !== marker) throw e;} } "#, &[], ); } #[test] fn fjson_apply_reports_source_conversion_and_depth_errors() { common::run( r#" import {apply, parse} from 'avril:fjson'; for (const source of ['[]', 'null', 'a:', 'a: 1\na: 2', 'a: 1e9999', "// \ud800\na: 1"]) { try {apply(source); throw Error('Accepted invalid source');} catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes(':')) throw e;} } const cyclic = {}; cyclic.self = cyclic; for (const overrides of [null, [], 1, undefined, () => {}, Symbol('x'), 1n, cyclic, {value: 1n}, {value: '\ud800'}, {'\udfff': 1}]) { try {apply('', overrides); throw Error('Accepted invalid overrides');} catch(e) {if (!(e instanceof TypeError)) throw e;} } const arrayCycle = []; arrayCycle.push(arrayCycle); try {apply('', {value: arrayCycle}); throw Error('Accepted cyclic array');} catch(e) {if (!(e instanceof TypeError)) throw e;} let object = {leaf: true}; for (let depth = 0; depth < 128; depth++) object = {child: object}; const text = apply('', object); if (JSON.stringify(parse(text)) !== JSON.stringify(object)) throw Error('Object depth round trip'); object = {child: object}; try {apply('', object); throw Error('Accepted excessive object nesting');} catch(e) {if (!(e instanceof RangeError)) throw e;} let array = null; for (let depth = 0; depth < 128; depth++) array = [array]; const arrays = apply('', {value: array}); if (JSON.stringify(parse(arrays).value) !== JSON.stringify(array)) throw Error('Array depth round trip'); array = [array]; try {apply('', {value: array}); throw Error('Accepted excessive array nesting');} catch(e) {if (!(e instanceof RangeError)) throw e;} "#, &[], ); } #[test] fn fjson_is_permissionless_private_and_survives_poisoned_globals() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("main.js"); std::fs::write(&path, r#" import {apply, parse, stringify} from 'avril:fjson'; import {pledge} from 'avril:std'; const parseError = SyntaxError, typeError = TypeError, rangeError = RangeError; if (Symbol.for('__avril.fjson') in globalThis) throw Error('Registry'); const poison = () => {throw Error('Poisoned')}; JSON.parse = poison; JSON.stringify = poison; globalThis.JSON = null; globalThis.SyntaxError = poison; globalThis.TypeError = poison; globalThis.RangeError = poison; Object.prototype.Err = 'poison'; Array.prototype[Symbol.iterator] = poison; Symbol.for = poison; Object.keys = poison; Array.isArray = poison; Set.prototype.has = poison; Set.prototype.add = poison; Set.prototype.delete = poison; pledge({net: false, read: false, write: false, jsExec: false}); if ((await import('avril:fjson')).parse !== parse) throw Error('Lost module'); if (parse(stringify({answer: 42})).answer !== 42) throw Error('Poisoned internals'); const patched = parse(apply('options: {keep: 1, old: true}', {options: {old: undefined, answer: 42}})); if (patched.options.keep !== 1 || patched.options.answer !== 42 || patched.options.old !== undefined) { throw Error('Poisoned apply'); } try {apply('a:'); throw Error('Accepted invalid source');} catch(e) {if (!(e instanceof parseError)) throw e;} try {apply('', {value: '\ud800'}); throw Error('Applied invalid Unicode');} catch(e) {if (!(e instanceof typeError)) throw e;} try {parse('a:'); throw Error('Accepted invalid source');} catch(e) {if (!(e instanceof parseError)) throw e;} try {parse("'\ud800'"); throw Error('Lost Unicode validation');} catch(e) {if (!(e instanceof parseError)) throw e;} try {stringify('\ud800'); throw Error('Serialized invalid Unicode');} catch(e) {if (!(e instanceof typeError)) throw e;} let nested = null; for (let depth = 0; depth < 130; depth++) nested = [nested]; try {stringify(nested); throw Error('Serialized excessive nesting');} catch(e) {if (!(e instanceof rangeError)) throw e;} try {await import('ext:fjson/main.js'); throw Error('Escaped');} catch(e) {if (!e.message.includes('private')) throw e;} console.log('isolated'); "#).unwrap(); let output = std::process::Command::new(env!("CARGO_BIN_EXE_avril")) .args(["run", "-0"]) .arg(path) .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"isolated\n"); }