Skip to content
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[package]
name = "bit-string"
version = "0.4.3"
version = "0.4.4"
edition = "2024"
description = "A compact owned bit string type with editing, matching, and bitwise operations."
readme = "README.md"
repository = "https://github.com/jcfangc/bit-string"
license = "MIT OR Apache-2.0"
keywords = ["bitstring", "bits", "bitset", "sequence"]
categories = ["data-structures", "no-std"]
exclude = ["/benches", "/src/**/tests_for_*", ".github/"]
exclude = ["/benches", "/src/**/tests_for_*", "/tests", ".github/"]


[dependencies]
Expand Down
19 changes: 12 additions & 7 deletions src/bit_str/impls_for_matching/impls_for_find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ impl<'bs> BitStr<'bs> {
// SIMD on the aligned remainder.
if so != 0 {
let first_bits = (WORD_BITS - so).min(self.bit_len);
let max = first_bits.saturating_sub(needle_len);
let max = first_bits.min(self.bit_len.saturating_sub(needle_len));
for p in 0..=max {
if self.bits_equal_at(p, needle) {
return true;
Expand Down Expand Up @@ -78,7 +78,7 @@ impl<'bs> BitStr<'bs> {

// Unaligned: scan the first partial word, then SIMD for the rest.
let first_bits = (WORD_BITS - so).min(self.bit_len);
let max = first_bits.saturating_sub(needle_len);
let max = first_bits.min(self.bit_len.saturating_sub(needle_len));
for p in 0..=max {
if self.bits_equal_at(p, needle) {
return Some(p);
Expand Down Expand Up @@ -144,13 +144,18 @@ impl<'bs> BitStr<'bs> {
if remaining > 0 {
let aligned = &words[sw + 1..];

if aligned.len() >= SMALL_WORDS
&& aligned
// Quick rejection via SIMD candidate scan — only when the
// aligned portion is large enough to amortize setup cost.
// When it's too small we skip the gate and scan directly
// (find_last_word has its own scalar fallback).
let maybe_candidate = aligned.len() < SMALL_WORDS
|| aligned
.find_any_candidate(remaining, needle_words, needle_len, &mut |pos| {
self.bits_equal_at(pos + first_bits, needle)
})
.is_some()
{
.is_some();

if maybe_candidate {
if let Some(pos) =
aligned.find_last_word(remaining, needle_words, needle_len, &mut |pos| {
self.bits_equal_at(pos + first_bits, needle)
Expand All @@ -162,7 +167,7 @@ impl<'bs> BitStr<'bs> {
}

// Check the first partial word.
let max = first_bits.saturating_sub(needle_len);
let max = first_bits.min(self.bit_len.saturating_sub(needle_len));
for p in (0..=max).rev() {
if self.bits_equal_at(p, needle) {
return Some(p);
Expand Down
2 changes: 2 additions & 0 deletions src/bit_string/impls_for_editing/impls_for_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ impl BitString {
*w |= value >> (WORD_BITS - shift);
}
}

self.words.mask_unused_bits(self.bit_len);
}
}

Expand Down
7 changes: 3 additions & 4 deletions src/traits/bits_arith/funcs_for_count_ones.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::SMALL_WORDS;
use crate::WORD_BITS;
use crate::low_mask;

#[inline]
pub(super) fn count_ones(bits: &[u64], bit_len: usize) -> usize {
Expand All @@ -8,23 +9,21 @@ pub(super) fn count_ones(bits: &[u64], bit_len: usize) -> usize {

// Fast path: for inputs too short to amortize SIMD setup, loop over
// the words directly with scalar popcnt, skipping dispatch entirely.
// No mask needed on the last word: mask_unused() is called after every
// mutation, so bits beyond `bit_len` are always zero.
if full_words < SMALL_WORDS {
let mut count = 0usize;
for i in 0..full_words {
count += bits[i].count_ones() as usize;
}
if rem != 0 {
count += bits[full_words].count_ones() as usize;
count += (bits[full_words] & low_mask(rem)).count_ones() as usize;
}
return count;
}

let mut count = count_full_words(&bits[..full_words]);

if rem != 0 {
count += bits[full_words].count_ones() as usize;
count += (bits[full_words] & low_mask(rem)).count_ones() as usize;
}

count
Expand Down
89 changes: 89 additions & 0 deletions tests/adversarial.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Adversarial tests — attack every public API with edge cases, fuzz-style
//! inputs, and adversarial combinations that might trigger panics, incorrect
//! results, or invariant violations.

use bit_string::BitString;
use core::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;

// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------

pub(crate) fn hash<T: Hash>(t: &T) -> u64 {
let mut h = DefaultHasher::new();
t.hash(&mut h);
h.finish()
}

pub(crate) fn bs(s: &str) -> BitString {
s.parse().unwrap()
}

/// Check internal invariants: bit_len matches actual bit count; unused bits
/// in the last word are zeroed.
pub(crate) fn view_has_same_invariants(bits: &BitString) -> bool {
let word_count = (bits.bit_len() + 63) / 64;
if bits.words().len() != word_count {
return false;
}
if bits.bit_len() == 0 {
return bits.words().is_empty();
}
let rem = bits.bit_len() % 64;
if rem != 0 {
let last = bits.words().last().unwrap();
let mask = (1u64 << rem).wrapping_sub(1);
if last & !mask != 0 {
return false;
}
}
true
}

/// Concatenate string slices into an owned String — used by tests that
/// build long bit patterns from repeated segments.
pub(crate) fn cat(parts: &[&str]) -> String {
parts.iter().copied().collect()
}

// ---------------------------------------------------------------------------
// Module declarations
// ---------------------------------------------------------------------------

#[path = "adversarial/tests_for_access.rs"]
mod tests_for_access;
#[path = "adversarial/tests_for_bitstr.rs"]
mod tests_for_bitstr;
#[path = "adversarial/tests_for_bitwise.rs"]
mod tests_for_bitwise;
#[path = "adversarial/tests_for_bugs.rs"]
mod tests_for_bugs;
#[path = "adversarial/tests_for_concat.rs"]
mod tests_for_concat;
#[path = "adversarial/tests_for_construction.rs"]
mod tests_for_construction;
#[path = "adversarial/tests_for_count.rs"]
mod tests_for_count;
#[path = "adversarial/tests_for_editing.rs"]
mod tests_for_editing;
#[path = "adversarial/tests_for_fmt.rs"]
mod tests_for_fmt;
#[path = "adversarial/tests_for_iter.rs"]
mod tests_for_iter;
#[path = "adversarial/tests_for_matching.rs"]
mod tests_for_matching;
#[path = "adversarial/tests_for_ord_hash.rs"]
mod tests_for_ord_hash;
#[path = "adversarial/tests_for_predicates.rs"]
mod tests_for_predicates;
#[path = "adversarial/tests_for_replace.rs"]
mod tests_for_replace;
#[path = "adversarial/tests_for_retain.rs"]
mod tests_for_retain;
#[path = "adversarial/tests_for_shift.rs"]
mod tests_for_shift;
#[path = "adversarial/tests_for_slice_drain.rs"]
mod tests_for_slice_drain;
#[path = "adversarial/tests_for_stress.rs"]
mod tests_for_stress;
84 changes: 84 additions & 0 deletions tests/adversarial/tests_for_access.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use super::*;
use int_interval::UsizeCO;

#[test]
fn attack_get_out_of_bounds() {
let bits = bs("10101"); // len 5
assert_eq!(bits.get(0), Some(true));
assert_eq!(bits.get(4), Some(true));
assert_eq!(bits.get(5), None);
assert_eq!(bits.get(usize::MAX), None);
assert_eq!(bits.get(usize::MAX / 2), None);
}

#[test]
fn attack_first_last_empty() {
let bits = BitString::new();
assert_eq!(bits.first(), None);
assert_eq!(bits.last(), None);
}

#[test]
fn attack_get_chunk_boundaries() {
// Empty
let empty = BitString::new();
assert_eq!(empty.get_chunk(0), 0);
assert_eq!(empty.get_chunk(usize::MAX), 0);

// Single bit
let one = bs("1");
assert_eq!(one.get_chunk(0), 1);
assert_eq!(one.get_chunk(1), 0);

// Cross-word boundary: 65 bits, start at 32
let mut bits = BitString::zeros(65);
bits.set_chunk(32, u64::MAX, 33);
let chunk = bits.get_chunk(32);
// Should have 33 valid bits
assert_eq!(chunk & ((1u64 << 33) - 1), (1u64 << 33) - 1);

// Start beyond len: should return 0
let bits = bs("1111");
assert_eq!(bits.get_chunk(4), 0);
assert_eq!(bits.get_chunk(100), 0);
}

#[test]
fn attack_read_write_consistency() {
// Every read should match what was written
let mut bits = BitString::zeros(256);
for i in 0..256 {
let val = i % 7 == 0 || i % 3 == 0;
bits.set(i, val);
assert_eq!(bits.get(i), Some(val));
}
assert!(view_has_same_invariants(&bits));
}

// ===========================================================================
// E. get_chunk on unaligned BitStr views
// ===========================================================================

#[test]
fn attack_get_chunk_unaligned_view() {
let a = bs(&cat(&[
"0".repeat(20).as_str(),
"1".repeat(20).as_str(),
"0".repeat(20).as_str(),
]));
let view = a
.as_bit_str()
.slice(UsizeCO::checked_from_start_len(17, 40).unwrap());
assert_eq!(view.get_chunk(0) & 0b111, 0b000);
assert_eq!(view.get_chunk(3) & ((1u64 << 17) - 1), (1u64 << 17) - 1);
assert_eq!(view.get_chunk(40), 0);
}

#[test]
fn attack_get_chunk_partial_last_word() {
let a = bs(&cat(&["0".repeat(30).as_str(), "10101010"]));
let view = a
.as_bit_str()
.slice(UsizeCO::checked_from_start_len(31, 4).unwrap());
assert_eq!(view.get_chunk(0) & 0xF, 0b1010);
}
67 changes: 67 additions & 0 deletions tests/adversarial/tests_for_bitstr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use super::*;
use int_interval::UsizeCO;

#[test]
fn attack_clone_invariants() {
for len in [0, 1, 63, 64, 65, 127, 128, 129] {
let bits = BitString::ones(len);
let clone = bits.clone();
assert_eq!(bits, clone);
assert!(view_has_same_invariants(&clone));

// Clone should be independent
if len > 0 {
let mut clone2 = bits.clone();
clone2.set(0, false).unwrap();
assert_ne!(bits, clone2);
}
}
}

#[test]
fn attack_bitstr_to_bit_string_word_boundaries() {
// to_bit_string on sub-views at various offsets
let bits = BitString::ones(200);
let view = bits.as_bit_str();

for start in [0, 1, 32, 63, 64, 65, 100, 127, 128, 129, 200] {
for len in [1, 63, 64, 65] {
if start + len > 200 {
continue;
}
let sub = view.slice(UsizeCO::checked_from_start_len(start, len).unwrap());
let owned = sub.to_bit_string();
assert_eq!(owned.bit_len(), len);
assert!(view_has_same_invariants(&owned));
assert_eq!(owned.count_ones(), len);
}
}
}

#[test]
fn attack_bitstr_source_after_mutation() {
// BitStr borrows source; verify it's correct even after the source is
// consumed (but not mutated, since BitStr borrows it).
let bits = bs("11001100");
let view = bits.as_bit_str();

// Create sub-views
let sub = view.slice(UsizeCO::checked_from_start_len(2, 4).unwrap());
assert_eq!(sub.to_bit_string().to_string(), "0011");

// All views should be consistent
assert_eq!(view.bit_len(), 8);
}

#[test]
fn attack_bitstr_slice_chain() {
let bits = bs("1100110011");
let v = bits.as_bit_str();
let v1 = v.slice_from(2); // "00110011"
let v2 = v1.slice_until(6); // "001100"
let v3 = v2.slice(UsizeCO::checked_from_start_len(2, 2).unwrap()); // "11"

assert_eq!(v3.to_bit_string().to_string(), "11");
assert_eq!(v3.start(), 4); // 0 + 2 + 2 = 4
assert_eq!(v3.bit_len(), 2);
}
Loading
Loading