Skip to content

fix(payload): reject zero-byte arbitrary input for Block#1892

Draft
goxberry wants to merge 1 commit into
goxberry/expect-cleanup-payload-handle-contractsfrom
goxberry/fix-payload-arbitrary-zero-block
Draft

fix(payload): reject zero-byte arbitrary input for Block#1892
goxberry wants to merge 1 commit into
goxberry/expect-cleanup-payload-handle-contractsfrom
goxberry/fix-payload-arbitrary-zero-block

Conversation

@goxberry
Copy link
Copy Markdown
Contributor

@goxberry goxberry commented May 22, 2026

What does this PR do?

Fixes a latent panic in lading_payload::block::Block::arbitrary (#[cfg(feature = "arbitrary")]). The previous implementation panicked when the fuzzer produced a zero for u32::arbitrary; the fix rejects that case via arbitrary::Error::IncorrectFormat so the fuzzer skips the input and tries another.

A regression test pins the new behavior.

The bug

#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for Block {
    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
        let total_bytes = u32::arbitrary(u)?;
        let bytes = u.bytes(total_bytes as usize).map(Bytes::copy_from_slice)?;
        Ok(Self {
            total_bytes: NonZeroU32::new(total_bytes).expect("total_bytes must be non-zero"),
            bytes,
            metadata: BlockMetadata::default(),
        })
    }
}

u32::arbitrary is permitted by contract to return any u32, including 0. When that happens, NonZeroU32::new(0) is None, and .expect(...) panics. Reachable from lading_fuzz, which enables the arbitrary feature.

The fix

let total_bytes =
    NonZeroU32::new(u32::arbitrary(u)?).ok_or(arbitrary::Error::IncorrectFormat)?;
let bytes = u
    .bytes(total_bytes.get() as usize)
    .map(Bytes::copy_from_slice)?;
Ok(Self { total_bytes, bytes, metadata: BlockMetadata::default() })

Three changes:

  1. NonZeroU32::new(...).expect(...).ok_or(arbitrary::Error::IncorrectFormat)?. This is the documented way to tell the arbitrary crate "skip this input, it's malformed."
  2. The bytes allocation moves below the rejection check, so a rejected input no longer allocates first.
  3. total_bytes.get() is used for the u.bytes(...) call, since total_bytes is now NonZeroU32.

Regression test

arbitrary_rejects_zero_total_bytes lives in a new #[cfg(all(test, feature = "arbitrary"))] submodule of block.rs. It constructs Unstructured::new(&[0u8; 16]) — guaranteeing u32::arbitrary reads four zero bytes — and asserts the result is Err(arbitrary::Error::IncorrectFormat). The test was written against the old code first; there, it panicked exactly at the .expect(), confirming the bug.

Motivation

Last targeted PR in the panic-cleanup phase of the stack started by #1882. After this lands, the only remaining work in lading_payload is the Cat-2 sites (~33 .expect() sites that need function-signature changes to thread Result through) and the final quarantine drop. Those will be the next sub-stack.

Related issues

Stacked on #1891.

Additional Notes

  • cargo build --all-targets --all-features
  • cargo clippy --all-targets --all-features
  • cargo test -p lading-payload --features arbitrary --lib ✓ (245 passed including the new regression test)
  • Diff: 2 files, +37 −3.

Copy link
Copy Markdown
Contributor Author

goxberry commented May 22, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@datadog-prod-us1-5

This comment has been minimized.

`Block::total_bytes` is `NonZeroU32`, but the
`#[cfg(feature = "arbitrary")] impl Arbitrary for Block` previously
called `NonZeroU32::new(u32::arbitrary(u)?).expect("...")`. When the
fuzzer happened to produce a zero (which it can, by contract), the
expect panicked instead of rejecting the input.

The fix uses `.ok_or(arbitrary::Error::IncorrectFormat)?`, the
standard arbitrary-crate signal for "this input shape is invalid;
skip it and try another." The `bytes` allocation is also moved after
the rejection check so a rejected input no longer allocates first.

Adds `arbitrary_rejects_zero_total_bytes` as a regression test under
`#[cfg(all(test, feature = "arbitrary"))]`. The test was written
against the old code and panicked there; against the fixed code it
passes by matching `Err(arbitrary::Error::IncorrectFormat)`.

Reachable from `lading_fuzz`, which enables the `arbitrary` feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant