diff --git a/fuzz/corpus/validate_pair/seed-044 b/fuzz/corpus/validate_pair/seed-044 new file mode 100644 index 0000000..fa7a06e Binary files /dev/null and b/fuzz/corpus/validate_pair/seed-044 differ diff --git a/fuzz/gen_fuzz_seeds.py b/fuzz/gen_fuzz_seeds.py index 969237d..e7423db 100644 --- a/fuzz/gen_fuzz_seeds.py +++ b/fuzz/gen_fuzz_seeds.py @@ -632,6 +632,14 @@ def validate_pair_seeds(): w(pair(menu_lane(4), True, shape("raw"), union(0, 0x33))) w(pair(menu_lane(0), True, shape("raw"), sandwich_packed(b"hi"))) + # W3-1 reproducer (the first wave-3 campaign crash): menu 2, + # aligned, padded shape, empty body. The all-zero 64-byte buffer is + # SHORTER than the layout's total (the maxLength reservation + # contributes 64 at the entry, end 68) while the all-zero indirect + # pair {0,0} reads fine — the validated-buffer/entry-window split + # the W3-1 invariant pin documents. + w(pair(menu_lane(2), True, shape("padded"), b"")) + # 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 2736e28..dfccb3f 100644 --- a/fuzz/shared/src/validate_pair.rs +++ b/fuzz/shared/src/validate_pair.rs @@ -126,10 +126,10 @@ pub enum Shape { /// hostile buffer body. #[derive(Debug, Arbitrary)] pub struct PairInput { - lane: DocLane, - mode: bool, - shape: Shape, - buffer: Vec, + pub lane: DocLane, + pub mode: bool, + pub shape: Shape, + pub buffer: Vec, } pub fn fuzz_validate_pair(data: &[u8]) { @@ -361,16 +361,37 @@ fn drive_pair(engine: &AlkTypeEngine, doc: &Value, root: &str, buffer: &[u8]) { ) { continue; } + // W3-1 (pinned contract, not engine behavior to + // change): an offset-indirect entry's range is + // the pair/reservation window (a declared + // maxLength contributes its full size), but the + // {data_offset, data_length} pair points + // absolutely into the whole buffer — the data + // may live anywhere in the buffer and the + // buffer may be shorter than the reservation + // (the wave-1 data_access bounds partition is + // the contract: the pointed-to window sits + // inside the buffer, nothing about the + // reservation). For every other encoding a + // successful read implies range.end ≤ len. + let indirect = + entry.meta.encoding == alktype::VariableEncoding::OffsetIndirect; + if !indirect { + assert!( + entry.range.end <= buffer.len(), + "a validated non-indirect leaf {path} \ + [{}, {}) sits inside the buffer ({} bytes)", + entry.range.start, + entry.range.end, + buffer.len() + ); + } match engine.read_field(buffer, path) { Ok(_) => {} Err(e) => panic!( "validate_bytes Ok ⇒ read_field({path}) Ok, got {e:?}" ), } - assert!( - entry.range.end <= buffer.len(), - "a validated leaf sits inside the buffer" - ); } } } @@ -844,4 +865,25 @@ mod corpus_replay { &buffers::sandwich_packed(b"hi"), )); } + + /// W3-1's contract pin: an aligned offset-indirect leaf's entry + /// window (a maxLength reservation) may exceed the validated + /// buffer — the pair is absolute and the data it names must sit in + /// the buffer, nothing about the reservation. The exact crashing + /// shape: menu 2, aligned, Padded transform, empty body — a 64-byte + /// zero buffer whose layout total is 68, validate_bytes Ok, the + /// all-zero pair {0,0} reading empty. + #[test] + fn validated_buffer_may_be_shorter_than_the_indirect_reservation_window() { + fuzz_validate_pair(&enc::pair( + enc::lane_menu(2), + true, + enc::shape_field(enc::SHAPE_PADDED), + &[], + )); + // And the exact artifact bytes replay through the entry. + let crash: [u8; 11] = + [0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0xD3, 0x04]; + fuzz_validate_pair(&crash); + } } \ No newline at end of file