use boa_engine::{Context, Source};
fn flatten(source: &str) -> String {
source
.lines()
.filter(|line| !line.trim_start().starts_with("import "))
.map(|line| line.strip_prefix("export ").unwrap_or(line))
.collect::<Vec<_>>()
.join("\n")
}
const FUZZ: &str = r#"
function Rng(seed) { this.s = (seed >>> 0) || 1; }
Rng.prototype.next = function () {
let x = this.s;
x ^= x << 13; x >>>= 0;
x ^= x >>> 17;
x ^= x << 5; x >>>= 0;
this.s = x;
return x;
};
Rng.prototype.below = function (n) { return n === 0 ? 0 : this.next() % n; };
Rng.prototype.pick = function (a) { return a[this.below(a.length)]; };
// Values chosen for what they do to a JSON layer rather than for variety:
// the marker key itself, the prototype key the format defines a rule for,
// `toJSON` because `encode` consults it first, the numbers JSON cannot
// represent, and the strings that need escaping.
const ATOMS = [
0, 1, -1, 3.5, -0, 1e308, -1e308, 5e-324, NaN, Infinity, -Infinity,
'', 'a', '$map', '__proto__', 'toJSON', 'constructor', '"', '\\', '\u0000',
'\u202e', '😀', true, false, null, undefined,
];
// Field names are drawn from what §4.6 actually admits —
// `[\p{XID_Start}_][\p{XID_Continue}]*` — because a generator that invents
// field names the language cannot spell tests a program nobody can write.
// `$map` is the case that matters: `$` is in neither XID class, so a ZD
// record can never carry the marker as a field, which is the whole
// argument the marker rests on. It stays in `ATOMS` as a string *value*,
// where it is legal and must survive, and the malformed-payload battery
// below covers `$map` arriving from something that is not this runtime.
// `__proto__`, `toJSON` and `constructor` are all legal identifiers here
// and are the three that collide with JavaScript rather than with ZD.
const FIELDS = ['a', 'b', '__proto__', 'toJSON', 'constructor', 'Ключ', '_0'];
// Map keys are scalars the encoded form can still tell apart, which is
// narrower than the values a map can hold — deliberately, and the gap is
// worth stating because the fuzz found it.
//
// A `Map` distinguishes its keys by SameValueZero; the wire distinguishes
// them by their JSON. Those disagree for every value JSON cannot spell:
// `undefined`, `NaN`, `Infinity` and `-Infinity` all encode to `null`, so
// a map holding any two of them as keys arrives with one entry where it
// left with two — silently, which is the one thing this format must not
// do. Objects and arrays disagree the same way: two structurally equal
// records are two keys going in and one coming back.
//
// Whether a ZD program can *build* such a map is a language question and
// not this file's to answer, so the generator stays inside the keys the
// format is known to round-trip and the collapse is recorded against the
// wire format's compatibility rule (#144) rather than asserted here.
// Asserting it either way would be this test deciding the language.
const KEYS = [0, 1, -1, 3.5, 1e308, '', 'a', '$map', '__proto__', 'toJSON', '"', true, false, null];
function value(rng, depth) {
if (depth <= 0 || rng.below(3) === 0) return rng.pick(ATOMS);
switch (rng.below(3)) {
case 0: {
const out = [];
for (let i = rng.below(4); i > 0; i--) out.push(value(rng, depth - 1));
return out;
}
case 1: {
const out = {};
for (let i = rng.below(4); i > 0; i--) {
Object.defineProperty(out, rng.pick(FIELDS), {
value: value(rng, depth - 1),
enumerable: true, configurable: true, writable: true,
});
}
return out;
}
default: {
const out = new Map();
for (let i = rng.below(4); i > 0; i--) {
out.set(rng.pick(KEYS), value(rng, depth - 1));
}
return out;
}
}
}
// Every shape the marker can be malformed into. Each one used to convert
// silently; each one must now throw, and must still throw when it is buried
// rather than at the root.
const MALFORMED = [
'{"$map":{}}',
'{"$map":null}',
'{"$map":"pairs"}',
'{"$map":7}',
'{"$map":[],"x":1}',
'{"x":1,"$map":[]}',
'{"$map":[1]}',
'{"$map":[[1]]}',
'{"$map":[[1,2,3]]}',
'{"$map":[[]]}',
'{"$map":[null]}',
'{"$map":[[1,2],[3]]}',
];
function fuzz(cases) {
const failures = [];
const rng = new Rng(0x5eed);
for (let i = 0; i < cases; i++) {
const seed = rng.next();
const v = value(new Rng(seed), 5);
// Round trip. The encoded form is what crosses the wire, so the
// encoded form is what has to survive it.
let before, after;
try {
before = stringify(v);
after = stringify(parse(before));
} catch (e) {
failures.push('seed ' + seed + ' threw on a value it produced: ' + e);
continue;
}
if (before !== after) {
failures.push('seed ' + seed + ' did not survive: ' + before + ' -> ' + after);
continue;
}
// Idempotence on text this runtime did not write. A second trip must
// not move what the first one settled on.
let third;
try {
third = stringify(parse(after));
} catch (e) {
failures.push('seed ' + seed + ' threw on its own output: ' + e);
continue;
}
if (third !== after) {
failures.push('seed ' + seed + ' is not idempotent: ' + after + ' -> ' + third);
}
}
// Loud refusal, at the root and buried, in an array and in a record.
for (const bad of MALFORMED) {
for (const [where, text] of [
['root', bad],
['in an array', '[' + bad + ']'],
['in a record', '{"f":' + bad + '}'],
['under a map', '{"$map":[[1,' + bad + ']]}'],
]) {
let threw = false;
try { parse(text); } catch (e) { threw = e instanceof Error; }
if (!threw) {
failures.push('a malformed map was accepted ' + where + ': ' + text);
}
}
}
// A well-formed map still decodes, or the check above would pass by
// refusing everything.
const ok = parse('{"$map":[[1,2],[3,4]]}');
if (!(ok instanceof Map) || ok.size !== 2 || ok.get(1) !== 2) {
failures.push('a well-formed map did not decode');
}
// `__proto__` is a legal ZD identifier, so it must land as an own field
// and leave the prototype alone.
const poisoned = parse('{"__proto__":{"polluted":1}}');
if (Object.getPrototypeOf(poisoned) !== Object.prototype) {
failures.push('decode moved a record\'s prototype');
}
if (({}).polluted !== undefined) {
failures.push('decode polluted Object.prototype');
}
if (!Object.prototype.hasOwnProperty.call(poisoned, '__proto__')) {
failures.push('decode dropped a __proto__ field instead of keeping it');
}
return failures.join('\n');
}
"#;
#[test]
fn generated_values_survive_the_wire_and_malformed_ones_are_refused() {
let cases: usize = std::env::var("ZDC_FUZZ_CASES")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(2_000);
let mut context = Context::default();
for (what, source) in [
("wire.js", flatten(zdc_runtime::WIRE_JS)),
("the fuzz harness", FUZZ.to_string()),
] {
context
.eval(Source::from_bytes(source.as_bytes()))
.unwrap_or_else(|e| panic!("{what} failed to evaluate: {e}"));
}
let report = context
.eval(Source::from_bytes(format!("fuzz({cases})").as_bytes()))
.expect("running the fuzz")
.to_string(&mut context)
.expect("the report is a string")
.to_std_string_escaped();
assert!(
report.is_empty(),
"the wire format did not hold on {cases} generated values:\n{report}"
);
}