From 2842ab5423ba21c5dc4135201e52dc600bf3c69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:37:58 -0300 Subject: [PATCH 1/7] hardening: add debug_assert and safety comment to decode_fixed_vec_le --- crates/ssz/src/decode.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ssz/src/decode.rs b/crates/ssz/src/decode.rs index 6986f4d..10f9f6f 100644 --- a/crates/ssz/src/decode.rs +++ b/crates/ssz/src/decode.rs @@ -77,6 +77,7 @@ fn decode_uint( fn decode_fixed_vec_le(bytes: &[u8], item_size: usize) -> Result, DecodeError> { #[cfg(target_endian = "little")] { + debug_assert_eq!(item_size, core::mem::size_of::(), "item_size must equal size_of::() for safe memcpy"); if !bytes.len().is_multiple_of(item_size) { return Err(DecodeError::InvalidByteLength { expected: item_size, @@ -85,6 +86,12 @@ fn decode_fixed_vec_le(bytes: &[u8], item_size: usize) -> Result, Deco } let count = bytes.len() / item_size; let mut result = Vec::::with_capacity(count); + // SAFETY: `T` is a primitive integer (u8/u16/u32/u64/u128) with no + // padding and a valid representation for any bit pattern. `item_size` + // equals `size_of::()` (asserted above), and the little-endian + // memory layout matches SSZ wire format (cfg-guarded). The length + // check guarantees `count * size_of::() == bytes.len()`, so the + // copy stays within the allocated capacity. unsafe { core::ptr::copy_nonoverlapping( bytes.as_ptr(), From 765befe75132209d5ecc5c2793a8e67ec81d28cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:51:30 -0300 Subject: [PATCH 2/7] fix: rustfmt debug_assert_eq line length --- crates/ssz/src/decode.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ssz/src/decode.rs b/crates/ssz/src/decode.rs index 10f9f6f..a24988d 100644 --- a/crates/ssz/src/decode.rs +++ b/crates/ssz/src/decode.rs @@ -77,7 +77,11 @@ fn decode_uint( fn decode_fixed_vec_le(bytes: &[u8], item_size: usize) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - debug_assert_eq!(item_size, core::mem::size_of::(), "item_size must equal size_of::() for safe memcpy"); + debug_assert_eq!( + item_size, + core::mem::size_of::(), + "item_size must equal size_of::() for safe memcpy" + ); if !bytes.len().is_multiple_of(item_size) { return Err(DecodeError::InvalidByteLength { expected: item_size, From 4f4bd959372ab40b8ed78a83b93a1628d9d5a84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:11:42 -0300 Subject: [PATCH 3/7] refactor: remove redundant item_size parameter from decode_fixed_vec_le Compute size_of::() inside the function instead of taking it as a parameter. This makes a size mismatch impossible by construction, replacing the debug_assert added in the previous commit. --- crates/ssz/src/decode.rs | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/crates/ssz/src/decode.rs b/crates/ssz/src/decode.rs index a24988d..6007734 100644 --- a/crates/ssz/src/decode.rs +++ b/crates/ssz/src/decode.rs @@ -74,14 +74,10 @@ fn decode_uint( } #[cfg(feature = "alloc")] -fn decode_fixed_vec_le(bytes: &[u8], item_size: usize) -> Result, DecodeError> { +fn decode_fixed_vec_le(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - debug_assert_eq!( - item_size, - core::mem::size_of::(), - "item_size must equal size_of::() for safe memcpy" - ); + let item_size = core::mem::size_of::(); if !bytes.len().is_multiple_of(item_size) { return Err(DecodeError::InvalidByteLength { expected: item_size, @@ -91,11 +87,10 @@ fn decode_fixed_vec_le(bytes: &[u8], item_size: usize) -> Result, Deco let count = bytes.len() / item_size; let mut result = Vec::::with_capacity(count); // SAFETY: `T` is a primitive integer (u8/u16/u32/u64/u128) with no - // padding and a valid representation for any bit pattern. `item_size` - // equals `size_of::()` (asserted above), and the little-endian - // memory layout matches SSZ wire format (cfg-guarded). The length - // check guarantees `count * size_of::() == bytes.len()`, so the - // copy stays within the allocated capacity. + // padding and a valid representation for any bit pattern. The + // little-endian memory layout matches SSZ wire format (cfg-guarded). + // The length check guarantees `count * size_of::() == bytes.len()`, + // so the copy stays within the allocated capacity. unsafe { core::ptr::copy_nonoverlapping( bytes.as_ptr(), @@ -109,7 +104,6 @@ fn decode_fixed_vec_le(bytes: &[u8], item_size: usize) -> Result, Deco #[cfg(not(target_endian = "little"))] { let _ = bytes; - let _ = item_size; unreachable!() } } @@ -132,7 +126,7 @@ impl SszDecode for u8 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes, 1) + decode_fixed_vec_le(bytes) } #[cfg(not(target_endian = "little"))] { @@ -159,7 +153,7 @@ impl SszDecode for u16 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes, 2) + decode_fixed_vec_le(bytes) } #[cfg(not(target_endian = "little"))] { @@ -186,7 +180,7 @@ impl SszDecode for u32 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes, 4) + decode_fixed_vec_le(bytes) } #[cfg(not(target_endian = "little"))] { @@ -213,7 +207,7 @@ impl SszDecode for u64 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes, 8) + decode_fixed_vec_le(bytes) } #[cfg(not(target_endian = "little"))] { @@ -240,7 +234,7 @@ impl SszDecode for u128 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes, 16) + decode_fixed_vec_le(bytes) } #[cfg(not(target_endian = "little"))] { From e8b4b25dcffcffc1bdcdcbff242e3c045adf7f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:18:28 -0300 Subject: [PATCH 4/7] docs: rewrite safety documentation for decode_fixed_vec_le Split into a doc-level safety contract (what callers must uphold) and a block-level SAFETY comment (why the operations are sound). --- crates/ssz/src/decode.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ssz/src/decode.rs b/crates/ssz/src/decode.rs index 6007734..686266f 100644 --- a/crates/ssz/src/decode.rs +++ b/crates/ssz/src/decode.rs @@ -73,6 +73,17 @@ fn decode_uint( Ok(from_le_bytes(arr)) } +/// Bulk-decode `bytes` into a `Vec` via a single `memcpy`. +/// +/// # Safety contract (not enforced by the type system) +/// +/// `T` must satisfy: +/// - No padding bytes (`size_of::()` equals its data width). +/// - Valid for any bit pattern (no invalid representations). +/// - Little-endian in-memory layout matching SSZ wire format. +/// +/// Only called for primitive integers (u8, u16, u32, u64, u128), which +/// satisfy all three requirements on little-endian targets. #[cfg(feature = "alloc")] fn decode_fixed_vec_le(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] @@ -86,11 +97,10 @@ fn decode_fixed_vec_le(bytes: &[u8]) -> Result, DecodeError> { } let count = bytes.len() / item_size; let mut result = Vec::::with_capacity(count); - // SAFETY: `T` is a primitive integer (u8/u16/u32/u64/u128) with no - // padding and a valid representation for any bit pattern. The - // little-endian memory layout matches SSZ wire format (cfg-guarded). - // The length check guarantees `count * size_of::() == bytes.len()`, - // so the copy stays within the allocated capacity. + // SAFETY: caller upholds the safety contract above. The length check + // guarantees `count * size_of::() == bytes.len()`, and + // `with_capacity(count)` allocates at least that many bytes, so the + // copy and `set_len` stay within the allocated capacity. unsafe { core::ptr::copy_nonoverlapping( bytes.as_ptr(), From 20b33ed39fae8907a6b6aefe8f7e8c3851d55763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:59:12 -0300 Subject: [PATCH 5/7] refactor: mark decode_fixed_vec_le as unsafe fn The function has a safety contract that the type system cannot enforce (T must have no padding and be valid for any bit pattern), so callers should explicitly acknowledge it via an unsafe block. --- crates/ssz/src/decode.rs | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/crates/ssz/src/decode.rs b/crates/ssz/src/decode.rs index 686266f..9af9a18 100644 --- a/crates/ssz/src/decode.rs +++ b/crates/ssz/src/decode.rs @@ -75,17 +75,14 @@ fn decode_uint( /// Bulk-decode `bytes` into a `Vec` via a single `memcpy`. /// -/// # Safety contract (not enforced by the type system) +/// # Safety /// /// `T` must satisfy: /// - No padding bytes (`size_of::()` equals its data width). /// - Valid for any bit pattern (no invalid representations). /// - Little-endian in-memory layout matching SSZ wire format. -/// -/// Only called for primitive integers (u8, u16, u32, u64, u128), which -/// satisfy all three requirements on little-endian targets. #[cfg(feature = "alloc")] -fn decode_fixed_vec_le(bytes: &[u8]) -> Result, DecodeError> { +unsafe fn decode_fixed_vec_le(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { let item_size = core::mem::size_of::(); @@ -97,18 +94,11 @@ fn decode_fixed_vec_le(bytes: &[u8]) -> Result, DecodeError> { } let count = bytes.len() / item_size; let mut result = Vec::::with_capacity(count); - // SAFETY: caller upholds the safety contract above. The length check - // guarantees `count * size_of::() == bytes.len()`, and - // `with_capacity(count)` allocates at least that many bytes, so the - // copy and `set_len` stay within the allocated capacity. - unsafe { - core::ptr::copy_nonoverlapping( - bytes.as_ptr(), - result.as_mut_ptr() as *mut u8, - bytes.len(), - ); - result.set_len(count); - } + // The length check guarantees `count * size_of::() == bytes.len()`, + // and `with_capacity(count)` allocates at least that many bytes, so + // the copy and `set_len` stay within the allocated capacity. + core::ptr::copy_nonoverlapping(bytes.as_ptr(), result.as_mut_ptr() as *mut u8, bytes.len()); + result.set_len(count); Ok(result) } #[cfg(not(target_endian = "little"))] @@ -136,7 +126,8 @@ impl SszDecode for u8 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes) + // SAFETY: primitive integers have no padding and are valid for any bit pattern. + unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] { @@ -163,7 +154,8 @@ impl SszDecode for u16 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes) + // SAFETY: primitive integers have no padding and are valid for any bit pattern. + unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] { @@ -190,7 +182,8 @@ impl SszDecode for u32 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes) + // SAFETY: primitive integers have no padding and are valid for any bit pattern. + unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] { @@ -217,7 +210,8 @@ impl SszDecode for u64 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes) + // SAFETY: primitive integers have no padding and are valid for any bit pattern. + unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] { @@ -244,7 +238,8 @@ impl SszDecode for u128 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - decode_fixed_vec_le(bytes) + // SAFETY: primitive integers have no padding and are valid for any bit pattern. + unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] { From 48b8230a22b6f00eb58aff705748171bcb2f1bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:12:32 -0300 Subject: [PATCH 6/7] docs: separate safety and correctness requirements Split the doc comment into a Safety section (valid-for-any-bit-pattern, the actual soundness requirement) and a Correctness section (little-endian layout, enforced by cfg). --- crates/ssz/src/decode.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ssz/src/decode.rs b/crates/ssz/src/decode.rs index 9af9a18..1656929 100644 --- a/crates/ssz/src/decode.rs +++ b/crates/ssz/src/decode.rs @@ -77,10 +77,14 @@ fn decode_uint( /// /// # Safety /// -/// `T` must satisfy: -/// - No padding bytes (`size_of::()` equals its data width). -/// - Valid for any bit pattern (no invalid representations). -/// - Little-endian in-memory layout matching SSZ wire format. +/// `T` must be valid for any bit pattern (no invalid representations), +/// since arbitrary wire bytes are reinterpreted as `T` values. +/// +/// # Correctness +/// +/// `T` must have a little-endian in-memory layout matching SSZ wire +/// format. This is enforced at compile time by the `#[cfg(target_endian)]` +/// gate on the only code path that calls this function. #[cfg(feature = "alloc")] unsafe fn decode_fixed_vec_le(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] From 94cfea6147f33ee0d1d70a96d9c79b2e714b58d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:12:32 -0300 Subject: [PATCH 7/7] docs: separate safety and correctness requirements Split the doc comment into a Safety section (valid-for-any-bit-pattern, the actual soundness requirement) and a Correctness section (little-endian layout, enforced by cfg). --- crates/ssz/src/decode.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ssz/src/decode.rs b/crates/ssz/src/decode.rs index 1656929..55754f0 100644 --- a/crates/ssz/src/decode.rs +++ b/crates/ssz/src/decode.rs @@ -130,7 +130,7 @@ impl SszDecode for u8 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - // SAFETY: primitive integers have no padding and are valid for any bit pattern. + // SAFETY: primitive integers are valid for any bit pattern. unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] @@ -158,7 +158,7 @@ impl SszDecode for u16 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - // SAFETY: primitive integers have no padding and are valid for any bit pattern. + // SAFETY: primitive integers are valid for any bit pattern. unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] @@ -186,7 +186,7 @@ impl SszDecode for u32 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - // SAFETY: primitive integers have no padding and are valid for any bit pattern. + // SAFETY: primitive integers are valid for any bit pattern. unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] @@ -214,7 +214,7 @@ impl SszDecode for u64 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - // SAFETY: primitive integers have no padding and are valid for any bit pattern. + // SAFETY: primitive integers are valid for any bit pattern. unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))] @@ -242,7 +242,7 @@ impl SszDecode for u128 { fn ssz_decode_fixed_vec(bytes: &[u8]) -> Result, DecodeError> { #[cfg(target_endian = "little")] { - // SAFETY: primitive integers have no padding and are valid for any bit pattern. + // SAFETY: primitive integers are valid for any bit pattern. unsafe { decode_fixed_vec_le(bytes) } } #[cfg(not(target_endian = "little"))]