cargo : block-buffer @ 0.10.4
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

datastructure-impl-boundsdatastructure-impl-correctdatastructure-impl-safedatastructure-impl-testedhas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignunsafe-documentedunsafe-minimalunsafe-safeunsafe-testeduses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

block-buffer 0.10.4 is a foundational RustCrypto staging buffer for block-oriented hash/cipher primitives. Four unsafe blocks are minimal, documented, and sound given generic-array's layout invariant and the new BlockSize > 0 runtime guard; two low-severity quality findings only.

Report

Subject

block-buffer is a small, no_std Rust crate from the RustCrypto organisation that provides a fixed-size buffer for block-oriented data processing. It is the staging buffer used by most RustCrypto hash and stream/block primitives (SHA-1, SHA-2, BLAKE2, AES-CTR, and similar) to accumulate input until a full block is available, then call a user-supplied compression function on each block. The crate exposes two kinds of buffer (EagerBuffer, position in 0..BlockSize; LazyBuffer, position in 0..=BlockSize), Merkle-Damgård-style padding helpers (digest_pad, len64_padding_be/le, len128_padding_be), and a set_data helper for output-block-generating primitives.

Methodology

The published crate contents were compared against the upstream Git repository (RustCrypto/utils, at the commit recorded in .cargo_vcs_info.json, subfolder block-buffer/) using diff. The two source files (src/lib.rs, src/sealed.rs, ~420 lines combined) were read in full. The integration tests in tests/mod.rs (~200 lines) were read in full. The CI workflow at .github/workflows/block-buffer.yml in the VCS repository was reviewed to understand what is exercised on each commit. Every unsafe block was reviewed for soundness, with particular attention to the layout assumption it makes about generic_array::GenericArray<u8, N> and to the invariants it relies on for pos and BlockSize. The crate dependency graph (a single dependency on generic-array = "0.14") was reviewed against the dependency uses in code.

Results

The published crate matches the upstream VCS repository byte-for-byte in source and tests; the differences are confined to Cargo's automatic normalisation of Cargo.toml, the presence of Cargo.toml.orig, and the addition of .cargo_vcs_info.json.

The crate ships no binary or other non-textual artefacts, justifying has-binaries. There is no build.rs and no proc_macro declaration in Cargo.toml, justifying has-build-exec and has-install-exec. Source code uses only core and generic_array, with no I/O, no process spawning, no JIT or interpreter, no concurrency primitives, and no cryptography of its own, justifying uses-crypto, uses-exec, uses-jit, uses-interpreter, impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-algorithm, and impl-concurrency. The padding helpers implement the canonical Merkle-Damgård length-encoding pattern but do not themselves hash anything; they merely format bytes and call back into a user-supplied compression function.

The crate contains four unsafe blocks (one in src/lib.rs:200 using unreachable_unchecked to elide bounds checks, one in src/lib.rs:348 and two in src/sealed.rs reinterpreting &[u8]/&mut [u8] as block slices), justifying uses-unsafe. Each carries a SAFETY comment (justifying unsafe-documented) and exists only to elide bounds checks or panic branches that the optimiser cannot otherwise remove, which is a legitimate minimal use (justifying unsafe-minimal). The soundness of the slice-reinterpretation casts depends on GenericArray<u8, N> having the same layout as [u8; N], which is a documented and widely-relied-upon invariant of generic-array 0.14; combined with the BlockSize > 0 runtime check in Default::default and try_new (added in 0.10.4 in response to the unsoundness fixed in RustCrypto/utils#844) and the type-level constraint that BlockSize < 256, the unsafe blocks are sound (justifying unsafe-safe). The crate also implements a simple block-oriented data structure (justifying impl-datastructure); its invariants on pos are upheld by all constructors and internal mutators, its operations are bounds-correct, and it has no advertised time/space bounds beyond O(1) buffer operations and O(n) data shuffling, all of which hold trivially (justifying datastructure-impl-safe, datastructure-impl-correct, datastructure-impl-bounds).

Tests: tests/mod.rs contains five integration tests covering eager and lazy digest_blocks, set_data, the BE/LE 64-bit and 128-bit padding helpers, and try_new bounds. There are no inline #[cfg(test)] unit tests in the source files, justifying has-unit-tests = false, but the file-level test suite covers the public API, justifying has-integration-tests. There are no fuzz tests, no property tests, and no Miri job in CI, justifying has-fuzz-tests = false, has-property-tests = false, and unsafe-tested = false (see FINDING-2). The fixed-input test coverage is sufficient to assert datastructure-impl-tested at this granularity, but additional randomized testing would meaningfully raise confidence.

Two low-severity findings were recorded. FINDING-1 notes that try_new is declared as Result-returning but panics on BlockSize == 0 instead of returning Err; this is a minor API inconsistency, not a real-world hazard, because zero block sizes are a type-level misconfiguration. FINDING-2 notes the absence of randomized testing for the unsafe code paths.

No malicious code, data exfiltration, time-bombs, or other suspicious behaviour was observed (justifying is-benign).

Conclusion

block-buffer 0.10.4 is a small, focused utility crate. Its unsafe code is minimal, documented, and sound given the established layout guarantees of generic-array and the runtime guard against zero block sizes added in this very release. The two findings are low-severity quality observations.

Findings(2)

FINDING-1 quality low

try_new panics on zero block size instead of returning Err

BlockBuffer::try_new is declared as pub fn try_new(buf: &[u8]) -> Result<Self, Error> but unconditionally panics with "Block size can not be equal to zero" when BlockSize::USIZE == 0, rather than returning Err(Error). Callers reading the signature may reasonably assume a try_* constructor does not panic. The behaviour is consistent with Default::default and new, but the inconsistency between signature and behaviour is a minor API quality concern. Zero-block-size buffers are a misconfiguration at the type level, so this does not affect real usage.

FINDING-2 quality low

No randomized testing for unsafe slice-reinterpretation code

The crate contains four unsafe blocks (in src/lib.rs and src/sealed.rs) that reinterpret &[u8]/&mut [u8] as &[Block<N>]/&mut [Block<N>] via raw-pointer casts, relying on the layout of generic_array::GenericArray<u8, N>. The unit tests in tests/mod.rs cover the documented behaviour with fixed inputs but do not exercise the unsafe paths under randomized input, Miri, or fuzzing. The CI workflow (.github/workflows/block-buffer.yml) runs cargo test only. Given that the crate is foundational to most RustCrypto hash and cipher implementations, property tests or a Miri job in CI would meaningfully raise confidence. Justifies unsafe-tested = false.

Annotations(4)

CHANGELOG.md

CHANGELOG.md, line 7-11

## 0.10.4 (2023-03-09)
### Fixed
- Unsoundness triggered by zero block size  ([#844])

[#844]: https://github.com/RustCrypto/utils/pull/844

0.10.4 fixes an unsoundness triggered by BlockSize == 0 (RustCrypto/utils#844). The fix is the runtime panic in Default::default and try_new; this prevents any BlockBuffer instance from existing with a zero block size, which the unsafe slice-reinterpretation code relies on.

src/lib.rs

src/lib.rs, line 11-15

use core::{fmt, marker::PhantomData, slice};
use generic_array::{
    typenum::{IsLess, Le, NonZero, U256},
    ArrayLength, GenericArray,
};

Imports limited to core (no_std crate) and generic_array. No std, no network, no filesystem, no environment, no process. Justifies uses-network, uses-filesystem, uses-environment, uses-exec, uses-jit, uses-interpreter, uses-concurrency, uses-crypto, impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-algorithm, impl-concurrency.

src/lib.rs, line 55-64

pub struct BlockBuffer<BlockSize, Kind>
where
    BlockSize: ArrayLength<u8> + IsLess<U256>,
    Le<BlockSize, U256>: NonZero,
    Kind: BufferKind,
{
    buffer: Block<BlockSize>,
    pos: u8,
    _pd: PhantomData<Kind>,
}

BlockBuffer<BlockSize, Kind> is a fixed-size byte buffer parameterised by block size and kind (Eager: 0..BlockSize, Lazy: 0..=BlockSize). The type-level constraint Le<BlockSize, U256>: NonZero bounds BlockSize < 256, which lets pos: u8 store all valid positions. Justifies impl-datastructure.

src/lib.rs, line 72-82

    fn default() -> Self {
        if BlockSize::USIZE == 0 {
            panic!("Block size can not be equal to zero");
        }
        Self {
            buffer: Default::default(),
            pos: 0,
            _pd: PhantomData,
        }
    }
}

Default panics when BlockSize::USIZE == 0. This runtime check guards the soundness of unsafe code that divides by or strides over BlockSize. Together with the matching check in try_new (called by new), these are the only constructors, so any existing BlockBuffer has BlockSize > 0. Supports unsafe-safe.

src/lib.rs, line 193-205

    /// Return current cursor position.
    #[inline(always)]
    pub fn get_pos(&self) -> usize {
        let pos = self.pos as usize;
        if !Kind::invariant(pos, BlockSize::USIZE) {
            debug_assert!(false);
            // SAFETY: `pos` never breaks the invariant
            unsafe {
                core::hint::unreachable_unchecked();
            }
        }
        pos
    }

Unsafe unreachable_unchecked to elide bounds checks. Sound because the invariant on pos is upheld by all constructors and set_pos_unchecked callers; the debug_assert!(false) catches violations in debug builds. Has a SAFETY comment. Justifies uses-unsafe and supports unsafe-documented and unsafe-minimal.

src/lib.rs, line 341-350

#[inline(always)]
fn to_blocks_mut<N: ArrayLength<u8>>(data: &mut [u8]) -> (&mut [Block<N>], &mut [u8]) {
    let nb = data.len() / N::USIZE;
    let (left, right) = data.split_at_mut(nb * N::USIZE);
    let p = left.as_mut_ptr() as *mut Block<N>;
    // SAFETY: we guarantee that `blocks` does not point outside of `data`, and `p` is valid for
    // mutation
    let blocks = unsafe { slice::from_raw_parts_mut(p, nb) };
    (blocks, right)
}

Unsafe from_raw_parts_mut reinterprets a &mut [u8] as &mut [Block<N>] where Block<N> = GenericArray<u8, N>. Sound because GenericArray<u8, N> has the same layout as [u8; N] (documented invariant of generic-array 0.14), nb * N::USIZE <= data.len() so the resulting slice stays in-bounds, and u8 has alignment 1 so no alignment concerns arise. Supports unsafe-safe.

src/lib.rs, line 138-176

    pub fn digest_blocks(
        &mut self,
        mut input: &[u8],
        mut compress: impl FnMut(&[Block<BlockSize>]),
    ) {
        let pos = self.get_pos();
        // using `self.remaining()` for some reason
        // prevents panic elimination
        let rem = self.size() - pos;
        let n = input.len();
        // Note that checking condition `pos + n < BlockSize` is
        // equivalent to checking `n < rem`, where `rem` is equal
        // to `BlockSize - pos`. Using the latter allows us to work
        // around compiler accounting for possible overflow of
        // `pos + n` which results in it inserting unreachable
        // panic branches. Using `unreachable_unchecked` in `get_pos`
        // we convince compiler that `BlockSize - pos` never underflows.
        if Kind::invariant(n, rem) {
            // double slicing allows to remove panic branches
            self.buffer[pos..][..n].copy_from_slice(input);
            self.set_pos_unchecked(pos + n);
            return;
        }
        if pos != 0 {
            let (left, right) = input.split_at(rem);
            input = right;
            self.buffer[pos..].copy_from_slice(left);
            compress(slice::from_ref(&self.buffer));
        }

        let (blocks, leftover) = Kind::split_blocks(input);
        if !blocks.is_empty() {
            compress(blocks);
        }

        let n = leftover.len();
        self.buffer[..n].copy_from_slice(leftover);
        self.set_pos_unchecked(n);
    }

digest_blocks: stages incoming data through the buffer, calling compress once per full block. The condition Kind::invariant(n, rem) decides whether the input fits in the current block; the comment explains that using rem rather than pos + n avoids panic branches the optimizer otherwise can't eliminate. pos + n cannot overflow because BlockSize < 256 and pos + n <= BlockSize is enforced on the branches that reach set_pos_unchecked. Supports datastructure-impl-bounds, datastructure-impl-correct.

src/lib.rs, line 290-338

    pub fn digest_pad(
        &mut self,
        delim: u8,
        suffix: &[u8],
        mut compress: impl FnMut(&Block<BlockSize>),
    ) {
        if suffix.len() > BlockSize::USIZE {
            panic!("suffix is too long");
        }
        let pos = self.get_pos();
        self.buffer[pos] = delim;
        for b in &mut self.buffer[pos + 1..] {
            *b = 0;
        }

        let n = self.size() - suffix.len();
        if self.size() - pos - 1 < suffix.len() {
            compress(&self.buffer);
            let mut block = Block::<BlockSize>::default();
            block[n..].copy_from_slice(suffix);
            compress(&block);
        } else {
            self.buffer[n..].copy_from_slice(suffix);
            compress(&self.buffer);
        }
        self.set_pos_unchecked(0)
    }

    /// Pad message with 0x80, zeros and 64-bit message length using
    /// big-endian byte order.
    #[inline]
    pub fn len64_padding_be(&mut self, data_len: u64, compress: impl FnMut(&Block<BlockSize>)) {
        self.digest_pad(0x80, &data_len.to_be_bytes(), compress);
    }

    /// Pad message with 0x80, zeros and 64-bit message length using
    /// little-endian byte order.
    #[inline]
    pub fn len64_padding_le(&mut self, data_len: u64, compress: impl FnMut(&Block<BlockSize>)) {
        self.digest_pad(0x80, &data_len.to_le_bytes(), compress);
    }

    /// Pad message with 0x80, zeros and 128-bit message length using
    /// big-endian byte order.
    #[inline]
    pub fn len128_padding_be(&mut self, data_len: u128, compress: impl FnMut(&Block<BlockSize>)) {
        self.digest_pad(0x80, &data_len.to_be_bytes(), compress);
    }
}

digest_pad implements Merkle-Damgård-style padding (delim byte 0x80, zeros, big/little-endian length suffix). When pos+1+suffix.len() does not fit in the current block, two blocks are emitted: the current buffer with delim and zeros, then a fresh zero-block with the suffix at its tail. len64_padding_be/le and len128_padding_be are thin wrappers. buffer[pos] is in-bounds because the Eager invariant requires pos < BlockSize; size - pos - 1 does not underflow for the same reason. The crate does not itself compute any hash.

src/sealed.rs

src/sealed.rs, line 20-67

    #[inline(always)]
    fn split_blocks<N: ArrayLength<u8>>(data: &[u8]) -> (&[Block<N>], &[u8]) {
        let nb = data.len() / N::USIZE;
        let blocks_len = nb * N::USIZE;
        let tail_len = data.len() - blocks_len;
        // SAFETY: we guarantee that created slices do not point
        // outside of `data`
        unsafe {
            let blocks_ptr = data.as_ptr() as *const Block<N>;
            let tail_ptr = data.as_ptr().add(blocks_len);
            (
                slice::from_raw_parts(blocks_ptr, nb),
                slice::from_raw_parts(tail_ptr, tail_len),
            )
        }
    }
}

impl Sealed for super::Lazy {
    #[inline(always)]
    fn invariant(pos: usize, block_size: usize) -> bool {
        pos <= block_size
    }

    #[inline(always)]
    fn split_blocks<N: ArrayLength<u8>>(data: &[u8]) -> (&[Block<N>], &[u8]) {
        if data.is_empty() {
            return (&[], &[]);
        }
        let (nb, tail_len) = if data.len() % N::USIZE == 0 {
            (data.len() / N::USIZE - 1, N::USIZE)
        } else {
            let nb = data.len() / N::USIZE;
            (nb, data.len() - nb * N::USIZE)
        };
        let blocks_len = nb * N::USIZE;
        // SAFETY: we guarantee that created slices do not point
        // outside of `data`
        unsafe {
            let blocks_ptr = data.as_ptr() as *const Block<N>;
            let tail_ptr = data.as_ptr().add(blocks_len);
            (
                slice::from_raw_parts(blocks_ptr, nb),
                slice::from_raw_parts(tail_ptr, tail_len),
            )
        }
    }
}

Two unsafe blocks reinterpreting &[u8] as &[Block<N>] plus tail. Same soundness argument as to_blocks_mut in lib.rs: layout compatibility of GenericArray<u8, N> with [u8; N], slice bounds upheld by nb * N::USIZE <= data.len(), and Block<u8> alignment is 1. split_blocks is unreachable with N::USIZE == 0 because BlockBuffer constructors reject zero block sizes (see lib.rs:72-82). Each unsafe block carries a SAFETY comment. Supports unsafe-safe, unsafe-documented, unsafe-minimal.