cargo : block-buffer @ 0.12.0
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

Audit of block-buffer 0.12.0, the fixed-size block-buffer data structure backing every RustCrypto hash and MAC. Heavy but well-bounded unsafe use centred on MaybeUninit<Array<u8, BS>> is sound; two low-severity quality findings (undocumented unsafe blocks FINDING-1, no miri/fuzz coverage FINDING-2). Safe to use.

Report

Subject

block-buffer 0.12.0 is a small no_std crate that provides fixed-size buffers for block-oriented data processing. It exposes two flavours: EagerBuffer<BS> (yields a full block to the caller as soon as one is filled, position stays strictly below BS) and LazyBuffer<BS> (keeps the last full block in the buffer, position can equal BS), plus a read-side ReadBuffer<BS> used by XOFs. The whole point of the crate is to avoid the cost of zero-initialising the buffer on every reset — it stores its byte buffer as MaybeUninit<Array<u8, BS>> and tracks how much of it is initialised through a kind-specific cursor. Every RustCrypto hash and MAC crate depends on this primitive.

Methodology

The three source files (src/lib.rs, src/sealed.rs, src/read.rs, ~710 lines) and the integration test (tests/mod.rs, 346 lines) were read in full and compared against the upstream RustCrypto/utils workspace at the commit recorded in .cargo_vcs_info.json; sources match exactly. The auto-generated Cargo.toml was diffed against Cargo.toml.orig. Each of the roughly twenty unsafe blocks was traced against the buffer-kind invariant it relies on (the eager invariant strictly bounds the cursor below BS-1 so the user's data never overlaps the cursor byte; the lazy invariant stores the cursor separately as u8). The Eager::get_pos/set_pos pair — which encodes the cursor in the last byte of the otherwise MaybeUninit buffer — was followed through default, try_new, deserialize, and digest_blocks to confirm that set_pos always precedes get_pos. The CI workflow vcs-root/.github/workflows/block-buffer.yml was inspected: it exercises cargo test and cargo test --all-features on stable and MSRV plus a cargo build matrix over thumbv7em-none-eabi and wasm32-unknown-unknown. No miri, sanitizer, or fuzz step is configured.

Results

The published source files match the upstream tree at the recorded commit byte-for-byte; differences against VCS are limited to cargo's Cargo.toml normalisation, an auto-generated Cargo.lock, and packaging artefacts.

The crate declares build = false and ships no procedural macros, no install hooks, and no binary artefacts (justifying has-binaries, has-build-exec, has-install-exec). It is #![no_std], performs no I/O of any kind (justifying uses-filesystem, uses-network, uses-environment, uses-exec, uses-jit, uses-interpreter, uses-concurrency), implements no cryptographic algorithm itself (justifying uses-crypto, impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-algorithm, impl-concurrency) — the buffer simply ferries bytes between the caller and a downstream block-level compress function.

The crate's primary contribution is an in-place block buffer data structure (justifying impl-datastructure). The implementation uses unsafe to optimise the buffer-default path (avoiding zero-initialisation), to advance the cursor without bounds checks, and to call compress on a MaybeUninit::assume_init_ref reference once enough bytes have been written. Each block was traced against the documented kind invariant and found sound (justifying uses-unsafe, unsafe-safe, datastructure-impl-safe). The unsafe is used only where strictly necessary for the optimisation goal of the crate (justifying unsafe-minimal).

Two low-severity quality findings were recorded:

  • FINDING-1: the crate carries a top-level #![allow(clippy::undocumented_unsafe_blocks)] // TODO(tarcieri): document all unsafe blocks lint allow. Most blocks DO carry // SAFETY: comments, but a few (e.g. src/lib.rs:347 in BlockBuffer::deserialize) rely on contextual reasoning that is not recorded inline (justifying unsafe-documented = false).
  • FINDING-2: CI runs only cargo test and cargo test --all-features; there is no miri, sanitizer, or fuzz step (justifying has-fuzz-tests = false, has-property-tests = false). The integration tests in tests/mod.rs exercise the public API (eager/lazy digest_blocks, pad_with_zeros, digest_pad, len64_padding_*, len128_padding_*, try_new, serialize/deserialize with rejection of invalid positions and non-zero "garbage" bytes), which validates output bytes byte-for-byte but cannot detect UB in the unsafe interior (justifying datastructure-impl-tested for happy-path correctness, unsafe-tested = false for the missing adversarial coverage). The crate also has no in-source #[test] blocks beyond the integration test (justifying has-unit-tests = false).

The data structure satisfies its documented invariants. BlockBuffer::deserialize validates both the cursor position against the kind invariant and that any bytes beyond the cursor are zero, so neither serialize nor deserialize can be used to construct an in-memory representation that breaks the invariant from safe code (justifying datastructure-impl-correct, datastructure-impl-bounds).

No malicious behaviour, obfuscation, or supply-chain anomaly was observed (justifying is-benign).

Conclusion

block-buffer 0.12.0 is a small, performance-oriented unsafe-using data-structure crate that backs every RustCrypto hash and MAC. The unsafe code is concentrated, well-bounded, and sound under the documented invariants. Both findings are quality issues: a sweep to attach the remaining // SAFETY: comments and a miri job in CI would close the audit gap. No memory-safety incident was demonstrated; safe to use.

Findings(2)

FINDING-1 quality low

Crate-level allow on `clippy::undocumented_unsafe_blocks` with outstanding TODO

src/lib.rs:41 carries a crate-level lint allow:

#![allow(clippy::undocumented_unsafe_blocks)] // TODO(tarcieri): document all unsafe blocks

The crate has roughly twenty unsafe blocks and the majority do carry // SAFETY: comments, but a few do not (e.g. src/lib.rs:347 unsafe { res.set_data_unchecked(data) } in BlockBuffer::deserialize, where the safety contract is satisfied by the preceding K::invariant check plus the split_at(pos) slicing, but no inline comment records that). The crate is the buffering layer for every RustCrypto hash and MAC; downstream refactor risk is amplified by the missing safety comments.

The fix is a sweep that attaches // SAFETY: to the few remaining undocumented blocks and removes the crate-level allow. Quality finding; no concrete soundness incident was found.

FINDING-2 quality low

No miri/sanitizer/fuzz coverage for the unsafe buffer internals

src/lib.rs, src/sealed.rs, and src/read.rs together contain roughly twenty unsafe blocks centred on MaybeUninit<Array<u8, BS>> and ptr::copy_nonoverlapping. The CI workflow block-buffer.yml runs only cargo test and cargo test --all-features on stable and MSRV; no miri, no sanitizer, no fuzzing, and no property-based testing is configured. A bug in the buffer-kind invariants would surface as undefined behaviour rather than a deterministic failure.

The integration tests in tests/mod.rs are happy-path coverage of the public API (digest_blocks, pad_with_zeros, digest_pad, len64_padding_*, serialize/deserialize); they validate output bytes but cannot catch UB in the unsafe internals. Given the heavy unsafe surface in a crate that backs every RustCrypto hash and MAC, the absence of miri coverage is a quality gap.

This is a quality finding; no concrete UB was identified.

Annotations(4)

src/lib.rs

src/lib.rs, line 36-46

#![no_std]
#![doc(
    html_logo_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg",
    html_favicon_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg"
)]
#![allow(clippy::undocumented_unsafe_blocks)] // TODO(tarcieri): document all unsafe blocks

pub use hybrid_array as array;

use array::{Array, ArraySize, typenum::Sum};
use core::{fmt, mem::MaybeUninit, ptr, slice};

Crate-level lint allow with TODO acknowledges that not every unsafe block carries a // SAFETY: comment; see FINDING-1.

src/lib.rs, line 93-126

/// Buffer for block processing of data.
pub struct BlockBuffer<BS: BlockSizes, K: BufferKind> {
    buffer: MaybeUninit<Array<u8, BS>>,
    pos: K::Pos,
}

impl<BS: BlockSizes, K: BufferKind> Default for BlockBuffer<BS, K> {
    #[inline]
    fn default() -> Self {
        let mut buffer = MaybeUninit::uninit();
        let mut pos = Default::default();
        K::set_pos(&mut buffer, &mut pos, 0);
        Self { buffer, pos }
    }
}

impl<BS: BlockSizes, K: BufferKind> Clone for BlockBuffer<BS, K> {
    #[inline]
    fn clone(&self) -> Self {
        // SAFETY: `BlockBuffer` does not implement `Drop` (i.e. it could be a `Copy` type),
        // so we can safely clone it using `ptr::read`.
        unsafe { ptr::read(self) }
    }
}

impl<BS: BlockSizes, K: BufferKind> fmt::Debug for BlockBuffer<BS, K> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        f.debug_struct(K::NAME)
            .field("pos", &self.get_pos())
            .field("block_size", &BS::USIZE)
            .field("data", &self.get_data())
            .finish()
    }
}

BlockBuffer<BS, K> holds a MaybeUninit<Array<u8, BS>> and a kind-specific cursor (() for Eager, where the cursor is stored in the last byte of the buffer; u8 for Lazy). The whole point of the MaybeUninit is to avoid the cost of zeroing the buffer in default() (justifying uses-unsafe and unsafe-minimal). Clone is implemented via ptr::read because BlockBuffer has no Drop except the conditional zeroize-on-drop.

src/lib.rs, line 159-208

    #[inline]
    pub fn digest_blocks(&mut self, mut input: &[u8], mut compress: impl FnMut(&[Array<u8, BS>])) {
        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 K::invariant(n, rem) {
            // SAFETY: we have checked that length of `input` is smaller than
            // number of remaining bytes in `buffer`, so we can safely write data
            // into them and update cursor position.
            unsafe {
                let buf_ptr = self.buffer.as_mut_ptr().cast::<u8>().add(pos);
                ptr::copy_nonoverlapping(input.as_ptr(), buf_ptr, input.len());
                self.set_pos_unchecked(pos + input.len());
            }
            return;
        }
        if pos != 0 {
            let (left, right) = input.split_at(rem);
            input = right;
            // SAFETY: length of `left` is equal to number of remaining bytes in `buffer`,
            // so we can copy data into it and process `buffer` as fully initialized block.
            let block = unsafe {
                let buf_ptr = self.buffer.as_mut_ptr().cast::<u8>().add(pos);
                ptr::copy_nonoverlapping(left.as_ptr(), buf_ptr, left.len());
                self.buffer.assume_init_ref()
            };
            compress(slice::from_ref(block));
        }

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

        // SAFETY: `leftover` is always smaller than block size,
        // so it satisfies the method's safety requirements for all buffer kinds
        unsafe {
            self.set_data_unchecked(leftover);
        }
    }

digest_blocks is the core write path. The fast branch (K::invariant(n, rem)) copies input directly behind the existing data and advances the cursor without touching the cursor byte (safe because the eager-kind invariant strictly bounds the position below BS-1). The slow branch fills the remaining bytes of the partial block, calls compress on a fully-initialised block via assume_init_ref, then splits and compresses any whole subsequent blocks, then stores the leftover via set_data_unchecked. Each unsafe block carries a SAFETY comment explaining the invariant.

src/read.rs

ReadBuffer<BS> is the analogous read-side buffer used by XOFs. Cursor is stored in buffer[0] (a fully-initialised Array<u8, BS>, not a MaybeUninit); the invariant is 0 < pos <= BS. The unsafe blocks are limited to set_pos_unchecked cursor advances and unreachable_unchecked in get_pos after a debug_assert that validates the invariant.

src/sealed.rs

Sealed traits encoding the eager-vs-lazy buffer kinds. Eager is the unusual case: cursor is stored in the LAST byte of the (otherwise MaybeUninit) buffer, which is safe because (a) the eager invariant strictly bounds the data range below BS-1 so user data never overlaps the cursor byte, (b) set_pos always writes that byte before get_pos is allowed to read it, and (c) the only public construction paths (Default, try_new, deserialize, digest_blocks) all call set_pos before returning. BlockSizes is restricted to U1..U255 so the cursor fits in a u8.

tests/mod.rs

Integration test covering eager and lazy digest_blocks, eager digest_pad/len64_padding_*/len128_padding_* byte-for-byte against hex-literal expected outputs, try_new length validation, and serialize/deserialize round-trip including rejection of invalid positions and non-zero "garbage" bytes (justifying has-integration-tests, datastructure-impl-tested). Tests run only under cargo test; no miri/fuzz coverage of the unsafe interior — see FINDING-2.