Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
66 changes: 66 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 21 additions & 3 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
17 changes: 14 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------

Expand Down
13 changes: 12 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion examples/writebuf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");

Expand Down
5 changes: 5 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target
corpus
artifacts
coverage
Cargo.lock
32 changes: 32 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions fuzz/fuzz_targets/finish_with.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
truncate_with: Option<String>,
}

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);
}
});
92 changes: 92 additions & 0 deletions fuzz/fuzz_targets/write_sequence.rs
Original file line number Diff line number Diff line change
@@ -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<Op>,
}

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);
});
Loading
Loading