From 85ddd8aede9382e03edea2601241934e0c2e3518 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 11:37:22 -0700 Subject: [PATCH 1/5] Allow ReadCursor::transaction to return data borrowed from the input The closure's cursor argument used an elided lifetime, which made the buffer lifetime higher-ranked and prevented borrowed slices from escaping the transaction. Bind it to the cursor's lifetime instead. Fixes #12 --- src/read.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/read.rs b/src/read.rs index aab4809..3d8e7fb 100644 --- a/src/read.rs +++ b/src/read.rs @@ -60,7 +60,7 @@ impl<'a> ReadCursor<'a> { /// state if an error occurs pub fn transaction(&mut self, read: T) -> Result where - T: FnOnce(&mut ReadCursor) -> Result, + T: FnOnce(&mut ReadCursor<'a>) -> Result, { let start = self.pos; let result = read(self); @@ -391,4 +391,21 @@ mod tests { // insufficient bytes assert!(cursor.read_array::<2>().is_err()); } + + #[test] + fn transaction_can_return_borrowed_slice() { + // length-prefixed field: the slice borrowed from the input must be able + // to escape the transaction (issue #12) + fn field<'a>(cursor: &mut ReadCursor<'a>) -> Result<&'a [u8], ReadError> { + cursor.transaction(|cur| { + let len = cur.read_u8()?; + cur.read_bytes(len.into()) + }) + } + + let mut cursor = ReadCursor::new(&[0x02, 0xCA, 0xFE, 0x05, 0x01]); + assert_eq!(field(&mut cursor).unwrap(), &[0xCA, 0xFE]); + assert!(field(&mut cursor).is_err()); + assert_eq!(cursor.position(), 3); + } } From 1d7abcd1f3af18bf8dcd4588a9e1dc1d6e508eb2 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 11:37:22 -0700 Subject: [PATCH 2/5] Accept FnOnce closures in WriteCursor::at_pos at_pos invokes its closure exactly once, so require FnOnce like the other transaction methods (see #1). Fixes #13 --- src/write.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/write.rs b/src/write.rs index 9d1ab94..3355222 100644 --- a/src/write.rs +++ b/src/write.rs @@ -85,7 +85,7 @@ impl<'a> WriteCursor<'a> { /// the operation pub fn at_pos(&mut self, pos: usize, write: T) -> Result where - T: Fn(&mut WriteCursor) -> Result, + T: FnOnce(&mut WriteCursor) -> Result, { let start = self.pos; self.seek_to(pos)?; @@ -446,4 +446,27 @@ mod test { }) ); } + + #[test] + fn at_pos_accepts_fn_once_closure() { + // not Copy, so a closure that consumes it is FnOnce only (issue #13) + struct Header([u8; 2]); + impl Header { + fn into_bytes(self) -> [u8; 2] { + self.0 + } + } + + let mut buffer = [0u8; 3]; + let mut cursor = WriteCursor::new(&mut buffer); + cursor.skip(2).unwrap(); + cursor.write_u8(0xFF).unwrap(); + + let header = Header([0xCA, 0xFE]); + cursor + .at_pos(0, move |cur| cur.write_bytes(&header.into_bytes())) + .unwrap(); + + assert_eq!(cursor.written(), &[0xCA, 0xFE, 0xFF]); + } } From 3997467fb36643ebc326a55e9ae5719cbc476f3d Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 11:42:25 -0700 Subject: [PATCH 3/5] Replace Kani proofs with no_panic link-time checks The Kani harnesses covered two routines on buffers of up to 10 bytes. Annotate every public routine with #[no_panic] behind a CI-only no-panic feature instead, and check it by linking a test that calls each routine in an optimized build with overflow checks enabled. A separate test fails if a public routine is missing the attribute or is not called by the check, since the linker only examines code that is called. --- Cargo.toml | 15 ++++- README.md | 30 +++++----- src/lib.rs | 3 - src/proofs.rs | 63 -------------------- src/read.rs | 35 +++++++++++ src/write.rs | 37 ++++++++++++ tests/no_panic.rs | 116 +++++++++++++++++++++++++++++++++++++ tests/no_panic_coverage.rs | 73 +++++++++++++++++++++++ 8 files changed, 289 insertions(+), 83 deletions(-) delete mode 100644 src/proofs.rs create mode 100644 tests/no_panic.rs create mode 100644 tests/no_panic_coverage.rs diff --git a/Cargo.toml b/Cargo.toml index 7d06ad2..a151131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ unreachable_pub = "deny" trivial_casts = "deny" missing_docs = "deny" warnings = "deny" -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } unused = { level = "deny", priority = -1 } missing_copy_implementations = "deny" @@ -24,8 +23,18 @@ unwrap_used = "deny" expect_used = "deny" indexing_slicing = "deny" -[dev-dependencies] -kani = "0.0.1" +[dependencies] +no-panic = { version = "0.1", optional = true } +[features] +# CI-only: fails to link if any public routine can panic. Requires an optimized +# build (see the `no-panic` profile); debug builds will report false positives. +no-panic = ["dep:no-panic"] +[[test]] +name = "no_panic" +required-features = ["no-panic"] +[profile.no-panic] +inherits = "release" +overflow-checks = true diff --git a/README.md b/README.md index 732da6b..4dc134d 100644 --- a/README.md +++ b/README.md @@ -59,24 +59,26 @@ fn main() { } ``` -## Formal Verification +## Panic-freedom checks -This library is formally verified to be panic-free using the [Kani Rust Verifier](https://model-checking.github.io/kani/). -Kani uses bit-precise model checking to mathematically prove the absence of panics, out-of-bounds -accesses, and overflows across all possible execution paths and inputs. +When the `no-panic` feature is enabled, every public routine is annotated with +[`no_panic`](https://github.com/dtolnay/no-panic). If the compiled code for a routine still contains a +path that could panic, the check fails at link time. CI runs it with: -To run the mathematical proofs yourself: +```bash +cargo test --profile no-panic --features no-panic --test no_panic +``` -1. **Install Kani**: - ```bash - cargo install --locked kani-verifier - cargo kani setup - ``` +What the check does and doesn't tell you: -2. **Run Verification**: - ```bash - cargo kani - ``` +* It covers every input, not a sample: it checks that no panic path survives in the compiled code. +* It applies to the configuration that was checked: an optimized build (`opt-level = 3`) with + `overflow-checks = true` and `panic = "unwind"`. Debug builds and size-optimized builds keep + some unreachable panic paths, so the check reports false failures there. Those builds are + still panic-free; they just can't be checked this way. +* The linker only examines code that is called, so `tests/no_panic.rs` calls every public routine. + `tests/no_panic_coverage.rs` fails if a routine is missing the annotation or the call. +* The `no-panic` feature exists for this crate's CI. Don't enable it in your own builds. ## License Licensed under the terms of the MIT or Apache v2 licenses at your choice. diff --git a/src/lib.rs b/src/lib.rs index 64600c2..edcc2b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,5 @@ mod read; mod write; -#[cfg(kani)] -mod proofs; - pub use read::*; pub use write::*; diff --git a/src/proofs.rs b/src/proofs.rs deleted file mode 100644 index 5b98ae4..0000000 --- a/src/proofs.rs +++ /dev/null @@ -1,63 +0,0 @@ -mod proofs { - use crate::{ReadCursor, WriteCursor}; - - /// This proof mathematically verifies that `read_u32_le` is entirely panic-free. - /// - /// # Deductive Proof by Composition - /// `scursor` is designed to be "safe by composition." Because all integer and floating-point - /// read operations (e.g., `read_u16`, `read_u64`, `read_f32`, both LE and BE) are built using - /// the exact same underlying primitive (`read_array`), proving that `read_u32_le` is panic-free - /// inherently proves that the foundation is sound. - /// - /// By formally verifying this single method, Kani proves that for any arbitrary slice length: - /// 1. `checked_add` prevents all integer overflows when calculating indices. - /// 2. `input.get(..).and_then(|s| s.first_chunk::())` never triggers out-of-bounds panics. - /// 3. The `?` operator successfully propagates `ReadError` instead of panicking on failure. - #[kani::proof] - fn prove_read_u32_le_no_panic() { - // Generate an arbitrary input array of up to 10 bytes - let len: usize = kani::any(); - kani::assume(len <= 10); - - let mut input = [0u8; 10]; - for i in 0..10 { - input[i] = kani::any(); - } - - let slice = &input[..len]; - let mut cursor = ReadCursor::new(slice); - - // This read should NEVER panic, regardless of the buffer size or contents - let _ = cursor.read_u32_le(); - } - - /// This proof mathematically verifies that `write_u32_le` is entirely panic-free. - /// - /// # Deductive Proof by Composition - /// Similar to the read side, all integer and floating-point write operations - /// are built upon the singular `write_bytes` primitive. - /// - /// By formally verifying this single method, Kani proves that for any arbitrary slice length - /// and any arbitrary 32-bit input value: - /// 1. `checked_add` inside `write_bytes` prevents integer overflow. - /// 2. `dest.get_mut(..)` prevents all out-of-bounds panics when accessing the buffer. - /// 3. `copy_from_slice` is proven safe because it only executes when `get_mut` successfully - /// returns a subslice of the exact matching length. - /// 4. The `write_overflow` error path is returned correctly instead of panicking. - #[kani::proof] - fn prove_write_u32_le_no_panic() { - // Generate an arbitrary mutable output buffer of up to 10 bytes - let len: usize = kani::any(); - kani::assume(len <= 10); - - let mut buffer = [0u8; 10]; - let slice = &mut buffer[..len]; - let mut cursor = WriteCursor::new(slice); - - // Generate an arbitrary 32-bit value to write - let val: u32 = kani::any(); - - // This write should NEVER panic, regardless of whether there is enough room in the buffer - let _ = cursor.write_u32_le(val); - } -} diff --git a/src/read.rs b/src/read.rs index 3d8e7fb..e3da5d9 100644 --- a/src/read.rs +++ b/src/read.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "no-panic")] +use no_panic::no_panic; + /// Secure read-only cursor #[derive(Copy, Clone, Debug)] pub struct ReadCursor<'a> { @@ -18,11 +21,13 @@ pub struct TrailingBytes { impl<'a> ReadCursor<'a> { /// Construct a cursor from a borrowed slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn new(input: &'a [u8]) -> Self { Self { pos: 0, input } } /// Read a single unsigned byte from the cursor + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u8(&mut self) -> Result { match self.input.get(self.pos) { Some(x) => { @@ -36,6 +41,7 @@ impl<'a> ReadCursor<'a> { /// Expect the cursor to be empty or return and error indicating how many trailing /// bytes are present + #[cfg_attr(feature = "no-panic", no_panic)] pub fn expect_empty(&self) -> Result<(), TrailingBytes> { let remaining = self.remaining(); match core::num::NonZeroUsize::new(remaining) { @@ -45,6 +51,7 @@ impl<'a> ReadCursor<'a> { } /// Return the number of bytes remaining to be read + #[cfg_attr(feature = "no-panic", no_panic)] pub fn remaining(&self) -> usize { self.input.len().saturating_sub(self.pos) } @@ -52,12 +59,14 @@ impl<'a> ReadCursor<'a> { /// Return the position of the cursor within the original input slice /// /// This is synonymous with the number of bytes consumed by the cursor + #[cfg_attr(feature = "no-panic", no_panic)] pub fn position(&self) -> usize { self.pos } /// Perform a transaction on the buffer, returning it to its initial /// state if an error occurs + #[cfg_attr(feature = "no-panic", no_panic)] pub fn transaction(&mut self, read: T) -> Result where T: FnOnce(&mut ReadCursor<'a>) -> Result, @@ -72,11 +81,13 @@ impl<'a> ReadCursor<'a> { } /// Return true if there are no more bytes remaining to be read + #[cfg_attr(feature = "no-panic", no_panic)] pub fn is_empty(&self) -> bool { self.remaining() == 0 } /// Read the rest of the buffer as a borrowed slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_all(&mut self) -> &'a [u8] { match self.input.get(self.pos..) { None => &[], @@ -88,6 +99,7 @@ impl<'a> ReadCursor<'a> { } /// Read a count of bytes as a borrowed slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_bytes(&mut self, count: usize) -> Result<&'a [u8], ReadError> { let end = self.pos.checked_add(count).ok_or(ReadError)?; let ret = self.input.get(self.pos..end).ok_or(ReadError)?; @@ -96,6 +108,7 @@ impl<'a> ReadCursor<'a> { } /// Read a fixed-size array of bytes + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_array(&mut self) -> Result<[u8; N], ReadError> { let chunk = self .input @@ -110,57 +123,68 @@ impl<'a> ReadCursor<'a> { /// little-endian read routines impl<'a> ReadCursor<'a> { /// Read a u16 from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u16_le(&mut self) -> Result { Ok(u16::from_le_bytes(self.read_array()?)) } /// Read a i16 from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i16_le(&mut self) -> Result { Ok(i16::from_le_bytes(self.read_array()?)) } /// Read a u32 from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u32_le(&mut self) -> Result { Ok(u32::from_le_bytes(self.read_array()?)) } /// Read a i32 from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i32_le(&mut self) -> Result { Ok(i32::from_le_bytes(self.read_array()?)) } /// Read a 48-bit unsigned number from a little-endian representation, store it in the first 6 bytes of a u64 + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u48_le(&mut self) -> Result { let [b0, b1, b2, b3, b4, b5] = self.read_array::<6>()?; Ok(u64::from_le_bytes([b0, b1, b2, b3, b4, b5, 0, 0])) } /// Read a u64 number from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u64_le(&mut self) -> Result { Ok(u64::from_le_bytes(self.read_array()?)) } /// Read a i64 number from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i64_le(&mut self) -> Result { Ok(i64::from_le_bytes(self.read_array()?)) } /// Read a u128 number from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u128_le(&mut self) -> Result { Ok(u128::from_le_bytes(self.read_array()?)) } /// Read a i128 number from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i128_le(&mut self) -> Result { Ok(i128::from_le_bytes(self.read_array()?)) } /// Read an IEEE-754 f32 from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_f32_le(&mut self) -> Result { Ok(f32::from_le_bytes(self.read_array()?)) } /// Read an IEEE-754 f64 from a little-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_f64_le(&mut self) -> Result { Ok(f64::from_le_bytes(self.read_array()?)) } @@ -169,57 +193,68 @@ impl<'a> ReadCursor<'a> { /// big-endian read routines impl<'a> ReadCursor<'a> { /// Read a u16 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u16_be(&mut self) -> Result { Ok(u16::from_be_bytes(self.read_array()?)) } /// Read a i16 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i16_be(&mut self) -> Result { Ok(i16::from_be_bytes(self.read_array()?)) } /// Read a u32 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u32_be(&mut self) -> Result { Ok(u32::from_be_bytes(self.read_array()?)) } /// Read a i32 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i32_be(&mut self) -> Result { Ok(i32::from_be_bytes(self.read_array()?)) } /// Read a 48-bit unsigned number from a big-endian representation, store it in the first 6 bytes of a u64 + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u48_be(&mut self) -> Result { let [b0, b1, b2, b3, b4, b5] = self.read_array::<6>()?; Ok(u64::from_be_bytes([0, 0, b0, b1, b2, b3, b4, b5])) } /// Read a u64 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u64_be(&mut self) -> Result { Ok(u64::from_be_bytes(self.read_array()?)) } /// Read a i64 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i64_be(&mut self) -> Result { Ok(i64::from_be_bytes(self.read_array()?)) } /// Read a u128 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_u128_be(&mut self) -> Result { Ok(u128::from_be_bytes(self.read_array()?)) } /// Read a i128 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_i128_be(&mut self) -> Result { Ok(i128::from_be_bytes(self.read_array()?)) } /// Read an IEEE-754 f32 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_f32_be(&mut self) -> Result { Ok(f32::from_be_bytes(self.read_array()?)) } /// Read an IEEE-754 f64 from a big-endian representation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn read_f64_be(&mut self) -> Result { Ok(f64::from_be_bytes(self.read_array()?)) } diff --git a/src/write.rs b/src/write.rs index 3355222..c27775c 100644 --- a/src/write.rs +++ b/src/write.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "no-panic")] +use no_panic::no_panic; + /// Secure write cursor /// /// Provides routines for incrementally writing to a borrowed slice @@ -30,21 +33,25 @@ pub enum WriteError { impl<'a> WriteCursor<'a> { /// Construct a cursor from a borrowed mutable slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn new(dest: &'a mut [u8]) -> WriteCursor<'a> { WriteCursor { dest, pos: 0 } } /// Current position of the cursor within the underlying slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn position(&self) -> usize { self.pos } /// Get a range within the underlying slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn get(&self, range: core::ops::Range) -> Option<&[u8]> { self.dest.get(range) } /// Advance the cursor a count of bytes + #[cfg_attr(feature = "no-panic", no_panic)] pub fn skip(&mut self, count: usize) -> Result<(), WriteError> { let new_pos = self .pos @@ -54,6 +61,7 @@ impl<'a> WriteCursor<'a> { } /// Seek the cursor to an absolute position within the underlying slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn seek_to(&mut self, pos: usize) -> Result<(), WriteError> { if self.dest.len() < pos { return Err(WriteError::BadSeek { @@ -67,6 +75,7 @@ impl<'a> WriteCursor<'a> { /// Perform a write transaction which returns the cursor to the original /// position if an error occurs + #[cfg_attr(feature = "no-panic", no_panic)] pub fn transaction(&mut self, write: T) -> Result where T: FnOnce(&mut WriteCursor) -> Result, @@ -83,6 +92,7 @@ impl<'a> WriteCursor<'a> { /// Perform a write transaction at particular position. The cursor is always /// returned to its original position regardless of the success or failure of /// the operation + #[cfg_attr(feature = "no-panic", no_panic)] pub fn at_pos(&mut self, pos: usize, write: T) -> Result where T: FnOnce(&mut WriteCursor) -> Result, @@ -96,11 +106,13 @@ impl<'a> WriteCursor<'a> { } /// Return the data that has been written so far as a borrowed slice + #[cfg_attr(feature = "no-panic", no_panic)] pub fn written(&self) -> &[u8] { self.dest.get(0..self.pos).unwrap_or(&[]) } /// Return the data that has been written since a particular write position + #[cfg_attr(feature = "no-panic", no_panic)] pub fn written_since(&'a self, pos: usize) -> Result<&'a [u8], WriteError> { match self.dest.get(pos..self.pos) { Some(x) => Ok(x), @@ -109,11 +121,13 @@ impl<'a> WriteCursor<'a> { } /// Number of bytes remaining to be written + #[cfg_attr(feature = "no-panic", no_panic)] pub fn remaining(&self) -> usize { self.dest.len().saturating_sub(self.pos) } /// Write a slice of bytes to the cursor + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), WriteError> { let new_pos = self .pos @@ -133,6 +147,7 @@ impl<'a> WriteCursor<'a> { } /// Write a single u8 to the cursor + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u8(&mut self, value: u8) -> Result<(), WriteError> { let new_pos = self.pos.checked_add(1).ok_or(WriteError::NumericOverflow)?; match self.dest.get_mut(self.pos) { @@ -152,57 +167,68 @@ impl<'a> WriteCursor<'a> { /// little-endian write routines impl<'a> WriteCursor<'a> { /// Write a u16 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u16_le(&mut self, value: u16) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write a i16 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i16_le(&mut self, value: i16) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write a u32 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u32_le(&mut self, value: u32) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write a i32 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i32_le(&mut self, value: i32) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write the lower 6-bytes of a u64 (u48) in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u48_le(&mut self, value: u64) -> Result<(), WriteError> { let [b0, b1, b2, b3, b4, b5, _, _] = value.to_le_bytes(); self.write_bytes(&[b0, b1, b2, b3, b4, b5]) } /// Write a u64 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u64_le(&mut self, value: u64) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write a i64 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i64_le(&mut self, value: i64) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write an IEEE-754 f32 in little endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_f32_le(&mut self, value: f32) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write an IEEE-754 f64 in little endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_f64_le(&mut self, value: f64) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write a u128 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u128_le(&mut self, value: u128) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } /// Write a i128 in little-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i128_le(&mut self, value: i128) -> Result<(), WriteError> { self.write_bytes(&value.to_le_bytes()) } @@ -211,57 +237,68 @@ impl<'a> WriteCursor<'a> { /// big-endian write routines impl<'a> WriteCursor<'a> { /// Write a u16 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u16_be(&mut self, value: u16) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write a i16 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i16_be(&mut self, value: i16) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write a u32 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u32_be(&mut self, value: u32) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write a i32 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i32_be(&mut self, value: i32) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write the lower 6-bytes of a u64 (u48) in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u48_be(&mut self, value: u64) -> Result<(), WriteError> { let [_, _, b2, b3, b4, b5, b6, b7] = value.to_be_bytes(); self.write_bytes(&[b2, b3, b4, b5, b6, b7]) } /// Write a u64 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u64_be(&mut self, value: u64) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write a i64 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i64_be(&mut self, value: i64) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write a u128 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_u128_be(&mut self, value: u128) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write a i128 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_i128_be(&mut self, value: i128) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write an IEEE-754 f32 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_f32_be(&mut self, value: f32) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } /// Write an IEEE-754 f64 in big-endian format + #[cfg_attr(feature = "no-panic", no_panic)] pub fn write_f64_be(&mut self, value: f64) -> Result<(), WriteError> { self.write_bytes(&value.to_be_bytes()) } diff --git a/tests/no_panic.rs b/tests/no_panic.rs new file mode 100644 index 0000000..8629cff --- /dev/null +++ b/tests/no_panic.rs @@ -0,0 +1,116 @@ +//! Link-time panic check for every public routine. +//! +//! Every public function is annotated with `#[no_panic]` when the `no-panic` +//! feature is enabled. The check happens when this test binary is linked, so +//! each routine must be called here. `tests/no_panic_coverage.rs` verifies that +//! this file stays in sync with the public API. +//! +//! Run with: +//! +//! ```text +//! cargo test --profile no-panic --features no-panic --test no_panic +//! ``` + +use scursor::{ReadCursor, WriteCursor}; +use std::hint::black_box; + +#[inline(never)] +fn exercise_read_cursor(input: &[u8], count: usize) { + let mut cursor = ReadCursor::new(input); + + black_box(cursor.read_u8().ok()); + black_box(cursor.read_bytes(count).ok()); + black_box(cursor.read_array::<0>().ok()); + black_box(cursor.read_array::<3>().ok()); + black_box(cursor.read_array::<32>().ok()); + + black_box(cursor.read_u16_le().ok()); + black_box(cursor.read_i16_le().ok()); + black_box(cursor.read_u32_le().ok()); + black_box(cursor.read_i32_le().ok()); + black_box(cursor.read_u48_le().ok()); + black_box(cursor.read_u64_le().ok()); + black_box(cursor.read_i64_le().ok()); + black_box(cursor.read_u128_le().ok()); + black_box(cursor.read_i128_le().ok()); + black_box(cursor.read_f32_le().ok()); + black_box(cursor.read_f64_le().ok()); + + black_box(cursor.read_u16_be().ok()); + black_box(cursor.read_i16_be().ok()); + black_box(cursor.read_u32_be().ok()); + black_box(cursor.read_i32_be().ok()); + black_box(cursor.read_u48_be().ok()); + black_box(cursor.read_u64_be().ok()); + black_box(cursor.read_i64_be().ok()); + black_box(cursor.read_u128_be().ok()); + black_box(cursor.read_i128_be().ok()); + black_box(cursor.read_f32_be().ok()); + black_box(cursor.read_f64_be().ok()); + + black_box(cursor.transaction(|cur| cur.read_bytes(count)).ok()); + black_box(cursor.expect_empty().ok()); + black_box(cursor.remaining()); + black_box(cursor.position()); + black_box(cursor.is_empty()); + black_box(cursor.read_all()); +} + +#[inline(never)] +fn exercise_write_cursor(dest: &mut [u8], value: u128, pos: usize) { + let mut cursor = WriteCursor::new(dest); + + black_box(cursor.write_u8(value as u8).ok()); + black_box(cursor.write_bytes(black_box(&[0xCA, 0xFE])).ok()); + black_box(cursor.skip(pos).ok()); + black_box(cursor.seek_to(pos).ok()); + + black_box(cursor.write_u16_le(value as u16).ok()); + black_box(cursor.write_i16_le(value as i16).ok()); + black_box(cursor.write_u32_le(value as u32).ok()); + black_box(cursor.write_i32_le(value as i32).ok()); + black_box(cursor.write_u48_le(value as u64).ok()); + black_box(cursor.write_u64_le(value as u64).ok()); + black_box(cursor.write_i64_le(value as i64).ok()); + black_box(cursor.write_u128_le(value).ok()); + black_box(cursor.write_i128_le(value as i128).ok()); + black_box(cursor.write_f32_le(value as f32).ok()); + black_box(cursor.write_f64_le(value as f64).ok()); + + black_box(cursor.write_u16_be(value as u16).ok()); + black_box(cursor.write_i16_be(value as i16).ok()); + black_box(cursor.write_u32_be(value as u32).ok()); + black_box(cursor.write_i32_be(value as i32).ok()); + black_box(cursor.write_u48_be(value as u64).ok()); + black_box(cursor.write_u64_be(value as u64).ok()); + black_box(cursor.write_i64_be(value as i64).ok()); + black_box(cursor.write_u128_be(value).ok()); + black_box(cursor.write_i128_be(value as i128).ok()); + black_box(cursor.write_f32_be(value as f32).ok()); + black_box(cursor.write_f64_be(value as f64).ok()); + + black_box( + cursor + .transaction(|cur| cur.write_u16_le(value as u16)) + .ok(), + ); + black_box( + cursor + .at_pos(pos, |cur| cur.write_u32_be(value as u32)) + .ok(), + ); + black_box(cursor.written_since(pos).map(<[u8]>::len).ok()); + black_box(cursor.get(pos..pos.wrapping_add(4)).map(<[u8]>::len)); + black_box(cursor.written().len()); + black_box(cursor.remaining()); + black_box(cursor.position()); +} + +#[test] +fn public_api_links_without_panic_paths() { + let input: Vec = (0..=255).collect(); + exercise_read_cursor(black_box(&input), black_box(7)); + + let mut dest = [0u8; 256]; + exercise_write_cursor(black_box(&mut dest), black_box(u128::MAX), black_box(5)); +} diff --git a/tests/no_panic_coverage.rs b/tests/no_panic_coverage.rs new file mode 100644 index 0000000..f82b9e2 --- /dev/null +++ b/tests/no_panic_coverage.rs @@ -0,0 +1,73 @@ +//! Ensures every public routine is annotated with `#[no_panic]` and exercised by +//! `tests/no_panic.rs`. Functions that are never called are not checked by the +//! linker, so a missing call would silently skip the check. + +const DRIVER: &str = include_str!("no_panic.rs"); +const ATTRIBUTE: &str = "#[cfg_attr(feature = \"no-panic\", no_panic)]"; + +/// Names of all `pub fn` items in `source`, each paired with whether it is +/// immediately preceded by the no_panic attribute. +fn public_fns(source: &str) -> Vec<(String, bool)> { + let lines: Vec<&str> = source.lines().map(str::trim).collect(); + lines + .iter() + .enumerate() + .filter_map(|(i, line)| { + let rest = line.strip_prefix("pub fn ")?; + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + let annotated = i > 0 && lines[i - 1] == ATTRIBUTE; + Some((name, annotated)) + }) + .collect() +} + +/// The body of the driver function `name`, up to the next driver function. +fn driver_body(name: &str) -> &'static str { + let start = DRIVER + .find(&format!("fn {name}(")) + .unwrap_or_else(|| panic!("driver function {name} not found")); + let body = &DRIVER[start..]; + let end = body[1..].find("\nfn ").map_or(body.len(), |i| i + 1); + &body[..end] +} + +fn check(source: &str, driver_fn: &str, type_name: &str) { + let body = driver_body(driver_fn); + let fns = public_fns(source); + assert!(!fns.is_empty(), "no public functions found for {type_name}"); + + for (name, annotated) in fns { + assert!( + annotated, + "{type_name}::{name} is missing the no_panic attribute" + ); + let called = body.contains(&format!(".{name}(")) + || body.contains(&format!(".{name}::<")) + || body.contains(&format!("{type_name}::{name}(")); + assert!( + called, + "{type_name}::{name} is not exercised by {driver_fn} in tests/no_panic.rs" + ); + } +} + +#[test] +fn every_read_cursor_routine_is_checked() { + check( + include_str!("../src/read.rs"), + "exercise_read_cursor", + "ReadCursor", + ); +} + +#[test] +fn every_write_cursor_routine_is_checked() { + check( + include_str!("../src/write.rs"), + "exercise_write_cursor", + "WriteCursor", + ); +} From 1eb926cfddf51b7e8360b28bf2d01240dd977ad8 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 11:42:25 -0700 Subject: [PATCH 4/5] Add round-trip property tests Check every numeric routine's byte layout and round trip, that failed reads and writes leave the position and buffer unchanged, and that transactions are all-or-nothing. --- Cargo.toml | 3 + tests/roundtrip.rs | 143 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/roundtrip.rs diff --git a/Cargo.toml b/Cargo.toml index a151131..5fe4ce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ indexing_slicing = "deny" [dependencies] no-panic = { version = "0.1", optional = true } +[dev-dependencies] +proptest = "1" + [features] # CI-only: fails to link if any public routine can panic. Requires an optimized # build (see the `no-panic` profile); debug builds will report false positives. diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..642212c --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,143 @@ +//! Property tests: every value round-trips through the write and read routines +//! with the expected byte layout, and failed operations change nothing. + +use proptest::prelude::*; +use scursor::{ReadCursor, WriteCursor, WriteError}; + +macro_rules! numeric { + ($($name:ident: $ty:ty => $write:ident, $read:ident, $to_bytes:ident;)*) => { + $( + mod $name { + use super::*; + + const SIZE: usize = core::mem::size_of::<$ty>(); + + proptest! { + #[test] + fn round_trips(value: $ty, prefix in 0usize..8, spare in 0usize..8) { + let mut buffer = vec![0xAA; prefix + SIZE + spare]; + let mut writer = WriteCursor::new(&mut buffer); + writer.skip(prefix).unwrap(); + writer.$write(value).unwrap(); + prop_assert_eq!(writer.position(), prefix + SIZE); + prop_assert_eq!(writer.written_since(prefix).unwrap(), &value.$to_bytes()[..]); + + let mut reader = ReadCursor::new(&buffer); + reader.read_bytes(prefix).unwrap(); + // compare bytes so that NaN payloads are checked too + prop_assert_eq!(reader.$read().unwrap().to_le_bytes(), value.to_le_bytes()); + prop_assert_eq!(reader.remaining(), spare); + } + + #[test] + fn short_buffer_changes_nothing(value: $ty, prefix in 0usize..8, short in 0usize..SIZE, fill: u8) { + let mut buffer = vec![fill; prefix + short]; + let original = buffer.clone(); + let mut writer = WriteCursor::new(&mut buffer); + writer.skip(prefix).unwrap(); + prop_assert_eq!( + writer.$write(value), + Err(WriteError::WriteOverflow { remaining: short, written: SIZE }) + ); + prop_assert_eq!(writer.position(), prefix); + prop_assert_eq!(&buffer, &original); + + let mut reader = ReadCursor::new(&original); + reader.read_bytes(prefix).unwrap(); + prop_assert!(reader.$read().is_err()); + prop_assert_eq!(reader.position(), prefix); + } + } + } + )* + }; +} + +numeric! { + u16_le: u16 => write_u16_le, read_u16_le, to_le_bytes; + u16_be: u16 => write_u16_be, read_u16_be, to_be_bytes; + i16_le: i16 => write_i16_le, read_i16_le, to_le_bytes; + i16_be: i16 => write_i16_be, read_i16_be, to_be_bytes; + u32_le: u32 => write_u32_le, read_u32_le, to_le_bytes; + u32_be: u32 => write_u32_be, read_u32_be, to_be_bytes; + i32_le: i32 => write_i32_le, read_i32_le, to_le_bytes; + i32_be: i32 => write_i32_be, read_i32_be, to_be_bytes; + u64_le: u64 => write_u64_le, read_u64_le, to_le_bytes; + u64_be: u64 => write_u64_be, read_u64_be, to_be_bytes; + i64_le: i64 => write_i64_le, read_i64_le, to_le_bytes; + i64_be: i64 => write_i64_be, read_i64_be, to_be_bytes; + u128_le: u128 => write_u128_le, read_u128_le, to_le_bytes; + u128_be: u128 => write_u128_be, read_u128_be, to_be_bytes; + i128_le: i128 => write_i128_le, read_i128_le, to_le_bytes; + i128_be: i128 => write_i128_be, read_i128_be, to_be_bytes; + f32_le: f32 => write_f32_le, read_f32_le, to_le_bytes; + f32_be: f32 => write_f32_be, read_f32_be, to_be_bytes; + f64_le: f64 => write_f64_le, read_f64_le, to_le_bytes; + f64_be: f64 => write_f64_be, read_f64_be, to_be_bytes; +} + +const U48_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + +proptest! { + #[test] + fn u48_le_round_trips_lower_48_bits(value: u64) { + let mut buffer = [0u8; 6]; + let mut writer = WriteCursor::new(&mut buffer); + writer.write_u48_le(value).unwrap(); + prop_assert_eq!(writer.written(), &value.to_le_bytes()[..6]); + prop_assert_eq!(ReadCursor::new(&buffer).read_u48_le().unwrap(), value & U48_MASK); + } + + #[test] + fn u48_be_round_trips_lower_48_bits(value: u64) { + let mut buffer = [0u8; 6]; + let mut writer = WriteCursor::new(&mut buffer); + writer.write_u48_be(value).unwrap(); + prop_assert_eq!(writer.written(), &value.to_be_bytes()[2..]); + prop_assert_eq!(ReadCursor::new(&buffer).read_u48_be().unwrap(), value & U48_MASK); + } + + #[test] + fn read_transaction_is_all_or_nothing( + input in proptest::collection::vec(any::(), 0..32), + start in 0usize..32, + counts in proptest::collection::vec(0usize..16, 0..4), + ) { + let mut cursor = ReadCursor::new(&input); + let _ = cursor.read_bytes(start); + let before = cursor.position(); + let result = cursor.transaction(|cur| { + counts.iter().try_for_each(|&count| cur.read_bytes(count).map(|_| ())) + }); + let total: usize = counts.iter().sum(); + if before + total <= input.len() { + prop_assert!(result.is_ok()); + prop_assert_eq!(cursor.position(), before + total); + } else { + prop_assert!(result.is_err()); + prop_assert_eq!(cursor.position(), before); + } + } + + #[test] + fn write_transaction_restores_position_on_failure( + len in 0usize..32, + start in 0usize..32, + values in proptest::collection::vec(any::(), 0..8), + ) { + let mut buffer = vec![0u8; len]; + let mut cursor = WriteCursor::new(&mut buffer); + let _ = cursor.seek_to(start); + let before = cursor.position(); + let result = cursor.transaction(|cur| { + values.iter().try_for_each(|&value| cur.write_u32_le(value)) + }); + if before + 4 * values.len() <= len { + prop_assert!(result.is_ok()); + prop_assert_eq!(cursor.position(), before + 4 * values.len()); + } else { + prop_assert!(result.is_err()); + prop_assert_eq!(cursor.position(), before); + } + } +} From acfcb62d9617473cc5c3e2e450ccb2c9b3fea5d8 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 11:42:25 -0700 Subject: [PATCH 5/5] Add CI workflow, declare MSRV, and update changelog CI runs fmt, clippy, and tests on stable, the no_panic link check, and a build on the minimum supported Rust version (1.77, required by slice::first_chunk). --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 7 +++++++ Cargo.toml | 1 + 3 files changed, 46 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e4eb2bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - run: cargo fmt --check + - run: cargo clippy + - run: cargo test + + no-panic: + name: no-panic link check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --profile no-panic --features no-panic --test no_panic + + msrv: + name: MSRV (1.77) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.77 + # build only: dev-dependencies require a newer toolchain + - run: cargo build diff --git a/CHANGELOG.md b/CHANGELOG.md index 863b05b..b3dff8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +### 0.5.1 ### +* :bug: `ReadCursor::transaction` closures can now return data borrowed from the input. See [#12](https://github.com/stepfunc/scursor/issues/12). +* :bug: `WriteCursor::at_pos` now accepts `FnOnce` closures. See [#13](https://github.com/stepfunc/scursor/issues/13). +* :wrench: Replaced the Kani proofs with link-time `no_panic` checks on every public routine, run in CI. +* :wrench: Added round-trip property tests and a CI workflow. +* Declared the minimum supported Rust version (1.77). + ### 0.5.0 ### * :star: Optimized all integer and floating point read operations using the stabilized `slice::first_chunk` (Rust 1.77+). * :star: Added missing symmetric big-endian read/write routines for `u32`, `i32`, `f32`, `f64`, and `u48`. diff --git a/Cargo.toml b/Cargo.toml index 5fe4ce0..3d10071 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "scursor" version = "0.5.0" license = "MIT OR Apache-2.0" edition = "2021" +rust-version = "1.77" description = "Secure cursor library with support for read and write transactions" repository = "https://github.com/stepfunc/scursor" readme = "README.md"