feat(compressors): add streaming compression crate - #722
Conversation
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
✅ Version increments look sufficient
|
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…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
There was a problem hiding this comment.
can you run these benchmarks and report the time/allocations results?
There was a problem hiding this comment.
🤖 On it - running the full benchmark suite now and will report time and allocation figures.
AI response generated by rocket
There was a problem hiding this comment.
🤖 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) { |
There was a problem hiding this comment.
we don't need this API for now
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
|
🔄 [AspBot] ## Automated multi-facet review — PR #722 ( 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, Overall assessment: REQUEST CHANGES — one High-severity, safe-by-default hardening item; everything else is Medium/Low polish.🔴 HighH1 — Decompression is effectively UNBOUNDED BY DEFAULT for every format (CWE-409/770/400)
🟠 Medium
🟡 Low (defense-in-depth / polish)
✅ Verified cleanNo memory-safety bug, no exploitable panic, no integer-overflow bug. All 4 Build/clippy/test status
Review performed by an automated multi-agent review team. Line numbers reference head |
… 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 |
There was a problem hiding this comment.
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.
Adds
compressors, a streaming compression crate forbytesbufbyte sequences.Five formats, each behind a cargo feature of its own:
deflate,zlib,gzip,brotliandzstd. None is enabled by default, so a build that speaks only brotli never compilesflate2.Native
bytesbufintegrationInput is read segment by segment straight out of a
BytesView, and output is written into the uninitialized spare capacity of aBytesBuf. 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
Resourcescarries 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-streamfeature,CompressionStreampresents that as afutures_core::Stream, turning any stream of byte sequences into its compressed or decompressed counterpart.Runtime format selection
Formatresolves aContent-Encodingtoken when the format is a peer's decision rather than yours, andCompressorBuilder::build_formatproduces 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:
DecompressorLimitsdocuments 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 abuild_gzip-style method per enabled format plusbuild_format(Format, ..);<Brotli>gains brotli's quality, window and content mode.buildreturns aBuildErrorrather than deferring the failure to the first chunk.compress/decompressat the crate root take any operation, statically dispatched.core::Compressionis the contract the formats share, so an API can name an operation:impl Compression<Mode = Compress>accepts any compressor and no decompressor.Testing