From b91fbb2ed91433ac11e3cc061fc475f20d4d47f3 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 31 Mar 2026 22:12:40 -0600 Subject: [PATCH] test: mutation testing, block boundary tests, exact-fit decode tests (0.3.2) Added cargo-mutants with 88 mutants tested, 59 caught, 0 missed. Added targeted tests for 254-byte block boundary encoding (interop-verified) and exact-fit/too-small decode buffer sizing for the batch zero path. 111 tests total (was 106). Co-Authored-By: Claude Opus 4.6 (1M context) --- .cargo/mutants.toml | 24 +++++------------ CHANGELOG.md | 10 +++++++ Cargo.toml | 2 +- README.md | 4 +-- src/lib.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 20 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 5eb106c..df3ba13 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -5,29 +5,19 @@ test_tool = "nextest" # are sufficient to catch mutants. Full proptests run separately via just test-unit. additional_cargo_test_args = ["-E", "not test(proptests)"] -# Excluded mutants (false positives): +# Excluded mutants (false positives only): # -# 1. legacy-msrv const_copy (lines 172-178): dead code under default features, -# only compiled with --features legacy-msrv. Tested separately in just test-unit. +# - legacy-msrv const_copy: dead code under default features, only compiled +# with --features legacy-msrv. Tested separately. # -# 2. usize comparisons `> 0` mutated to `>= 0` or `< 0`: no-ops because usize -# is always >= 0. These are tautological mutations, not real test gaps. +# - usize `> 0` → `>= 0` or `< 0`: tautological on usize (always >= 0). # -# 3. `< 254` → `<= 254` in max_run: changes the block boundary from 254 to 255, -# which is a 1-instruction difference caught by the COBS spec (code byte 0xFF -# means exactly 254). Existing tests cover 254/255/256 boundaries but the -# mutant still passes because both values produce valid (different) encodings -# that round-trip correctly. -# -# 4. Decode batch bounds check `> dest.len()` → `>= dest.len()`: overly strict -# (rejects exact-fit), not unsound. Caught by proptest but not by unit tests -# in the fast subset. +# - `< 254` → `<= 254` in max_run: both branches yield 254 when remaining +# is exactly 254, so the mutation is a semantic no-op. exclude_re = [ - "replace const_copy with \\(\\)", "in const_copy", + "replace const_copy with \\(\\)", "replace > with < in encode", "replace > with >= in encode", "replace < with <= in encode", - "replace > with == in decode", - "replace > with >= in decode", ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 726686f..8ef24eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.3.2 — 2026-03-31 + +### Testing + +- **Mutation testing passes clean** — 88 mutants tested, 59 caught, 0 missed. + Added targeted tests for 254-byte block boundary encoding and exact-fit + decode buffer sizing that close gaps found by `cargo-mutants`. +- **111 tests** (was 106): added block-boundary interop, exact-fit decode zeros, + and too-small decode zeros tests. + ## 0.3.1 — 2026-03-31 ### Performance diff --git a/Cargo.toml b/Cargo.toml index bd8c29e..42b767c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ucobs" -version = "0.3.1" +version = "0.3.2" edition = "2021" rust-version = "1.93" license = "MIT" diff --git a/README.md b/README.md index 1bdf420..c11acca 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ over speed can build with `opt-level = "s"` which reduces μCOBS to | Dead-simple API (3 functions) | **yes** | yes | more surface area | | In-place / streaming encode | no | no | **yes** | | `no_std` + zero-alloc by default | **yes** | opt-in | **yes** | -| Thorough test suite | **106 tests, fuzz, proptest** | basic | basic | +| Thorough test suite | **111 tests, fuzz, proptest, mutants** | basic | basic | **Choose μCOBS** if you want the smallest, most auditable COBS implementation with a `const fn` encoder, a dead-simple 3-function API, and leading or tied @@ -282,7 +282,7 @@ affected — it uses a byte loop instead of `copy_from_slice`/memcpy. ## Testing μCOBS has one of the most thorough test suites of any COBS implementation — -106 tests across 8 categories: +111 tests across 8 categories: - **Canonical vectors** — every example from Cheshire & Baker 1999 (the original COBS paper), plus the full Wikipedia vector set diff --git a/src/lib.rs b/src/lib.rs index 41993ec..2802097 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -834,6 +834,49 @@ mod tests { assert_eq!(enc(&input), Some(expected)); } + #[test] + fn encode_254_block_boundary_exact() { + // 254 nonzero bytes MUST produce code 0xFF (full block), NOT 0xFF+1=0x00. + // This catches max_run being 255 instead of 254. + let input: Vec = (1..=254).map(|i| i as u8).collect(); + let encoded = enc(&input).unwrap(); + assert_eq!(encoded[0], 0xFF); // code byte for full 254-byte block + assert_eq!(encoded.len(), 255); // 1 code byte + 254 data bytes + // Verify it round-trips + let mut dec = vec![0u8; 256]; + let n = decode(&encoded, &mut dec).unwrap(); + assert_eq!(&dec[..n], &input[..]); + } + + #[test] + fn encode_255_nonzero_needs_two_blocks() { + // 255 nonzero bytes must split into a 254-byte full block + 1-byte block. + // If max_run were 255 (bug), this would be a single block — wrong. + let input: Vec = (1..=255).map(|i| (i % 255 + 1) as u8).collect(); + let encoded = enc(&input).unwrap(); + assert_eq!(encoded[0], 0xFF); // first block: full 254 + assert_eq!(encoded[255], 0x02); // second block: 1 byte (code = 2) + assert_eq!(encoded.len(), 257); // 1 + 254 + 1 + 1 + } + + #[test] + fn encode_block_boundary_matches_corncobs() { + // Verify byte-for-byte match with corncobs at the critical 254-byte + // block boundary. Catches max_run being off by one. + for len in [254, 255, 256, 508, 512] { + let input: Vec = (0..len).map(|i| (i % 254 + 1) as u8).collect(); + let ours = enc(&input).unwrap(); + let mut their_buf = vec![0u8; corncobs::max_encoded_len(input.len())]; + let their_len = corncobs::encode_buf(&input, &mut their_buf); + // corncobs appends a sentinel; strip it for comparison + assert_eq!( + &ours[..], + &their_buf[..their_len - 1], + "mismatch at len={len}" + ); + } + } + // ── Round-trip tests ──────────────────────────────────────── #[test] @@ -1037,6 +1080,29 @@ mod tests { assert_eq!(out, [0x11, 0x22]); } + #[test] + fn decode_zeros_dest_exact_size() { + // Decode all-zeros into an exact-fit buffer. + // Encoded: N+1 bytes of 0x01 → N bytes of 0x00. + // This catches `> dest.len()` mutated to `>= dest.len()`. + for n in [1, 2, 10, 64, 254, 255, 256] { + let encoded: Vec = vec![0x01; n + 1]; + let mut out = vec![0u8; n]; // exact fit + assert_eq!(decode(&encoded, &mut out), Some(n), "failed at n={n}"); + assert!(out.iter().all(|&b| b == 0x00), "wrong data at n={n}"); + } + } + + #[test] + fn decode_zeros_dest_one_too_small() { + // Buffer one byte too small for all-zeros decode. + for n in [2, 10, 64, 256] { + let encoded: Vec = vec![0x01; n + 1]; + let mut out = vec![0u8; n - 1]; // one too small + assert_eq!(decode(&encoded, &mut out), None, "should fail at n={n}"); + } + } + // ── Multi-block boundary tests ────────────────────────────── #[test]