From d47c4835320e535fc0de0ad98df5fc6075440e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:54:18 -0300 Subject: [PATCH] Add regression test and fuzz coverage for zero first offset in lists leanEthereum/leanSpec#1175 reports that leanSpec's SSZ list decoder accepts first_offset == 0, deriving a zero element count while the offset/scope boundary describes one element spanning the whole input. libssz already rejects this up front (the num_items == 0 check in decode_variable_length_items_with_max), but nothing pinned that behavior. Pin it with a regression test using the issue's exact payload, and extend the offset_adversarial fuzz target with list-of-variable-length items types: it previously only exercised ContainerDecoder, never the list offset-table path where this class of bug lives. --- crates/ssz/tests/encode_decode.rs | 25 +++++++++++++++++++++++++ fuzz/fuzz_targets/offset_adversarial.rs | 19 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/ssz/tests/encode_decode.rs b/crates/ssz/tests/encode_decode.rs index 610027a..ac5e65b 100644 --- a/crates/ssz/tests/encode_decode.rs +++ b/crates/ssz/tests/encode_decode.rs @@ -150,6 +150,31 @@ fn vec_of_vecs_empty() { assert_eq!(Vec::>::from_ssz_bytes(&encoded).unwrap(), val); } +// A zero first offset would imply 0 elements while the offset/scope boundary +// describes 1 element spanning the whole input; it must be rejected up front. +// See https://github.com/leanEthereum/leanSpec/issues/1175 +#[test] +fn vec_of_vecs_zero_first_offset() { + let bytes = [0x00, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC, 0xDD]; + assert_eq!( + Vec::>::from_ssz_bytes(&bytes), + Err(DecodeError::InvalidFirstOffset { + expected: 4, + got: 0 + }) + ); + + // Same with no trailing data: only an offset table pointing at itself. + let bytes = [0x00, 0x00, 0x00, 0x00]; + assert_eq!( + Vec::>::from_ssz_bytes(&bytes), + Err(DecodeError::InvalidFirstOffset { + expected: 4, + got: 0 + }) + ); +} + // ── ContainerEncoder / ContainerDecoder ── /// Simulates a container: { a: u32, b: Vec, c: u16 } diff --git a/fuzz/fuzz_targets/offset_adversarial.rs b/fuzz/fuzz_targets/offset_adversarial.rs index b2d6a76..25f65f8 100644 --- a/fuzz/fuzz_targets/offset_adversarial.rs +++ b/fuzz/fuzz_targets/offset_adversarial.rs @@ -34,6 +34,11 @@ struct FixedThenVar { data: Vec, } +#[derive(Debug, PartialEq, SszEncode, SszDecode)] +struct VarItemsField { + items: Vec>, +} + fuzz_target!(|data: &[u8]| { // Attempt decode of each struct — must never panic regardless of input. // If decode succeeds, roundtrip must hold. @@ -61,4 +66,18 @@ fuzz_target!(|data: &[u8]| { let decoded = FixedThenVar::from_ssz_bytes(&encoded).unwrap(); assert_eq!(decoded, v, "FixedThenVar roundtrip"); } + + // Lists of variable-length items have their own offset table, exercising + // decode_variable_length_items_with_max rather than ContainerDecoder. + if let Ok(v) = Vec::>::from_ssz_bytes(data) { + let encoded = v.to_ssz(); + let decoded = Vec::>::from_ssz_bytes(&encoded).unwrap(); + assert_eq!(decoded, v, "Vec> roundtrip"); + } + + if let Ok(v) = VarItemsField::from_ssz_bytes(data) { + let encoded = v.to_ssz(); + let decoded = VarItemsField::from_ssz_bytes(&encoded).unwrap(); + assert_eq!(decoded, v, "VarItemsField roundtrip"); + } });