From bd5178f3ac24c55ae4322df552ad6632a17efee5 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 10:45:54 -0600 Subject: [PATCH 01/19] Fix clippy and rustdoc warnings - Replace `x.len() == 0` with `x.is_empty()` in test.rs - Qualify the `[WriteBuf]` intra-doc link in `Truncated`'s docs as `[WriteBuf](crate::WriteBuf)` so rustdoc resolves it from inside the `truncated` module This makes the workspace clean under `-D warnings` so we can start enforcing it in CI. --- src/test.rs | 8 ++++---- src/truncated.rs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test.rs b/src/test.rs index c8642d7..20a1fc9 100644 --- a/src/test.rs +++ b/src/test.rs @@ -20,7 +20,7 @@ fn rfind_utf8_end_test() { for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { 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]; @@ -63,7 +63,7 @@ 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 { + if input.is_empty() { continue; } @@ -131,7 +131,7 @@ 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 { + if input.is_empty() { continue; } @@ -227,7 +227,7 @@ 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 { + if input.is_empty() { continue; } diff --git a/src/truncated.rs b/src/truncated.rs index de39978..246177a 100644 --- a/src/truncated.rs +++ b/src/truncated.rs @@ -4,9 +4,9 @@ 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. pub struct Truncated<'a>(pub(crate) &'a str); impl<'a> Truncated<'a> { From a4ab17a4ffc023bce6fc92b064382a6e37b721b9 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 10:47:19 -0600 Subject: [PATCH 02/19] Fix `!#[no_std]` typo in README The Rust crate-level attribute syntax is `#![no_std]`, not `!#[no_std]`. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f4ca985..ef8c9a8 100644 --- a/README.md +++ b/README.md @@ -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 } From cb3a8c22d2d0b7f2b28a6ce8f50402f51d285d8d Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 10:48:18 -0600 Subject: [PATCH 03/19] CI checks: fail on doc and clippy warnings - Add `RUSTDOCFLAGS: -D warnings` to the docs step and switch to `cargo doc --no-deps` so we only validate this crate's docs. - Pass `--all-targets --all-features -- -D warnings` to clippy so any future warning fails the build. - Bump `actions/checkout@v3` to `@v4`. --- .github/workflows/unit-test.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 57a7fed..e474c2b 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,10 @@ 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 From 913e5707174ba15ea398dbe101dbef73627c8ddc Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:03:34 -0600 Subject: [PATCH 04/19] Add Cargo `[lints]` table and address pedantic findings Centralize lint configuration in `Cargo.toml` so individual files no longer need crate-level `#![deny(...)]` attributes. This required a few minor changes: - `#[must_use]` on the `WriteBuf` accessors and `Truncated::get`. - `#[allow(clippy::used_underscore_items, reason = "...")]` on the three public methods that delegate to the `_finish_with` / `_write` helpers, since the leading underscore is intentional to disambiguate them from the public API. - `#[allow(clippy::doc_link_with_quotes, reason = "...")]` on the README include, because the readme uses real Markdown links with quoted Unicode names like `["U+200D" "Zero Width Joiner"]` that clippy mistakes for malformed intra-doc links. - Restructured the doc-comments on `WriteBuf::finish`, `finish_with`, `finish_with_or`, and the `fmt::Write` impl to use `# Errors` (the convention) for the `Err` case. - Replaced the test-only `pub` helpers with `fn` (private), restricted `utf8_char_width` to `pub(crate)`, dropped `.iter()` on `for ... in TEST_CASES`, swapped `..input.len() + 1` for `..=input.len()`, and elided the redundant lifetime in `impl fmt::Write for WriteBuf<'_>`. --- Cargo.toml | 9 ++++++++ src/lib.rs | 56 ++++++++++++++++++++++++++++++++++++++---------- src/test.rs | 24 ++++++++++----------- src/truncated.rs | 1 + src/utf8.rs | 2 +- 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cf6547e..d29ae68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,12 @@ clap = { version = "4.0.0", features = ["derive"] } [badges] maintenance = { status = "experimental" } # this line is currently experimental + +[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/src/lib.rs b/src/lib.rs index eb5c183..82e6a76 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; @@ -80,16 +84,19 @@ 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 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 } @@ -102,11 +109,13 @@ impl<'a> WriteBuf<'a> { } /// 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 +127,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::get`]. pub fn finish(self) -> Result<&'a str, Truncated<'a>> { self.into_result() } @@ -153,9 +167,15 @@ impl<'a> WriteBuf<'a> { /// 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) @@ -180,6 +200,16 @@ impl<'a> WriteBuf<'a> { /// let _ = write!(writer, "abcdef"); /// assert_eq!(writer.finish_with_or("!", "...").unwrap_err().get(), "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 +297,25 @@ impl<'a> WriteBuf<'a> { } } -impl<'a> fmt::Write for WriteBuf<'a> { +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 20a1fc9..5561e82 100644 --- a/src/test.rs +++ b/src/test.rs @@ -17,7 +17,7 @@ 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.is_empty() { @@ -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,7 +62,7 @@ fn format_enough_space_just_enough_reserved() { #[test] fn format_truncation() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + for (input, last_valid_idx_after_cut) in TEST_CASES { if input.is_empty() { continue; } @@ -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,7 +130,7 @@ fn finish_with_enough_space() { #[test] fn finish_with_overwrite() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + for (input, last_valid_idx_after_cut) in TEST_CASES { if input.is_empty() { continue; } @@ -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,7 +226,7 @@ fn truncated_result_ext_ok() { #[test] fn truncated_result_ext_err() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + for (input, last_valid_idx_after_cut) in TEST_CASES { if input.is_empty() { continue; } diff --git a/src/truncated.rs b/src/truncated.rs index 246177a..9602f99 100644 --- a/src/truncated.rs +++ b/src/truncated.rs @@ -11,6 +11,7 @@ pub struct Truncated<'a>(pub(crate) &'a str); impl<'a> Truncated<'a> { /// Get the inner string slice. + #[must_use] pub fn get(&self) -> &'a str { self.0 } diff --git a/src/utf8.rs b/src/utf8.rs index 2f728f1..067bc83 100644 --- a/src/utf8.rs +++ b/src/utf8.rs @@ -22,7 +22,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 From 02af9e03a30f7e08843559c2bf518f1ec23a8fd8 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:05:46 -0600 Subject: [PATCH 05/19] Polish Cargo.toml metadata - Add `documentation = "https://docs.rs/fmtbuf"`. While docs.rs is the de facto default, an explicit field shows up in tooling and crates.io listings. - Bump the maintenance badge from `experimental` to `passively-maintained`. The crate is feature-stable for the 0.2.0 release; new versions will be bug fixes and small improvements. --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d29ae68..bb73ca1 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"] @@ -21,7 +22,7 @@ std = [] clap = { version = "4.0.0", features = ["derive"] } [badges] -maintenance = { status = "experimental" } # this line is currently experimental +maintenance = { status = "passively-maintained" } [lints.rust] missing_docs = "deny" From c2eb5a9d2e0914ae78020d808806a552d636522a Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:06:53 -0600 Subject: [PATCH 06/19] Derive `Clone`, `Copy`, `PartialEq`, `Eq`, and `Hash` on `Truncated` These all come for free since the inner `&'a str` already implements them. They make `Truncated` usable in tests (assert_eq! on results), hash maps and sets, and pattern-match guards without callers having to unwrap the inner string first. --- src/truncated.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/truncated.rs b/src/truncated.rs index 9602f99..f0e1a15 100644 --- a/src/truncated.rs +++ b/src/truncated.rs @@ -7,6 +7,7 @@ use core::fmt; /// 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> { From 85af8bb16a07792718912d82d5082b773fbd9561 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:08:44 -0600 Subject: [PATCH 07/19] Rename `Truncated::get` to `written` and add `written_len` The inherent accessors on `Truncated` now mirror the methods on `TruncatedResultExt`, so callers see the same vocabulary whether they are holding a `Truncated` directly or a `Result<&str, Truncated<'_>>`: match writer.finish_with("...") { Ok(s) => s, Err(e) => { eprintln!("truncated at {} bytes", e.written_len()); e.written() } } `get` was a poor name in retrospect: `Truncated` is an error type that *carries* the validly-written string, not a string-like value. --- CHANGELOG.md | 4 ++-- README.md | 2 +- examples/writebuf.rs | 2 +- src/lib.rs | 8 ++++---- src/test.rs | 14 +++++++------- src/truncated.rs | 13 ++++++++++--- 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f310e..84d9300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,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/README.md b/README.md index ef8c9a8..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); 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/src/lib.rs b/src/lib.rs index 82e6a76..5a1160f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,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 { @@ -133,7 +133,7 @@ impl<'a> WriteBuf<'a> { /// /// 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::get`]. + /// [`Truncated::written`]. pub fn finish(self) -> Result<&'a str, Truncated<'a>> { self.into_result() } @@ -163,7 +163,7 @@ 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"); /// ``` /// @@ -198,7 +198,7 @@ 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 diff --git a/src/test.rs b/src/test.rs index 5561e82..8d3706c 100644 --- a/src/test.rs +++ b/src/test.rs @@ -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()); } } @@ -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, ""); } @@ -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"); diff --git a/src/truncated.rs b/src/truncated.rs index f0e1a15..352ffa2 100644 --- a/src/truncated.rs +++ b/src/truncated.rs @@ -11,11 +11,18 @@ use core::fmt; pub struct Truncated<'a>(pub(crate) &'a str); impl<'a> Truncated<'a> { - /// Get the inner string slice. + /// 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 get(&self) -> &'a str { + 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<'_> { @@ -84,7 +91,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(), } } From 678d6dc752a471e18d4b49b44b44a1724c2513f9 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:10:02 -0600 Subject: [PATCH 08/19] Implement `fmt::Debug` for `WriteBuf` Lets users `dbg!()` and log a `WriteBuf`. The implementation prints the position, total capacity (`target.len()`), reserve, truncated flag, and the validly-written portion as a `&str`. The raw target bytes are deliberately not printed: per the type docs, the bytes beyond `position` may be uninitialized sentinels that are not meaningful to the caller. --- src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 5a1160f..0c25194 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -297,6 +297,18 @@ impl<'a> 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. /// From 26a25135d8326dd37c5303e351ee13bf615e5f39 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:15:40 -0600 Subject: [PATCH 09/19] Cover edge cases and the new `Debug` impl with regression tests 4 new tests: - `zero_length_buffer_does_not_panic`: exercises every public entry point on a `WriteBuf` constructed with a zero-length slice. Each must report truncation rather than panicking. - `reserve_larger_than_buffer_through_finish_with`: pins down the observable behavior when `reserve > target.len()` -- writes are rejected, but `finish_with` and `finish_with_or` still place the truncated suffix (using the documented "last bytes that start at a valid UTF-8 boundary" rule when the suffix is too long). - `truncation_flag_survives_subsequent_writes`: after a write hits truncation, a second `write_str` must immediately `Err` and leave `position` unchanged; `finish_with_or` then uses the truncated suffix. - `debug_format_describes_state` (std-only, since `format!` needs `alloc`): asserts the new `WriteBuf` `Debug` impl shows the user-visible state and never the raw target bytes (which can be uninitialized sentinels). --- src/test.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/test.rs b/src/test.rs index 8d3706c..f03aeb3 100644 --- a/src/test.rs +++ b/src/test.rs @@ -272,3 +272,90 @@ 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}"); +} From 8b4cc909e5676212706b9cbe2a803f493179c562 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:19:07 -0600 Subject: [PATCH 10/19] Add `proptest` property tests for the UTF-8 invariants The new `tests/proptest.rs` exercises the public API with random inputs and buffer sizes 512 times, checking these properties: - `finish` never panics and produces valid UTF-8 (the type system already enforces this, but assert it directly via `core::str::from_utf8` on the underlying bytes). - `finish` results are always a prefix of the input string, and the entire input round-trips when `is_truncated()` is false. - All length accessors agree: `written().len()`, `written_bytes().len()`, `position()`, and (when the result is `Err`) `Truncated::written_len()`. - `finish_with` never panics, produces valid UTF-8, and the output never exceeds the buffer size. - `finish_with` `Ok` results always end with the requested suffix. - After a sequence of `write_str` calls of arbitrary lengths, `position()` matches `written().len()` and the bytes form valid UTF-8. --- Cargo.toml | 1 + tests/proptest.rs | 121 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/proptest.rs diff --git a/Cargo.toml b/Cargo.toml index bb73ca1..c3e1807 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ std = [] [dev-dependencies] clap = { version = "4.0.0", features = ["derive"] } +proptest = "1" [badges] maintenance = { status = "passively-maintained" } 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()); + } +} From 899e22ffeba2e8d70013577f0cecea418f2d1c9c Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:22:02 -0600 Subject: [PATCH 11/19] Update CHANGELOG for the 0.2.0 additions Adds an `[Unreleased]` placeholder so post-0.2.0 work has a place to land, and expands the `## [0.2.0]` Added section to cover the items added during the release-readiness pass: - `Truncated` derives `Clone`, `Copy`, `PartialEq`, `Eq`, `Hash`. - `Truncated::written()` (renamed from the never-released `get`) and `Truncated::written_len()` for parity with `TruncatedResultExt`. - `fmt::Debug` for `WriteBuf`. --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84d9300..82123c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,21 @@ All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + ## [0.2.0] ### 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). ### Changed From d39764535d156e409316093f4a94b68cc94a8ecd Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:24:47 -0600 Subject: [PATCH 12/19] Add MSRV CI job pinned to `rust-version` Catches the case where a contributor inadvertently uses a feature newer than the declared MSRV. The job reads the `rust-version` field out of `Cargo.toml`, installs that exact toolchain, and builds the crate against both feature configurations (`default` and `--no-default-features`). `cargo build` rather than `cargo test` here, because dev-dependencies (proptest, clap) may bump their MSRVs ahead of ours. Library MSRV applies to consumers, not test tooling. --- .github/workflows/unit-test.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index e474c2b..cef39b8 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -30,3 +30,19 @@ jobs: run: cargo fmt --check - name: 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 From e4eda4b44f747f96609386657bab4dfd70a61607 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:28:07 -0600 Subject: [PATCH 13/19] Add `cargo fuzz` harness for the truncation logic The fuzz harness exercises both `finish` and `finish_with` / `finish_with_or` against arbitrary `(buf_len, reserve, input, finish_with, truncate_with)` tuples, asserting that: - The written portion is always valid UTF-8. - The written byte length never exceeds the original buffer size. - No panic under any input Run on demand with: cargo +nightly fuzz run finish_with -- -max_total_time=60 --- fuzz/.gitignore | 5 ++++ fuzz/Cargo.toml | 25 +++++++++++++++++ fuzz/fuzz_targets/finish_with.rs | 48 ++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/finish_with.rs 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..719efe8 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,25 @@ +[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 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); + } +}); From 9a3090e1bd2bc9f26b2d735f64d55236000797fa Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:36:02 -0600 Subject: [PATCH 14/19] Run the fuzzer in CI on PRs touching fuzz-relevant code Gate every PR that modifies `src/`, `fuzz/`, or `Cargo.toml` with a short fuzz session. PR-time is the right place for this -- after a bug-introducing change merges, fuzz only confirms it broke; before merge, fuzz can block it. `push: trunk` is kept as a backstop for direct pushes that bypass review, and `workflow_dispatch` is wired up for ad-hoc longer runs (e.g. before tagging a release). The corpus is cached under a shared prefix so PRs and trunk runs all build on whatever corpus the most recent run left behind. Per-run `key` (`github.run_id`) ensures every run writes a fresh entry even when the prefix matches a previous one. Crash inputs under `fuzz/artifacts/` are uploaded on failure for triage. No `schedule:` -- the fuzzer offers no signal when the code hasn't changed, so a periodic run would just burn CI minutes. Path filtering keeps docs-only and unrelated PRs from paying the cost. --- .github/workflows/fuzz.yml | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/fuzz.yml diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..3e56a8b --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,61 @@ +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: 'Total fuzzing time in seconds' + required: false + default: '300' + +env: + CARGO_TERM_COLOR: always + +jobs: + fuzz: + runs-on: ubuntu-latest + 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 shared `fuzz-corpus-finish_with-` prefix, so every run + # bootstraps from whatever corpus the most recent run left behind. This keeps the + # corpus growing across PRs and pushes without ref-specific fragmentation. + uses: actions/cache@v4 + with: + path: fuzz/corpus + key: fuzz-corpus-finish_with-${{ github.run_id }} + restore-keys: fuzz-corpus-finish_with- + - name: Run fuzzer + run: | + cargo +nightly fuzz run finish_with -- \ + -max_total_time=${{ github.event.inputs.duration_seconds || '300' }} + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts + path: fuzz/artifacts/ + if-no-files-found: ignore From 69f2629bf845f8692166184c31fe531e660ab19a Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:46:58 -0600 Subject: [PATCH 15/19] Forbid `unsafe_code` in `truncated` and `utf8` modules Both modules currently contain zero `unsafe`. Any future edit that tries to add `unsafe` to either module fails the build rather than slipping in unnoticed. --- src/truncated.rs | 2 ++ src/utf8.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/truncated.rs b/src/truncated.rs index 352ffa2..3bf2cee 100644 --- a/src/truncated.rs +++ b/src/truncated.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Contains the [`Truncated`] error type and the [`TruncatedResultExt`] helper trait. use core::fmt; diff --git a/src/utf8.rs b/src/utf8.rs index 067bc83..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 From 7446eacf1182962877f344742add9c8b085bd3d3 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:48:10 -0600 Subject: [PATCH 16/19] Tighten SECURITY.md description of the unsafe surface The previous wording said "there is only one `unsafe` block". Strictly there are three `unsafe { ... }` blocks in `src/lib.rs`, all calling the single helper `from_utf8_expect`. The user-facing claim ("one bypass") was still true, but the wording no longer matched the code. The new text describes what is actually there: one `unsafe fn` that performs the bypass, an invariant upheld by `core::fmt::Write` and verified in debug builds, and `#![forbid(unsafe_code)]` on the sibling modules so the unsafe surface cannot grow accidentally. --- .github/SECURITY.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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. From b5a93e7742267eb193d751ca6abc078182407583 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:48:44 -0600 Subject: [PATCH 17/19] Document the MSRV in CONTRIBUTING.md After the MSRV CI job was added, a contributor with a recent toolchain can write code that compiles locally but fails the 1.81 build on CI. Mention the `rust-version` field in `Cargo.toml` as the source of truth and call out that CI enforces it, so the failure mode is documented rather than surprising. --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) 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 -------- From 2615fd23787a0fa18d9e18edc7ff9443662d32c2 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 11:54:09 -0600 Subject: [PATCH 18/19] Add `WriteBuf::capacity`, `remaining`, and `clear` Three small accessors that round out the inspection and reuse surface of `WriteBuf`: - `capacity` returns the size of the target buffer (`target.len()`), the constant counterpart to `position`. - `remaining` returns the number of bytes still available for `write_str`. Truncation-aware: returns `0` when the buffer is marked truncated, since further writes via `core::fmt::Write` would error immediately. Saturates when `reserve > capacity` rather than underflowing. - `clear` resets `position` and the `truncated` flag so the buffer can be reused for a new formatting attempt. The configured `reserve` is preserved (it's a config setting, not per-write state). --- CHANGELOG.md | 7 +++-- src/lib.rs | 63 ++++++++++++++++++++++++++++++++++++++ src/test.rs | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82123c3..3da96f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - ## [0.2.0] ### Added @@ -20,6 +18,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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 diff --git a/src/lib.rs b/src/lib.rs index 0c25194..8879fff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,6 +89,43 @@ impl<'a> WriteBuf<'a> { 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 { @@ -108,6 +145,32 @@ 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] { diff --git a/src/test.rs b/src/test.rs index f03aeb3..ab86fcc 100644 --- a/src/test.rs +++ b/src/test.rs @@ -359,3 +359,89 @@ fn debug_format_describes_state() { 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); +} From 970605ef43800c1c83cc217bbd0b473190dd6893 Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Wed, 29 Apr 2026 12:02:34 -0600 Subject: [PATCH 19/19] Add `write_sequence` fuzz target for multi-call write paths The existing `finish_with` target exercises the suffix-placement path (one `write_str` then `finish_with*`). The new `write_sequence` target focuses on the orthogonal axis: a random sequence of `write_str`, `clear`, and `set_reserve` calls, asserting type-wide invariants after every operation. This catches state-corruption bugs that only surface when truncation hits midstream and the caller continues writing or reconfigures the reserve, which the suffix target cannot reach. Invariants enforced after each op: - Once truncated, every subsequent `write_str` must `Err`. - `capacity()` is fixed to the buffer length. - `position()` matches `written().len()` and `written_bytes().len()`. - `written_bytes()` is always valid UTF-8. - `remaining()` matches the public formula `truncated ? 0 : capacity - position - reserve` (saturating). - `clear()` resets position and the truncated flag. CI: switched the `fuzz.yml` workflow to a matrix over `[finish_with, write_sequence]` so both run in parallel on PRs that touch fuzz-relevant code. Per-target corpus caching (`fuzz/corpus/`) means the targets do not share state. The default per-target time is 150 s; previously it was 300 s total for the single target. --- .github/workflows/fuzz.yml | 29 +++++---- fuzz/Cargo.toml | 7 +++ fuzz/fuzz_targets/write_sequence.rs | 92 +++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 fuzz/fuzz_targets/write_sequence.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 3e56a8b..ae372d1 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -22,9 +22,9 @@ on: workflow_dispatch: inputs: duration_seconds: - description: 'Total fuzzing time in seconds' + description: 'Per-target fuzzing time in seconds' required: false - default: '300' + default: '150' env: CARGO_TERM_COLOR: always @@ -32,6 +32,12 @@ env: 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 @@ -40,22 +46,21 @@ jobs: 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 shared `fuzz-corpus-finish_with-` prefix, so every run - # bootstraps from whatever corpus the most recent run left behind. This keeps the - # corpus growing across PRs and pushes without ref-specific fragmentation. + # `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 - key: fuzz-corpus-finish_with-${{ github.run_id }} - restore-keys: fuzz-corpus-finish_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 finish_with -- \ - -max_total_time=${{ github.event.inputs.duration_seconds || '300' }} + 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 - path: fuzz/artifacts/ + name: fuzz-artifacts-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }}/ if-no-files-found: ignore diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 719efe8..11918fc 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -23,3 +23,10 @@ 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/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); +});