diff --git a/fuzz/corpus/validate_pair/seed-045 b/fuzz/corpus/validate_pair/seed-045 new file mode 100644 index 0000000..dd97b70 Binary files /dev/null and b/fuzz/corpus/validate_pair/seed-045 differ diff --git a/fuzz/corpus/validate_pair/seed-046 b/fuzz/corpus/validate_pair/seed-046 new file mode 100644 index 0000000..f3df3c3 Binary files /dev/null and b/fuzz/corpus/validate_pair/seed-046 differ diff --git a/fuzz/corpus/validate_pair/seed-047 b/fuzz/corpus/validate_pair/seed-047 new file mode 100644 index 0000000..fa7a06e Binary files /dev/null and b/fuzz/corpus/validate_pair/seed-047 differ diff --git a/fuzz/gen_fuzz_seeds.py b/fuzz/gen_fuzz_seeds.py index e7423db..be5a9c5 100644 --- a/fuzz/gen_fuzz_seeds.py +++ b/fuzz/gen_fuzz_seeds.py @@ -640,6 +640,21 @@ def validate_pair_seeds(): # the W3-1 invariant pin documents. w(pair(menu_lane(2), True, shape("padded"), b"")) + # W3-2 (the second campaign crash): serde_json's non-`float_ + # roundtrip` parser drifts one ulp re-parsing its own emission of + # adversarial f64 bits. Menu 255 % 10 = 5 (u8 tag | f64), aligned, + # padded body with the f64 bits 0x5bffffffffffffff. The harness + # pins the round-trip with one-ulp slack (upstream property). + w(pair(menu_lane(255), True, shape("padded"), + b"\xff" * 7 + b"\x5b")) + w(bytes.fromhex( + "00230000" + "ff" * 25 + "5b" + "ff" * 2 + b"encoding".hex() + + "ff" * 36 + b"string".hex() + "ff" * 16 + + "0260010081000100" + "3d0021000030010401")) + # Deterministic minimal form of the same shape for the record: + w(pair(menu_lane(5), True, shape("raw"), + b"\x07" + b"\xff" * 7 + b"\x5c")) + # Aligned truncation sweep over the fixed-shape menu 7 body. for n in range(1, len(be)): w(pair(menu_lane(7), True, shape("trunc", n), be)) diff --git a/fuzz/shared/src/validate_pair.rs b/fuzz/shared/src/validate_pair.rs index dfccb3f..956900e 100644 --- a/fuzz/shared/src/validate_pair.rs +++ b/fuzz/shared/src/validate_pair.rs @@ -249,6 +249,53 @@ fn assert_engine_shape(engine: &AlkTypeEngine) { } } +/// Structural serde round-trip equality with the W3-2 f64 slack: +/// numbers agree either exactly or within one ulp of each other's +/// f64 bit patterns (serde_json's non-`float_roundtrip` parse of its +/// own re-emitted shortest repr can drift one ulp on adversarial +/// magnitudes — upstream, not an alktype contract). Keys and value +/// shapes must match exactly. +fn assert_values_agree_with_ulp_slack(a: &Value, b: &Value) { + match (a, b) { + (Value::Number(x), Value::Number(y)) => { + let (xf, yf) = (x.as_f64(), y.as_f64()); + match (xf, yf) { + (Some(xf), Some(yf)) if xf.is_finite() && yf.is_finite() => { + if x == y { + return; + } + let (xb, yb) = (xf.to_bits(), yf.to_bits()); + let drift = xb.abs_diff(yb); + assert!( + drift <= 1, + "number drift beyond one ulp: {x} vs {y} (bit diff {drift})" + ); + } + _ => assert_eq!(x, y, "number mismatch"), + } + } + (Value::Array(x), Value::Array(y)) => { + assert_eq!(x.len(), y.len(), "array length mismatch"); + for (i, (xa, ya)) in x.iter().zip(y.iter()).enumerate() { + assert_values_agree_with_ulp_slack(xa, ya); + let _ = i; + } + } + (Value::Object(x), Value::Object(y)) => { + assert_eq!(x.len(), y.len(), "object size mismatch"); + let mut xk: Vec<&String> = x.keys().collect(); + let mut yk: Vec<&String> = y.keys().collect(); + xk.sort(); + yk.sort(); + assert_eq!(xk, yk, "object keys mismatch"); + for k in xk { + assert_values_agree_with_ulp_slack(&x[k], &y[k]); + } + } + (x, y) => assert_eq!(x, y, "value shape mismatch"), + } +} + /// Unknown paths must always error, echo the path, and never panic. fn junk_paths(engine: &AlkTypeEngine, _root: &str) { for junk in JUNK_PATHS { @@ -396,13 +443,24 @@ fn drive_pair(engine: &AlkTypeEngine, doc: &Value, root: &str, buffer: &[u8]) { } } - // serde-safety: the output survives to_vec + parse back, - // structurally equal (preserve_order keeps key order). + // serde-safe encode: the output survives to_vec and parse + // back. W3-2: the comparison carries a documented f64 + // slack — the wire→f64 decode is exact, but serde_json's + // non-`float_roundtrip` parser (lexical concise-float on + // the re-emitted shortest repr) can drift one ulp on + // adversarial magnitudes (probe: 0x5bffffffffffffff emits + // 1.4536774485912136e+135 and parses back one ulp low; + // ryu's own float parse of the same digits is exact, so + // this is the parse side, upstream of this crate). Without + // slack the fuzzer fires this on adversarial f64 bits — an + // upstream serde_json property, not an alktype contract. + // Structural round-trip is asserted field-shape-wise with + // per-number comparison at the f64 bits ± 1 ulp. let bytes = serde_json::to_vec(&value) .expect("a materialized Value is serde-serializable"); let back = serde_json::from_slice::(&bytes) .expect("a materialized Value survives its own JSON encoding"); - assert_eq!(value, back, "materialize output serde round-trips"); + assert_values_agree_with_ulp_slack(&value, &back); } } }