diff --git a/.github/SECURITY.md b/.github/SECURITY.md index b8a4e52..8e94082 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,8 +2,13 @@ Security ======== This library does not use any crazy features of Rust. -At the time of writing, there is only one `unsafe` block, which bypasses a UTF-8 check which should not be needed if -everything going into `write!` follows Rust's UTF-8 policy. +At the time of writing, there is exactly one `unsafe fn` (`from_utf8_expect` in `src/lib.rs`) that bypasses Rust's +UTF-8 check. +Its safety invariant is upheld by construction: every byte written into the buffer comes from a `&str` validated by +`core::fmt::Write`, and debug builds re-verify the invariant via `core::str::from_utf8` before reading the buffer +back as a `&str`. +The two sibling modules (`truncated` and `utf8`) both have `#![forbid(unsafe_code)]`, so the unsafe surface cannot +spread without an explicit edit to that policy. Any security vulnerabilities are likely [higher-level concerns](https://www.rust-lang.org/policies/security) than this little format library. diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..ae372d1 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,66 @@ +name: fuzz + +on: + # Gate every PR that touches fuzz-relevant code, plus the same files on direct push to + # `trunk` as a backstop. `paths:`-filtered so docs-only / CI-only PRs don't pay the cost. + # No `schedule:` -- the fuzzer offers no signal when the code hasn't changed. + # Use `workflow_dispatch` for ad-hoc longer runs (e.g. before tagging a release). + pull_request: + branches: [trunk] + paths: + - 'src/**' + - 'fuzz/**' + - 'Cargo.toml' + - '.github/workflows/fuzz.yml' + push: + branches: [trunk] + paths: + - 'src/**' + - 'fuzz/**' + - 'Cargo.toml' + - '.github/workflows/fuzz.yml' + workflow_dispatch: + inputs: + duration_seconds: + description: 'Per-target fuzzing time in seconds' + required: false + default: '150' + +env: + CARGO_TERM_COLOR: always + +jobs: + fuzz: + runs-on: ubuntu-latest + strategy: + # Run targets in parallel rather than serially; each one has its own corpus and shares no + # state with the other. + fail-fast: false + matrix: + target: [finish_with, write_sequence] + steps: + - uses: actions/checkout@v4 + - name: Install nightly toolchain + run: rustup toolchain install nightly --profile minimal --component rust-src + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + - name: Restore corpus + # `key` is per-run via `github.run_id` so each job writes a fresh entry; + # `restore-keys` matches the per-target prefix, so every run bootstraps from whatever + # corpus the most recent run for the same target left behind. + uses: actions/cache@v4 + with: + path: fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} + restore-keys: fuzz-corpus-${{ matrix.target }}- + - name: Run fuzzer + run: | + cargo +nightly fuzz run ${{ matrix.target }} -- \ + -max_total_time=${{ github.event.inputs.duration_seconds || '150' }} + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }}/ + if-no-files-found: ignore diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 57a7fed..cef39b8 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -13,7 +13,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Test/Debug run: cargo test --verbose - name: Test/Debug+nostd @@ -23,8 +23,26 @@ jobs: - name: Test/Release+nostd run: cargo test --no-default-features --release --verbose - name: Docs - run: cargo doc --verbose + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --verbose - name: Format Check run: cargo fmt --check - name: Clippy - run: cargo clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + msrv: + # Pin to the `rust-version` in Cargo.toml so a stricter MSRV bump fails CI rather than silently + # working on the runner's current toolchain. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Read MSRV from Cargo.toml + id: msrv + run: echo "version=$(grep '^rust-version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" >> "$GITHUB_OUTPUT" + - name: Install MSRV toolchain + run: rustup toolchain install ${{ steps.msrv.outputs.version }} --profile minimal + - name: Build + run: cargo +${{ steps.msrv.outputs.version }} build --verbose + - name: Build/nostd + run: cargo +${{ steps.msrv.outputs.version }} build --no-default-features --verbose diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f310e..3da96f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `Truncated<'_>` error type, returned by the `WriteBuf::finish*` family. Carries the portion of the output that was - successfully written so callers don't have to track it separately. + successfully written so callers don't have to track it separately. Implements `Debug`, `Display`, `Error`, and + derives `Clone`, `Copy`, `PartialEq`, `Eq`, and `Hash` so it can be compared, hashed, and stored. +- `Truncated::written()` and `Truncated::written_len()` -- inherent accessors that mirror the methods on + `TruncatedResultExt` so the same vocabulary works whether you hold the `Truncated` directly or a + `Result<&str, Truncated<'_>>`. - `WriteBuf::written()` returns the written portion as a `&str` (companion to the existing `written_bytes()` accessor). +- `fmt::Debug` impl on `WriteBuf` for diagnostic logging. Shows position, capacity, reserve, the truncated flag, and + the validly-written `&str`; deliberately omits the raw target bytes (which may be uninitialized). +- `WriteBuf::capacity()` returning the size of the target buffer. +- `WriteBuf::remaining()` returning the number of bytes still available for `write_str` operations + (truncation-aware; saturates on `reserve > capacity - position`). +- `WriteBuf::clear()` resetting `position` and the `truncated` flag for buffer reuse. The configured `reserve` is + preserved. ### Changed @@ -31,8 +42,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Replace `let n = writer.finish().unwrap();` with `let s = writer.finish().unwrap();` -- the success value is now `&str` rather than the byte count. -- For the error path, use `e.get()` to access the partially-written `&str`: - `writer.finish().unwrap_or_else(|e| e.get())`. +- For the error path, use `e.written()` to access the partially-written `&str`: + `writer.finish().unwrap_or_else(|e| e.written())`. - If a `finish_with*` call passed a non-`str` buffer, convert via `core::str::from_utf8` first. ## [0.1.2] — 2025-09-24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83d388e..9761bdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,9 @@ This little open-source project is full Rust with no external dependencies, so e of the box. Just grab [Rust](https://www.rust-lang.org/) from [Rustup](https://rustup.rs/) and you're good to go. +The minimum supported Rust version (MSRV) is tracked by the `rust-version` field in `Cargo.toml`. CI builds the crate +against the MSRV toolchain, so a feature newer than that will fail there even if your local toolchain accepts it. + Building -------- diff --git a/Cargo.toml b/Cargo.toml index cf6547e..c3e1807 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ keywords = ["buffer", "truncation", "utf-8", "ffi", "no-std"] categories = ["development-tools::ffi", "no-std"] readme = "README.md" repository = "https://github.com/tgockel/fmtbuf" +documentation = "https://docs.rs/fmtbuf" [features] default = ["std"] @@ -19,6 +20,16 @@ std = [] [dev-dependencies] clap = { version = "4.0.0", features = ["derive"] } +proptest = "1" [badges] -maintenance = { status = "experimental" } # this line is currently experimental +maintenance = { status = "passively-maintained" } + +[lints.rust] +missing_docs = "deny" +unsafe_op_in_unsafe_fn = "deny" +unreachable_pub = "warn" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +module_name_repetitions = "allow" diff --git a/README.md b/README.md index f4ca985..276cc54 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ let written = match writer.finish_with_or("!", "…") { Ok(s) => s, // <- won't be hit since 🚀🚀🚀 is 12 bytes Err(e) => { println!("writing was truncated"); - e.get() + e.written() } }; assert_eq!("🚀…", written); @@ -77,9 +77,9 @@ pub unsafe extern "C" fn mylib_strerror( Features -------- -### `!#[no_std]` +### `#![no_std]` -Support for `!#[no_std]` is enabled by disabling the default features and not re-enabling the `"std"` feature. +Support for `#![no_std]` is enabled by disabling the default features and not re-enabling the `"std"` feature. ```toml fmtbuf = { version = "*", default-features = false } diff --git a/examples/writebuf.rs b/examples/writebuf.rs index 2da59aa..6a4a1b3 100644 --- a/examples/writebuf.rs +++ b/examples/writebuf.rs @@ -64,7 +64,7 @@ fn main() { }; let (contents, truncated) = match result { Ok(s) => (s, false), - Err(e) => (e.get(), true), + Err(e) => (e.written(), true), }; println!("{contents}"); diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..5c404b9 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,5 @@ +target +corpus +artifacts +coverage +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..11918fc --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "fmtbuf-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } + +[dependencies.fmtbuf] +path = ".." + +# Standalone workspace so the parent crate's `[lints]` table does not apply to fuzz code. +[workspace] + +[[bin]] +name = "finish_with" +path = "fuzz_targets/finish_with.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "write_sequence" +path = "fuzz_targets/write_sequence.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/finish_with.rs b/fuzz/fuzz_targets/finish_with.rs new file mode 100644 index 0000000..77c7c6e --- /dev/null +++ b/fuzz/fuzz_targets/finish_with.rs @@ -0,0 +1,48 @@ +#![no_main] + +use std::fmt::Write; + +use arbitrary::Arbitrary; +use fmtbuf::{TruncatedResultExt, WriteBuf}; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +struct Input { + buf_len: u8, + reserve: u8, + input: String, + finish_with: Option, + truncate_with: Option, +} + +fuzz_target!(|input: Input| { + let buf_len = usize::from(input.buf_len); + let reserve = usize::from(input.reserve); + + // Path 1: write then finish. + { + let mut buf = vec![0u8; buf_len]; + let mut writer = WriteBuf::with_reserve(&mut buf, reserve); + let _ = writer.write_str(&input.input); + let result = writer.finish(); + // The library must produce valid UTF-8 in the written portion. + assert!(core::str::from_utf8(result.written().as_bytes()).is_ok()); + // And the byte length must be bounded by the buffer. + assert!(result.written().len() <= buf_len); + } + + // Path 2: write then finish_with / finish_with_or based on the optional suffixes. + { + let mut buf = vec![0u8; buf_len]; + let mut writer = WriteBuf::with_reserve(&mut buf, reserve); + let _ = writer.write_str(&input.input); + let result = match (input.finish_with.as_deref(), input.truncate_with.as_deref()) { + (None, None) => writer.finish(), + (Some(f), None) => writer.finish_with(f), + (None, Some(t)) => writer.finish_with_or("", t), + (Some(f), Some(t)) => writer.finish_with_or(f, t), + }; + assert!(core::str::from_utf8(result.written().as_bytes()).is_ok()); + assert!(result.written().len() <= buf_len); + } +}); diff --git a/fuzz/fuzz_targets/write_sequence.rs b/fuzz/fuzz_targets/write_sequence.rs new file mode 100644 index 0000000..0af0bf0 --- /dev/null +++ b/fuzz/fuzz_targets/write_sequence.rs @@ -0,0 +1,92 @@ +//! Fuzz a random sequence of `write_str` / `clear` / `set_reserve` calls. The interesting bug +//! class here is state corruption when truncation hits midstream and the caller keeps writing, +//! clearing, or reconfiguring the reserve. +//! +//! The companion target `finish_with` covers the suffix variants of the finish family. This one +//! focuses on the multi-call write path, asserting type-wide invariants after every operation. + +#![no_main] + +use std::fmt::Write; + +use arbitrary::Arbitrary; +use fmtbuf::WriteBuf; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +enum Op { + Write(String), + Clear, + SetReserve(u8), +} + +#[derive(Debug, Arbitrary)] +struct Input { + buf_len: u8, + initial_reserve: u8, + ops: Vec, +} + +fuzz_target!(|input: Input| { + let buf_len = usize::from(input.buf_len); + let initial_reserve = usize::from(input.initial_reserve); + + let mut buf = vec![0u8; buf_len]; + let mut writer = WriteBuf::with_reserve(&mut buf, initial_reserve); + assert_eq!(writer.capacity(), buf_len); + + for op in &input.ops { + let was_truncated = writer.truncated(); + + match op { + Op::Write(s) => { + let result = writer.write_str(s); + if was_truncated { + // Once a write is rejected, every subsequent write must immediately fail. This + // is the load-bearing invariant of the truncated flag. + assert!(result.is_err(), "write_str succeeded after a previous truncation"); + } + } + Op::Clear => { + writer.clear(); + assert!(!writer.truncated()); + assert_eq!(writer.position(), 0); + assert_eq!(writer.written(), ""); + } + Op::SetReserve(n) => { + writer.set_reserve(usize::from(*n)); + assert_eq!(writer.reserve(), usize::from(*n)); + } + } + + // Invariants that must hold after every operation, regardless of branch above. + assert_eq!(writer.capacity(), buf_len, "capacity is fixed"); + assert!(writer.position() <= writer.capacity()); + assert_eq!(writer.position(), writer.written().len()); + assert_eq!(writer.position(), writer.written_bytes().len()); + // `written()` returning `&str` already proves valid UTF-8, but assert the underlying bytes + // independently to catch any divergence between `written` and `written_bytes`. + assert!(core::str::from_utf8(writer.written_bytes()).is_ok()); + + let expected_remaining = if writer.truncated() { + 0 + } else { + writer + .capacity() + .saturating_sub(writer.position()) + .saturating_sub(writer.reserve()) + }; + assert_eq!(writer.remaining(), expected_remaining); + } + + // Final finish must respect the same invariants as the standalone `finish_with` target. + let final_truncated = writer.truncated(); + let result = writer.finish(); + assert_eq!(result.is_err(), final_truncated); + let written = match result { + Ok(s) => s, + Err(t) => t.written(), + }; + assert!(core::str::from_utf8(written.as_bytes()).is_ok()); + assert!(written.len() <= buf_len); +}); diff --git a/src/lib.rs b/src/lib.rs index eb5c183..8879fff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,9 @@ #![doc = include_str!("../README.md")] #![cfg_attr(not(feature = "std"), no_std)] +#![allow( + clippy::doc_link_with_quotes, + reason = "README.md links use bracketed text containing quoted Unicode names like `\"U+200D\"`; these are real Markdown links, not malformed intra-doc links." +)] mod truncated; mod utf8; @@ -66,7 +70,7 @@ impl<'a> WriteBuf<'a> { /// /// // Only 7 bytes are writable; the rest is held for the suffix. /// let _ = write!(writer, "abcdefgh"); - /// let written = writer.finish_with("\0").unwrap_err().get(); + /// let written = writer.finish_with("\0").unwrap_err().written(); /// assert_eq!(written, "abcdefg\0"); /// ``` pub fn with_reserve(target: &'a mut [u8], reserve: usize) -> Self { @@ -80,16 +84,56 @@ impl<'a> WriteBuf<'a> { /// Get the position in the target buffer. The value is one past the end of written content and the next position to /// be written to. + #[must_use] pub fn position(&self) -> usize { self.position } + /// Get the total size of the target buffer in bytes. This is fixed for the lifetime of the [`WriteBuf`]. + #[inline] + #[must_use] + pub const fn capacity(&self) -> usize { + self.target.len() + } + + /// Get the number of bytes still available for `write_str` to consume. + /// + /// This is `capacity - position - reserve` (saturating on `reserve`), or `0` if [`WriteBuf::truncated`] is set, + /// since further writes via [`core::fmt::Write`] would immediately fail. Use this as a fast pre-check before a + /// `write!` call: + /// + /// ``` + /// use fmtbuf::WriteBuf; + /// use core::fmt::Write; + /// + /// let mut buf: [u8; 16] = [0xff; 16]; + /// let mut writer = WriteBuf::new(&mut buf); + /// write!(writer, "hello").unwrap(); + /// assert_eq!(writer.remaining(), 11); + /// ``` + /// + /// To query the raw arithmetic distance regardless of the truncated flag, use + /// `buf.capacity().saturating_sub(buf.position()).saturating_sub(buf.reserve())`. + #[inline] + #[must_use] + pub fn remaining(&self) -> usize { + if self.truncated { + 0 + } else { + // `position <= target.len()` by invariant; `reserve` is unconstrained by `with_reserve`'s contract, + // so saturate only the second subtraction. + (self.target.len() - self.position).saturating_sub(self.reserve) + } + } + /// Get if a truncated write has happened. + #[must_use] pub fn truncated(&self) -> bool { self.truncated } /// Get the count of reserved bytes. + #[must_use] pub fn reserve(&self) -> usize { self.reserve } @@ -101,12 +145,40 @@ impl<'a> WriteBuf<'a> { self.reserve = count; } + /// Reset the buffer for reuse. Sets [`position`](Self::position) to zero and clears the + /// [`truncated`](Self::truncated) flag, so the buffer can be written to again. The configured + /// [`reserve`](Self::reserve) is preserved, and the bytes already in the target are not overwritten (they are + /// already considered uninitialized/sentinel). + /// + /// ``` + /// use fmtbuf::WriteBuf; + /// use core::fmt::Write; + /// + /// let mut buf: [u8; 4] = [0xff; 4]; + /// let mut writer = WriteBuf::new(&mut buf); + /// // First attempt overflows. + /// let _ = write!(writer, "too long"); + /// assert!(writer.truncated()); + /// + /// // Reset and try a shorter string. + /// writer.clear(); + /// write!(writer, "ok").unwrap(); + /// assert_eq!(writer.written(), "ok"); + /// ``` + #[inline] + pub fn clear(&mut self) { + self.position = 0; + self.truncated = false; + } + /// Get the contents that have been written so far. + #[must_use] pub fn written_bytes(&self) -> &[u8] { &self.target[..self.position] } /// Get the contents that have been written so far. + #[must_use] pub fn written(&self) -> &str { // safety: The only way to write into the buffer is with valid UTF-8, so there is no reason to check the // contents for validity. @@ -118,8 +190,13 @@ impl<'a> WriteBuf<'a> { /// /// # Returns /// - /// In both the `Ok` and `Err` cases, the successfully-written portion of the output is returned as a `&str`. The - /// `Ok` case indicates the truncation did not occur, while `Err` indicates that it did. + /// On success, the successfully-written portion of the output as a `&str`. + /// + /// # Errors + /// + /// Returns `Err(Truncated)` if any prior write into this buffer was rejected by truncation. The `Truncated` + /// carries the same successfully-written `&str` that the `Ok` case would have returned, accessible via + /// [`Truncated::written`]. pub fn finish(self) -> Result<&'a str, Truncated<'a>> { self.into_result() } @@ -149,13 +226,19 @@ impl<'a> WriteBuf<'a> { /// let writer = WriteBuf::new(&mut buf); /// /// // Finish writing with too many bytes: - /// let written = writer.finish_with("12345").unwrap_err().get(); + /// let written = writer.finish_with("12345").unwrap_err().written(); /// assert_eq!(written, "2345"); /// ``` /// - /// # Returns + /// # Errors /// - /// The returned value has the same meaning as [`WriteBuf::finish`]. + /// Returns `Err(Truncated)` if any prior write into this buffer was rejected by truncation, or if `suffix` + /// did not fit alongside the prior content. The `Truncated` carries the successfully-written `&str` (which + /// includes the suffix when the suffix could be placed). See [`WriteBuf::finish`] for the full semantics. + #[allow( + clippy::used_underscore_items, + reason = "`_finish_with` is the shared internal helper for both `finish_with` and `finish_with_or`; the leading underscore disambiguates it from this public method." + )] pub fn finish_with(self, suffix: impl AsRef) -> Result<&'a str, Truncated<'a>> { let suffix = suffix.as_ref(); self._finish_with(suffix, suffix) @@ -178,8 +261,18 @@ impl<'a> WriteBuf<'a> { /// let mut buf: [u8; 4] = [0xff; 4]; /// let mut writer = WriteBuf::new(&mut buf); /// let _ = write!(writer, "abcdef"); - /// assert_eq!(writer.finish_with_or("!", "...").unwrap_err().get(), "a..."); + /// assert_eq!(writer.finish_with_or("!", "...").unwrap_err().written(), "a..."); /// ``` + /// + /// # Errors + /// + /// Returns `Err(Truncated)` when truncation occurred -- either because a prior write was rejected, or + /// because `normal_suffix` could not be placed and `truncated_suffix` was used instead. The `Truncated` + /// carries the successfully-written `&str`. See [`WriteBuf::finish`] for the full semantics. + #[allow( + clippy::used_underscore_items, + reason = "`_finish_with` is the shared internal helper for both `finish_with` and `finish_with_or`; the leading underscore disambiguates it from this public method." + )] pub fn finish_with_or( self, normal_suffix: impl AsRef, @@ -267,21 +360,37 @@ impl<'a> WriteBuf<'a> { } } -impl<'a> fmt::Write for WriteBuf<'a> { +impl fmt::Debug for WriteBuf<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WriteBuf") + .field("position", &self.position) + .field("capacity", &self.target.len()) + .field("reserve", &self.reserve) + .field("truncated", &self.truncated) + .field("written", &self.written()) + .finish() + } +} + +impl fmt::Write for WriteBuf<'_> { /// Append `s` to the target buffer. /// - /// # Error + /// # Errors /// - /// An error is returned if the entirety of `s` can not fit in the target buffer or if a previous `write_str` - /// operation failed. If this occurs, as much as `s` that can fit into the buffer will be written up to the last - /// valid Unicode code point. In other words, if the target buffer have 6 writable bytes left and `s` is the two - /// code points `"♡🐶"` (a.k.a.: the 7 byte `b"\xe2\x99\xa1\xf0\x9f\x90\xb6"`), then only `♡` will make it to the - /// output buffer, making the target of your ♡ ambiguous. + /// Returns `Err(fmt::Error)` if the entirety of `s` can not fit in the target buffer or if a previous + /// `write_str` operation failed. When the input doesn't fit, as much of `s` as can fit into the buffer will + /// be written up to the last valid Unicode code point. In other words, if the target buffer has 6 writable + /// bytes left and `s` is the two code points `"♡🐶"` (a.k.a. the 7 byte `b"\xe2\x99\xa1\xf0\x9f\x90\xb6"`), + /// then only `♡` will make it to the output buffer, making the target of your ♡ ambiguous. /// /// Truncation marks this buffer as truncated, which can be observed with [`WriteBuf::truncated`]. Future write /// attempts will immediately return in `Err`. This also affects the behavior of [`WriteBuf::finish`] family of /// functions, which will always return the `Err` case to indicate truncation. For [`WriteBuf::finish_with_or`], /// the `normal_suffix` will not be attempted. + #[allow( + clippy::used_underscore_items, + reason = "`_write` is the shared internal helper for `fmt::Write`; the leading underscore distinguishes it from the trait method." + )] fn write_str(&mut self, s: &str) -> fmt::Result { self._write(s.as_bytes()) } diff --git a/src/test.rs b/src/test.rs index c8642d7..ab86fcc 100644 --- a/src/test.rs +++ b/src/test.rs @@ -17,10 +17,10 @@ static TEST_CASES: &[(&str, usize)] = &[ #[test] fn rfind_utf8_end_test() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + for (input, last_valid_idx_after_cut) in TEST_CASES { let result = rfind_utf8_end(input.as_bytes()); assert_eq!(result, input.len(), "input=\"{input}\""); - if input.len() == 0 { + if input.is_empty() { continue; } let input_truncated = &input.as_bytes()[..input.len() - 1]; @@ -34,7 +34,7 @@ fn rfind_utf8_end_test() { #[test] fn format_enough_space() { - for (input, _) in TEST_CASES.iter() { + for (input, _) in TEST_CASES { let mut buf: [u8; 128] = [0xff; 128]; let mut writer = WriteBuf::new(&mut buf); @@ -48,9 +48,9 @@ fn format_enough_space() { #[test] fn format_enough_space_just_enough_reserved() { - for (input, _) in TEST_CASES.iter() { + for (input, _) in TEST_CASES { let mut buf: [u8; 128] = [0xff; 128]; - let mut writer = WriteBuf::with_reserve(&mut buf[..input.len() + 1], 1); + let mut writer = WriteBuf::with_reserve(&mut buf[..=input.len()], 1); writer.write_str(input).unwrap(); assert_eq!(input.len(), writer.position()); @@ -62,8 +62,8 @@ fn format_enough_space_just_enough_reserved() { #[test] fn format_truncation() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { - if input.len() == 0 { + for (input, last_valid_idx_after_cut) in TEST_CASES { + if input.is_empty() { continue; } @@ -75,7 +75,7 @@ fn format_truncation() { assert!(writer.truncated()); write!(writer, "!!!").expect_err("writes should fail here"); - let written = writer.finish().unwrap_err().get(); + let written = writer.finish().unwrap_err().written(); assert_eq!(*last_valid_idx_after_cut, written.len()); } } @@ -86,7 +86,7 @@ struct SimpleString { } impl SimpleString { - pub fn from_segments(segments: &[&str]) -> Self { + fn from_segments(segments: &[&str]) -> Self { let mut out = Self { storage: [0; 128], size: 0, @@ -97,13 +97,13 @@ impl SimpleString { out } - pub fn append(&mut self, value: &str) { + fn append(&mut self, value: &str) { let value = value.as_bytes(); self.storage[self.size..self.size + value.len()].copy_from_slice(value); self.size += value.len(); } - pub fn as_str(&self) -> &str { + fn as_str(&self) -> &str { core::str::from_utf8(&self.storage[..self.size]).unwrap() } } @@ -116,7 +116,7 @@ impl From<&str> for SimpleString { #[test] fn finish_with_enough_space() { - for (input, _) in TEST_CASES.iter() { + for (input, _) in TEST_CASES { let mut buf: [u8; 128] = [0xff; 128]; let mut writer = WriteBuf::new(&mut buf); @@ -130,8 +130,8 @@ fn finish_with_enough_space() { #[test] fn finish_with_overwrite() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { - if input.len() == 0 { + for (input, last_valid_idx_after_cut) in TEST_CASES { + if input.is_empty() { continue; } @@ -139,7 +139,7 @@ fn finish_with_overwrite() { let mut writer = WriteBuf::new(&mut buf[..input.len()]); writer.write_str(input).unwrap(); - let written = writer.finish_with("?").unwrap_err().get(); + let written = writer.finish_with("?").unwrap_err().written(); assert_eq!(written.len(), last_valid_idx_after_cut + 1); let expected_written = SimpleString::from_segments(&[ core::str::from_utf8(&input.as_bytes()[..*last_valid_idx_after_cut]).unwrap(), @@ -154,7 +154,7 @@ fn finish_with_or_with_longer_normal_closer() { let mut buf: [u8; 4] = [0xff; 4]; let writer = WriteBuf::new(&mut buf); - let written = writer.finish_with_or("0123456789", "abc").unwrap_err().get(); + let written = writer.finish_with_or("0123456789", "abc").unwrap_err().written(); assert_eq!(written.len(), 3); assert_eq!("abc", written); } @@ -164,7 +164,7 @@ fn finish_with_full_overwrite_utf8() { let mut buf: [u8; 4] = [0xff; 4]; let writer = WriteBuf::new(&mut buf); - let written = writer.finish_with("🚀12").unwrap_err().get(); + let written = writer.finish_with("🚀12").unwrap_err().written(); assert_eq!(written.len(), 2); assert_eq!("12", written); } @@ -177,7 +177,7 @@ fn finish_with_all_continuation_bytes() { let mut buf: [u8; 2] = [0xff; 2]; let writer = WriteBuf::new(&mut buf); - let written = writer.finish_with("🚀").unwrap_err().get(); + let written = writer.finish_with("🚀").unwrap_err().written(); assert_eq!(written, ""); } @@ -190,7 +190,7 @@ fn write_rejected_when_remaining_below_reserve() { assert!(writer.truncated()); assert_eq!(writer.position(), 0); - let written = writer.finish().unwrap_err().get(); + let written = writer.finish().unwrap_err().written(); assert_eq!(written, ""); } @@ -211,7 +211,7 @@ fn set_reserve_should_not_change_written() { #[test] fn truncated_result_ext_ok() { - for (input, _) in TEST_CASES.iter() { + for (input, _) in TEST_CASES { let mut buf: [u8; 128] = [0xff; 128]; let mut writer = WriteBuf::new(&mut buf); @@ -226,8 +226,8 @@ fn truncated_result_ext_ok() { #[test] fn truncated_result_ext_err() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { - if input.len() == 0 { + for (input, last_valid_idx_after_cut) in TEST_CASES { + if input.is_empty() { continue; } @@ -264,7 +264,7 @@ fn truncated_result_ext_empty_err() { #[test] fn truncated_result_ext_no_unwrap() { // The trait dispatches directly on the raw `Result` from the finish family, - // without needing an intermediate `.unwrap()` or `.get()`. + // without needing an intermediate `.unwrap()` or `.written()`. let mut buf: [u8; 4] = [0xff; 4]; let mut writer = WriteBuf::new(&mut buf); let _ = write!(writer, "abcdef"); @@ -272,3 +272,176 @@ fn truncated_result_ext_no_unwrap() { let written: &str = writer.finish_with_or("!", "...").written(); assert_eq!(written, "a..."); } + +#[test] +fn zero_length_buffer_does_not_panic() { + // `write_str` to an empty buffer is rejected (truncated), not a panic. + let mut buf: [u8; 0] = []; + let mut writer = WriteBuf::new(&mut buf); + write!(writer, "anything").unwrap_err(); + assert!(writer.truncated()); + assert_eq!(writer.position(), 0); + assert_eq!(writer.written(), ""); + + // `finish` on a fresh empty buffer reports `Ok("")` because no write was attempted. + let mut buf: [u8; 0] = []; + let written = WriteBuf::new(&mut buf).finish().unwrap(); + assert_eq!(written, ""); + + // `finish_with` on a fresh empty buffer cannot place the suffix; reports truncated empty. + let mut buf: [u8; 0] = []; + let result = WriteBuf::new(&mut buf).finish_with("x"); + assert!(result.is_err()); + assert_eq!(result.written(), ""); + + // `finish_with_or` likewise. + let mut buf: [u8; 0] = []; + let result = WriteBuf::new(&mut buf).finish_with_or("!", "..."); + assert!(result.is_err()); + assert_eq!(result.written(), ""); +} + +#[test] +fn reserve_larger_than_buffer_through_finish_with() { + // `with_reserve` allows `reserve > target.len()`; no write succeeds, and the finish family + // still does its best to place a suffix (writes ignore reserve once the buffer is being + // closed out). + let mut buf: [u8; 4] = [0xff; 4]; + let mut writer = WriteBuf::with_reserve(&mut buf, 10); + write!(writer, "x").unwrap_err(); + assert!(writer.truncated()); + let result = writer.finish_with("!"); + assert!(result.is_err(), "buffer is marked truncated"); + assert_eq!(result.written(), "!"); + + let mut buf: [u8; 4] = [0xff; 4]; + let mut writer = WriteBuf::with_reserve(&mut buf, 10); + write!(writer, "x").unwrap_err(); + let result = writer.finish_with_or("normal", "trunc"); + assert!(result.is_err()); + // `truncated` (5 bytes) does not fit in the 4-byte buffer; the documented behavior is to + // keep the last bytes that start at a valid UTF-8 boundary -- here, "runc". + assert_eq!(result.written(), "runc"); +} + +#[test] +fn truncation_flag_survives_subsequent_writes() { + // After a write hits truncation, the next `write_str` must immediately return `Err` and + // leave the previous `position` unchanged. + let mut buf: [u8; 4] = [0xff; 4]; + let mut writer = WriteBuf::new(&mut buf); + write!(writer, "abcdef").unwrap_err(); + assert!(writer.truncated()); + let position_after_first = writer.position(); + + write!(writer, "ghi").unwrap_err(); + assert!(writer.truncated()); + assert_eq!(writer.position(), position_after_first); + + let result = writer.finish_with_or("!", "..."); + assert!(result.is_err()); + assert_eq!(result.written(), "a..."); +} + +#[cfg(feature = "std")] +#[test] +fn debug_format_describes_state() { + // The `Debug` impl shows the user-visible state but never the raw target bytes (those + // can be uninitialized sentinels). Gated on `std` because `format!` requires `alloc`. + let mut buf: [u8; 8] = [0xff; 8]; + let mut writer = WriteBuf::new(&mut buf); + write!(writer, "hi").unwrap(); + let s = format!("{writer:?}"); + assert!(s.contains("position: 2"), "{s}"); + assert!(s.contains("capacity: 8"), "{s}"); + assert!(s.contains("reserve: 0"), "{s}"); + assert!(s.contains("truncated: false"), "{s}"); + assert!(s.contains("written: \"hi\""), "{s}"); + assert!(!s.contains("0xff"), "raw target bytes leaked: {s}"); +} + +#[test] +fn capacity_returns_target_len() { + let mut buf: [u8; 16] = [0xff; 16]; + let writer = WriteBuf::new(&mut buf); + assert_eq!(writer.capacity(), 16); + + let mut buf: [u8; 0] = []; + let writer = WriteBuf::new(&mut buf); + assert_eq!(writer.capacity(), 0); + + let mut buf: [u8; 8] = [0xff; 8]; + let writer = WriteBuf::with_reserve(&mut buf, 3); + assert_eq!( + writer.capacity(), + 8, + "capacity reflects target.len(), not target.len() - reserve" + ); +} + +#[test] +fn remaining_decreases_with_writes() { + let mut buf: [u8; 16] = [0xff; 16]; + let mut writer = WriteBuf::new(&mut buf); + assert_eq!(writer.remaining(), 16); + + write!(writer, "hello").unwrap(); + assert_eq!(writer.remaining(), 11); + assert_eq!(writer.position() + writer.remaining(), writer.capacity()); +} + +#[test] +fn remaining_subtracts_reserve() { + let mut buf: [u8; 10] = [0xff; 10]; + let mut writer = WriteBuf::with_reserve(&mut buf, 3); + assert_eq!(writer.remaining(), 7); + + write!(writer, "abc").unwrap(); + assert_eq!(writer.remaining(), 4); + + // Reserve larger than the buffer saturates `remaining` to zero rather than underflowing. + let mut buf: [u8; 5] = [0xff; 5]; + let writer = WriteBuf::with_reserve(&mut buf, 100); + assert_eq!(writer.remaining(), 0); +} + +#[test] +fn remaining_is_zero_after_truncation() { + let mut buf: [u8; 4] = [0xff; 4]; + let mut writer = WriteBuf::new(&mut buf); + write!(writer, "abcdef").unwrap_err(); + assert!(writer.truncated()); + // Truncation may have rolled `position` back from a multi-byte UTF-8 boundary, leaving + // raw arithmetic capacity behind, but `remaining` reports zero because further writes + // would immediately fail. + assert_eq!(writer.remaining(), 0); +} + +#[test] +fn clear_resets_position_and_truncation() { + let mut buf: [u8; 4] = [0xff; 4]; + let mut writer = WriteBuf::new(&mut buf); + let _ = write!(writer, "too long"); + assert!(writer.truncated()); + + writer.clear(); + assert_eq!(writer.position(), 0); + assert!(!writer.truncated()); + assert_eq!(writer.remaining(), 4); + assert_eq!(writer.written(), ""); + + // The cleared buffer accepts a fresh write. + write!(writer, "ok").unwrap(); + assert_eq!(writer.written(), "ok"); + assert_eq!(writer.position(), 2); +} + +#[test] +fn clear_preserves_reserve() { + let mut buf: [u8; 8] = [0xff; 8]; + let mut writer = WriteBuf::with_reserve(&mut buf, 3); + write!(writer, "abc").unwrap(); + writer.clear(); + assert_eq!(writer.reserve(), 3, "clear must not touch the reserve config"); + assert_eq!(writer.remaining(), 5); +} diff --git a/src/truncated.rs b/src/truncated.rs index de39978..3bf2cee 100644 --- a/src/truncated.rs +++ b/src/truncated.rs @@ -1,19 +1,30 @@ +#![forbid(unsafe_code)] + //! Contains the [`Truncated`] error type and the [`TruncatedResultExt`] helper trait. use core::fmt; /// An error type indicating that a result was truncated. /// -/// This is used from [`WriteBuf`] functions in the `Result::Err` case. It only provides access to the inner value; -/// for the `WriteBuf` finish family, `Truncated<'_>` records the part of the string slice that was validly -/// written before truncation. +/// This is used from [`WriteBuf`](crate::WriteBuf) functions in the `Result::Err` case. It only provides access +/// to the inner value; for the `WriteBuf` finish family, `Truncated<'_>` records the part of the string slice +/// that was validly written before truncation. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct Truncated<'a>(pub(crate) &'a str); impl<'a> Truncated<'a> { - /// Get the inner string slice. - pub fn get(&self) -> &'a str { + /// Get the successfully-written portion of the output. This is the same `&str` that the `Ok` arm of the + /// originating [`WriteBuf::finish`](crate::WriteBuf::finish) call would have carried. + #[must_use] + pub fn written(&self) -> &'a str { self.0 } + + /// Get the byte length of the successfully-written portion. + #[must_use] + pub fn written_len(&self) -> usize { + self.0.len() + } } impl fmt::Debug for Truncated<'_> { @@ -82,7 +93,7 @@ impl<'a> TruncatedResultExt<'a> for Result<&'a str, Truncated<'a>> { fn written(&self) -> &'a str { match self { Ok(s) => s, - Err(t) => t.get(), + Err(t) => t.written(), } } diff --git a/src/utf8.rs b/src/utf8.rs index 2f728f1..ec6f51e 100644 --- a/src/utf8.rs +++ b/src/utf8.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + const CODE_UNIT_INDICATE_WIDTH: [u8; 256] = [ // low order nibble // 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f @@ -22,7 +24,7 @@ const CODE_UNIT_INDICATE_WIDTH: [u8; 256] = [ /// If `code_unit` is a UTF-8 starting character, then return `Some(len)`, where `len` is the number of code units the /// encoded run represents. If `code_unit` is a continuation character (a value seen in the middle of an encoded run), /// then return `None`. -pub const fn utf8_char_width(code_unit: u8) -> Option { +pub(crate) const fn utf8_char_width(code_unit: u8) -> Option { let x = CODE_UNIT_INDICATE_WIDTH[code_unit as usize]; if x == 0 { None diff --git a/tests/proptest.rs b/tests/proptest.rs new file mode 100644 index 0000000..830e31a --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,121 @@ +//! Property tests for the `fmtbuf` UTF-8 truncation invariants. These run against the public API. +//! +//! For any UTF-8 input and any buffer size, the crate must: +//! +//! 1. Never panic (under any input). +//! 2. Produce valid UTF-8 in the written portion. +//! 3. Keep state consistent: `written()`, `written_bytes()`, `position()`, and `Truncated::written_len()` +//! all agree on length. +//! 4. For `finish()`: the result is a prefix of the original input (truncated at a valid char boundary +//! when the input doesn't fit). + +use core::fmt::Write; +use fmtbuf::{TruncatedResultExt, WriteBuf}; +use proptest::prelude::*; + +fn write_then_finish<'a>(buf: &'a mut [u8], input: &str) -> Result<&'a str, fmtbuf::Truncated<'a>> { + let mut writer = WriteBuf::new(buf); + let _ = writer.write_str(input); + writer.finish() +} + +fn write_then_finish_with<'a>(buf: &'a mut [u8], input: &str, suffix: &str) -> Result<&'a str, fmtbuf::Truncated<'a>> { + let mut writer = WriteBuf::new(buf); + let _ = writer.write_str(input); + writer.finish_with(suffix) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn finish_never_panics_and_yields_valid_utf8( + input in ".{0,64}", + buf_len in 0usize..=128, + ) { + let mut buf = vec![0u8; buf_len]; + let result = write_then_finish(&mut buf, &input); + let written = result.written(); + // valid_utf8: enforced by the type (returning &str), but also assert the bytes form + // valid UTF-8 if interpreted directly. + prop_assert!(core::str::from_utf8(written.as_bytes()).is_ok()); + } + + #[test] + fn finish_result_is_prefix_of_input( + input in ".{0,64}", + buf_len in 0usize..=128, + ) { + let mut buf = vec![0u8; buf_len]; + let result = write_then_finish(&mut buf, &input); + let written = result.written(); + prop_assert!( + input.starts_with(written), + "written {written:?} is not a prefix of input {input:?}" + ); + // If we report no truncation, the entire input must have made it through. + if !result.is_truncated() { + prop_assert_eq!(written, input.as_str()); + } + } + + #[test] + fn lengths_are_consistent_after_finish( + input in ".{0,64}", + buf_len in 0usize..=128, + ) { + let mut buf = vec![0u8; buf_len]; + let result = write_then_finish(&mut buf, &input); + let len_via_ext = result.written_len(); + let len_via_str = result.written().len(); + prop_assert_eq!(len_via_ext, len_via_str); + if let Err(t) = result { + prop_assert_eq!(t.written_len(), t.written().len()); + prop_assert_eq!(t.written_len(), len_via_ext); + } + } + + #[test] + fn finish_with_never_panics_and_yields_valid_utf8( + input in ".{0,64}", + suffix in ".{0,16}", + buf_len in 0usize..=128, + ) { + let mut buf = vec![0u8; buf_len]; + let result = write_then_finish_with(&mut buf, &input, &suffix); + let written = result.written(); + prop_assert!(core::str::from_utf8(written.as_bytes()).is_ok()); + prop_assert!(written.len() <= buf_len); + } + + #[test] + fn finish_with_ok_ends_with_suffix( + input in ".{0,32}", + suffix in ".{0,16}", + buf_len in 0usize..=128, + ) { + let mut buf = vec![0u8; buf_len]; + let result = write_then_finish_with(&mut buf, &input, &suffix); + if let Ok(written) = result { + prop_assert!( + written.ends_with(&*suffix), + "Ok-arm result {written:?} should end with suffix {suffix:?}" + ); + } + } + + #[test] + fn position_matches_written_after_writes( + inputs in proptest::collection::vec(".{0,16}", 0..4), + buf_len in 0usize..=64, + ) { + let mut buf = vec![0u8; buf_len]; + let mut writer = WriteBuf::new(&mut buf); + for input in &inputs { + let _ = writer.write_str(input); + } + prop_assert_eq!(writer.position(), writer.written().len()); + prop_assert_eq!(writer.position(), writer.written_bytes().len()); + prop_assert!(core::str::from_utf8(writer.written_bytes()).is_ok()); + } +}