cargo / bit-vec / audit
cargo : bit-vec @ 0.8.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

no_std bit-vector with a four-fn unsafe surface, each documented and Miri-tested. One medium correctness finding (derived Deserialize impls skip the last-block invariant, enabling panics or wrong aggregates from hostile input) and two low quality findings. Safe to deploy for in-process use; gate untrusted serialized input.

Report

Subject

bit-vec is a no_std-capable Rust crate that provides BitVec, a compact growable vector of bits backed by a Vec of unsigned integer blocks (default u32, generic over the BitBlock trait for u8/u16/u32/u64/usize). The API covers construction, indexed get/set, mutating bitwise operations (and, or, xor, nand, nor, xnor, difference, negate), aggregate predicates (all, any, none, count_ones, count_zeros), iteration (forward, mutable via smart pointers, double-ended), growth (push, pop, grow, truncate, insert, append, split_off), and byte-level conversion (from_bytes, to_bytes). Optional serialization support is wired through serde, borsh, miniserde, and nanoserde derives behind cargo features.

Methodology

The crate consists of a single source file (src/lib.rs, 3186 lines) which was read end-to-end. The published crate contents were diffed against the upstream git tree at the commit recorded in .cargo_vcs_info.json; only the expected Cargo.toml/Cargo.toml.orig/.cargo_vcs_info.json differences are present. Package metadata (Cargo.toml, Cargo.toml.orig, README.md, RELEASES.md) and dev artefacts (.github/workflows/rust.yml, .vscode/settings.json, crusader.sh, .gitignore, .cargo-rdme.toml) were inspected. The 32 implementation-level unit tests in the tests module (lib.rs:1996-3185) were surveyed; CI runs cargo test, cargo miri test (with -Zmiri-strict-provenance), cargo fmt --check, cargo clippy, and cargo doc -Dwarnings. The insert carry-propagation logic was hand-traced at empty, mid-block, end-of-block, and end-of-vector positions; from_bytes/to_bytes round-tripping was verified by tracing the bit-ordering convention. Optional features (serde, borsh, miniserde, nanoserde, std) were treated as in-scope since none are marked unstable.

Results

The crate ships no binaries, no build.rs, no proc-macros, and no install hooks, justifying has-binaries, has-build-exec, and has-install-exec. It performs no network, filesystem, environment, exec, JIT, or interpreter operations and contains no cryptography, justifying uses-network, uses-filesystem, uses-environment, uses-exec, uses-jit, uses-interpreter, uses-crypto, impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, and impl-algorithm (the crate provides a data structure rather than a standalone algorithm). No threading or synchronization primitives appear; Send/Sync are derived solely through Vec<B> and usize, justifying uses-concurrency and impl-concurrency. The package implements a single data structure (BitVec), justifying impl-datastructure; its operations are linear or constant time without adversarial pathologies, justifying datastructure-impl-bounds. The dependency graph is small and uses well-known crates (serde, borsh, miniserde, nanoserde); dev-dependencies are rand, rand_xorshift, and serde_json. Nothing in the crate exhibits malicious behaviour, justifying is-benign.

Four unsafe fn declarations (storage_mut, get_unchecked, get_unchecked_mut, set_len) make up the entire unsafe surface; there are no unsafe { ... } blocks, no extern "C", and no raw pointer arithmetic. Each carries a # Safety doc block, the bodies are sound under the stated preconditions, and CI exercises them under Miri with strict provenance — justifying uses-unsafe, unsafe-safe, unsafe-documented, unsafe-minimal, and unsafe-tested.

The unit-test suite (32 tests) covers construction, equality, ordering, iteration, push/pop, truncation, growth, append/split_off, all bitwise ops, count_ones/count_zeros across 0..1000-bit sizes, insert at zero/end/block boundaries, and the serde/borsh/miniserde/nanoserde round-trips. These 32 in-module tests justify has-unit-tests; the crate ships no tests/ directory, justifying has-integration-tests. No property-based or fuzz testing is present (justifying has-property-tests and has-fuzz-tests), but the testing is sufficient for a data-structure crate of this complexity, justifying datastructure-impl-tested. datastructure-impl-safe holds: panics occur only on documented capacity overflow or bounds-violation paths, and Miri coverage rules out UB in the unsafe surface.

One medium-severity correctness finding (FINDING-1) was identified: the derived Deserialize impls reconstruct storage and nbits directly and do not validate the two internal invariants the rest of the file relies on (no excess storage blocks, unused bits in the last word zeroed). Hostile serialized input can produce a BitVec whose subsequent operations panic (DoS via set) or silently return incorrect results (all, count_ones, none, eq). Because a safe public API path can produce invariant-violating BitVecs, datastructure-impl-correct is asserted false. Two low-severity quality findings (FINDING-2, FINDING-3) cover an inconsistency in the insert/append length arithmetic and the non-standard clear() semantics that retains the previous length.

Conclusion

bit-vec is a focused, well-bounded data-structure crate with a small unsafe surface that is properly documented and exercised under Miri. The implementation is correct for in-process use. The single non-trivial concern is that the serialization derives do not validate the structural invariants on deserialization; users who deserialize BitVec from untrusted input should layer their own validation (or call truncate(len()) to force fix_last_block) until the derives are replaced with custom impls. The other findings are minor stylistic inconsistencies.

Findings(3)

FINDING-1 correctness medium

Deserialization does not validate the last-block invariant

The file header (lib.rs lines 17-24) declares two structural invariants: the underlying storage must have no excess blocks (point 2), and unused bits in the last word must be zero (point 3). Numerous methods (all, count_ones, count_zeros, none, any, eq, set, hash) rely on these invariants for correctness.

When the serde, borsh, miniserde, or nanoserde features are enabled, the struct uses an auto-derived Deserialize impl that reconstructs storage and nbits directly from the input without validating either invariant. A peer that supplies a crafted serialized representation can produce a BitVec for which:

  • set(i, x) panics when i / B::bits() exceeds storage.len() (DoS).
  • all() returns false even when every used bit is 1 (unused high bits flip the mask comparison).
  • count_ones() / count_zeros() return inflated counts.
  • none() / any() return incorrect results.
  • PartialEq returns false for two BitVecs whose used bits agree.

The runtime check ensure_invariant (lib.rs:531) only fires when the crate itself is compiled with cfg(test); consumer code never triggers it. Consumers deserializing BitVec from untrusted sources (network, files, IPC) are exposed to silent correctness bugs and panics.

FINDING-2 quality low

insert lacks ensure_invariant call and unchecked nbits arithmetic

The insert method (lib.rs:1647) deviates from the conventions used by neighbouring methods in two ways:

  1. It does not call self.ensure_invariant() at entry, while 19 other methods (get, set, or, and, xor, grow, pop, truncate, split_off, append, etc.) do. The debug-only check would help catch invariant violations introduced before insert is called.
  2. It increments self.nbits with self.nbits += 1 (lib.rs:1662). The analogous push method (lib.rs:1578) uses checked_add(1).expect("Capacity overflow"). The unchecked path can wrap silently in release builds for pathological nbits == usize::MAX inputs. append (lib.rs:1171) has the same pattern (self.nbits += other.len();).

In practice the overflow is unreachable on 64-bit targets, and the missing ensure_invariant only affects debug-build diagnostics. The cost of fixing is minimal and improves consistency.

FINDING-3 quality low

clear() does not truncate length, surprising vs std::Vec::clear

BitVec::clear (lib.rs:1606) sets every storage word to zero but leaves nbits unchanged. After clear(), the vector still reports its previous length, with every bit being false.

This contradicts the standard Vec::clear semantics that Rust users expect (length becomes 0). The doc comment is brief (Clears all bits in this vector.) and does not call out the divergence. Users porting code from Vec may write bv.clear(); bv.push(...) expecting an empty vector and instead append after the existing (now-zero) bits.

A more descriptive doc note or a rename (fill_zero/reset) would prevent surprise. The existing test test_small_clear confirms the behaviour but the doc does not.

Annotations(2)

.github/workflows/rust.yml

CI runs cargo test (stable), cargo miri test with MIRIFLAGS=-Zmiri-strict-provenance on nightly, cargo fmt, cargo clippy --workspace --tests --examples, and cargo doc -Dwarnings. Miri coverage justifies unsafe-tested.

src/lib.rs

src/lib.rs, line 17-26

// (1) Be careful, most things can overflow here because the amount of bits in
//     memory can overflow `usize`.
// (2) Make sure that the underlying vector has no excess length:
//     E. g. `nbits == 16`, `storage.len() == 2` would be excess length,
//     because the last word isn't used at all. This is important because some
//     methods rely on it (for *CORRECTNESS*).
// (3) Make sure that the unused bits in the last word are zeroed out, again
//     other methods rely on it for *CORRECTNESS*.
// (4) `BitSet` is tightly coupled with `BitVec`, so any changes you make in
// `BitVec` will need to be reflected in `BitSet`.

File header declares the structural invariants the rest of the file depends on: (1) overflow vigilance, (2) no excess length, (3) unused bits in the last word zeroed. Supports is-benign by establishing the implementation contract; the violation surface for these invariants is the basis of FINDING-1. No I/O, environment, network, crypto, jit, interpreter, or concurrency imports anywhere in src/, justifying uses-network, uses-filesystem, uses-environment, uses-exec, uses-concurrency, uses-crypto, uses-jit, uses-interpreter, impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-algorithm, impl-concurrency.

src/lib.rs, line 462-470

    /// Exposes the raw block storage of this `BitVec`.
    ///
    /// # Safety
    ///
    /// Can probably cause unsafety. Only really intended for `BitSet`.
    #[inline]
    pub unsafe fn storage_mut(&mut self) -> &mut Vec<B> {
        &mut self.storage
    }

pub unsafe fn storage_mut exposes raw access to the storage Vec<B> for BitSet (a sibling crate). Safety doc is informal ("Can probably cause unsafety") but the method body is sound — invariant maintenance is the caller's responsibility. Contributes to uses-unsafe, unsafe-documented, unsafe-safe, unsafe-minimal.

src/lib.rs, line 565-592

    /// Retrieves the value at index `i`, without doing bounds checking.
    ///
    /// For a safe alternative, see `get`.
    ///
    /// # Safety
    ///
    /// Calling this method with an out-of-bounds index is undefined behavior
    /// even if the resulting reference is not used.
    ///
    /// # Examples
    ///
    /// ```
    /// use bit_vec::BitVec;
    ///
    /// let bv = BitVec::from_bytes(&[0b01100000]);
    /// unsafe {
    ///     assert_eq!(bv.get_unchecked(0), false);
    ///     assert_eq!(bv.get_unchecked(1), true);
    /// }
    /// ```
    #[inline]
    pub unsafe fn get_unchecked(&self, i: usize) -> bool {
        self.ensure_invariant();
        let w = i / B::bits();
        let b = i % B::bits();
        let block = *self.storage.get_unchecked(w);
        block & (B::one() << b) != B::zero()
    }

pub unsafe fn get_unchecked skips the bounds check that get performs. Safety section explicitly states out-of-bounds calls are UB. Body delegates to Vec::get_unchecked, which is the standard unchecked path. Contributes to uses-unsafe.

src/lib.rs, line 618-647

    /// Retrieves a smart pointer to the value at index `i`, without doing bounds checking.
    ///
    /// # Safety
    ///
    /// Calling this method with out-of-bounds `index` may cause undefined behavior even when
    /// the result is not used.
    ///
    /// # Examples
    ///
    /// ```
    /// use bit_vec::BitVec;
    ///
    /// let mut bv = BitVec::from_bytes(&[0b01100000]);
    /// unsafe {
    ///     *bv.get_unchecked_mut(0) = true;
    ///     *bv.get_unchecked_mut(1) = false;
    /// }
    /// assert_eq!(bv, BitVec::from_bytes(&[0b10100000]));
    /// ```
    #[inline]
    pub unsafe fn get_unchecked_mut(&mut self, index: usize) -> MutBorrowedBit<B> {
        let value = self.get_unchecked(index);
        MutBorrowedBit {
            #[cfg(debug_assertions)]
            old_value: value,
            new_value: value,
            vec: Rc::new(RefCell::new(self)),
            index,
        }
    }

pub unsafe fn get_unchecked_mut mirrors get_unchecked for mutable access via the MutBorrowedBit smart pointer. Same UB precondition documented.

src/lib.rs, line 1588-1596

    /// Sets the number of bits that this `BitVec` considers initialized.
    ///
    /// # Safety
    ///
    /// Almost certainly can cause bad stuff. Only really intended for `BitSet`.
    #[inline]
    pub unsafe fn set_len(&mut self, len: usize) {
        self.nbits = len;
    }

pub unsafe fn set_len resets nbits without resizing storage. Documented as "only really intended for BitSet". A larger nbits than storage supports causes subsequent set to panic (already covered by FINDING-1's panic-on-malformed-deserialize discussion); this is the unsafe-API counterpart.

src/lib.rs, line 1647-1676

    pub fn insert(&mut self, at: usize, bit: bool) {
        assert!(
            at <= self.nbits,
            "insertion index (is {at}) should be <= nbits (is {nbits})",
            nbits = self.nbits
        );

        let last_block_bits = self.nbits % B::bits();
        let block_at = at / B::bits(); // needed block
        let bit_at = at % B::bits(); // index within the block

        if last_block_bits == 0 {
            self.storage.push(B::zero());
        }

        self.nbits += 1;

        let mut carry = self.storage[block_at] >> (B::bits() - 1);
        let lsbits_mask = (B::one() << bit_at) - B::one();
        let set_bit = if bit { B::one() } else { B::zero() } << bit_at;
        self.storage[block_at] = (self.storage[block_at] & lsbits_mask)
            | ((self.storage[block_at] & !lsbits_mask) << 1)
            | set_bit;

        for block_ref in &mut self.storage[block_at + 1..] {
            let curr_carry = *block_ref >> (B::bits() - 1);
            *block_ref = *block_ref << 1 | carry;
            carry = curr_carry;
        }
    }

Insert path identified in FINDING-2: no ensure_invariant() and unchecked nbits += 1. Logic itself is correct (carry-propagation across blocks, push-new-block when last block is full) — verified by tracing edge cases at 0, mid-block, end-of-block, and end-of-vector positions. Supports impl-datastructure, datastructure-impl-safe, datastructure-impl-bounds.

src/lib.rs, line 236-254

#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
    feature = "borsh",
    derive(borsh::BorshDeserialize, borsh::BorshSerialize)
)]
#[cfg_attr(
    feature = "miniserde",
    derive(miniserde::Deserialize, miniserde::Serialize)
)]
#[cfg_attr(
    feature = "nanoserde",
    derive(DeBin, DeJson, DeRon, SerBin, SerJson, SerRon)
)]
pub struct BitVec<B = u32> {
    /// Internal representation of the bit vector
    storage: Vec<B>,
    /// The number of valid bits in the internal representation
    nbits: usize,
}

Public struct BitVec derives Serialize/Deserialize for all four serialization frameworks (serde, borsh, miniserde, nanoserde) directly on the raw fields. No custom impl validates the structural invariants on deserialization — the root cause of FINDING-1.

src/lib.rs, line 1604-1611

    /// Clears all bits in this vector.
    #[inline]
    pub fn clear(&mut self) {
        self.ensure_invariant();
        for w in &mut self.storage {
            *w = B::zero();
        }
    }

Source of FINDING-3: clear only zeroes storage, never nbits. The behaviour is documented in tests but the surface documentation does not flag the divergence from std::Vec::clear.

src/lib.rs, line 364-392

    pub fn from_bytes(bytes: &[u8]) -> Self {
        let len = bytes
            .len()
            .checked_mul(u8::bits())
            .expect("capacity overflow");
        let mut bit_vec = BitVec::with_capacity(len);
        let complete_words = bytes.len() / B::bytes();
        let extra_bytes = bytes.len() % B::bytes();

        bit_vec.nbits = len;

        for i in 0..complete_words {
            let mut accumulator = B::zero();
            for idx in 0..B::bytes() {
                accumulator |= B::from_byte(reverse_bits(bytes[i * B::bytes() + idx])) << (idx * 8)
            }
            bit_vec.storage.push(accumulator);
        }

        if extra_bytes > 0 {
            let mut last_word = B::zero();
            for (i, &byte) in bytes[complete_words * B::bytes()..].iter().enumerate() {
                last_word |= B::from_byte(reverse_bits(byte)) << (i * 8);
            }
            bit_vec.storage.push(last_word);
        }

        bit_vec
    }

from_bytes byte→bit mapping: each byte is bit-reversed and placed so that the byte's MSB lands at the lowest BitVec index covered by that byte. Verified consistent with to_bytes (lines 1315-1340) and with the doctest at line 358-362. Supports datastructure-impl-tested, has-unit-tests.