Skip to content

feat(compressors): add streaming compression crate - #722

Open
martintmk wants to merge 103 commits into
mainfrom
user/martintomka/20260901-add-compressors-crate
Open

feat(compressors): add streaming compression crate#722
martintmk wants to merge 103 commits into
mainfrom
user/martintomka/20260901-add-compressors-crate

Conversation

@martintmk

@martintmk martintmk commented Sep 2, 2026

Copy link
Copy Markdown
Member

Adds compressors, a streaming compression crate for bytesbuf byte sequences.

Five formats, each behind a cargo feature of its own: deflate, zlib, gzip, brotli and zstd. None is enabled by default, so a build that speaks only brotli never compiles flate2.

let resources = Resources::global();

let compressed = gzip::compress(b"hello", resources)?;
assert_eq!(gzip::decompress(compressed, resources)?.to_vec(), b"hello".to_vec());

Native bytesbuf integration

Input is read segment by segment straight out of a BytesView, and output is written into the uninitialized spare capacity of a BytesBuf. A view is a chain of segments, so nothing is flattened into a contiguous buffer on the way in and nothing is copied out of a scratch buffer on the way back. Output buffers come from the caller's own memory provider, and so does any input view the crate has to build.

The whole-buffer conveniences take anything implementing the sealed InputData trait, so a caller with a plain slice does not have to build a view first -- gzip::compress(b"hello", resources) and gzip::compress(view, resources) are both accepted, and an existing view is forwarded without a copy.

Resource pooling

Resources carries what an engine draws on -- a memory provider and recycled engine state -- and is what every API takes instead of the two separately.

Building a compressor allocates and initializes a substantial amount of state; on a small message that setup can cost as much as the compression itself. Recycling it is therefore on by default, so a service compressing many small bodies spends its budget compressing rather than getting ready to. Resources::global() shares one set process-wide, with_pool_capacity(n) sizes or disables it, and recycling is transparent: it applies to the engines that benefit and quietly skips the rest.

Building a compressor, then using it

Each format module's compress/decompress is the whole-buffer convenience. When a setting matters, build the compressor through its builder and hand it to the crate-level compress, which accepts any implementor of Compression, however it was constructed:

let compressor = gzip::Compressor::builder()
    .level(Level::HIGH)
    .output_chunk_size(chunk(16 * 1024))
    .build(resources);

let body = BytesView::copied_from_slice(b"a response body", resources.memory());
let compressed = compressors::compress(body, compressor)?;

The same compressor can instead be driven incrementally, or handed to CompressionStream; building it is the same either way. Committing to a format also unlocks that format's own settings, and zstd, whose native library validates the parameters it is given, reports that from build rather than deferring it to the first chunk:

let compressor = brotli::Compressor::builder()
    .quality(brotli::Quality::new(4).expect("in range"))
    .window_size(brotli::WindowSize::new(18).expect("in range"))
    .mode(brotli::Mode::Text)
    .build(resources)?;

Streamed compression and decompression

An engine is a state machine, not a one-shot transform, so a stream of any length moves through it while the output it has buffered but not yet handed back stays bounded by the configured chunk size. Pending input and the engine's own window and tables are additional, and depend on the format. Behind the futures-stream feature, CompressionStream presents that as a futures_core::Stream, turning any stream of byte sequences into its compressed or decompressed counterpart.

Runtime format selection

The format module is where a format that is only known at runtime lives, and it has the same shape as every compile-time format module: a Compressor, a Decompressor, and compress / decompress / decompress_with_limits, with the Format threaded through.

let format = Format::from_content_encoding(encoding).expect("a supported encoding");

let compressed = format::compress(format, b"negotiated body", resources)?;
let plain = format::decompress(format, compressed, resources)?;

CompressorBuilder::build_format produces one when the level or the chunk size matters. It returns the module's own Compressor -- a concrete type holding the chosen format internally -- rather than a boxed trait object, so the runtime-format path is not a second-class citizen and the mechanics that drive an engine stay out of the public API.

let compressor = CompressorBuilder::new()
    .level(Level::FAST)
    .build_format(format, resources)?;

Bounded decompression

Every one of these formats can expand its input by orders of magnitude. Nothing in the crate accumulates, so the exposure is in what a caller buffers: DecompressorLimits documents what each format bounds by default, why a ratio alone is not protection, and what to set for untrusted input.

let decompressor = gzip::Decompressor::builder()
    .limits(DecompressorLimits::new().max_output_len(NonZeroU64::new(8 * 1024 * 1024).unwrap()))
    .build(resources);

let plain = compressors::decompress(untrusted, decompressor)?;

Each bound takes a non-zero type, so "allow nothing" is not expressible by accident.

Shape of the API

  • CompressorBuilder<T> / DecompressorBuilder<T> carry every setting that means the same thing in every format. The type parameter names the format: <()> has not chosen one and gains a build_gzip-style method per enabled format plus build_format(Format, ..); <Brotli> gains brotli's quality, window and content mode.
  • zstd validates its configuration as it applies it, so its build returns a BuildError rather than deferring the failure to the first chunk. Brotli's construction is infallible: no value its builders can express is one the encoder rejects.
  • compress / decompress at the crate root take any engine, statically dispatched.
  • core::Compression is the contract the formats share, so an API can name an engine: impl Compression<Mode = Compress> accepts any compressor and no decompressor. How an engine is actually driven -- push, pull, end of input -- lives on a crate-private supertrait, and the trait is Sized so no dyn Compression can expose it. Neither is public API.
  • Error and BuildError both implement recoverable::Recovery, so a caller with a uniform retry policy can classify either. A truncated stream reports Unknown rather than Retry: re-running the same decode is deterministic, so whether asking again helps belongs to whoever owns the byte source.
  • Error::other wraps a foreign failure and detects its recovery from an io::Error anywhere in the cause chain; Error::other_with_recovery takes the classification when the caller knows better.
  • A build with no format enabled still gets the shared contract, the builders, Resources, and the format module's types -- there is simply no Format variant to hand them.
  • Resources implements ThreadAware. Relocation moves the memory provider, since a NUMA-aware provider will want the destination's memory; the engine pool is a deliberate no-op, because every clone shares it and an idle engine is plain memory with no affinity to where it was built.

Documentation

docs/DESIGN.md records the user-visible policies that span several APIs -- format selection, what is uniform across formats and what is not, how decompression is bounded, stream framing, and why the public surface is sealed. docs/IMPLEMENTATION.md covers the mechanisms: the pump state machine, the unsafe initialized-output contract every backend adapter honours, engine pooling and its exclusions, and the async driving rules.

Testing

  • One contract suite runs every format through the same scenarios, so a format that behaves differently from its siblings fails there rather than surprising a consumer.
  • 100% line coverage, no surviving mutants. Test modules are excluded from the coverage gate, so the figure is production code.
  • Clippy clean across the feature matrix, including a build with no format at all, and doctests pass on a single-format build rather than only under --all-features.

martintmk and others added 14 commits September 1, 2026 16:34
Import the compressed crate as compressors and integrate it with the Oxidizer workspace dependency, documentation, coverage, and mutation conventions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5cd05f9b-fab4-477e-bec1-5dd8aca5034a
Restore the imported interoperability fixtures byte-for-byte after text normalization altered their binary contents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5cd05f9b-fab4-477e-bec1-5dd8aca5034a
Use repository spelling conventions, format uncommon numeric ratios as code, and regenerate the crate README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5cd05f9b-fab4-477e-bec1-5dd8aca5034a
Add behavior-focused tests to close every uncovered line reported by
the official two-config coverage gate (lcov-all-features.info and
lcov-no-default.info), and add or extend tests to catch every mutant
cargo mutants reported missed for the compressors package.

Coverage:
- Restructure Wrapper::expects_zlib_header to drop its unreachable
  Gzip match arm instead of excluding it; Gzip decompressors are never
  pooled, so the arm could never execute.
- Use a captured format identifier in the chunk-size assertion in
  format/mod.rs so the assertion's argument shares a line with its
  always-executed condition.

Mutants fixed with new or rewritten tests:
- compression.rs: boxed Compressing::flush delegation.
- limits.rs: RATIO_FLOOR_BYTES pinned to a literal `32_768`.
- pool.rs: round trip and capacity bound coverage for decompressor and
  zstd pooling (previously only "disables recycling" and "poisoned
  pool" were tested).
- zstd/mod.rs: WindowLog::MAX pinned to an independently computed
  expected value.
- brotli/codec.rs, flate/codec.rs, zstd/codec.rs: mode mapping,
  remaining_output delegation to FormatLimits, Drop returning engines
  to the pool, and the flush completion guard in step().

Final results:
- cargo coverage-gate --package compressors: 100.0%, OK.
- cargo mutants -p compressors --no-shuffle --jobs 6: 421 mutants
  tested, 291 caught, 113 unviable, 17 timeouts, 0 missed.

No new coverage exclusions or mutants::skip attributes were added;
every gap was closed with a test or a structural refactor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5cd05f9b-fab4-477e-bec1-5dd8aca5034a
…rmats

Reworks the crate's public surface so that what is common to every format
lives in one place, and only what is genuinely format-specific stays in the
format modules.

* `CompressorBuilder<T = ()>` and `DecompressorBuilder<T = ()>` replace the
  five per-format builders and the runtime-format ones. The type parameter
  names the format: `()` has not chosen one and gains a `build_gzip`-style
  method per enabled format plus `build_format(Format, ..)` returning a boxed
  operation, while `CompressorBuilder<Brotli>` gains brotli's own settings and
  a `build` returning the concrete compressor. Each format module keeps its
  own marker type, setters and `build`, so no shared code enumerates formats.
* Builds that can fail now say so. Brotli and zstd validate their
  configuration as they apply it, so their `build` returns the new
  `BuildError` instead of deferring the failure to the first `pull`.
* `Compressor` and `Decompressor` expose only `builder` and `new`; the
  operations moved onto `Compression`, `Compressing` and `Decompressing`,
  which now live in the `core` module along with the byte counters.
* `Resources` bundles the memory provider and engine recycling that every
  operation needs, and is what the public APIs accept instead of a memory
  provider and a pool separately. Recycling is on by default, so `Pool` is now
  an implementation detail reached through `Resources::enable_pooling`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
… traits

`Compressing` and `Decompressing` existed to carry one method each, which made
every signature choose between naming a direction and naming the contract.
`Compression` now carries both directions on its own:

* `flush` moves onto `Compression` with a default that does nothing, which is
  the truth for decompression: its output is already produced as soon as the
  input allows, so there is nothing buffered to release early. Compressors
  override it.
* `take_remainder` is gone, and with it the idea that a decompressor hands back
  input it did not use. All pushed input is consumed, so `TrailingData::Preserve`
  becomes `TrailingData::Ignore`: a single-stream decoder still stops at the end
  of its stream, it simply does not offer the bytes after it.
* The runtime builders now produce `Box<dyn Compression<Mode = Compress>>` and
  `Box<dyn Compression<Mode = Decompress>>` rather than the direction traits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
…ions

The one-shot conveniences were provided methods on `Compression`, which meant
importing the trait to compress a buffer and reading `x.compress(input)` as
though the compressor were the thing being compressed. They are now plain
functions at the crate root:

    compressors::compress(input, gzip::Compressor::new(resources))?
    compressors::decompress(input, decompressor)?

Each takes the operation generically, so a concrete compressor stays statically
dispatched and unboxed, while a boxed one from `build_format` still fits. The
direction is part of the bound, so handing `compress` a decompressor does not
compile.

`process`, the loop both of them wrap, is now a `pub(crate)` free function
rather than a trait method: nothing outside the crate needed it once the two
directions had names of their own.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
`format` was a public module holding one public item, so every mention of a
runtime format read `compressors::format::Format`. The enum is now
`compressors::Format`, and the module that defines it is private, along with the
`build_format` methods that have to know every format by name.

The generator macros move out of it to `crate::macros`, where they no longer
look like part of the runtime-format story.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
`gzip::CompressorBuilder` and friends were aliases for
`CompressorBuilder<Gzip>`, which gave every builder two names and made the
format modules look like they owned a builder type they do not. The shared type
is the only name now; a format module contributes its marker, its own settings
and its `build`, and nothing else.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
The bounds belong to the decompressor that enforces them, and the name now says so, matching the builder that carries them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
`Output` is what one step of the [`Compression`] contract reports, so it belongs
with the trait rather than in a module of its own, and is reached the same way:
`compressors::core::Output`, not `compressors::Output`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
The trait exists so an API can name an operation -- `impl Compression<Mode =
Compress>` accepts any compressor and no decompressor. Driving one is this
crate's business, so `push`, `pull`, `end_input`, `flush` and the byte counters
are now `#[doc(hidden)]`, and the trait documentation says plainly that they are
internal and can change: callers reach for `compress`, `decompress` or
`CompressionStream`.

Also repairs the intra-doc links that the recent moves left dangling -- the
per-format builder aliases, `Pool`, `Output` and the private `builder` module --
so the documentation builds without warnings again.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
`gzip` was on by default, so a dependent that wanted only brotli still compiled
flate2 unless it remembered `default-features = false`. Nothing is on now: a
dependent names the formats it actually speaks, and a build that names none
still gets the contract, the builders and `Resources`.

The crate documentation illustrates itself with gzip, so its examples grow the
hidden `#[cfg(feature = "gzip")]` shims that let a doctest compile either way,
and the intra-doc links that need a format follow the workspace pattern of being
checked only in a build that has one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
The crate documentation taught the `Compression` trait: the Streaming section
was a hand-written push/pull loop, and Choosing a format explained boxed trait
objects. Neither is what a caller should reach for, and both contradict the
trait's own documentation, which now says its methods are internal.

Streaming is `CompressionStream`, choosing a format is `Format`, and both
examples draw their memory from the resources they compress with, which is the
shape to copy.

Security said the same thing three times and repeated calibration that
`DecompressorLimits` documents properly. It now says what the exposure is, what
to set for untrusted input, and where to read the detail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ Version increments look sufficient

cargo semver-checks compared the 1 crate(s) this PR publishes against their previous version-bump commit in git history. Every version increment is sufficient for the detected API changes.

Crate Baseline Baseline commit This PR Minimum required Status
compressors new crate 0.1.0 0.1.0 ✅ ok

This check is informational and does not block the merge.

View the check run

@martintmk martintmk added the agency-rocket Touched by a rocket skill label Sep 2, 2026
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.0%. Comparing base (8624f90) to head (fa5ef10).

Additional details and impacted files
@@            Coverage Diff            @@
##             main     #722     +/-   ##
=========================================
  Coverage   100.0%   100.0%             
=========================================
  Files         611      634     +23     
  Lines       83142    84733   +1591     
=========================================
+ Hits        83142    84733   +1591     
Flag Coverage Δ
linux 100.0% <100.0%> (?)
linux-arm 100.0% <100.0%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread crates/compressors/examples/tokio_stream.rs Outdated
…e gap

Two CI failures, both from this branch.

`anvil-fmt` checks with the pinned nightly rustfmt, which honours
`format_code_in_doc_comments`; a stable `cargo fmt` silently drops that option, so
the code inside doc examples was never formatted locally. Reformatted with the
same toolchain CI uses.

Coverage sat at 99.7% against a 100% gate, on nine lines this branch introduced:
the default `flush` -- which only a decompressor reaches, and nothing called --
and the byte counters a boxed operation forwards. Both are now covered by tests
worth having: that flushing a decompressor is a no-op rather than an error or an
end of stream, and that boxing an operation does not lose its counters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
Comment thread crates/compressors/benches/compressors_codec.rs
The tokio_stream example drove its synthetic upstream with tokio::time::interval directly. A tick::PeriodicTimer over a tick::Clock does the same thing while keeping the example honest about how time should be reached in this workspace: a test can drive the clock instantly instead of waiting on the runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
Comment thread crates/compressors/src/stream.rs Outdated
martintmk and others added 3 commits September 2, 2026 13:43
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
`Drop` moved the engine into the pool unconditionally, so a pool that could
not keep it -- disabled, poisoned, or already at capacity -- freed it inside
`Drop::drop`, while the value being destroyed was still borrowed. Borrow the
engine instead and take it only when it will be stored, leaving the rest to
ordinary drop glue.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
Miri cannot run either of the crate's native compression engines.
`zstd-safe` binds the native zstd library, and Miri cannot call foreign
functions at all; `flate2`'s `zlib-rs` backend trips Stacked Borrows
whenever a deflate or inflate stream is dropped, an open upstream soundness
bug (trifectatechfoundation/zlib-rs#491) with no released fix.

Only the brotli path would survive, which does not justify gating every other
format's tests on `cfg(miri)`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
@martintmk

Copy link
Copy Markdown
Member Author

🔄 [AspBot] ## Automated multi-facet review — PR #722 (feat(compressors): add streaming compression crate)

This PR was reviewed across build, correctness, complexity, consolidation, idiomaticity, documentation, security, and performance facets. Overall this is a well-engineered, defensively-written, and unusually well-documented crate. The typestate builders, sealed traits, canonical error type, MaybeUninit-based zero-copy output path, and saturating limit arithmetic are all high quality.

Overall assessment: REQUEST CHANGES — one High-severity, safe-by-default hardening item; everything else is Medium/Low polish.


🔴 High

H1 — Decompression is effectively UNBOUNDED BY DEFAULT for every format (CWE-409/770/400)
FormatLimits::new(max_ratio, max_output) has no stream-count parameter, and per-format decompressor defaults ship no absolute output cap — brotli defaults to new(None, None) (zero bounds), zstd to ratio-only 250,000×, deflate/gzip to ratio-only 1,100×. decompress() accumulates all output into one BytesBuf, so a small crafted body can expand to multi-GB → OOM DoS on the crate's stated use case (decompressing untrusted Content-Encoding bodies). Ratio-only caps are not real protection (250,000× lets ~1 MiB → ~244 GiB). The enforcement mechanism itself is sound — only the default policy is permissive.

  • Fix (prepared): add a max_streams param to FormatLimits::new and ship conservative safe-by-default caps for every decompressor — 64 MiB absolute output + 1024 streams — with explicit opt-out via with_max_output_len / with_max_streams / UNLIMITED. Values chosen to sit above the largest legitimate default-path test payload (~22.5 MiB) and concatenation count (2–3), so existing tests remain green. Also update the two doc lines that now contradict the bounded defaults (limits.rs:67-68, flate/mod.rs:21).

🟠 Medium

  • Security M1 — zstd decompressor window-log defaults to 128 MiB for untrusted input (zstd/codec.rs:190-194); consider a stricter max_window_log default or document the per-stream cost.
  • Security/Correctness M2/M3 — gzip multi-member: unbounded member count + a fresh new_gzip inflate-state allocation per member (raw/zlib recycle via reset(), only gzip reallocates) → ~1000× alloc/CPU amplification from many tiny empty members. Truncated subsequent member is mislabeled corrupt_data vs unexpected_end_of_stream. Fix via the default max_streams above + reuse inflate state + EOF label.
  • Security M4unsafe { output.advance(produced) } (engine.rs:348) is OOB-sound, but delegates the initialized invariant to the safe Codec::step; mark Codec::step unsafe with a # Safety clause or zero-fill in the driver.
  • Perf H1 (Medium impact) — brotli & zstd zero-fill the whole output chunk (up to 64 KiB) before every engine step even though both backends are write-only; flate proves it's avoidable via *_uninit. The MaybeUninit abstraction is defeated on the hottest path.
  • Perf H2/H3 — empty source chunks enter the full hot codec path (reserve + zero-fill + native step + self-wake); whole-buffer compress/decompress pays streaming-chunking overhead. Perf H4 — the flagship CompressionStream incremental path has zero benchmark coverage.
  • ComplexityPump::pull is a single ~185-line function and the StreamEnd match has 12 guard-heavy arms recomputing max_streams() up to 3×/iteration; extract cohesive helpers.
  • Consolidation — the decompressor limit-delegation trio and stream_ended() logic are byte-identical across all three codecs; the unsafe initialize() helper is copy-pasted verbatim in two files (duplicated unsafe is the riskiest kind).

🟡 Low (defense-in-depth / polish)

  • stream.rs source is not fused (possible re-poll-after-None panic on caller-supplied Compression); self-wake spin can peg CPU on a perpetually-ready empty source.
  • Pooled engine buffers are not zeroized between uses (in-memory remanence; reset-on-checkout already prevents any functional cross-message leak).
  • zstd is the only C parser on the untrusted path — add a cargo audit/cargo deny CI gate.
  • output_chunk_size has no upper bound; dead done_reported field; brotli collapses NeedsMoreInput/NeedsMoreOutput into one state; crate-root module named core forces ::core:: disambiguation and inconsistent Result spellings; a handful of near-duplicate per-format unit tests could share a harness.

✅ Verified clean

No memory-safety bug, no exploitable panic, no integer-overflow bug. All 4 unsafe blocks proven sound (the task-flagged flate/codec.rs:233 from_raw_parts is test-only and sound). FFI return codes checked; no content-size trust / no huge-alloc bomb pre-allocation; saturating integer math; no PII in error messages; fail-closed config validation; zero-copy input/output paths; no O(n²) append; dependencies current and advisory-clean (flate2 → zlib-rs only, avoiding the C-zlib CVE class). Idiomaticity is exemplary.

Build/clippy/test status

⚠️ Not verified by compiler. The review environment's egress to index.crates.io / static.crates.io was blocked, so cargo build/clippy/test could not fetch dependencies. Source was reviewed at head 17f82b1 via the GitHub API; the H1 fix was implemented on the real tree and parse-checked with rustfmt (clean), and verified complete/non-regressing by static inspection (all 12 FormatLimits::new sites accounted for, enforcement plumbing confirmed end-to-end, existing test payloads confirmed under the new caps). Please run cargo build/test/clippy -p compressors in an environment with crates.io access to confirm.


Review performed by an automated multi-agent review team. Line numbers reference head 17f82b1.

… soundness

Addresses an automated multi-facet review, plus three rounds of follow-up
review that corrected the first two attempts at the main finding.

Decompression was effectively unbounded by default: brotli declared no
bounds at all, and no format bounded total output or concatenated stream
count. Ratio bounds alone cannot separate a bomb from legitimate
highly-compressible data.

The bounds belong to the APIs that accumulate, not to every decompressor.
`Pump` counts output for its whole life and never resets, so a cap in
`FormatLimits` would have capped total bytes ever produced rather than bytes
buffered -- breaking the crate's central promise that a stream of any length
passes through in bounded memory. Instead a single
`DecompressorLimits::for_buffered_output` fills the bounds a caller left
unset, and only the entry points that buffer a whole result apply it: each
format's `decompress` and `decompress_with_limits`, and the same pair on
`Format`. Explicit values and explicit removals survive untouched, so
overriding one bound can no longer silently drop the others. Driving a
decompressor directly, or through `CompressionStream`, still carries only the
format's ratio bound.

`Codec` is now an unsafe trait. Its reported output count is load-bearing --
the engine declares exactly that many bytes of uninitialized capacity
initialized -- so the obligation now sits on implementors where the compiler
can see it, rather than in a doc comment.

Zstd writes through `zstd_safe::WriteBuf` instead of zero-filling the output
chunk before every step and transmuting it. That removes a memset of up to
64 KiB per step and one of the two copies of the unsafe `initialize` helper.

A truncated later member now reports `unexpected_end_of_stream` rather than
`corrupt_data`. Reaching that branch means the codec wants input that is not
coming; whether an earlier member completed says nothing about it, and data the
codec knows to be malformed already fails through its own error path.

Also removes the write-only `Pump::done_reported` field.

Testing: every test now runs in under a second, down from a worst case of
16.5s, by building large fixtures cheaply rather than compressing megabytes.
Every drain loop is bounded, so a test that would spin now fails instead of
hanging -- which also lets mutation testing reach a verdict. The handful of
mutations that remove termination outright are marked skipped with their
reason.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
Comment thread crates/compressors/src/lib.rs Outdated
Miri cannot run either of the crate's compression engines: `zstd-safe`
binds the native zstd library and Miri cannot call foreign functions, while
`flate2`'s `zlib-rs` backend trips Stacked Borrows whenever a deflate or
inflate stream is dropped (trifectatechfoundation/zlib-rs#491).

The crate already carries `package.metadata.anvil.miri.exclude`, which the
`anvil-miri` recipe honours and which is why the `pr-runtime-analysis` job
passes. This job builds its own `cargo miri` command line, so the exclusion
has to be spelled out here as well.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2214a6ae-e9a0-4550-bff3-63b47d8c9ed8
Comment thread crates/compressors/src/error.rs
Adds `docs/SECURITY.md`, the threat model the crate is written against: what
makes compressed input untrusted and why decompression does not upgrade content
trust, which resource each budget bounds and which it does not, which defaults
apply to which consumption mode, what the caller still owns, and how the claims
are verified.

Two pairings it states outright, because each is a case where the obvious bound
does not fire: a ratio cannot bound output in a format with no structural
expansion ceiling, and an output cap cannot bound stream count, because many tiny
members cost engine setup while producing almost no output.

Also records what is deliberately out of scope -- authenticity, compression side
channels of the CRIME/BREACH family, upstream backend defects -- and the one
known gap, that there is no fuzz target yet.

The guides are now reached through a `documentation` module rather than a list of
links in the crate root, so the crate docs point at one place and the prose stays
as Markdown that reads on GitHub too.

Addresses the security-model review thread.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e45d226a-3e10-419e-a316-a16a849db56a

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large new crate with a broad public API surface and soundness-sensitive codec boundary, which warrants final human review.

Review details
  • Files reviewed: 47/49 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…on surface

The deterministic suite covers chosen cuts, chunk sizes and corruption offsets.
What it cannot cover is their product: a member boundary landing on a `BytesView`
span boundary, on output exhaustion, on an exact limit and on trailing bytes at
once. This campaign explores that space.

One target for every backend, since CI charges per target and what is interesting
is the interaction between this crate's framing and *some* engine rather than any
one of them. It generates a format, a payload, a corruption (truncate, bit flip,
append, concatenate), a span layout, an output chunk size, a trailing-data and
multi-stream policy, and a bound placed at or beside the real output size.

Asserted: decompression never panics and always terminates, an unmutated stream
round-trips, and a bound below the real output is refused with a limit error.
Deliberately not asserted is that decompression *succeeds* -- malformed input is
supposed to fail.

Everything goes through the public API. The push/pull state machine is sealed, so
this reaches it the way callers do, which is also the surface an attacker reaches:
the operation ordering a fuzz-only entry point would expose is chosen by trusted
calling code, never by input bytes. That avoids widening the public surface, or
adding a feature, purely for the harness.

Every generated dimension is capped -- 4 KiB payloads, 16 spans, 4 concatenated
copies -- and a 1 MiB output ceiling is applied to every run underneath whatever
the scenario asked for, so a generated expansion bomb cannot spend the campaign
budget on one input.

The first run found a bug, in the harness rather than the crate: a `JustUnder`
bound on a one-byte payload clamps back to one byte, because a bound must be
non-zero, so decoding correctly succeeded where the assertion expected refusal.
The guard now requires two bytes for that case to be meaningful.

No workflow or justfile changes: `tests/bolero_*.rs` is auto-discovered, and the
existing `fuzz-testing` job gives each target a 60s libfuzzer run on Linux.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e45d226a-3e10-419e-a316-a16a849db56a

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large new, security-sensitive crate (including unsafe codec boundary code and many new APIs), so it warrants final human review despite only minor actionable feedback.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

crates/compressors/src/engine.rs:499

  • The invalid byte-counts check is shared by both compression and decompression codecs, but the error message says "compression engine". If a decompressor backend misreports counts, this will be misleading during diagnostics; consider using a direction-neutral message (e.g., "codec" or "backend engine").
  • Files reviewed: 48/50 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

martintmk and others added 2 commits September 7, 2026 10:25
…erify

The gzip abandonment regression dirtied an engine in one `Resources` and checked
the recovery through another. Each `Resources` owns its own pool, so the engine
under test was never the engine that was dirtied -- the assertion compared a fresh
engine against a fresh baseline and would have held however broken the reset was.
The test name claimed reuse coverage the test did not have.

Every test that asserts a specific engine history now uses a private pool holding
exactly one idle engine, for both the setup and the verification. Capacity one
means the engine a drop returns is the engine the next build receives; a private
pool means no concurrently running test can take it in between.

Verified the fix rather than assuming it: with `Pool::take_compressor`'s `reset()`
removed, the repaired test now fails, and the recovered stream is missing the gzip
magic entirely because it is mid-stream continuation data. Before the fix that
mutation could not have been caught here.

The shared pool stays where engine identity is not what is being asserted, and for
the concurrency scenario, which is about a handle being shared rather than about
which engine any one request gets.

Addresses the pooling-determinism review thread.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e45d226a-3e10-419e-a316-a16a849db56a
Several pooling tests were named as though they exercised a recycled engine --
`an_engine_abandoned_mid_stream_is_cleaned_before_reuse`, `levels_never_share_engines`,
`pool_capacity_bounds_retention_without_changing_output`. For brotli, which
recycles nothing, and for a gzip decompressor, which is deliberately never
recycled, that claim is not true: no state is returned, so no reset could leak.

The property they actually assert is universal, and is the one worth asserting:
reuse is invisible. Renaming them to say so makes the claim honest without tying
the suite to which engines happen to pool today. That coupling is what is being
avoided -- gating these on the current pooling matrix would mean editing the
contract every time an engine gains or loses a reset, when the property holds
either way and the tests keep passing unchanged.

The section comment now records that reasoning, and points at `Pool` for the exact
mechanics: retention, keying, capacity and poisoning are tested there, while what
lives in the contract is the caller-visible behaviour.

No test bodies changed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e45d226a-3e10-419e-a316-a16a849db56a

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes introduce the new crate with consistent feature gating, documented security/limits model, and comprehensive tests/benchmarks, and no objective issues were found in the reviewed diffs.

Review details
  • Files reviewed: 48/50 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large new crate with multiple backends and soundness-sensitive codec boundaries, so it warrants final human review despite only minor nits found.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

.spelling:885

  • .spelling contains a duplicate entry for zstd's (it already appears earlier in the file). Duplicates add noise and make future dictionary edits harder to review.
  • Files reviewed: 48/50 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large new crate with multiple backends and unsafe codec adapters, so it needs final human review despite only minor specific issues found here.

Review details

Suppressed comments (2)

crates/compressors/src/core/output.rs:57

  • matches!(*self, ...) forces dereferencing (and, if Output is Copy, copying) the entire Output value just to test the variant. Since Output::Data carries a large BytesView (noted above as ~272 bytes), these predicates should match on &self to avoid an unnecessary copy.
    pub fn is_data(&self) -> bool {
        matches!(*self, Self::Data(_))
    }

crates/compressors/src/core/output.rs:78

  • Same as above: matches!(*self, ...) needlessly dereferences (and potentially copies) the full Output value. Matching on self avoids copying a large enum payload when checking Progress/Done.
    pub fn is_progress(&self) -> bool {
        matches!(*self, Self::Progress)
    }
  • Files reviewed: 48/50 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

}

fn run(scenario: &Scenario) {
let Some(&format) = Format::ALL.get(usize::from(scenario.format) % Format::ALL.len().max(1)) else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖: The Linux fuzz job compiles this target with no compressor-format features, so every generated case returns at Format::ALL.get(...) and the advertised campaign performs no decompression. Pass --all-features (or explicit format features) to both Linux cargo bolero list and cargo bolero test invocations.

compressors declares default = []; unlike the non-Linux fallback, the Linux commands in justfiles/extended.just do not enable any features. This is the wiring gap in the earlier fuzz-target thread: with Format::ALL empty, line 123 returns for every scenario, so the required 60-second job can remain green without exercising a backend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 460b188a-08ea-418c-802a-41d557329876

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large, security-relevant new crate (compression/decompression + unsafe codec boundary) and warrants final human review despite no specific blocking issues found in the provided diffs.

Review details
  • Files reviewed: 49/51 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

crates/compressors/src/core/output.rs uses matches!(*self, ...) / match *self on a non-Copy enum, which needs to be corrected to inspect &self without moving.

Review details

Suppressed comments (3)

crates/compressors/src/core/output.rs:57

  • Output contains BytesView (which is not Copy), so using matches!(*self, ...) attempts to move out of &self. Match on self instead so the enum is inspected by reference.
    pub fn is_data(&self) -> bool {
        matches!(*self, Self::Data(_))
    }

crates/compressors/src/core/output.rs:65

  • as_data matches on *self, which would move the Output out of &self. Match on self and bind the payload by reference.
    pub fn as_data(&self) -> Option<&BytesView> {
        match *self {
            Self::Data(ref data) => Some(data),
            _ => None,
        }

crates/compressors/src/core/output.rs:72

  • These predicate helpers also use matches!(*self, ...), which tries to move out of &self for a non-Copy enum. Use matches!(self, ...) consistently.
    /// Whether the engine needs more input before it can produce more output.
    #[must_use]
    pub fn is_need_input(&self) -> bool {
        matches!(*self, Self::NeedInput)
    }
  • Files reviewed: 49/51 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Auto-resolve overlapping dependency, spelling, and Miri exclusions while preserving both branches' changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 460b188a-08ea-418c-802a-41d557329876

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are consistent with the workspace’s packaging/LFS policy and include substantial tests and documentation for the new crate’s safety and API contracts.

Review details
  • Files reviewed: 49/51 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 460b188a-08ea-418c-802a-41d557329876

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes appear internally consistent with the stated design/security goals and include substantial documentation plus deterministic and property-based coverage for the new API surface.

Review details
  • Files reviewed: 49/51 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bfd4e9b5-eda8-4dd8-b847-84c16f13a895

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large new compression/decompression crate with unsafe codec boundaries and multiple backend integrations that warrant final human review.

Review details
  • Files reviewed: 49/51 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread crates/compressors/tests/bolero_compressors.rs
Comment thread crates/compressors/src/limits.rs Outdated
/// Pass `None` to remove the total size bound, including the buffering conveniences' default cap.
/// Only remove this bound when the caller can safely retain the entire decompressed output.
#[must_use]
pub fn max_output_len(mut self, bytes: impl Into<Option<NonZeroU64>>) -> Self {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖: max_output_len(NonZeroU64::new(configured_limit)) now silently disables the decompression ceiling when configured_limit is zero. Keep bound-setting value-only and expose bound removal through an explicitly named operation; apply the same separation to the ratio and stream setters.

NonZeroU64::new(0) returns None, and this setter interprets None as Limit::Unlimited, overriding the buffering helper's 64 MiB fallback. An invalid external limit can therefore turn bounded decompression into unbounded memory growth instead of being rejected.

Resolve spelling and lockfile additions while preserving both branches' packages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 460b188a-08ea-418c-802a-41d557329876

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are coherent and well-tested overall, with only a small maintainability nit called out in the review comments.

Review details
  • Files reviewed: 49/51 changed files
  • Comments generated: 1
  • Review effort level: Lite


//! Public format contract in a build with no compression backends enabled.

#![cfg(not(any(feature = "brotli", feature = "deflate", feature = "gzip", feature = "zlib", feature = "zstd")))]
Constant remaining-output mutants preserve output but reduce progress to one byte per step, so mutation runs time out instead of reaching a semantic verdict.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 460b188a-08ea-418c-802a-41d557329876

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large new crate with multiple backends and unsafe codec/pump invariants, which warrants final human review despite only minor review notes found.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

crates/compressors/src/stream.rs:112

  • The doc comment implies a source of plain BytesView values will compile and then only fail at runtime when polled, but CompressionStream only implements Stream when the source item type is Result<BytesView, E>. As written, a plain-view source won’t type-check as a Stream consumer at all, so this should be described as a trait-bound/impl-availability constraint rather than a runtime failure mode.
  • Files reviewed: 49/51 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Require NonZero values in all three max setters so a failed NonZero conversion cannot silently remove a bound. Restore explicit unbounded operations and represent configured infinity with MAX while reserving None for unset overrides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a large new crate with format backends and unsafe codec boundaries, so it warrants final human review even though only minor issues were identified.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

crates/compressors/tests/format.rs:11

  • The test name says it verifies an unknown content-encoding is rejected, but it actually asserts that the well-known "identity" token maps to None. Either rename the test (recommended) or change the asserted token to something genuinely unknown to keep the intent aligned with the assertion.
  • Files reviewed: 49/51 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agency-rocket Touched by a rocket skill

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants