fuzz: W3-2 — serde_json parse-side one-ulp float drift pinned with slack
The restarted validate_pair campaign found a second crash: materialize produces f64 0x5bffffffffffffff, serde_json emits the shortest repr 1.4536774485912136e+135, and the non-`float_roundtrip` parse side (lexical concise-float over re-parsed digits) lands one ulp low — probe-verified upstream of this crate (ryu's own float parser accepts the same digits exactly; the std parser is exact; only serde_json's concise reparse drifts). The harness's structural serde round-trip assertion assumed Value equality holds for every finite f64 — upstream parse-side drift breaks that assumption on adversarial magnitudes. - assert_values_agree_with_ulp_slack replaces the bare Value equality: keys/shapes exact, numbers equal-or-within-one-ulp (bit diff ≤ 1) - the exact artifact bytes pinned as corpus seed-047, plus deterministic minimal forms as seed-045/seed-046 (45→48 seeds) - upstream note: enabling serde_json's float_roundtrip feature would remove the drift; the crate pins serde_json default features + preserve_order by design, so the slack is the honest pin Verification: corpus replay 30/30 green (48 seeds), fuzz build clean, clippy -D warnings clean.
This commit is contained in:
1 parent
9ca9922fd4
commit
b7ead99724
5 files changed
+76
-3
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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))
|
||||
|
||||
@@ -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::<Value>(&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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user