Skip to content

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

Draft
martintmk wants to merge 20 commits into
mainfrom
user/martintomka/20260901-add-compressors-crate
Draft

feat(compressors): add streaming compression crate#722
martintmk wants to merge 20 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 data = BytesView::copy_from_slice("hello", memory);
let compressed = gzip::compress(data, Resources::global())?;
assert_eq!(gzip::decompress(compressed, Resources::global())?.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. Every allocation comes from the caller's own memory provider.

Resource pooling

Resources carries what a codec 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, enable_pooling(n) sizes or disables it, and recycling is transparent: it applies to the engines that benefit and quietly skips the rest.

Streamed compression and decompression

A codec is a state machine, not a one-shot transform, so a stream of any length moves through it with a bounded working set -- one pending input view and one output chunk, however many gigabytes pass through. 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

Format resolves a Content-Encoding token when the format is a peer's decision rather than yours, and CompressorBuilder::build_format produces an operation for it when the level or the chunk size matters.

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.

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.
  • Brotli and zstd validate their configuration as they apply it, so their build returns a BuildError rather than deferring the failure to the first chunk.
  • compress / decompress at the crate root take any operation, statically dispatched.
  • core::Compression is the contract the formats share, so an API can name an operation: impl Compression<Mode = Compress> accepts any compressor and no decompressor.

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.
  • Clippy clean across the feature matrix, including a build with no format at all.

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 (5899abe) to head (05ec5ee).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##             main     #722     +/-   ##
=========================================
  Coverage   100.0%   100.0%             
=========================================
  Files         584      609     +25     
  Lines       62909    66105   +3196     
=========================================
+ Hits        62909    66105   +3196     
Flag Coverage Δ
linux 75.8% <100.0%> (?)
linux-arm 75.8% <100.0%> (?)
windows 80.1% <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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can you run these benchmarks and report the time/allocations results?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 On it - running the full benchmark suite now and will report time and allocation figures.


AI response generated by rocket

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Ran the full suite on Windows (x86_64, 8 cores). Criterion medians; allocation figures from alloc_tracker, which counts allocations made through Rust's global allocator.

Read the zstd rows with care. zstd allocates its contexts through its own allocator rather than Rust's, so its allocation counts understate the true cost. Its timings are unaffected.

Whole-buffer throughput, by payload size

Format 1 KiB 64 KiB 1 MiB
deflate 15.4 µs 618 µs 9.85 ms
zlib 16.1 µs 741 µs 9.20 ms
gzip 21.6 µs 585 µs 11.0 ms
brotli 741 µs 5.47 ms 36.6 ms
zstd 9.87 µs 152 µs 2.12 ms

Decompression:

Format 1 KiB 64 KiB 1 MiB
deflate 4.64 µs 40.0 µs 906 µs
zlib 4.81 µs 43.8 µs 962 µs
gzip 7.21 µs 120 µs 1.96 ms
brotli 12.5 µs 118 µs 2.10 ms
zstd 4.66 µs 46.0 µs 634 µs

Allocations per operation

The claim this crate exists for: input is never flattened and output is written into caller memory, so allocation count is flat in the payload size.

Operation 1 KiB 64 KiB 1 MiB
deflate / zlib / gzip compress 2 allocs, 1 080 B 2 allocs, 1 080 B 3 allocs, 1 598 B
deflate / zlib decompress 2 allocs, 1 120 B 2 allocs, 1 120 B 4 allocs, 2 144 B
gzip decompress 3 allocs, 48 672 B 3 allocs, 48 672 B 5 allocs, 49 696 B
zstd compress 2 allocs, 968 B 2 allocs, 968 B 3 allocs, 1 480 B
zstd decompress 2 allocs, 1 008 B 2 allocs, 1 008 B 4 allocs, 2 032 B
brotli compress 23 allocs 23 allocs 40 allocs
brotli decompress 15 allocs 15 allocs 17 allocs

Compressing a megabyte through the deflate family costs three allocations and ~1.6 KB. Brotli's engine allocates its own workspace, which is why its counts are an order of magnitude higher and largely independent of this crate.

What pooling buys (4 KiB payload)

Format Compress fresh → pooled Bytes fresh → pooled
deflate 37.3 µs → 30.2 µs (-19%) 381 112 B → 1 080 B (-99.7%)
zlib 33.8 µs → 28.0 µs (-17%) 381 112 B → 1 080 B
gzip 44.0 µs → 31.6 µs (-28%) 381 112 B → 1 080 B
zstd 24.8 µs → 14.8 µs (-40%) (allocates outside Rust)
brotli 1.351 ms → 1.339 ms (-1%) unchanged -- brotli exposes no reset, so it is not pooled

Decompression: deflate 7.54 → 6.72 µs, zlib 8.42 → 6.52 µs, zstd 7.76 → 7.11 µs. Gzip decompressors are deliberately not pooled (the engine's reset cannot express gzip framing), and the numbers show it: 11.5 → 12.7 µs, i.e. noise.

The 353 KB saved per pooled deflate-family compressor is the headline. That is the engine's window and hash tables, allocated and thrown away per message without pooling.

Segmentation -- the bytesbuf claim

Compressing the same 1 MiB payload delivered as different span layouts:

Layout Time Allocations
contiguous 478 µs 2
16 KiB spans 484 µs 2
1 KiB spans 604 µs 3
64 B spans 1.88 ms 3

A view split into 16 KiB spans costs 1.3% over contiguous, with the same two allocations -- no flattening happens. Even a pathological 64-byte-span view stays at three allocations; only per-segment call overhead grows.

Output chunk size

Chunk Time Allocations
1 KiB 2.23 ms 6
8 KiB 2.18 ms 2
64 KiB (default) 2.79 ms 2
512 KiB 2.18 ms 2

Flat from 8 KiB up, so the default is comfortably inside the plateau.

Levels

Format L1 L6 L9 Ratio at L1 → L9
deflate 94.4 µs 489 µs 938 µs 4.40 → 7.51
gzip 171 µs 564 µs 1.00 ms 4.39 → 7.49
brotli 285 µs 4.78 ms 104.5 ms 7.02 → 10.10
zstd 97.0 µs 108 µs 2.36 ms 8.68 → 9.20

Brotli level 9 costs 367x level 1 for 44% better compression; zstd reaches a better ratio than brotli level 1 at a quarter of brotli's level-6 cost. This is why the portable Level scale is anchored on each format's balanced point rather than stretched across its native range.

Brotli window

Window Compress Decompress Ratio
2^10 779 µs 11.0 µs 8.75
2^16 772 µs 11.2 µs 10.26
2^18 562 µs 11.1 µs 9.61
2^22 (default) 618 µs 11.4 µs 9.61

Confirms what WindowSize documents: the ratio is not monotonic in the window, a small window is not reliably cheaper, and decompressor cost tracks the data rather than the declared window. Leave it alone unless a measurement on real payloads says otherwise.


AI response generated by rocket

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
impl<S, C> CompressionStream<S, C> {
/// Returns the source stream and compression operation.
#[must_use]
pub fn into_parts(self) -> (S, C) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

we don't need this API for now

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
//! `gzip`, `brotli` and `zstd`. Each lives in its own module and exposes the same handful of items,
//! so moving between them is a change of import rather than a change of code.
//!
//! Compression engines normally speak `std::io::Read` and `std::io::Write`, which assume a single

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I believe the main highlight of this crate is direct bytesbuf itnegration, resource pooling, and unified compresison API that supports treaming of large data. Metions of "Compression engines normally speak std::io::Read and std::io::Write, which assume a single" should be only secondary highlight.

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.

1 participant