diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 1bc7ed4fa..41a3b146e 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -389,8 +389,10 @@ jobs:
run: cargo +${{ env.RUST_NIGHTLY }} careful nextest run --all-features --workspace ${{ needs.delta.outputs.exclude_not_affected }} --color always --target ${{ matrix.target }} --no-tests=pass
- name: Miri (stacked borrows)
if: success() || failure()
- # Most excluded crates generate code rather than run it, so Miri has no
- # aliasing or provenance violations to find in them:
+ # Excluded crates fall into two groups.
+ #
+ # Crates in the first group generate code rather than run it, so Miri has
+ # no aliasing or provenance violations to find in them:
#
# * the `*_macros_impl` crates are proc-macro implementations whose
# tests are snapshot comparisons of generated token streams;
@@ -408,11 +410,24 @@ jobs:
# Interpreting them anyway cost ~75 min of the ~3h16m step
# (`multitude_macros_impl` alone ran 30 tests in 45 min).
#
+ # The second group is `compressors`, whose backends Miri cannot all run:
+ # `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
+ # (https://github.com/trifectatechfoundation/zlib-rs/issues/491). Brotli
+ # is pure Rust and would run; excluding the whole crate rather than
+ # maintaining a brotli-only Miri configuration is a separate decision,
+ # taken because gating every other format's tests on `cfg(miri)` costs
+ # more than the remaining coverage is worth. The crate also carries
+ # `package.metadata.anvil.miri.exclude`, which is what the `anvil-miri`
+ # recipe honours; this job builds its own command line, so it needs the
+ # exclusion spelled out here too.
+ #
# Note `rest_over_grpc` is deliberately NOT excluded: its build half is
# already `#[cfg_attr(miri, ignore)]`, and the tests that do run cover the
# runtime transcoding path over `bytes`/`hyper`, which is exactly the kind
# of buffer handling Miri needs to check.
- run: cargo +${{ env.RUST_NIGHTLY }} miri test --all-features --workspace ${{ needs.delta.outputs.exclude_not_affected }} --exclude data_privacy_macros_impl --exclude fundle_macros_impl --exclude internity_macros_impl --exclude metabench --exclude metabench_macros_impl --exclude multitude_macros_impl --exclude observed_macros_impl --exclude ohno_macros_impl --exclude routerama_build --exclude templated_uri_macros_impl --exclude thread_aware_macros_impl --lib --tests
+ run: cargo +${{ env.RUST_NIGHTLY }} miri test --all-features --workspace ${{ needs.delta.outputs.exclude_not_affected }} --exclude compressors --exclude data_privacy_macros_impl --exclude fundle_macros_impl --exclude internity_macros_impl --exclude metabench --exclude metabench_macros_impl --exclude multitude_macros_impl --exclude observed_macros_impl --exclude ohno_macros_impl --exclude routerama_build --exclude templated_uri_macros_impl --exclude thread_aware_macros_impl --lib --tests
model-checking:
if: (github.event_name == 'pull_request' || github.event_name == 'merge_group') && needs.delta.outputs.skip != 'true'
diff --git a/.spelling b/.spelling
index 6bcecc2df..dd5433dde 100644
--- a/.spelling
+++ b/.spelling
@@ -716,6 +716,21 @@ userland
WebIO
WebSocket
wiremock
+Zlib
+Zstandard
+Zstd's
+brotli
+brotli's
+checksum
+codec's
+decompressor
+decompressors
+flate2
+PNG
+resumable
+selectable
+semver
+WOFF
reentrantly
rebalancing
unsizing
@@ -867,7 +882,13 @@ hardcoding
Idempotence
symlinks
junctions
+zlib
+zstd's
+unkeyed
+incompressible
+decompressor's
recurse
+flate's
Cancelling
TUI
mutex's
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ecc7f14c7..73f8a79ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Please see each crate's change log below:
- [`cachet_memory`](./crates/cachet_memory/CHANGELOG.md)
- [`cachet_service`](./crates/cachet_service/CHANGELOG.md)
- [`cachet_tier`](./crates/cachet_tier/CHANGELOG.md)
+- [`compressors`](./crates/compressors/CHANGELOG.md)
- [`data_privacy`](./crates/data_privacy/CHANGELOG.md)
- [`data_privacy_macros`](./crates/data_privacy_macros/CHANGELOG.md)
- [`data_privacy_macros_impl`](./crates/data_privacy_macros_impl/CHANGELOG.md)
diff --git a/Cargo.lock b/Cargo.lock
index 0bb898a6a..c0e52e290 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -50,6 +50,21 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
[[package]]
name = "alloc_tracker"
version = "0.7.6"
@@ -826,6 +841,27 @@ dependencies = [
"cc",
]
+[[package]]
+name = "brotli"
+version = "8.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -1170,6 +1206,29 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "compressors"
+version = "0.1.0"
+dependencies = [
+ "bolero",
+ "brotli",
+ "bytesbuf",
+ "cfg_aliases",
+ "criterion",
+ "flate2",
+ "futures",
+ "futures-core",
+ "metabench",
+ "mutants",
+ "pin-project-lite",
+ "recoverable",
+ "thread_aware",
+ "tick",
+ "tokio",
+ "tokio-stream",
+ "zstd-safe",
+]
+
[[package]]
name = "compact_str"
version = "0.9.1"
@@ -1960,6 +2019,15 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "zlib-rs",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -6872,8 +6940,33 @@ dependencies = [
"syn 3.0.5",
]
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
+
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 89fd29d90..278efa2d5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -79,6 +79,7 @@ azure_storage_blob = { version = "1.0.0", default-features = false }
backtrace = { version = "0.3.76", default-features = false }
base64 = { version = "0.23.0", default-features = false, features = ["alloc"] }
bolero = { version = "0.13.4", default-features = false }
+brotli = { version = "8.0.4", default-features = false }
bumpalo = { version = "3.20.2", default-features = false }
bytemuck = { version = "1.25.0", default-features = false }
bytes = { version = "1.11.1", default-features = false }
@@ -89,12 +90,14 @@ cachet_memory = { path = "crates/cachet_memory", default-features = false, versi
cachet_service = { path = "crates/cachet_service", default-features = false, version = "0.5.0" }
cachet_tier = { path = "crates/cachet_tier", default-features = false, version = "0.5.0" }
cc = { version = "1.2", default-features = false }
+cfg_aliases = { version = "0.2.2", default-features = false }
chrono = { version = "0.4.40", default-features = false }
chrono-tz = { version = "0.10.4", default-features = false }
chumsky = { version = "0.13.0", default-features = false }
clap = { version = "4.6.4", default-features = false }
# The latest command-group release still uses nix 0.27 on Unix; accept the duplicate until upstream updates.
command-group = { version = "5.0.1", default-features = false }
+compressors = { path = "crates/compressors", default-features = false, version = "0.1.0" }
const-hex = { version = "1.15.0", default-features = false }
criterion = { version = "0.8.2", default-features = false }
crossterm = { version = "0.29.0", default-features = false }
@@ -118,6 +121,7 @@ fetch_options = { path = "crates/fetch_options", default-features = false, versi
fetch_tls = { path = "crates/fetch_tls", default-features = false, version = "0.4.0" }
fetch_winhttp = { path = "crates/fetch_winhttp", default-features = false, version = "0.1.1" }
fetch_winhttp_impl = { path = "crates/fetch_winhttp_impl", default-features = false, version = "0.1.1" }
+flate2 = { version = "1.1.10", default-features = false }
foldhash = { version = "0.2.0", default-features = false }
fundle = { path = "crates/fundle", default-features = false, version = "0.3.5" }
fundle_macros = { path = "crates/fundle_macros", default-features = false, version = "0.3.5" }
@@ -268,6 +272,7 @@ time = { version = "0.3.47", default-features = false }
tokio = { version = "1.48.0", default-features = false }
tokio-native-tls = { version = "0.3.1", default-features = false }
tokio-rustls = { version = "0.26.4", default-features = false }
+tokio-stream = { version = "0.1.19", default-features = false }
tonic = { version = "0.14.2", default-features = false }
tonic-prost = { version = "0.14.2", default-features = false }
tonic-prost-build = { version = "0.14.2", default-features = false }
@@ -291,6 +296,7 @@ windows-sys = { version = "0.61.2", default-features = false }
wiremock = { version = "0.6.5", default-features = false }
xxhash-rust = { version = "0.8.15", default-features = false }
zerocopy = { version = "0.8.26", default-features = false }
+zstd-safe = { version = "7.2.4", default-features = false }
# >>> anvil-managed: anvil-workspace-lints
[workspace.lints]
diff --git a/README.md b/README.md
index c63c987a0..ab6c4372e 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ These are the primary crates built out of this repo:
- [`cachet_memory`](./crates/cachet_memory/README.md) - In-memory cache tier backed by Moka for the cachet caching library.
- [`cachet_service`](./crates/cachet_service/README.md) - Layered service integration for the cachet caching library.
- [`cachet_tier`](./crates/cachet_tier/README.md) - Core cache tier trait and abstractions for building cache backends.
+- [`compressors`](./crates/compressors/README.md) - Streaming compression and decompression over bytesbuf byte sequences.
- [`data_privacy`](./crates/data_privacy/README.md) - Mechanisms to classify, manipulate, and redact sensitive data.
- [`fetch`](./crates/fetch/README.md) - "Universal, composable and resilient HTTP client."
- [`fetch_azure`](./crates/fetch_azure/README.md) - Azure SDK HTTP transport backed by the fetch HTTP client.
diff --git a/crates/compressors/CHANGELOG.md b/crates/compressors/CHANGELOG.md
new file mode 100644
index 000000000..825c32f0d
--- /dev/null
+++ b/crates/compressors/CHANGELOG.md
@@ -0,0 +1 @@
+# Changelog
diff --git a/crates/compressors/Cargo.toml b/crates/compressors/Cargo.toml
new file mode 100644
index 000000000..ec5361889
--- /dev/null
+++ b/crates/compressors/Cargo.toml
@@ -0,0 +1,104 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+[package]
+name = "compressors"
+version = "0.1.0"
+description = "Streaming compression and decompression over bytesbuf byte sequences"
+readme = "README.md"
+keywords = ["compression", "gzip", "streaming", "zero-copy"]
+categories = ["compression", "network-programming"]
+edition = { workspace = true }
+rust-version = { workspace = true }
+authors = { workspace = true }
+license = { workspace = true }
+homepage = { workspace = true }
+include = { workspace = true }
+repository = "https://github.com/microsoft/oxidizer/tree/main/crates/compressors"
+
+# Miri cannot run all of this crate's backends. `zstd-safe` binds the native zstd library, and Miri
+# cannot call foreign functions; `flate2`'s `zlib-rs` backend has an open soundness bug that trips
+# Stacked Borrows whenever a Deflate or Inflate is dropped
+# (https://github.com/trifectatechfoundation/zlib-rs/issues/491). Brotli is pure Rust and would run;
+# excluding the whole crate rather than maintaining a brotli-only Miri configuration is a separate
+# decision, taken because the per-format `cfg(miri)` scaffolding the rest would need costs more than
+# the remaining coverage is worth.
+[package.metadata.anvil.miri]
+exclude = true
+
+[package.metadata.docs.rs]
+all-features = true
+
+# `cfg_aliases` is used only by `build.rs`. cargo-machete does not scan build
+# scripts, so it reports a false positive; ignore it here.
+[package.metadata.cargo-machete]
+ignored = ["cfg_aliases"]
+
+[package.metadata.cargo_check_external_types]
+allowed_external_types = [
+ "bytesbuf::*",
+ "futures_core::stream::Stream",
+ "recoverable::*",
+ "thread_aware_core::thread_aware::ThreadAware",
+]
+
+[features]
+brotli = ["dep:brotli"]
+# No format is enabled by default: a dependent names the ones it actually speaks, so a build that
+# only needs brotli never compiles flate2, and vice versa. A build with no format at all still gets
+# the shared contract and builders, which is what a crate that only re-exports them needs.
+default = []
+deflate = ["dep:flate2"]
+futures-stream = ["dep:futures-core", "dep:pin-project-lite"]
+gzip = ["dep:flate2"]
+zlib = ["dep:flate2"]
+zstd = ["dep:zstd-safe"]
+
+[dependencies]
+brotli = { workspace = true, features = ["std"], optional = true }
+bytesbuf = { workspace = true, features = ["std"] }
+# `zlib-rs` rather than flate2's default `miniz_oxide`: it is the fastest of flate2's backends and,
+# unlike the `zlib`/`zlib-ng` bindings, is pure Rust, so the deflate family stays free of C on a path
+# that parses untrusted input. The cost is recorded above -- it is what keeps this crate out of Miri.
+flate2 = { workspace = true, features = ["zlib-rs"], optional = true }
+futures-core = { workspace = true, optional = true }
+pin-project-lite = { workspace = true, optional = true }
+recoverable = { workspace = true }
+thread_aware = { workspace = true, features = ["std"] }
+zstd-safe = { workspace = true, features = ["std"], optional = true }
+
+[build-dependencies]
+cfg_aliases = { workspace = true }
+
+[dev-dependencies]
+bolero = { workspace = true, features = ["std"] }
+brotli = { workspace = true, features = ["std"] }
+bytesbuf = { workspace = true, features = ["std", "test-util"] }
+criterion = { workspace = true }
+flate2 = { workspace = true, features = ["zlib-rs"] }
+futures = { workspace = true, features = ["executor"] }
+futures-core = { workspace = true }
+metabench = { workspace = true }
+mutants = { workspace = true }
+pin-project-lite = { workspace = true }
+thread_aware = { path = "../thread_aware", default-features = false, features = ["std", "test-utils"] }
+tick = { path = "../tick", features = ["tokio", "test-util"] }
+tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
+tokio-stream = { workspace = true }
+zstd-safe = { workspace = true, features = ["std"] }
+
+[[example]]
+name = "round_trip"
+required-features = ["brotli", "deflate", "gzip", "zlib", "zstd"]
+
+[[example]]
+name = "tokio_stream"
+required-features = ["futures-stream", "gzip"]
+
+[[bench]]
+harness = false
+name = "compressors_codec"
+required-features = ["brotli", "deflate", "futures-stream", "gzip", "zlib", "zstd"]
+
+[lints]
+workspace = true
diff --git a/crates/compressors/README.md b/crates/compressors/README.md
new file mode 100644
index 000000000..edb246610
--- /dev/null
+++ b/crates/compressors/README.md
@@ -0,0 +1,242 @@
+
+
+
+# Compressors
+
+[](https://crates.io/crates/compressors)
+[](https://docs.rs/compressors)
+[](https://crates.io/crates/compressors)
+[](https://github.com/microsoft/oxidizer/actions/workflows/main.yml)
+[](https://codecov.io/gh/microsoft/oxidizer)
+[](https://github.com/microsoft/oxidizer/blob/main/LICENSE)
+
+
+
+
+Streaming compression and decompression over [`bytesbuf`][__link0] byte sequences.
+
+Each supported format – `deflate`, `zlib`, `gzip`, `brotli`, `zstd` – lives in a module of its
+own behind a cargo feature of its own. What those modules share is uniform: `compress`,
+`decompress`, `Compressor` and `Decompressor` have the same shape in every one of them, so
+moving a call site between formats is a change of import. Their builders are not uniform:
+`brotli` and `zstd` add format-specific settings, and their compressor `build` returns a
+[`Result`][__link1], so switching a builder call site can take more than an import change.
+
+**Engine** below means a third-party format implementation (`flate2`, the `brotli` crate,
+`zstd-safe`) together with the working memory it allocates. A `Compressor` or a `Decompressor`
+owns one, configured and positioned in a single stream, and returns it to [`Resources`][__link2] on drop.
+
+This crate is distinguished by:
+
+* **It reads and writes [`bytesbuf`][__link3] sequences directly.** Input is read from a [`BytesView`][__link4]’s
+ segments where they already sit, and output is written into the uninitialized spare capacity
+ of a [`BytesBuf`][__link5]. Nothing is flattened into an intermediate buffer on the
+ way in, and nothing is copied out of one on the way back.
+* **It recycles engine state.** [`Resources`][__link6] keeps the window and hash tables an engine
+ allocates and hands them to the next compressor or decompressor that needs them. On a small
+ message that setup costs about as much as the compression itself, so the saving is worth
+ having.
+* **One API spans every format, at any size.** The same push/pull contract drives every engine,
+ so code is written once and works with whichever one it is given. Because an engine is a state
+ machine rather than a one-shot transform, a stream of any length passes through it while the
+ pending output it buffers stays bounded by the configured chunk size.
+
+Secondarily, this is also why the engines are not driven through `std::io`. That route works –
+[`BytesView`][__link7] implements `BufRead` over its segments and `BytesBufWriter` implements `Write`
+into segmented storage, so nothing has to be flattened to use it. What the direct adapters buy
+is narrower: output goes straight into a [`BytesBuf`][__link8]’s uninitialized spare
+capacity rather than through an intermediate buffer the adapter owns, engine state stays
+reusable from one stream to the next, and flush and chunk boundaries remain under this crate’s
+control.
+
+## Whole buffers
+
+Each format module has its own `compress` and `decompress` for the common case. The crate-level
+[`compress`][__link9] and [`decompress`][__link10] instead accept any engine implementing [`Compression`][__link11],
+however it was constructed.
+
+```rust
+use compressors::{Resources, gzip};
+
+let resources = Resources::global();
+let compressed = gzip::compress(b"hello", resources)?;
+
+assert_eq!(
+ gzip::decompress(compressed, resources)?.to_vec(),
+ b"hello".to_vec()
+);
+```
+
+## Streaming
+
+An engine is a state machine rather than 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
+their size depends on the format and its configuration. `CompressionStream`, behind the
+`futures-stream` feature, is how to reach that – it turns any stream of byte sequences into its
+compressed or decompressed counterpart:
+
+```rust
+use std::io::Error as IoError;
+
+use bytesbuf::BytesView;
+use compressors::{CompressionStream, Resources, gzip};
+use futures::{TryStreamExt, stream};
+
+let resources = Resources::global();
+let body = stream::iter(vec![
+ Ok::<_, IoError>(BytesView::copied_from_slice(b"a body ", resources.memory())),
+ Ok(BytesView::copied_from_slice(
+ b"in pieces",
+ resources.memory(),
+ )),
+]);
+
+let mut compressed = CompressionStream::compress(body, gzip::Compressor::new(resources));
+
+// Each chunk is inspected and dropped as it arrives, so the caller stays bounded too --
+// collecting them all would put the whole encoded body back in memory.
+let mut magic = Vec::new();
+while let Some(chunk) = compressed.try_next().await? {
+ if magic.is_empty() && chunk.len() >= 2 {
+ magic = chunk.range(0..2).to_vec();
+ }
+}
+
+assert_eq!(magic, vec![0x1f, 0x8b]);
+```
+
+## Choosing a format
+
+When the format is only known at runtime – from a `Content-Encoding` token, say – the
+[`format`][__link12] module resolves the token and carries the same shape every other
+format module does: a `Compressor`, a `Decompressor`, and the whole-buffer conveniences. Use
+[`CompressorBuilder::build_format`][__link13] instead when a level or chunk size has to be set on the
+result.
+
+Note that the `deflate` feature and the HTTP `deflate` content coding are not the same thing.
+`Format::Deflate` is raw DEFLATE (RFC 1951), which has no content-coding token, so
+`Format::Deflate.content_encoding()` returns `None`. The HTTP `deflate` token denotes a
+zlib-wrapped stream (RFC 1950), so `Format::from_content_encoding("deflate")` resolves to
+`Format::Zlib` and needs the `zlib` feature, not the `deflate` one.
+
+```rust
+use compressors::Resources;
+use compressors::format::{self, Format};
+
+let format = Format::from_content_encoding("gzip").expect("this build supports gzip");
+
+let resources = Resources::global();
+let compressed = format::compress(format, b"runtime selected", resources)?;
+
+assert_eq!(
+ format::decompress(format, compressed, resources)?.to_vec(),
+ b"runtime selected".to_vec()
+);
+```
+
+## Reusing engine state
+
+Building a compressor allocates and initializes a substantial amount of state – on a small
+message, as much work as the compression itself. [`Resources`][__link14] recycles it: hold one, hand it to
+every compressor and decompressor, and each engine returns to it on drop. The saving is roughly
+fixed per message, so it matters most for small bodies.
+
+Recycling is on by default, which is why every API that builds an engine asks for resources rather
+than for a memory provider alone. Set the capacity to zero with
+[`with_pool_capacity`][__link15] when compression is rare enough that
+retaining engine state costs more than rebuilding it.
+
+```rust
+use compressors::{Level, Resources, gzip};
+
+// Held once by the application, cloned into whatever needs it.
+let resources = Resources::global();
+
+// Per request: cheap to build, recycles the engine on drop.
+let compressor = gzip::Compressor::builder()
+ .level(Level::DEFAULT)
+ .build(resources);
+```
+
+Recycling applies only to the engines whose state is expensive enough to be worth retaining and
+is skipped for the rest, so calling code never has to know which engines benefit.
+
+## Security
+
+Every one of these formats can expand its input by orders of magnitude, so a decompressor
+pointed at untrusted data is a memory-exhaustion vector. A decompressor consumed through
+[`CompressionStream`][__link16] never accumulates – each chunk it hands back is bounded – so the
+exposure is in what the caller keeps, which makes it the conveniences that buffer a whole result
+that need bounding. Those add a 64 MiB output cap and a 1024 concatenated-stream cap to whatever
+the caller did not set.
+
+When you buffer decompressed output yourself, set
+[`max_output_len`][__link17] to what you can afford. That
+guardrail is for the common case, not a substitute for bounding how many bodies you decompress
+at once. [`DecompressorLimits`][__link18] documents what each format bounds by default, and why a ratio
+alone is not protection.
+
+Decompression can yield bytes before a checksum or trailer has rejected the stream, so treat
+them as provisional until the decompressor reports that it is done. A checksum match is not an
+authenticity claim either – none of these formats is authenticated – so decompressing does not
+make the content any more trustworthy than it was.
+
+See [`documentation`][__link19] for the full threat model.
+
+## Features
+
+Every format is a separate feature and none is on by default, so a build compiles only the
+engines it names:
+
+* `gzip` – the `gzip` module and `Format::Gzip`, via `flate2`. Accepted by essentially every
+ HTTP client and server, so it is the safe default when the peer’s capabilities are unknown.
+* `deflate` – the `deflate` module and `Format::Deflate`, via `flate2`. Raw DEFLATE, with no
+ HTTP content-coding token of its own.
+* `zlib` – the `zlib` module and `Format::Zlib`, via `flate2`. This is what the HTTP `deflate`
+ content coding actually denotes.
+* `brotli` – the `brotli` module and `Format::Brotli`, via the pure-Rust `brotli` crate.
+* `zstd` – the `zstd` module and `Format::Zstd`, via `zstd-safe`.
+* `futures-stream` – `CompressionStream`, presenting compression and decompression as a
+ `futures_core::Stream` over any stream of byte sequences.
+
+The deflate-family features share one dependency, so enabling more than one of them costs no
+more than enabling one. A build that needs only `brotli` or only `zstd` never compiles `flate2`
+at all, and a build that names no format at all still gets [`Compression`][__link20], the builders and
+[`Resources`][__link21], which is what a crate that only passes compressors and decompressors around
+needs.
+
+## Further reading
+
+See [`documentation`][__link22] for the longer form guides.
+
+
+
+
+This crate was developed as part of The Oxidizer Project . Browse this crate's source code .
+
+
+ [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQb11VxC_uAPOQbtUn4Wx2-BfAbid3Nt1Y27Pobprn8Z6FjFy9hYvRhcoQbjojVXQIV2Ewb89V-5l67DvobDbwJEokPYgIb-ONYOqfWe4RhZIKCaGJ5dGVzYnVmZTAuOS4wgmtjb21wcmVzc29yc2UwLjEuMA
+ [__link0]: https://crates.io/crates/bytesbuf/0.9.0
+ [__link1]: https://docs.rs/compressors/0.1.0/compressors/?search=Result
+ [__link10]: https://docs.rs/compressors/0.1.0/compressors/fn.decompress.html
+ [__link11]: https://docs.rs/compressors/0.1.0/compressors/?search=core::Compression
+ [__link12]: mod@crate::format
+ [__link13]: https://docs.rs/compressors/0.1.0/compressors/?search=CompressorBuilder::build_format
+ [__link14]: https://docs.rs/compressors/0.1.0/compressors/?search=Resources
+ [__link15]: https://docs.rs/compressors/0.1.0/compressors/?search=Resources::with_pool_capacity
+ [__link16]: https://docs.rs/compressors/0.1.0/compressors/?search=CompressionStream
+ [__link17]: https://docs.rs/compressors/0.1.0/compressors/?search=DecompressorLimits::max_output_len
+ [__link18]: https://docs.rs/compressors/0.1.0/compressors/?search=DecompressorLimits
+ [__link19]: https://docs.rs/compressors/0.1.0/compressors/documentation/index.html
+ [__link2]: https://docs.rs/compressors/0.1.0/compressors/?search=Resources
+ [__link20]: https://docs.rs/compressors/0.1.0/compressors/?search=core::Compression
+ [__link21]: https://docs.rs/compressors/0.1.0/compressors/?search=Resources
+ [__link22]: https://docs.rs/compressors/0.1.0/compressors/documentation/index.html
+ [__link3]: https://crates.io/crates/bytesbuf/0.9.0
+ [__link4]: https://docs.rs/bytesbuf/0.9.0/bytesbuf/?search=BytesView
+ [__link5]: https://docs.rs/bytesbuf/0.9.0/bytesbuf/?search=BytesBuf
+ [__link6]: https://docs.rs/compressors/0.1.0/compressors/?search=Resources
+ [__link7]: https://docs.rs/bytesbuf/0.9.0/bytesbuf/?search=BytesView
+ [__link8]: https://docs.rs/bytesbuf/0.9.0/bytesbuf/?search=BytesBuf
+ [__link9]: https://docs.rs/compressors/0.1.0/compressors/fn.compress.html
diff --git a/crates/compressors/benches/compressors_codec.rs b/crates/compressors/benches/compressors_codec.rs
new file mode 100644
index 000000000..0f21c5330
--- /dev/null
+++ b/crates/compressors/benches/compressors_codec.rs
@@ -0,0 +1,632 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Throughput and allocation behaviour of the compression engines.
+//!
+//! Every benchmark reports both time and allocations, because this crate's central claims are about
+//! allocation: input is consumed segment by segment without being flattened, output is written into
+//! a caller-supplied memory provider, and [`Resources`] recycles engine state. Timings alone would
+//! not show a regression in any of those.
+//!
+//! [`metabench`] runs the same workloads with Criterion, allocation tracking, and, on Linux,
+//! Gungraun. Payload preparation and resource warm-up are outside the measured functions; output
+//! disposal remains inside them. Resources are retained across Criterion iterations.
+//!
+//! Parameter names use Rust identifiers so Criterion and Gungraun report the same cases.
+//! Pass `--show-engine-output` to also display the compression-ratio and zstd working-set tables.
+//!
+//! Read the zstd rows with care. `zstd` allocates its compression and decompression contexts
+//! through its own allocator rather than Rust's, so those allocations are invisible here and the
+//! zstd rows understate the true cost. Its timings are directly comparable with the other formats;
+//! its allocation figures cover only what Rust's global allocator can see.
+
+use std::hint::black_box;
+use std::num::NonZeroUsize;
+
+use bytesbuf::BytesView;
+use bytesbuf::mem::GlobalPool;
+use compressors::brotli::{self, WindowSize};
+use compressors::format::Format;
+use compressors::{CompressorBuilder, DecompressorBuilder, Level, Resources};
+use criterion::{BenchmarkId, Criterion, Throughput};
+
+/// Sizes chosen to bracket real traffic: a small API response, a page, and a large document.
+const SIZES: [usize; 3] = [1024, 64 * 1024, 1024 * 1024];
+
+/// Builds a payload that compresses like real data rather than like a repeated string.
+///
+/// A repeated token collapses to a handful of bytes at every level, which hides the differences
+/// between formats and between levels.
+///
+/// The shape is JSON-like records mixing repeated structure with varying values. The field
+/// cardinalities -- 100,000 user ids, 1,000 scores, five tags, a boolean -- are chosen to keep that
+/// mixture stable across payload sizes rather than calibrated against production data; they are
+/// arbitrary in magnitude but deliberate in spread, so no field either collapses to a constant or
+/// becomes uniformly random. The seed is fixed, so every run compresses identical bytes and a
+/// change in the numbers reflects a change in the code.
+fn payload(size: usize) -> Vec {
+ let mut bytes = Vec::with_capacity(size + 128);
+ let mut seed = 0x2545_f491_4f6c_dd1d_u64;
+
+ let mut id = 0_u64;
+ while bytes.len() < size {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+
+ bytes.extend_from_slice(
+ format!(
+ r#"{{"id":{id},"user":"user_{}","score":{},"tag":"{}","ok":{}}},"#,
+ seed % 100_000,
+ seed % 1_000,
+ ["alpha", "beta", "gamma", "delta", "epsilon"][(seed % 5) as usize],
+ seed.is_multiple_of(2)
+ )
+ .as_bytes(),
+ );
+ id += 1;
+ }
+
+ bytes.truncate(size);
+ bytes
+}
+
+/// The backend the segmentation and chunk-size groups measure against.
+///
+/// Both groups are about this crate's own buffer handling rather than any engine's compression, so
+/// they fix one format instead of sweeping all five. This benchmark declares every format in its
+/// `required-features`, so deflate is always compiled in and can be named directly.
+const REPRESENTATIVE_FORMAT: Format = Format::Deflate;
+
+/// The native zstd level this crate's portable [`Level`] maps to.
+///
+/// Mirrors `compression_level` in `src/zstd/codec.rs`, which is crate-private and so cannot be
+/// called from a benchmark. Duplicated deliberately rather than measured at the raw scale values:
+/// the footprint is only interesting at the levels production actually reaches. If the production
+/// mapping changes, this table has to change with it -- the assertion below is what catches that.
+fn zstd_native_level(level: Level) -> i32 {
+ const MAPPING: [i32; 10] = [1, 1, 2, 2, 3, 3, 3, 6, 9, 12];
+
+ let native = MAPPING[usize::from(level.get().min(9))];
+
+ // Pins the duplicate against the public surface it mirrors: the crate documents `Level::DEFAULT`
+ // as zstd's own default of 3, so a mapping edit that broke that would fail here rather than
+ // quietly reporting a footprint for the wrong level.
+ assert!(
+ level != Level::DEFAULT || native == 3,
+ "the benchmark's level mapping has drifted from the crate's"
+ );
+
+ native
+}
+
+fn view(bytes: &[u8], memory: &GlobalPool) -> BytesView {
+ BytesView::copied_from_slice(bytes, memory)
+}
+
+/// Splits a payload into `segment` sized spans, the shape this crate exists to handle.
+fn fragmented(bytes: &[u8], segment: usize, memory: &GlobalPool) -> BytesView {
+ BytesView::from_views(bytes.chunks(segment).map(|chunk| BytesView::copied_from_slice(chunk, memory)))
+}
+
+fn chunk(size: usize) -> NonZeroUsize {
+ NonZeroUsize::new(size).expect("benchmark chunk sizes are never zero")
+}
+
+/// Compresses a view, returning the output so the optimiser cannot discard the work.
+fn compress(format: Format, level: Option, chunk_size: Option, input: &BytesView, resources: &Resources) -> BytesView {
+ let builder = CompressorBuilder::new();
+ let builder = match level {
+ Some(level) => builder.level(level),
+ None => builder,
+ };
+ let builder = match chunk_size {
+ Some(size) => builder.output_chunk_size(size),
+ None => builder,
+ };
+
+ let compressor = builder.build_format(format, resources).expect("the settings are accepted");
+
+ compressors::compress(input.clone(), compressor).expect("compression succeeds")
+}
+
+fn decompress(format: Format, input: &BytesView, resources: &Resources) -> BytesView {
+ let decompressor = DecompressorBuilder::new()
+ .build_format(format, resources)
+ .expect("the settings are accepted");
+
+ compressors::decompress(input.clone(), decompressor).expect("decompression succeeds")
+}
+
+/// Compresses with an explicit brotli window, which the runtime `Format` builder cannot express.
+fn compress_brotli(window: WindowSize, input: &BytesView, resources: &Resources) -> BytesView {
+ let compressor = brotli::Compressor::builder().window_size(window).build(resources);
+
+ compressors::compress(input.clone(), compressor).expect("compression succeeds")
+}
+
+struct Input {
+ format: Format,
+ bytes: BytesView,
+ resources: Resources,
+}
+
+impl Input {
+ fn new(format: Format, size: usize) -> Self {
+ let memory = GlobalPool::new();
+ Self {
+ format,
+ bytes: view(&payload(size), &memory),
+ resources: Resources::new(memory),
+ }
+ }
+
+ fn warm_compression(&self, level: Option, chunk_size: Option) {
+ drop(compress(self.format, level, chunk_size, &self.bytes, &self.resources));
+ }
+
+ fn into_compressed(mut self) -> Self {
+ self.bytes = compress(self.format, None, None, &self.bytes, &self.resources);
+ drop(decompress(self.format, &self.bytes, &self.resources));
+ self
+ }
+}
+
+fn compression_input(format: Format, size: usize) -> Input {
+ let input = Input::new(format, size);
+ input.warm_compression(None, None);
+ input
+}
+
+fn decompression_input(format: Format, size: usize) -> Input {
+ Input::new(format, size).into_compressed()
+}
+
+#[metabench::benchmark(COMPRESS, "compressors_codec", "compress")]
+#[bench::brotli_1024(&compression_input(Format::Brotli, 1024))]
+#[bench::brotli_65536(&compression_input(Format::Brotli, 64 * 1024))]
+#[bench::brotli_1048576(&compression_input(Format::Brotli, 1024 * 1024))]
+#[bench::deflate_1024(&compression_input(Format::Deflate, 1024))]
+#[bench::deflate_65536(&compression_input(Format::Deflate, 64 * 1024))]
+#[bench::deflate_1048576(&compression_input(Format::Deflate, 1024 * 1024))]
+#[bench::gzip_1024(&compression_input(Format::Gzip, 1024))]
+#[bench::gzip_65536(&compression_input(Format::Gzip, 64 * 1024))]
+#[bench::gzip_1048576(&compression_input(Format::Gzip, 1024 * 1024))]
+#[bench::zlib_1024(&compression_input(Format::Zlib, 1024))]
+#[bench::zlib_65536(&compression_input(Format::Zlib, 64 * 1024))]
+#[bench::zlib_1048576(&compression_input(Format::Zlib, 1024 * 1024))]
+#[bench::zstd_1024(&compression_input(Format::Zstd, 1024))]
+#[bench::zstd_65536(&compression_input(Format::Zstd, 64 * 1024))]
+#[bench::zstd_1048576(&compression_input(Format::Zstd, 1024 * 1024))]
+fn compress_payload(input: &Input) {
+ black_box(compress(input.format, None, None, &input.bytes, &input.resources));
+}
+
+#[metabench::benchmark(DECOMPRESS, "compressors_codec", "decompress")]
+#[bench::brotli_1024(&decompression_input(Format::Brotli, 1024))]
+#[bench::brotli_65536(&decompression_input(Format::Brotli, 64 * 1024))]
+#[bench::brotli_1048576(&decompression_input(Format::Brotli, 1024 * 1024))]
+#[bench::deflate_1024(&decompression_input(Format::Deflate, 1024))]
+#[bench::deflate_65536(&decompression_input(Format::Deflate, 64 * 1024))]
+#[bench::deflate_1048576(&decompression_input(Format::Deflate, 1024 * 1024))]
+#[bench::gzip_1024(&decompression_input(Format::Gzip, 1024))]
+#[bench::gzip_65536(&decompression_input(Format::Gzip, 64 * 1024))]
+#[bench::gzip_1048576(&decompression_input(Format::Gzip, 1024 * 1024))]
+#[bench::zlib_1024(&decompression_input(Format::Zlib, 1024))]
+#[bench::zlib_65536(&decompression_input(Format::Zlib, 64 * 1024))]
+#[bench::zlib_1048576(&decompression_input(Format::Zlib, 1024 * 1024))]
+#[bench::zstd_1024(&decompression_input(Format::Zstd, 1024))]
+#[bench::zstd_65536(&decompression_input(Format::Zstd, 64 * 1024))]
+#[bench::zstd_1048576(&decompression_input(Format::Zstd, 1024 * 1024))]
+fn decompress_payload(input: &Input) {
+ black_box(decompress(input.format, &input.bytes, &input.resources));
+}
+
+fn compression(criterion: &mut Criterion) {
+ let mut group = criterion.benchmark_group(COMPRESS.to_string());
+
+ for size in SIZES {
+ group.throughput(Throughput::Bytes(size as u64));
+
+ for &format in Format::ALL {
+ let input = compression_input(format, size);
+ let name = format!("{format:?}_{size}").to_lowercase();
+
+ group.bench_function(name, |bencher| {
+ bencher.iter(|| compress_payload(&input));
+ });
+ }
+ }
+
+ group.finish();
+}
+
+fn decompression(criterion: &mut Criterion) {
+ let mut group = criterion.benchmark_group(DECOMPRESS.to_string());
+
+ for size in SIZES {
+ group.throughput(Throughput::Bytes(size as u64));
+
+ for &format in Format::ALL {
+ let input = decompression_input(format, size);
+ let name = format!("{format:?}_{size}").to_lowercase();
+
+ group.bench_function(name, |bencher| {
+ bencher.iter(|| decompress_payload(&input));
+ });
+ }
+ }
+
+ group.finish();
+}
+
+fn pooling_input(format: Format, pooled: bool) -> Input {
+ let mut input = Input::new(format, 4096);
+ if !pooled {
+ input.resources = input.resources.with_pool_capacity(0);
+ }
+ input.warm_compression(None, None);
+ input
+}
+
+#[metabench::benchmark(POOL_COMPRESS, "compressors_codec/pooling", "compress")]
+#[bench::brotli_fresh(&pooling_input(Format::Brotli, false))]
+#[bench::brotli_pooled(&pooling_input(Format::Brotli, true))]
+#[bench::deflate_fresh(&pooling_input(Format::Deflate, false))]
+#[bench::deflate_pooled(&pooling_input(Format::Deflate, true))]
+#[bench::gzip_fresh(&pooling_input(Format::Gzip, false))]
+#[bench::gzip_pooled(&pooling_input(Format::Gzip, true))]
+#[bench::zlib_fresh(&pooling_input(Format::Zlib, false))]
+#[bench::zlib_pooled(&pooling_input(Format::Zlib, true))]
+#[bench::zstd_fresh(&pooling_input(Format::Zstd, false))]
+#[bench::zstd_pooled(&pooling_input(Format::Zstd, true))]
+fn compress_pooled(input: &Input) {
+ black_box(compress(input.format, None, None, &input.bytes, &input.resources));
+}
+
+#[metabench::benchmark(POOL_DECOMPRESS, "compressors_codec/pooling", "decompress")]
+#[bench::brotli_fresh(&pooling_input(Format::Brotli, false).into_compressed())]
+#[bench::brotli_pooled(&pooling_input(Format::Brotli, true).into_compressed())]
+#[bench::deflate_fresh(&pooling_input(Format::Deflate, false).into_compressed())]
+#[bench::deflate_pooled(&pooling_input(Format::Deflate, true).into_compressed())]
+#[bench::gzip_fresh(&pooling_input(Format::Gzip, false).into_compressed())]
+#[bench::gzip_pooled(&pooling_input(Format::Gzip, true).into_compressed())]
+#[bench::zlib_fresh(&pooling_input(Format::Zlib, false).into_compressed())]
+#[bench::zlib_pooled(&pooling_input(Format::Zlib, true).into_compressed())]
+#[bench::zstd_fresh(&pooling_input(Format::Zstd, false).into_compressed())]
+#[bench::zstd_pooled(&pooling_input(Format::Zstd, true).into_compressed())]
+fn decompress_pooled(input: &Input) {
+ black_box(decompress(input.format, &input.bytes, &input.resources));
+}
+
+/// The headline claim for [`Resources`]: recycling engine state removes per-message setup.
+///
+/// Also the regression guard for it, but only for the formats the pool actually reuses: the flate
+/// family and zstd. Brotli exposes no reset, so it is never pooled, and gzip decompressors are
+/// deliberately excluded because the engine's reset cannot restore gzip framing. Those rows are
+/// controls -- they should show no material penalty from holding `Resources`, not a speed-up.
+/// If a pooled row stops beating its unpooled counterpart, or stops allocating less, something has
+/// broken.
+fn pooling(criterion: &mut Criterion) {
+ let mut group = criterion.benchmark_group(POOL_COMPRESS.group_name());
+ group.throughput(Throughput::Bytes(4096));
+
+ for &format in Format::ALL {
+ for (label, pooled) in [("fresh", false), ("pooled", true)] {
+ let input = pooling_input(format, pooled);
+ let name = format!("{format:?}_{label}").to_lowercase();
+
+ group.bench_function(BenchmarkId::new(POOL_COMPRESS.benchmark_name(), &name), |bencher| {
+ bencher.iter(|| compress_pooled(&input));
+ });
+
+ let compressed = input.into_compressed();
+
+ group.bench_function(BenchmarkId::new(POOL_DECOMPRESS.benchmark_name(), &name), |bencher| {
+ bencher.iter(|| decompress_pooled(&compressed));
+ });
+ }
+ }
+
+ group.finish();
+}
+
+fn segmentation_input(segment: Option) -> Input {
+ let memory = GlobalPool::new();
+ let bytes = payload(64 * 1024);
+ let input = Input {
+ format: REPRESENTATIVE_FORMAT,
+ bytes: match segment {
+ Some(segment) => fragmented(&bytes, segment, &memory),
+ None => view(&bytes, &memory),
+ },
+ resources: Resources::new(memory),
+ };
+ input.warm_compression(None, None);
+ input
+}
+
+#[metabench::benchmark(SEGMENTATION, "compressors_codec", "segmentation")]
+#[bench::segments_64(&segmentation_input(Some(64)))]
+#[bench::segments_1024(&segmentation_input(Some(1024)))]
+#[bench::segments_16384(&segmentation_input(Some(16 * 1024)))]
+#[bench::contiguous(&segmentation_input(None))]
+fn compress_segmented(input: &Input) {
+ black_box(compress(input.format, None, None, &input.bytes, &input.resources));
+}
+
+/// Input arrives as a chain of spans, so the cost of that chain is the crate's reason to exist.
+///
+/// A regression here -- for instance flattening the view before handing it to the engine -- would
+/// show up as a jump in allocations for the fragmented cases.
+fn segmentation(criterion: &mut Criterion) {
+ let mut group = criterion.benchmark_group(SEGMENTATION.to_string());
+ group.throughput(Throughput::Bytes(64 * 1024));
+
+ // Deflate is the representative backend for both this group and `chunk_size`: it is the most
+ // widely deployed of the five and its engine takes the uninitialized output slice directly, so
+ // what these groups measure is this crate's own segment handling rather than a backend quirk.
+ // Sweeping every format here would multiply runtime without changing the conclusion.
+ //
+ // 64 B is the pathological case -- a view shredded far below any real segment size -- while
+ // 1 KiB and 16 KiB bracket what a real chained view looks like. Contiguous is the control.
+ for segment in [Some(64), Some(1024), Some(16 * 1024), None] {
+ let input = segmentation_input(segment);
+ let name = segment.map_or_else(|| "contiguous".to_owned(), |segment| format!("segments_{segment}"));
+
+ group.bench_function(name, |bencher| {
+ bencher.iter(|| compress_segmented(&input));
+ });
+ }
+
+ group.finish();
+}
+
+fn chunk_input(size: usize) -> Input {
+ let input = Input::new(REPRESENTATIVE_FORMAT, 256 * 1024);
+ input.warm_compression(None, Some(chunk(size)));
+ input
+}
+
+#[metabench::benchmark(CHUNK_SIZE, "compressors_codec", "chunk_size")]
+#[bench::chunks_1024(&chunk_input(1024), 1024)]
+#[bench::chunks_8192(&chunk_input(8 * 1024), 8 * 1024)]
+#[bench::chunks_65536(&chunk_input(64 * 1024), 64 * 1024)]
+#[bench::chunks_524288(&chunk_input(512 * 1024), 512 * 1024)]
+fn compress_chunked(input: &Input, size: usize) {
+ black_box(compress(input.format, None, Some(chunk(size)), &input.bytes, &input.resources));
+}
+
+/// The output chunk size trades per-call overhead against buffer churn.
+///
+/// Measured on one backend (see [`REPRESENTATIVE_FORMAT`]), so the numbers describe deflate rather
+/// than every engine. That is enough to settle a shared default -- the trade-off is a property of
+/// how often this crate hands the engine a slice, not of what the engine does with it -- but a
+/// claim about brotli or zstd specifically would need its own measurement.
+fn chunk_size(criterion: &mut Criterion) {
+ let mut group = criterion.benchmark_group(CHUNK_SIZE.to_string());
+ group.throughput(Throughput::Bytes(256 * 1024));
+
+ // 64 KiB is the implementation default; the others bracket the transition either side of it,
+ // so the measurements show where the plateau starts rather than only that the default is on it.
+ for size in [1024_usize, 8 * 1024, 64 * 1024, 512 * 1024] {
+ let input = chunk_input(size);
+
+ group.bench_function(format!("chunks_{size}"), |bencher| {
+ bencher.iter(|| compress_chunked(&input, size));
+ });
+ }
+
+ group.finish();
+}
+
+fn level_input(format: Format, level: Level) -> Input {
+ let input = Input::new(format, 64 * 1024);
+ input.warm_compression(Some(level), None);
+ input
+}
+
+#[metabench::benchmark(LEVELS, "compressors_codec", "levels")]
+#[bench::brotli_1(&level_input(Format::Brotli, Level::FAST), Level::FAST)]
+#[bench::brotli_6(&level_input(Format::Brotli, Level::DEFAULT), Level::DEFAULT)]
+#[bench::brotli_9(&level_input(Format::Brotli, Level::HIGH), Level::HIGH)]
+#[bench::deflate_1(&level_input(Format::Deflate, Level::FAST), Level::FAST)]
+#[bench::deflate_6(&level_input(Format::Deflate, Level::DEFAULT), Level::DEFAULT)]
+#[bench::deflate_9(&level_input(Format::Deflate, Level::HIGH), Level::HIGH)]
+#[bench::gzip_1(&level_input(Format::Gzip, Level::FAST), Level::FAST)]
+#[bench::gzip_6(&level_input(Format::Gzip, Level::DEFAULT), Level::DEFAULT)]
+#[bench::gzip_9(&level_input(Format::Gzip, Level::HIGH), Level::HIGH)]
+#[bench::zlib_1(&level_input(Format::Zlib, Level::FAST), Level::FAST)]
+#[bench::zlib_6(&level_input(Format::Zlib, Level::DEFAULT), Level::DEFAULT)]
+#[bench::zlib_9(&level_input(Format::Zlib, Level::HIGH), Level::HIGH)]
+#[bench::zstd_1(&level_input(Format::Zstd, Level::FAST), Level::FAST)]
+#[bench::zstd_6(&level_input(Format::Zstd, Level::DEFAULT), Level::DEFAULT)]
+#[bench::zstd_9(&level_input(Format::Zstd, Level::HIGH), Level::HIGH)]
+fn compress_at_level(input: &Input, level: Level) {
+ black_box(compress(input.format, Some(level), None, &input.bytes, &input.resources));
+}
+
+/// Compression levels, so the portable scale's cost across formats is visible rather than assumed.
+fn levels(criterion: &mut Criterion) {
+ let mut group = criterion.benchmark_group(LEVELS.to_string());
+ group.throughput(Throughput::Bytes(64 * 1024));
+
+ for &format in Format::ALL {
+ for level in [Level::FAST, Level::DEFAULT, Level::HIGH] {
+ let input = level_input(format, level);
+ let name = format!("{format:?}_{}", level.get()).to_lowercase();
+
+ group.bench_function(name, |bencher| {
+ bencher.iter(|| compress_at_level(&input, level));
+ });
+ }
+ }
+
+ group.finish();
+}
+
+fn window_input(exponent: u8) -> (Input, WindowSize) {
+ let input = Input::new(Format::Brotli, 1024);
+ let window = WindowSize::new(exponent).expect("benchmark window exponents are in the supported range");
+ drop(compress_brotli(window, &input.bytes, &input.resources));
+ (input, window)
+}
+
+fn window_compressed_input(exponent: u8) -> Input {
+ let (mut input, window) = window_input(exponent);
+ input.bytes = compress_brotli(window, &input.bytes, &input.resources);
+ drop(decompress(input.format, &input.bytes, &input.resources));
+ input
+}
+
+#[metabench::benchmark(WINDOW_COMPRESS, "compressors_codec/brotli_window", "compress")]
+#[bench::window_10(&window_input(10))]
+#[bench::window_16(&window_input(16))]
+#[bench::window_18(&window_input(18))]
+#[bench::window_22(&window_input(22))]
+fn compress_at_window((input, window): &(Input, WindowSize)) {
+ black_box(compress_brotli(*window, &input.bytes, &input.resources));
+}
+
+#[metabench::benchmark(WINDOW_DECOMPRESS, "compressors_codec/brotli_window", "decompress")]
+#[bench::window_10(&window_compressed_input(10))]
+#[bench::window_16(&window_compressed_input(16))]
+#[bench::window_18(&window_compressed_input(18))]
+#[bench::window_22(&window_compressed_input(22))]
+fn decompress_at_window(input: &Input) {
+ black_box(decompress(input.format, &input.bytes, &input.resources));
+}
+
+/// Guards the counter-intuitive shape of brotli's window setting.
+///
+/// Brotli is by far the heaviest allocator here, so shrinking its window looks like an obvious way
+/// to trim a service that compresses small messages. Measurement says otherwise: allocation and
+/// time both behave as a step function of the window, and *both get worse* below the step, so a
+/// small window costs memory and speed at once. The exponents below bracket that step so a change
+/// in it is visible rather than silent. The cause lies inside the brotli compressor, so treat these
+/// figures as the observed shape rather than as a rule about window sizes in general.
+fn brotli_window(criterion: &mut Criterion) {
+ let mut group = criterion.benchmark_group(WINDOW_COMPRESS.group_name());
+ group.throughput(Throughput::Bytes(1024));
+
+ for exponent in [10_u8, 16, 18, 22] {
+ let input = window_input(exponent);
+ let name = format!("window_{exponent}");
+
+ group.bench_function(BenchmarkId::new(WINDOW_COMPRESS.benchmark_name(), &name), |bencher| {
+ bencher.iter(|| compress_at_window(&input));
+ });
+
+ // The decompressor side matters independently: the window is recorded in the stream, so a
+ // reader inherits whatever the writer chose.
+ let compressed = window_compressed_input(exponent);
+
+ group.bench_function(BenchmarkId::new(WINDOW_DECOMPRESS.benchmark_name(), &name), |bencher| {
+ bencher.iter(|| decompress_at_window(&compressed));
+ });
+ }
+
+ group.finish();
+}
+
+/// Prints the ratio each format and level achieves.
+///
+/// The timing and allocation groups measure what a setting costs but not what it buys, which
+/// leaves the level groups undecidable on their own. Ratio is deterministic, so it is computed
+/// once rather than benchmarked.
+fn ratios() {
+ let memory = GlobalPool::new();
+ let resources = Resources::new(memory.clone());
+ let bytes = payload(64 * 1024);
+ let input = view(&bytes, &memory);
+
+ println!("\nCompression ratio (64 KiB of JSON-like input):\n");
+ println!("| Format | Level | Ratio |");
+ println!("|---------|-------|-------|");
+
+ for &format in Format::ALL {
+ for level in [Level::FAST, Level::DEFAULT, Level::HIGH] {
+ let compressed = compress(format, Some(level), None, &input, &resources);
+
+ #[expect(clippy::cast_precision_loss, reason = "a ratio needs no more precision than this")]
+ let ratio = bytes.len() as f64 / compressed.len() as f64;
+
+ println!("| {:<7} | {:<5} | {ratio:>5.2} |", format!("{format:?}"), level.get());
+ }
+ }
+
+ println!("\nCompression ratio by brotli window (64 KiB):\n");
+ println!("| Window | Ratio |");
+ println!("|--------|-------|");
+
+ for exponent in [10_u8, 16, 18, 22] {
+ let window = WindowSize::new(exponent).expect("exponents are in range");
+ let compressed = compress_brotli(window, &input, &resources);
+
+ #[expect(clippy::cast_precision_loss, reason = "a ratio needs no more precision than this")]
+ let ratio = bytes.len() as f64 / compressed.len() as f64;
+
+ println!("| 2^{exponent:<4} | {ratio:>5.2} |");
+ }
+}
+
+/// Reports zstd's real working-set size, which the global allocator cannot see.
+///
+/// `zstd` allocates its contexts through its own allocator, so every zstd row in the allocation
+/// table understates the cost. Asking zstd itself restores the comparison.
+fn zstd_footprint() {
+ let bytes = payload(64 * 1024);
+ // Sized from the payload rather than a round number, so the destination is a guaranteed upper
+ // bound for whatever zstd produces at any level.
+ let mut buffer = vec![0_u8; zstd_safe::compress_bound(bytes.len())];
+
+ println!("\nzstd working set, reported by zstd itself:\n");
+ println!("| Level | Compressor context bytes | Decompressor context bytes |");
+ println!("|-------|--------------------------|----------------------------|");
+
+ for level in [Level::FAST, Level::DEFAULT, Level::HIGH] {
+ // Through the same portable-to-native mapping the codec uses, so the footprint is measured
+ // at the levels production actually reaches rather than at the raw scale values.
+ let native = zstd_native_level(level);
+
+ // The contexts allocate lazily, so measure only after real work has sized them.
+ let mut context = zstd_safe::CCtx::create();
+ let written = context.compress(&mut *buffer, &bytes, native).expect("compression succeeds");
+
+ let mut decompressor = zstd_safe::DCtx::create();
+ let mut plain = vec![0_u8; bytes.len()];
+ decompressor
+ .decompress(&mut *plain, &buffer[..written])
+ .expect("decompression succeeds");
+
+ println!("| {:<5} | {:>10} | {:>10} |", level.get(), context.sizeof(), decompressor.sizeof());
+ }
+}
+
+fn benches(criterion: &mut Criterion) {
+ compression(criterion);
+ decompression(criterion);
+ pooling(criterion);
+ segmentation(criterion);
+ chunk_size(criterion);
+ levels(criterion);
+ brotli_window(criterion);
+
+ ratios();
+ zstd_footprint();
+}
+
+metabench::main!(
+ criterion = benches,
+ benchmarks = [
+ COMPRESS,
+ DECOMPRESS,
+ POOL_COMPRESS,
+ POOL_DECOMPRESS,
+ SEGMENTATION,
+ CHUNK_SIZE,
+ LEVELS,
+ WINDOW_COMPRESS,
+ WINDOW_DECOMPRESS,
+ ],
+);
diff --git a/crates/compressors/build.rs b/crates/compressors/build.rs
new file mode 100644
index 000000000..bf8a174df
--- /dev/null
+++ b/crates/compressors/build.rs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Defines `cfg(any_format)`: true when at least one format feature is enabled.
+//!
+//! Several items exist only to hold the crate together when no format is compiled -- the
+//! uninhabited `dispatch!` arm, and the lint suppressions for the parameters and helpers that arm
+//! leaves unused. Spelling that condition inline means repeating all five format features at every
+//! such site, and every future format has to be added to each copy. Naming it once here covers
+//! them all.
+//!
+//! This is derived from the enabled features rather than declared as a feature of its own on
+//! purpose. An internal feature that each format turned on would look equivalent, but Cargo lets
+//! anything enable it directly, and `cargo hack --feature-powerset` does exactly that: it would
+//! compile a configuration claiming a format exists while none does, which is the opposite of what
+//! these sites assume. A derived cfg cannot disagree with the features it is computed from, and it
+//! adds nothing to the powerset.
+
+use cfg_aliases::cfg_aliases;
+
+fn main() {
+ println!("cargo::rerun-if-changed=build.rs");
+
+ // Emits the matching `rustc-check-cfg` too, so `unexpected_cfgs` still catches a misspelling.
+ cfg_aliases! {
+ any_format: {
+ any(
+ feature = "brotli",
+ feature = "deflate",
+ feature = "gzip",
+ feature = "zlib",
+ feature = "zstd"
+ )
+ },
+ }
+}
diff --git a/crates/compressors/docs/DESIGN.md b/crates/compressors/docs/DESIGN.md
new file mode 100644
index 000000000..7c63ca6a9
--- /dev/null
+++ b/crates/compressors/docs/DESIGN.md
@@ -0,0 +1,223 @@
+# `compressors` design
+
+This document records the cross-cutting, user-visible decisions of the
+`compressors` crate — the ones that span several APIs and cannot be read off any
+single item. Per-item behaviour lives in the rustdoc and is not repeated here.
+The mechanisms that enforce these decisions are described separately in
+[IMPLEMENTATION.md](IMPLEMENTATION.md).
+
+## 1. Purpose
+
+`compressors` streams compression and decompression over [`bytesbuf`] byte
+sequences. It sits in front of several third-party engines — `flate2`, the
+`brotli` crate, `zstd-safe` — and gives them one contract, so that code written
+against it works with whichever format it is handed.
+
+Two properties motivate the crate's existence and constrain everything below:
+
+- **Segmented buffers throughout.** Input is read from a `BytesView`'s existing
+ segments and output is written into a `BytesBuf`'s uninitialized spare
+ capacity. Nothing is flattened on the way in and nothing is copied out on the
+ way back.
+- **Recycled engine state.** Building an engine allocates and initializes
+ working memory that, on a small message, costs about as much as the
+ compression itself. `Resources` retains that state between messages.
+
+## 2. Selecting a format
+
+A format is chosen in one of two ways, and both reach the same engines.
+
+**At compile time**, through a module: `gzip`, `deflate`, `zlib`, `brotli`,
+`zstd`. Each is behind a cargo feature of the same name, and none is enabled by
+default, so a build compiles only the engines it names.
+
+**At run time**, through the `format` module, which resolves a `Content-Encoding`
+token to a `Format` and offers the same shape the per-format modules do. This
+module is always compiled, including with no features at all, so a crate that
+only passes compressors around does not have to enable a format to name the type.
+
+The `deflate` feature and the HTTP `deflate` content coding are deliberately not
+the same thing, because the ecosystem's names disagree with the specifications:
+`Format::Deflate` is raw DEFLATE (RFC 1951) and has no content-coding token,
+while the HTTP `deflate` token denotes a zlib-wrapped stream (RFC 1950) and
+therefore resolves to `Format::Zlib`.
+
+## 3. What is uniform, and what is not
+
+The conveniences are uniform: `compress`, `decompress`, `Compressor` and
+`Decompressor` have the same shape in every format module, so moving a call site
+between formats is a change of import.
+
+The builders are deliberately not uniform. `brotli` and `zstd` expose settings
+that only they have — quality, window size, mode, native compression level — and
+a builder that might produce any format cannot honour them. Switching a builder
+call site between formats can therefore take more than an import change, and
+`zstd`'s compressor build returns a `Result` because its native library validates
+what it is given.
+
+## 4. Accepting input
+
+Whole-buffer entry points take `impl InputData`, a sealed trait implemented for
+`BytesView`, `&[u8]` and `&[u8; N]`. A caller who already holds a `BytesView`
+passes it through unchanged; a caller who holds a slice does not have to
+construct one, and does not have to decide which memory provider it should come
+from. The trait is sealed because the set of things this crate can accept
+without copying is a property of `bytesbuf`, not an extension point.
+
+## 5. Bounded work, at any size
+
+An engine is a state machine rather than a one-shot transform, so a stream of any
+length passes 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 their size depends on the format and its
+configuration — the chunk size is not a total-memory ceiling.
+
+Two consumption models are offered and they differ in what the *caller* retains:
+
+- `CompressionStream`, behind the `futures-stream` feature, yields one bounded
+ chunk at a time, so a consumer that processes and drops each chunk stays
+ bounded however long the stream is. This is the incremental model available to
+ callers: the push/pull mechanics underneath it are crate-private (see
+ §10), so a downstream crate reaches them through this adapter rather than
+ directly.
+- The whole-buffer conveniences accumulate the entire result, which is what makes
+ them convenient and also what makes them the APIs that need bounding.
+
+## 6. Bounding untrusted decompression
+
+[SECURITY.md](SECURITY.md) is the threat model this section summarises: 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.
+
+Every supported format can expand its input by orders of magnitude, so a
+decompressor pointed at untrusted data is a memory-exhaustion vector.
+
+The decision that shapes the whole area: **retained output is the boundary, not
+throughput.** A cumulative output cap protects a caller who accumulates; applying
+one to a pipeline that retains nothing only rejects legitimately long streams.
+The documentation is written to that condition rather than to "the input is
+untrusted".
+
+`DecompressorLimits` carries three independent bounds — a ratio, a total output
+length, and a count of concatenated streams. Two policies follow:
+
+- A ratio bound is a coarse backstop, not protection. Only the deflate family has
+ a structural expansion ceiling; brotli and zstd do not, so for them a ratio
+ loose enough to admit legitimate data is also loose enough to admit a bomb.
+ Those formats declare no ratio default rather than a reassuring one.
+- The conveniences that buffer a whole result add their own output and stream
+ caps on top of whatever the caller did not set, because those are the APIs
+ whose exposure the caller cannot otherwise bound.
+
+Because the boundary is retained output rather than throughput, the bound cannot
+be a property of the decompressor alone: the same decompressor is safe to stream
+unbounded and unsafe to buffer unbounded. So every `pull` states which it is,
+and the engine — not the caller — decides. Given a buffering destination, a
+decompressor applies the shared defaults for whichever bounds the caller left
+unset — 64 MiB of output and 1024 concatenated streams — alongside whatever it
+was configured with; whichever is tighter decides. Given a streaming destination
+it applies only its configured bounds.
+
+The two travel together in one value rather than as separate accessors, because
+they once did not: the entry point that retrofits them onto an already-built
+decompressor picked up the output half and silently kept no stream bound. That
+gap mattered precisely because the two bounds cover different attacks — many tiny
+members each cost a full engine setup while producing almost no output, so an
+output ceiling never trips on them.
+
+Putting that in the engine rather than in each buffering caller is what makes the
+bound load-bearing. The engine already narrows the output slice it offers per
+step by its remaining budget, so the ceiling stops production *at* the bound
+instead of letting a bomb expand freely and be measured afterwards. A caller
+cannot get it wrong by forgetting to count, and there is one implementation to
+test rather than one per format.
+
+## 7. Stream framing
+
+Whether bytes after a complete compressed stream begin another one is a
+per-format default, because the formats genuinely differ: gzip and zstd define
+concatenation, the others do not.
+
+When concatenation is off, `TrailingData` decides what happens to those bytes,
+and it defaults to `Reject`. Silently discarding a suffix is a parser
+differential: a proxy or scanner built on this crate would see only the benign
+prefix of a body whose tail something downstream still reads. Accepting a suffix
+is a legitimate need — a container whose framing puts other data after the
+compressed stream — but it is an opt-in.
+
+Decompression can also yield bytes before a checksum or trailer has rejected the
+stream, so output is provisional until the decompressor reports that it is done.
+
+## 8. Resources and recycling
+
+`Resources` is what an engine is built *with*, as distinct from a builder, which
+describes what it should *do*. It carries a memory provider and a pool of idle
+engine state, is cheap to clone, and is expected to be held once per application
+or per subsystem that wants its own memory accounting.
+
+Recycling is on by default — which is why every API that builds an engine asks
+for `Resources` rather than for a memory provider alone — and
+`with_pool_capacity` sets how many idle engines are retained, with zero
+disabling it.
+
+Which engines are recycled is deliberately *not* part of the contract. The pool
+retains the state that is expensive enough to be worth retaining and rebuilds the
+rest, so calling code never has to know which is which and the set can change
+without any change to calling code. [IMPLEMENTATION.md](IMPLEMENTATION.md)
+records the current set and why each entry is where it is.
+
+`Resources` implements `ThreadAware`, and the two halves are treated
+differently. The memory provider is relocated, because a NUMA-aware or
+per-thread provider will want to allocate from the destination's memory from
+then on. The pool is not: every clone shares one, an idle engine is a window and
+a set of hash tables with no affinity to where it was built, and re-homing or
+draining it on a move would discard the very state the type exists to retain —
+on every move.
+
+## 9. Errors
+
+Failures are split by when they can happen. `BuildError` reports a configuration
+an engine rejected at construction; `Error` reports everything that can go wrong
+while streaming. Keeping them apart means a failure to build is not something a
+caller has to consider on every chunk: once an engine exists, `BuildError` can no
+longer occur. `BuildError` converts into `Error` for code that handles both in
+one place.
+
+Both implement `recoverable::Recovery`, so a caller can distinguish a failure
+worth retrying from one that will recur.
+
+## 10. The public surface is sealed
+
+`Compression` names an engine — `impl Compression` accepts any
+compressor and no decompressor — and nothing more. How this crate drives one is
+not public API.
+
+Sealing that claim takes two things, because a supertrait alone is not enough.
+The mechanics live on a crate-private supertrait that no downstream crate can
+name, *and* `Compression` carries a `Sized` bound so no `dyn Compression` can be
+formed: a trait object resolves supertrait methods as inherent candidates,
+needing neither an import nor a nameable supertrait, so a vtable would have
+handed those mechanics to every downstream crate. Boxing a concrete compressor
+remains available and is how to hold one without naming its type.
+
+## 11. Feature policy
+
+Features are additive: enabling one never removes or changes behaviour available
+without it, and enabling all of them at once is valid. No format is on by
+default. A build that names no format still gets `Compression`, the builders,
+`Resources` and the `format` types.
+
+## Design tenets
+
+- The caller's memory provider is where buffers come from; the crate does not
+ reach for a global allocator behind their back.
+- Nothing is flattened or copied that the underlying buffers can express
+ directly.
+- What is bounded is what the caller retains.
+- Defaults are the safe reading of an ambiguous input, not the permissive one.
+- Recycling is transparent: a correct program cannot tell whether an engine was
+ reused.
+- The uniform surface is uniform; format-specific settings are reached through
+ format-specific builders rather than by widening the shared ones.
+
+[`bytesbuf`]: https://docs.rs/bytesbuf
diff --git a/crates/compressors/docs/IMPLEMENTATION.md b/crates/compressors/docs/IMPLEMENTATION.md
new file mode 100644
index 000000000..bbd238548
--- /dev/null
+++ b/crates/compressors/docs/IMPLEMENTATION.md
@@ -0,0 +1,216 @@
+# `compressors` implementation
+
+This document describes the internal mechanisms that enforce the policies in
+[DESIGN.md](DESIGN.md): the pump, the codec safety boundary, backend lifecycle,
+pooling, and the async driving rules. It covers the invariants that span modules;
+anything explainable from one source file is documented there instead.
+
+## Layers
+
+```
+compress / decompress / CompressionStream public entry points
+ Compressor / Decompressor per-format, macro-generated
+ Pump engine-agnostic state machine
+ Codec unsafe trait, one impl per direction
+ flate2 / brotli / zstd-safe third-party engines
+```
+
+`Pump` knows nothing about any format, and a `Codec` knows nothing about
+buffering or limits. Everything format-specific is in the `Codec` impl and the
+per-format module; everything policy-related is in `Pump` and the builders.
+
+## The push/pull contract
+
+The engine is driven by alternating `push` and `pull`. `pull` returns one of four
+outcomes, and the whole state machine exists to make each of them unambiguous:
+
+- `Data` — bytes are ready.
+- `NeedInput` — more input, or `end_input`, is required before progress.
+- `Progress` — work happened but produced nothing; call `pull` again without
+ pushing.
+- `Done` — the operation is complete.
+
+`Progress` and `NeedInput` are deliberately distinct. "No bytes right now" and
+"no bytes ever again without more input" require different responses, and
+conflating them turns a missing check into an infinite loop. `Output` is
+therefore an enum rather than `Option` plus a separate `is_finished`,
+and it is deliberately not `#[non_exhaustive]`: the variants describe a complete
+step, and forcing a wildcard arm would convert a missing-case bug from a compile
+error into silent misbehaviour.
+
+`Pump` tracks where it is with a `State`: `Open`, `Flushing`, `Finishing`,
+`BetweenStreams`, `AwaitingEof`, `AtStreamLimit` and `Done`. The states after
+`Finishing` are what implement the framing policy — concatenated streams, strict
+trailing-data rejection, and the stream-count limit — without any of it reaching
+the `Codec` impls.
+
+## The codec safety boundary
+
+`Codec` is an `unsafe` trait, and this is the crate's one soundness-critical
+contract.
+
+`Pump::pull` hands an engine the *uninitialized* spare capacity of a `BytesBuf`
+and then declares exactly as many bytes initialized as the engine reports
+writing. An implementation must therefore leave `output[..produced]` genuinely
+initialized, never read from `output`, never write past `output.len()`, and never
+touch memory outside the two slices it was given. Reporting a count it did not
+write exposes uninitialized memory.
+
+The three backend families split two to one on how they satisfy this:
+
+| Family | How it writes into uninitialized memory |
+|---|---|
+| flate (`deflate`, `zlib`, `gzip`) | `flate2`'s `*_uninit` entry points |
+| zstd | `zstd_safe::WriteBuf` |
+| brotli | initializes the slice first, because its encoder takes `&mut [u8]` |
+
+Brotli's zero-fill is a real cost that the other two do not pay, so it is done
+with a bulk `fill` rather than per element.
+
+What actually enforces the contract is engine-independent and lives in
+`Pump::pull`: before anything is advanced, a step that reports
+`consumed > supplied` or `produced > provided_output` is rejected and the pump
+is put into its failed state. That check is what stops an over-reporting engine
+from marking uninitialized output as initialized, for every backend. (zstd's
+`WriteBuf::filled_until` is a different thing: it records how much of the buffer
+zstd may read back, and does not reject a count.)
+
+Each family implements the trait twice, once per direction, giving six adapters.
+
+## Bounding one `pull`
+
+Two guards keep a single `pull` from running away, and both are the kind of
+invariant that is invisible until it is wrong:
+
+- `yields_to_the_caller` caps the engine calls and the input consumed per `pull`,
+ so one call cannot run until the stream ends.
+- `made_no_progress` detects a step that moved neither input nor output, which
+ means the engine is stuck; it is the only guard against an engine that can
+ never finish.
+
+Both are excluded from mutation testing, because a mutant of either produces a
+program that hangs rather than fails, and the harness records a timeout instead
+of a verdict. The exclusion is attached to the attribute rather than the doc
+comment, so the item's documentation stays about behaviour.
+
+## Bounding cumulative output
+
+Output bounds work differently from the two guards above: they are cumulative, and
+whether they apply at all depends on what the caller does with the result. `pull`
+therefore takes a `Destination`, and `Pump` combines two sources of budget:
+
+- `Codec::remaining_output` and `Codec::check_limits`, which report whatever the
+ decompressor was configured with. These apply to every destination.
+- `Pump`'s own `buffered_ceiling`, set at build time from
+ `DecompressorLimits::buffered_ceiling`. This applies only to
+ `Destination::Buffer`, since a caller that consumes and drops each chunk
+ retains nothing that cumulative output measures.
+
+`Pump::remaining_output` narrows one budget by the other and `Pump::check_limits`
+tests both, so the tighter bound decides and the two call sites cannot drift
+apart on which bounds they test.
+
+The budget is what the step loop uses to size the output slice it offers, so an
+expansion bomb stops producing at the bound rather than being measured after the
+fact — except for one probe byte. `limit_budget.max(1)` always offers at least
+one byte, so an engine can prove a stream ending exactly at the bound needs no
+more output. That byte is what makes the rejection test `total_out > bound`
+rather than `>=`, and it is never handed to the caller.
+
+## Backend lifecycle and pooling
+
+Pooling is what makes engine reuse worth the complexity, and the constraint is
+that a reused engine must be indistinguishable from a fresh one.
+
+An engine is reset before it is handed out, not when it is returned, so an engine
+dropped part-way through a stream cannot leak state into the next user. Reset is
+also what decides whether an engine can be pooled at all:
+
+| Engine | Pooled? | Why |
+|---|---|---|
+| flate compressors | yes | `reset` preserves container and level, so the pool keys on both |
+| `deflate` / `zlib` decompressors | yes | `reset` restores the framing |
+| `gzip` decompressor | **no** | `flate2`'s reset takes a boolean that cannot express gzip framing, so a recycled engine would silently decode as raw deflate |
+| zstd compressor and decompressor | yes | `reset` keeps the context's allocations, which is where the cost is |
+| brotli compressor and decompressor | **no** | no upstream reset; recycling its buffers through a custom allocator was measured and did not pay for itself |
+
+The gzip decompressor is the one gap worth explaining, because gzip is the most
+common encoding on the wire. Nothing about gzip prevents recycling — the obstacle
+is only that the engine's reset cannot express its framing. Taking that framing
+over here would mean owning header parsing and checksum validation permanently to
+route around an upstream API gap. If `flate2` gains a reset that can express gzip
+framing, gzip decompressors can start being pooled with no change to calling code.
+
+The zstd compressor pool is **unkeyed**. Checkout resets with
+`SessionAndParameters` and the compressor then applies its level unconditionally,
+so any idle context serves any level. Keying by level would fragment reuse and,
+worse, let a caller-chosen level grow the map without bound.
+
+Each engine class has its own `Mutex` rather than one lock over everything, so a
+compressor being returned never waits on a decompressor being taken. Poisoning is
+contained to the one class whose critical section panicked: every checkout treats
+a poisoned lock as "nothing to reuse" and builds a fresh engine.
+
+## Async driving
+
+`poll_compression` drives both directions and enforces two rules that the
+`Stream` contract does not:
+
+- The source is polled **only when the engine has nothing left to give**, so a
+ slow consumer never causes unbounded buffering. This is the streaming half of
+ the "what the caller retains" policy.
+- A `finished` flag latches once the last item has been yielded. Without it, a
+ failing engine would report the same error on every subsequent poll, and a
+ caller collecting the stream would accumulate errors until it ran out of
+ memory.
+
+Immediately-ready work per poll is capped so the task yields to its executor
+rather than starving its peers.
+
+## Runtime format dispatch
+
+`format::Compressor` and `format::Decompressor` hold an enum of the compiled-in
+formats and forward through a `dispatch!` macro, so adding a format does not add
+a match arm at every call site.
+
+Two details make the always-compiled requirement work:
+
+- **Brotli variants are boxed, the rest are inline.** Brotli's encoder state is
+ around 6480 bytes against roughly 1024 for the next largest, so an entirely
+ unboxed enum would make every runtime-format value — including a gzip one —
+ carry the brotli footprint. Boxing only brotli keeps the common path
+ allocation-free.
+- **A zero-format build needs an uninhabited placeholder.** With no feature
+ enabled the enum would have no variants, and matching a *reference* to a
+ zero-variant enum is not exhaustive. An `Impossible(Infallible)` variant keeps
+ the type well-formed; there is no way to construct one.
+
+## Test build configuration
+
+Feature-dependent code is gated `cfg(any(test, feature = "..."))` per
+[docs/optional-deps-in-test-builds.md](../../../docs/optional-deps-in-test-builds.md),
+and every optional dependency is mirrored as a non-optional dev-dependency. The
+crate's own test build therefore compiles every format, engine, stream adapter
+and pool without the test target enumerating features.
+
+The consequence worth knowing before adding a test: the test build is a
+*superset*, so a test cannot observe a format being absent. Assertions of the
+form "this token is rejected when the feature is off" have no configuration in
+which they hold and do not belong here. `cfg!` call sites need the same treatment
+as the attributes, since they are resolved at run time rather than during
+expansion.
+
+Shared fixtures and helpers live in `src/testing.rs`, following the workspace's
+`mod testing` convention, so production modules carry only production code.
+
+## Verification
+
+- The format contract suite drives every compiled format through the same
+ behavioural checks, so a new format cannot be added without satisfying them,
+ and a guard test fails if `Format::ALL` grows without the suite growing.
+- Round-trip tests decode fixtures produced by the system `gzip` to check
+ interoperability against a real encoder rather than only against this crate.
+- Drain loops in tests are step-capped so a spinning implementation fails rather
+ than hangs, which is also what lets mutation testing reach a verdict.
+- Benchmarks in `benches/` cover throughput and allocation behaviour; see
+ [benchmarks.md](../../../docs/benchmarks.md) for the conventions they follow.
diff --git a/crates/compressors/docs/SECURITY.md b/crates/compressors/docs/SECURITY.md
new file mode 100644
index 000000000..f3bf4aabb
--- /dev/null
+++ b/crates/compressors/docs/SECURITY.md
@@ -0,0 +1,186 @@
+# `compressors` security model
+
+The trust model this crate is written against, the resource budgets it does and
+does not enforce, and how those claims are verified. [DESIGN.md](DESIGN.md)
+covers why the API is shaped as it is; this covers what an attacker can do and
+what stops them.
+
+## 1. Trust model
+
+A compressed byte sequence is **untrusted** whenever its contents, its length, or
+its framing are chosen by anyone you would not run code on behalf of. An HTTP
+request or response body, an uploaded file, and a message off a queue are all
+untrusted. Data your own process compressed a moment ago is not.
+
+**Decompression does not upgrade content trust.** A stream that decodes cleanly,
+whose CRC-32 or XXH64 matches, and whose trailer is well formed, is exactly as
+untrusted afterwards as it was before. A checksum says the bytes survived the
+wire; it says nothing about who chose them. Every format here is unauthenticated:
+none carries a signature or a MAC, so a checksum match is not an authenticity
+claim, and this crate never treats it as one.
+
+What decompression *does* change is cost. The input is the attacker's budget, the
+output is yours, and the exchange rate is the format's business. That asymmetry is
+the whole threat.
+
+### In scope
+
+Resource exhaustion driven by the shape of the compressed input: unbounded
+expansion, unbounded stream counts, unbounded codec working memory, and engines
+that consume input without making progress.
+
+### Not in scope
+
+* **Authenticity and confidentiality.** Sign or encrypt separately, and do it
+ before decompressing if the sender is untrusted.
+* **Compression side channels.** Compressing attacker-influenced data together
+ with a secret leaks the secret through the output length -- the CRIME and BREACH
+ family. No bound here helps; do not compress secrets alongside untrusted input.
+* **Backend vulnerabilities.** Memory-safety defects in `flate2`/`zlib-rs`,
+ `brotli` or `zstd` are upstream. See
+ [IMPLEMENTATION.md](IMPLEMENTATION.md) for the codec safety boundary this crate
+ maintains on top of them.
+* **Process-wide limits.** Total concurrency, per-request deadlines and
+ cancellation belong to the application; see §5.
+
+## 2. The budgets are independent
+
+Each bound covers a resource the others do not. None substitutes for another.
+
+| Budget | Bounds | Configured by |
+|---|---|---|
+| Encoded input | Bytes you feed in | The caller, before `push` |
+| Decoded output | Cumulative bytes produced | [`max_output_len`] |
+| Expansion ratio | Output relative to input | [`max_ratio`] |
+| Stream count | Concatenated members decoded | [`max_streams`] |
+| Codec memory | Window/context the engine allocates | `max_window_log` (zstd), format defaults |
+| Retained memory | What the caller holds at once | The chunk size, and what the caller keeps |
+
+Two pairings are worth stating outright, because each is a case where the obvious
+bound does not fire:
+
+* **Ratio does not bound output.** A ratio loose enough to admit legitimate
+ highly-compressible data is loose enough to admit a bomb, in any format without
+ a structural expansion ceiling. Only deflate has one (~1032x). For brotli and
+ zstd the ratio is a coarse backstop, not protection.
+* **Output does not bound stream count.** Many tiny members each pay a full engine
+ setup while producing almost no output, so an output ceiling never trips on
+ them. That is what the stream cap is for.
+
+## 3. What applies where
+
+The bound that matters depends on what the caller *keeps*, not on how much the
+engine produces. A consumer that hands every chunk straight on retains only one
+chunk however long the stream is; a consumer that accumulates retains everything.
+
+| Consumption mode | Cumulative bounds | Why |
+|---|---|---|
+| Driving `Decompressor` directly | Only what you configured | You choose what to keep; a cumulative default would cap legitimate long streams |
+| [`CompressionStream`] | Only what you configured | Same: each chunk is yielded and dropped |
+| `compressors::decompress` | Yours, else 64 MiB output and 1024 streams | Buffers the whole result |
+| `::decompress` and `decompress_with_limits`, and the same pair on `Format` | Yours, else 64 MiB output and 1024 streams | Buffers the whole result |
+
+The defaults are **fallbacks, not overrides**: they fill only bounds left unset.
+Each limit setter accepts only a non-zero value. The matching
+[`unbounded_ratio`], [`unbounded_output_len`], or [`unbounded_streams`] method
+removes that bound, including its buffering fallback.
+[`DecompressorLimits::UNLIMITED`] removes every bound -- removing a bound is a
+decision too, not a request to restore its default.
+
+The engine, not the caller, applies them. Every `pull` states whether its output is
+being streamed or accumulated, and the pump narrows the slice it offers the engine
+by whichever bound is tighter. A bomb therefore stops being produced *at* the
+bound rather than being measured after the fact, and no buffering caller can get it
+wrong by forgetting to count.
+
+## 4. Format defaults
+
+| Format | Ratio | Output | Streams | Concatenated members | Window |
+|---|---|---|---|---|---|
+| `deflate` | 1100x | none | none | not decoded | 32 KiB, fixed |
+| `zlib` | 1100x | none | none | not decoded | 32 KiB, fixed |
+| `gzip` | 1100x | none | none | **decoded by default** | 32 KiB, fixed |
+| `brotli` | none | none | none | not decoded | up to 16 MiB (window bits 24) |
+| `zstd` | 250 000x | none | none | **decoded by default** | 128 MiB default, `max_window_log` to lower |
+
+Notes that matter for untrusted input:
+
+* **gzip and zstd decode concatenated members by default**, because that is what
+ their ecosystems produce. This is what makes the stream cap load-bearing for
+ them. Turn it off with `multi_stream(false)` if you expect exactly one.
+* **zstd's window is the largest single allocation an attacker controls.** A frame
+ header can ask for up to 128 MiB by default, before any output is produced.
+ Lower `max_window_log` when the producer is untrusted and you know your data
+ does not need a large window.
+* **Trailing data is rejected by default.** Bytes after a complete stream are an
+ error rather than being silently discarded, because silent discard is a request
+ smuggling primitive when a peer and this decoder disagree about where a message
+ ends.
+* **Output is provisional until `Done`.** A checksum or trailer can reject a stream
+ after earlier chunks were handed back. Do not act on decompressed bytes until the
+ decompressor reports completion.
+
+## 5. What the caller still owns
+
+These bounds are per operation. They say nothing about how many operations run at
+once, and none of them is a substitute for the following:
+
+* **Concurrency.** 64 MiB is a per-operation guardrail; a thousand concurrent
+ decompressions of 64 MiB is 64 GiB. Bound how many bodies you decompress at once.
+* **Deadlines and cancellation.** This crate performs no I/O and spawns nothing, so
+ it never blocks on the network, but it also cannot time an operation out. One
+ `pull` is bounded in work -- capped engine steps and input per call -- so a
+ caller driving it can always regain control and stop, but the decision to stop is
+ the caller's.
+* **Input length.** Nothing here bounds how much compressed input you accept. Cap
+ it where you read it.
+* **Memory provisioning.** Buffers come from the [`Resources`] memory provider the
+ caller supplies, so a caller that wants a hard ceiling can impose one there.
+
+## 6. How the model is verified
+
+Claims above are held up by tests rather than by review alone:
+
+* **Boundary tests.** Every cumulative bound is exercised at the bound and one past
+ it, so an off-by-one in either direction fails. The four-row fallback table --
+ unset, raised, removed, lowered -- is asserted for the output and stream bounds
+ through the crate-level `decompress`.
+* **The engine applies them, and it is tested there.** Pump-level tests cover the
+ tighter-of-two-bounds rule, that a streaming destination is unbounded, and that
+ an exactly-at-the-bound stream is admitted.
+* **State transitions.** Concatenated members, trailing data, truncation and
+ mid-stream corruption are covered per format through a shared contract suite, so
+ a format cannot quietly differ from its siblings.
+* **Cross-implementation fixtures.** gzip streams produced by the system `gzip`
+ are decoded in tests, so the decoder is not only checked against this crate's own
+ encoder.
+* **Codec boundary.** The engine rejects any backend that reports consuming or
+ producing more than it was given, before those counts are used to mark output
+ initialized. See [IMPLEMENTATION.md](IMPLEMENTATION.md).
+* **Coverage and mutation.** A 100% line-coverage gate runs over two feature
+ configurations, and `cargo-mutants` runs on every pull request; the limit
+ comparisons are mutation-covered, so a bound that stopped being enforced would
+ fail rather than pass quietly.
+* **Fuzzing.** `tests/bolero_compressors.rs` is a bounded campaign over the
+ decompression surface, run on every pull request. It generates a format, a
+ payload, a corruption (truncate, bit flip, append, concatenate), a span layout,
+ an output chunk size and a bound placed at or beside the real output size, and
+ asserts that decompression never panics and never fails to terminate, that an
+ unmutated stream round-trips, and that a bound below the real output is refused.
+ It covers what the deterministic tests cannot: the product of those dimensions,
+ where a member boundary coincides with a span boundary, an exact limit and
+ trailing bytes at once.
+
+Compressor-side operation ordering -- where a flush falls, a repeated
+`end_input` -- is not fuzzed. That ordering is chosen by trusted calling code
+rather than by input bytes, so it is covered by the deterministic suite instead.
+
+[`max_output_len`]: https://docs.rs/compressors/latest/compressors/struct.DecompressorLimits.html#method.max_output_len
+[`max_ratio`]: https://docs.rs/compressors/latest/compressors/struct.DecompressorLimits.html#method.max_ratio
+[`max_streams`]: https://docs.rs/compressors/latest/compressors/struct.DecompressorLimits.html#method.max_streams
+[`unbounded_output_len`]: https://docs.rs/compressors/latest/compressors/struct.DecompressorLimits.html#method.unbounded_output_len
+[`unbounded_ratio`]: https://docs.rs/compressors/latest/compressors/struct.DecompressorLimits.html#method.unbounded_ratio
+[`unbounded_streams`]: https://docs.rs/compressors/latest/compressors/struct.DecompressorLimits.html#method.unbounded_streams
+[`DecompressorLimits::UNLIMITED`]: https://docs.rs/compressors/latest/compressors/struct.DecompressorLimits.html#associatedconstant.UNLIMITED
+[`CompressionStream`]: https://docs.rs/compressors/latest/compressors/struct.CompressionStream.html
+[`Resources`]: https://docs.rs/compressors/latest/compressors/struct.Resources.html
diff --git a/crates/compressors/examples/round_trip.rs b/crates/compressors/examples/round_trip.rs
new file mode 100644
index 000000000..8f754108f
--- /dev/null
+++ b/crates/compressors/examples/round_trip.rs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Compressing and decompressing a whole buffer.
+
+use bytesbuf::mem::GlobalPool;
+use compressors::format::Format;
+use compressors::{Resources, Result, gzip};
+
+fn main() -> Result<()> {
+ // Held once and handed to every operation, so input views and output buffers both come from
+ // this provider and every engine returns to the same pool when its compressor drops.
+ let resources = Resources::new(GlobalPool::new());
+ let original = b"the quick brown fox jumps over the lazy dog. ".repeat(64);
+
+ let compressed = gzip::compress(&*original, &resources)?;
+ let decompressed = gzip::decompress(compressed.clone(), &resources)?;
+
+ assert_eq!(decompressed.to_vec(), original);
+ println!("gzip: {} -> {} bytes", original.len(), compressed.len());
+
+ // The same payload through a format chosen at run time.
+ for &format in Format::ALL {
+ let compressed = compressors::format::compress(format, &*original, &resources)?;
+ let decompressed = compressors::format::decompress(format, compressed.clone(), &resources)?;
+
+ assert_eq!(decompressed.to_vec(), original);
+ println!("{format:?}: {} bytes", compressed.len());
+ }
+
+ Ok(())
+}
diff --git a/crates/compressors/examples/tokio_stream.rs b/crates/compressors/examples/tokio_stream.rs
new file mode 100644
index 000000000..1d47e8572
--- /dev/null
+++ b/crates/compressors/examples/tokio_stream.rs
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Compressing and decompressing a body that arrives over time, on tokio.
+//!
+//! The body is never held whole: each chunk passes through the engine and leaves, so peak memory
+//! follows the chunk size rather than the size of the body.
+
+use std::env;
+use std::time::Duration;
+
+use bytesbuf::BytesView;
+use bytesbuf::mem::GlobalPool;
+use compressors::{CompressionStream, Resources, gzip};
+use tick::{Clock, ClockControl, PeriodicTimer};
+use tokio::sync::mpsc;
+use tokio_stream::wrappers::ReceiverStream;
+use tokio_stream::{Stream, StreamExt};
+
+/// How often a chunk arrives from the stand-in upstream.
+const ARRIVAL_PERIOD: Duration = Duration::from_millis(1);
+
+/// Stands in for an upstream that produces a body gradually, such as a socket.
+///
+/// Takes its clock rather than choosing one, so a caller can drive the arrivals in simulated time
+/// instead of waiting for them. Input views come from the same [`Resources`] the engines do, so one
+/// caller-owned provider covers the whole pipeline.
+fn body(clock: Clock, resources: Resources) -> impl Stream- > {
+ let (sender, receiver) = mpsc::channel(4);
+
+ tokio::spawn(async move {
+ let mut arrivals = PeriodicTimer::new(&clock, ARRIVAL_PERIOD);
+
+ for event in 0..200 {
+ arrivals.next().await;
+
+ let line = format!("{{\"event\":{event},\"message\":\"a log line\"}}\n");
+ let chunk = BytesView::copied_from_slice(line.as_bytes(), resources.memory());
+
+ if sender.send(Ok(chunk)).await.is_err() {
+ break;
+ }
+ }
+ });
+
+ ReceiverStream::new(receiver)
+}
+
+#[tokio::main]
+async fn main() -> Result<(), compressors::Error> {
+ // Held once and handed to everything: the arriving chunks, the compressor and the decompressor
+ // all draw on this provider and share its pool of recycled engines.
+ let resources = Resources::new(GlobalPool::new());
+
+ // Under `scripts/run-examples.rs` this runs as an automated check, where waiting out 200 real
+ // arrivals would be two seconds of wall clock and a dependency on runtime scheduling. A clock
+ // that advances itself on every query settles the same 200 arrivals immediately.
+ let clock = if env::var_os("IS_TESTING").is_some() {
+ ClockControl::new().auto_advance(ARRIVAL_PERIOD).to_clock()
+ } else {
+ Clock::new_tokio()
+ };
+
+ let compressed = CompressionStream::compress(body(clock, resources.clone()), gzip::Compressor::new(&resources));
+ let mut plain = CompressionStream::decompress(compressed, gzip::Decompressor::new(&resources));
+
+ let mut bytes = 0;
+ while let Some(chunk) = plain.next().await {
+ bytes += chunk?.len();
+ }
+
+ println!("{bytes} bytes recovered");
+
+ Ok(())
+}
diff --git a/crates/compressors/favicon.ico b/crates/compressors/favicon.ico
new file mode 100644
index 000000000..63472890a
--- /dev/null
+++ b/crates/compressors/favicon.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:731e5a17c9d7fde4ac4e760c0338dfebdb864bdd39dcd23e514a81c6d7cd5c5a
+size 27153
diff --git a/crates/compressors/logo.png b/crates/compressors/logo.png
new file mode 100644
index 000000000..9446a49ef
--- /dev/null
+++ b/crates/compressors/logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52677b9b3de2c50071a52efede37b6ac0726b1647f3142073183a8c52956beba
+size 59831
diff --git a/crates/compressors/src/brotli/codec.rs b/crates/compressors/src/brotli/codec.rs
new file mode 100644
index 000000000..dcf63ba6e
--- /dev/null
+++ b/crates/compressors/src/brotli/codec.rs
@@ -0,0 +1,355 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! The brotli codec.
+//!
+//! Brotli is a genuinely different engine from the deflate family: a different state type, a
+//! different way of signaling completion, and an output slice that must already be initialized.
+//! It is the format that proves the [`Codec`] abstraction is not just shaped around flate2.
+
+use std::mem::MaybeUninit;
+
+use brotli::enc::StandardAlloc;
+use brotli::enc::encode::{BrotliEncoderOperation, BrotliEncoderStateStruct};
+use brotli::{BrotliDecompressStream, BrotliResult, BrotliState, HeapAlloc, HuffmanCode};
+
+use crate::brotli::{Brotli, Mode};
+use crate::engine::{Codec, Operation, Step, StreamEnd};
+use crate::error::{Error, Result};
+use crate::level::Level;
+use crate::limits::FormatLimits;
+use crate::trailing::TrailingData;
+
+/// Brotli's native quality range is `0..=11`, wider than the portable [`Level`] scale of `0..=9`.
+///
+/// A round-to-nearest linear map, so the endpoints line up (`0 -> 0`, `9 -> 11`) and the mapping
+/// stays monotonic.
+fn portable_quality(level: Level) -> u32 {
+ let scaled = (u32::from(level.get()) * 11 + 4) / 9;
+ scaled.min(11)
+}
+
+/// Initializes an uninitialized output slice so brotli, which writes into `&mut [u8]`, can use it.
+///
+/// Unlike the deflate backend, which takes the spare capacity uninitialized through
+/// `compress_uninit`/`decompress_uninit`, brotli's binding only accepts an initialized `&mut [u8]`.
+/// The zero-fill is therefore real work this format pays and the others do not, so it is done as a
+/// single bulk fill that lowers to `memset` rather than a per-element store loop.
+fn initialize(output: &mut [MaybeUninit
]) -> &mut [u8] {
+ output.fill(MaybeUninit::new(0));
+
+ // SAFETY: every element of the slice was just initialized by the fill above, and `u8` has the
+ // same layout as `MaybeUninit`.
+ unsafe { &mut *(std::ptr::from_mut(output) as *mut [u8]) }
+}
+
+/// `compress_stream` reports failure only when it is driven inconsistently, for example by
+/// supplying new input after the encoder has already reached a terminal state. The engine's
+/// [`Pump`][crate::engine::Pump] never calls [`Codec::step`] again once a compressor reports
+/// [`Step::StreamEnd`].
+///
+/// Defensive against the encoder's contract rather than this crate's invariants: `compress_stream`
+/// does not document the complete set of conditions under which it reports failure, so this
+/// forwards whatever it reports rather than asserting a condition upstream has not promised.
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg_attr(test, mutants::skip)]
+#[cold]
+fn compress_stream_failed() -> Error {
+ Error::invalid_state("the brotli compression engine reported a failure")
+}
+
+/// Drives the `brotli` crate's encoder for one compressed stream.
+///
+/// The encoder is owned outright rather than borrowed from a pool: brotli exposes no reset, so
+/// there is nothing to hand back and no `Drop` work to do. `finished` latches once the encoder has
+/// emitted its terminal block, and the pump never calls `step` again after that -- which is what
+/// makes the "driven inconsistently" branch of `compress_stream` unreachable from here.
+pub(crate) struct BrotliCompress {
+ state: BrotliEncoderStateStruct,
+ finished: bool,
+}
+
+impl BrotliCompress {
+ pub(crate) fn new(level: Level, options: &Brotli) -> Self {
+ use brotli::enc::encode::BrotliEncoderParameter;
+
+ let mut state = BrotliEncoderStateStruct::new(StandardAlloc::default());
+ let quality = options
+ .quality
+ .map_or_else(|| portable_quality(level), |quality| u32::from(quality.get()));
+
+ let accepted = state.set_parameter(BrotliEncoderParameter::BROTLI_PARAM_QUALITY, quality)
+ && state.set_parameter(BrotliEncoderParameter::BROTLI_PARAM_LGWIN, u32::from(options.window_size.get()))
+ && state.set_parameter(BrotliEncoderParameter::BROTLI_PARAM_MODE, mode(options.mode));
+
+ // `set_parameter` refuses only an already-initialized encoder or an unrecognized parameter.
+ // The state was created two statements ago and all three identifiers are recognized, so a
+ // refusal here means the encoder's contract changed under us rather than that the caller
+ // configured something invalid -- `Quality`, `WindowSize` and `Mode` validate on
+ // construction, and the portable `Level` is mapped into `0..=11`.
+ assert!(accepted, "the brotli encoder rejected a parameter it documents as recognized");
+
+ Self { state, finished: false }
+ }
+}
+
+/// Maps our [`Mode`] onto brotli's numeric parameter.
+fn mode(mode: Mode) -> u32 {
+ match mode {
+ Mode::Generic => 0,
+ Mode::Text => 1,
+ Mode::Font => 2,
+ }
+}
+
+impl std::fmt::Debug for BrotliCompress {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("BrotliCompress")
+ .field("finished", &self.finished)
+ .finish_non_exhaustive()
+ }
+}
+
+// SAFETY: `step` initializes the whole slice through `initialize` before handing it to brotli as a
+// `&mut [u8]`, so every byte the reported count covers is initialized.
+unsafe impl Codec for BrotliCompress {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)> {
+ let brotli_operation = match operation {
+ Operation::Process => BrotliEncoderOperation::BROTLI_OPERATION_PROCESS,
+ Operation::Flush => BrotliEncoderOperation::BROTLI_OPERATION_FLUSH,
+ Operation::Finish => BrotliEncoderOperation::BROTLI_OPERATION_FINISH,
+ };
+
+ let out = initialize(output);
+ let mut available_in = input.len();
+ let mut input_offset = 0_usize;
+ let mut available_out = out.len();
+ let mut output_offset = 0_usize;
+ let mut total_out = None;
+
+ let ok = self.state.compress_stream(
+ brotli_operation,
+ &mut available_in,
+ input,
+ &mut input_offset,
+ &mut available_out,
+ out,
+ &mut output_offset,
+ &mut total_out,
+ &mut |_, _, _, _| (),
+ );
+
+ ok.then_some(()).ok_or_else(compress_stream_failed)?;
+
+ self.finished = self.state.is_finished();
+ let step = if self.finished {
+ Step::StreamEnd
+ } else if operation == Operation::Flush && available_in == 0 && !self.state.has_more_output() {
+ Step::FlushComplete
+ } else {
+ Step::Continue
+ };
+
+ Ok((step, input_offset, output_offset))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::Complete)
+ }
+}
+
+/// Drives the `brotli` crate's decoder, across as many concatenated streams as the configuration
+/// allows.
+///
+/// Like the encoder, the state is owned rather than pooled. `limits`, `multi_stream` and
+/// `trailing_data` are fixed policy; `needs_reset` spans one stream and defers the reset until
+/// another stream actually arrives. `total_out` is counted here rather than read back from the
+/// decoder, because the decoder reports per-call progress and the limits are cumulative.
+pub(crate) struct BrotliDecompress {
+ state: BrotliState, HeapAlloc, HeapAlloc>,
+ limits: FormatLimits,
+ multi_stream: bool,
+ trailing_data: TrailingData,
+ needs_reset: bool,
+ total_out: usize,
+}
+
+impl BrotliDecompress {
+ pub(crate) fn new(limits: FormatLimits, multi_stream: bool, trailing_data: TrailingData) -> Self {
+ Self {
+ state: Self::state(),
+ limits,
+ multi_stream,
+ trailing_data,
+ needs_reset: false,
+ total_out: 0,
+ }
+ }
+
+ /// A decoder restricted to RFC 7932.
+ ///
+ /// `new_strict` rather than `new`: the permissive constructor also accepts Large Window Brotli,
+ /// a non-standard extension whose declared window can reach 1 GiB, and the decoder sizes its
+ /// ring buffer from that declaration before producing any output. Nothing in this crate emits
+ /// such a stream -- [`WindowSize`][crate::brotli::WindowSize] tops out at the window the RFC
+ /// allows -- so accepting one would only widen what untrusted input can ask the allocator for.
+ fn state() -> BrotliState, HeapAlloc, HeapAlloc> {
+ BrotliState::new_strict(HeapAlloc::new(0), HeapAlloc::new(0), HeapAlloc::new(HuffmanCode::default()))
+ }
+}
+
+impl std::fmt::Debug for BrotliDecompress {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("BrotliDecompress")
+ .field("limits", &self.limits)
+ .field("multi_stream", &self.multi_stream)
+ .field("trailing_data", &self.trailing_data)
+ .finish_non_exhaustive()
+ }
+}
+
+// SAFETY: `step` initializes the whole slice through `initialize` before handing it to brotli as a
+// `&mut [u8]`, so every byte the reported count covers is initialized.
+unsafe impl Codec for BrotliDecompress {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ if self.needs_reset {
+ self.state = Self::state();
+ self.total_out = 0;
+ self.needs_reset = false;
+ }
+
+ let out = initialize(output);
+ let mut available_in = input.len();
+ let mut input_offset = 0_usize;
+ let mut available_out = out.len();
+ let mut output_offset = 0_usize;
+
+ let result = BrotliDecompressStream(
+ &mut available_in,
+ &mut input_offset,
+ input,
+ &mut available_out,
+ &mut output_offset,
+ out,
+ &mut self.total_out,
+ &mut self.state,
+ );
+
+ let step = match result {
+ BrotliResult::ResultSuccess => Step::StreamEnd,
+ BrotliResult::NeedsMoreInput | BrotliResult::NeedsMoreOutput => Step::Continue,
+ BrotliResult::ResultFailure => {
+ return Err(Error::corrupt_data("the compressed data is not a valid brotli stream"));
+ }
+ };
+
+ Ok((step, input_offset, output_offset))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ if !self.multi_stream {
+ return Ok(match self.trailing_data {
+ TrailingData::Ignore => StreamEnd::Complete,
+ TrailingData::Reject => StreamEnd::AwaitEof,
+ });
+ }
+
+ self.needs_reset = true;
+ Ok(StreamEnd::NextStream)
+ }
+
+ fn check_limits(&self, total_in: u64, total_out: u64, streams: u64) -> Result<()> {
+ self.limits.check(total_in, total_out, streams)
+ }
+
+ fn remaining_output(&self, total_out: u64) -> Option {
+ self.limits.remaining_output(total_out)
+ }
+
+ fn max_streams(&self) -> Option {
+ self.limits.max_streams()
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn quality_maps_the_portable_scale_onto_brotlis_range() {
+ assert_eq!(portable_quality(Level::MIN), 0, "the floor must line up");
+ assert_eq!(portable_quality(Level::HIGH), 11, "the ceiling must line up");
+
+ let mut previous = None;
+ for raw in 0..=Level::MAX.get() {
+ let level = Level::new(raw).unwrap();
+ let mapped = portable_quality(level);
+
+ assert!(Some(mapped) > previous, "mapping must be strictly monotonic at level {raw}");
+ assert!(mapped <= 11, "level {raw} mapped outside brotli's range");
+ previous = Some(mapped);
+ }
+ }
+
+ #[test]
+ fn every_mode_maps_to_its_own_brotli_parameter() {
+ assert_eq!(mode(Mode::Generic), 0);
+ assert_eq!(mode(Mode::Text), 1);
+ assert_eq!(mode(Mode::Font), 2);
+ }
+
+ #[test]
+ fn initialize_zeroes_the_whole_slice() {
+ let mut raw = [MaybeUninit::new(0xff_u8); 8];
+ let initialized = initialize(&mut raw);
+
+ assert_eq!(initialized, &[0_u8; 8]);
+ }
+
+ #[test]
+ fn every_expressible_configuration_is_accepted_by_the_engine() {
+ use crate::brotli::{Quality, WindowSize};
+
+ // Construction asserts that the encoder accepted every parameter, so this passing is what
+ // proves the assertion cannot fire for any configuration a caller can express.
+ for quality in Quality::MIN.get()..=Quality::MAX.get() {
+ for exponent in WindowSize::MIN.get()..=WindowSize::MAX.get() {
+ for chosen in [Mode::Generic, Mode::Text, Mode::Font] {
+ let mut settings = Brotli::new();
+ settings.quality = Quality::new(quality);
+ settings.window_size = WindowSize::new(exponent).unwrap();
+ settings.mode = chosen;
+
+ drop(BrotliCompress::new(Level::DEFAULT, &settings));
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn decompressor_debug_includes_its_policies() {
+ let codec = BrotliDecompress::new(FormatLimits::new(None, None, None), false, TrailingData::Reject);
+ let rendered = format!("{codec:?}");
+
+ assert!(rendered.contains("trailing_data"));
+ assert!(rendered.contains("Reject"));
+ }
+
+ #[test]
+ fn remaining_output_delegates_to_the_configured_limits() {
+ let codec = BrotliDecompress::new(FormatLimits::new(None, Some(100), None), false, TrailingData::Reject);
+
+ assert_eq!(Codec::remaining_output(&codec, 40), Some(60));
+ assert_eq!(Codec::remaining_output(&codec, 100), Some(0));
+ }
+
+ #[test]
+ fn compressor_debug_includes_its_finished_flag() {
+ let codec = BrotliCompress::new(Level::DEFAULT, &Brotli::new());
+ let rendered = format!("{codec:?}");
+
+ assert!(rendered.contains("BrotliCompress"));
+ assert!(rendered.contains("finished"));
+ }
+}
diff --git a/crates/compressors/src/brotli/mod.rs b/crates/compressors/src/brotli/mod.rs
new file mode 100644
index 000000000..8e74a568c
--- /dev/null
+++ b/crates/compressors/src/brotli/mod.rs
@@ -0,0 +1,312 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Brotli (RFC 7932): a general-purpose compressor with a static dictionary tuned for web content.
+//!
+//! Compresses text better than `gzip` for a given effort, which is why it is the usual choice for
+//! HTTP `Content-Encoding: br`. Where it lands on the speed/ratio trade depends on the payload and
+//! the level, so benchmark a representative corpus. Requires the `brotli` cargo feature.
+//!
+//! Brotli streams carry no magic bytes, so the format has to be known from context, such as a
+//! `Content-Encoding` header.
+//!
+//! # Examples
+//!
+//! ```
+//! use compressors::{Resources, brotli};
+//!
+//! let compressed = brotli::compress(b"the quick brown fox", &Resources::default())?;
+//!
+//! assert_eq!(
+//! brotli::decompress(compressed, &Resources::default())?.to_vec(),
+//! b"the quick brown fox".to_vec()
+//! );
+//! # Ok::<(), compressors::Error>(())
+//! ```
+
+mod codec;
+
+use crate::brotli::codec::{BrotliCompress, BrotliDecompress};
+use crate::limits::FormatLimits;
+
+/// Brotli's default bounds.
+///
+/// Brotli has no structural expansion ceiling, so a ratio bound cannot distinguish a bomb from
+/// legitimate highly-compressible data, and brotli therefore declares none. What bounds untrusted
+/// brotli is the cap the buffering conveniences apply; see
+/// [`DecompressorLimits`][crate::DecompressorLimits].
+pub(crate) const DEFAULT_LIMITS: FormatLimits = FormatLimits::new(None, None, None);
+use crate::macros::define_format;
+
+/// Selects brotli as the format of a [`CompressorBuilder`][crate::CompressorBuilder] or [`DecompressorBuilder`][crate::DecompressorBuilder], and carries
+/// the settings only brotli has.
+///
+/// Naming the format in the builder's type parameter is what gives that builder a `build` method
+/// producing this module's [`Compressor`] and [`Decompressor`], along with the setters below.
+#[derive(Debug, Clone)]
+pub struct Brotli {
+ quality: Option,
+ mode: Mode,
+ window_size: WindowSize,
+}
+
+impl Brotli {
+ /// The settings a brotli builder starts with: brotli's own defaults, and the portable
+ /// [`Level`][crate::Level] left in charge of the quality.
+ pub(crate) const fn new() -> Self {
+ Self {
+ quality: None,
+ mode: Mode::Generic,
+ window_size: WindowSize::DEFAULT,
+ }
+ }
+}
+
+define_format! {
+ name = "brotli",
+ format = Brotli,
+ build_method = build_brotli,
+ compressor_codec = BrotliCompress,
+ compressor_build = infallible,
+ new_compressor = |level, format, _pool| BrotliCompress::new(level, format),
+ decompressor_codec = BrotliDecompress,
+ decompressor_build = infallible,
+ default_limits = DEFAULT_LIMITS,
+ new_decompressor = |limits, multi_stream, trailing_data, _format, _pool| {
+ BrotliDecompress::new(limits, multi_stream, trailing_data)
+ },
+ multi_stream_default = false,
+}
+
+/// The kind of data brotli should tune its model for.
+///
+/// Brotli ships a static dictionary of common web text, and its entropy model can be biased
+/// towards a particular kind of input. Choosing correctly is worth a few percent on the ratio;
+/// choosing wrongly costs about as much, so leave it at [`Mode::Generic`] unless you know.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
+#[non_exhaustive]
+pub enum Mode {
+ /// No assumption about the input. The default.
+ #[default]
+ Generic,
+ /// UTF-8 text.
+ Text,
+ /// A WOFF 2.0 font.
+ Font,
+}
+
+/// A compression quality on brotli's native `0..=11` scale.
+///
+/// Quality zero is brotli's fastest mode; it still compresses. The portable [`Level`][crate::Level] scale maps
+/// onto this range, while this type makes every native quality reachable.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct Quality(u8);
+
+impl Quality {
+ /// Brotli's fastest quality.
+ pub const MIN: Self = Self(0);
+
+ /// Brotli's strongest quality.
+ pub const MAX: Self = Self(11);
+
+ /// Creates a native brotli quality.
+ #[must_use]
+ pub const fn new(quality: u8) -> Option {
+ if quality <= Self::MAX.0 { Some(Self(quality)) } else { None }
+ }
+
+ /// Returns the native brotli quality.
+ #[must_use]
+ pub const fn get(self) -> u8 {
+ self.0
+ }
+}
+
+impl TryFrom for Quality {
+ type Error = crate::Error;
+
+ fn try_from(quality: u8) -> core::result::Result {
+ Self::new(quality).ok_or_else(|| {
+ crate::Error::invalid_configuration(format!(
+ "brotli quality {quality} is out of range; expected {}..={}",
+ Self::MIN.get(),
+ Self::MAX.get()
+ ))
+ })
+ }
+}
+
+impl From for u8 {
+ #[inline]
+ fn from(quality: Quality) -> Self {
+ quality.get()
+ }
+}
+
+/// The base-2 logarithm of brotli's sliding window, in bytes.
+///
+/// A larger window lets the compressor find matches further back, which is what helps on large inputs.
+///
+/// RFC 7932 gives the declared window a format-level role: it is what a decoder must be prepared to
+/// buffer, so raising it is a cost the reader may pay as well as the writer.
+///
+/// It is tempting to read this as a memory dial and shrink it to economize, but that is not
+/// dependable in either direction. Shrinking the window does not reduce compressor memory or raise
+/// throughput smoothly, and below some point it can worsen both; the ratio is not monotonic either,
+/// since a window comparable to the payload can beat a much larger one. Where those turning points
+/// fall depends on the payload, the level and the backend version, so they are not values this
+/// crate can state.
+///
+/// The practical advice is to leave this alone unless a measurement on real payloads says
+/// otherwise.
+///
+/// This is a newtype rather than a bare `u8` for the same reason [`Level`][crate::Level] is: an out-of-range
+/// value is a configuration mistake to report, not a panic to suffer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct WindowSize(u8);
+
+impl WindowSize {
+ /// The smallest window brotli accepts, 1 KiB.
+ pub const MIN: Self = Self(10);
+
+ /// Brotli's default window, 4 MiB.
+ pub const DEFAULT: Self = Self(22);
+
+ /// The largest window brotli accepts without the large-window extension, 16 MiB.
+ pub const MAX: Self = Self(24);
+
+ /// Creates a window size from its base-2 exponent, or returns `None` outside `10..=24`.
+ #[must_use]
+ pub const fn new(exponent: u8) -> Option {
+ if exponent < Self::MIN.0 || exponent > Self::MAX.0 {
+ return None;
+ }
+
+ Some(Self(exponent))
+ }
+
+ /// Returns the base-2 exponent.
+ #[must_use]
+ pub const fn get(self) -> u8 {
+ self.0
+ }
+}
+
+impl Default for WindowSize {
+ fn default() -> Self {
+ Self::DEFAULT
+ }
+}
+
+impl TryFrom for WindowSize {
+ type Error = crate::Error;
+
+ fn try_from(exponent: u8) -> core::result::Result {
+ Self::new(exponent).ok_or_else(|| {
+ crate::Error::invalid_configuration(format!(
+ "brotli window size 2^{exponent} is out of range; expected the exponent in {}..={}",
+ Self::MIN.get(),
+ Self::MAX.get()
+ ))
+ })
+ }
+}
+
+impl From for u8 {
+ #[inline]
+ fn from(window_size: WindowSize) -> Self {
+ window_size.get()
+ }
+}
+
+/// Settings that only brotli has.
+///
+/// The portable settings -- [`level`][crate::CompressorBuilder::level] and
+/// [`output_chunk_size`][crate::CompressorBuilder::output_chunk_size] -- are shared with every other format
+/// and are also reachable from a [`CompressorBuilder<()>`][crate::CompressorBuilder] that has not
+/// chosen a format yet. These are not: a builder that might produce any format cannot honour a
+/// setting only brotli has, so reach for them through this concrete builder. Box the result when a
+/// call site needs to hold one of several formats without naming which.
+///
+/// # Examples
+///
+/// ```
+/// use compressors::Resources;
+/// use compressors::brotli::{self, Mode, Quality, WindowSize};
+///
+/// let compressor = Box::new(
+/// brotli::Compressor::builder()
+/// .quality(Quality::new(8).expect("8 is in range"))
+/// .mode(Mode::Text)
+/// .window_size(WindowSize::new(20).expect("20 is in range"))
+/// .build(&Resources::default()),
+/// );
+/// # let _ = compressor;
+/// ```
+impl crate::CompressorBuilder {
+ /// Sets brotli's native quality, overriding any portable [`Level`][crate::Level].
+ #[must_use]
+ pub const fn quality(mut self, quality: Quality) -> Self {
+ self.format.quality = Some(quality);
+ self
+ }
+
+ /// Tunes the entropy model for a particular kind of input.
+ #[must_use]
+ pub const fn mode(mut self, mode: Mode) -> Self {
+ self.format.mode = mode;
+ self
+ }
+
+ /// Sets the sliding window size.
+ ///
+ /// A larger declared window can increase decompressor memory, so raising it is a cost paid by
+ /// the reader as well as the writer.
+ #[must_use]
+ pub const fn window_size(mut self, window_size: WindowSize) -> Self {
+ self.format.window_size = window_size;
+ self
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod quality_tests {
+ use super::*;
+
+ #[test]
+ fn every_native_quality_is_representable() {
+ for quality in Quality::MIN.get()..=Quality::MAX.get() {
+ assert_eq!(Quality::new(quality).map(Quality::get), Some(quality));
+ }
+
+ assert_eq!(Quality::new(12), None);
+ assert_eq!(Quality::try_from(8).unwrap(), Quality::new(8).unwrap());
+ assert_eq!(u8::from(Quality::MAX), 11);
+
+ let error = Quality::try_from(12).unwrap_err();
+ assert!(error.is_invalid_configuration(), "got {error}");
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod window_size_tests {
+ use super::*;
+
+ #[test]
+ fn every_valid_exponent_is_representable() {
+ for exponent in WindowSize::MIN.get()..=WindowSize::MAX.get() {
+ assert_eq!(WindowSize::new(exponent).map(WindowSize::get), Some(exponent));
+ }
+
+ assert_eq!(WindowSize::new(WindowSize::MIN.get() - 1), None);
+ assert_eq!(WindowSize::new(WindowSize::MAX.get() + 1), None);
+ assert_eq!(WindowSize::default(), WindowSize::DEFAULT);
+ assert_eq!(WindowSize::try_from(20).unwrap(), WindowSize::new(20).unwrap());
+ assert_eq!(u8::from(WindowSize::DEFAULT), 22);
+
+ let error = WindowSize::try_from(WindowSize::MAX.get() + 1).unwrap_err();
+ assert!(error.is_invalid_configuration(), "got {error}");
+ }
+}
diff --git a/crates/compressors/src/builder.rs b/crates/compressors/src/builder.rs
new file mode 100644
index 000000000..f8f596e77
--- /dev/null
+++ b/crates/compressors/src/builder.rs
@@ -0,0 +1,286 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! The builders every format shares.
+//!
+//! [`CompressorBuilder`] and [`DecompressorBuilder`] hold the settings that mean the same thing
+//! whichever format ends up being used: the level, the output chunk size, the decompression limits
+//! and the trailing-data policy. The type parameter records whether a format has been chosen yet.
+//!
+//! What an engine is built *with* -- memory and an engine pool -- is not a setting, so it lives in
+//! [`Resources`][crate::Resources] and is supplied to `build` instead.
+//!
+//! `CompressorBuilder<()>` has not chosen one. It is the builder to hold when the format is a
+//! runtime decision: each enabled format's module adds a `build_gzip`-style method to it, beside
+//! `build_format` for a `Format` value.
+//!
+//! `CompressorBuilder` has. Committing to a format -- which `gzip::Compressor::builder()`
+//! does -- adds the settings only that format has, along with a `build` method returning that
+//! format's own compressor rather than a boxed one.
+
+use std::num::NonZeroUsize;
+
+use crate::level::Level;
+use crate::limits::DecompressorLimits;
+use crate::trailing::TrailingData;
+
+/// How much output a single `pull` produces before handing control back.
+///
+/// This bounds pending output only: a caller streaming hundreds of gigabytes never accumulates
+/// more than one chunk of it. Pending input and the engine's own window and tables are additional,
+/// and their size depends on the format and its configuration.
+///
+/// 64 KiB is chosen to sit above the point where per-call overhead dominates while staying small
+/// enough that a chunk is cheap to hold and to hand on -- the `chunk_size` benchmark group is what
+/// this trade is measured with. It is a default for the common case, not a tuned optimum for any
+/// particular one; a caller with a different latency or memory budget sets its own with
+/// [`output_chunk_size`][CompressorBuilder::output_chunk_size].
+pub(crate) const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;
+
+/// Configures a compressor.
+///
+/// The type parameter selects the format, and defaults to `()` for a builder that has not chosen
+/// one yet: it carries only the settings every format shares, and gains a `build_gzip`-style method
+/// per enabled format plus [`build_format`][CompressorBuilder::build_format]. Committing to a
+/// format -- which `gzip::Compressor::builder` does -- adds that
+/// format's own settings and a `build` returning its concrete compressor.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "gzip")]
+/// # {
+/// use compressors::format::Format;
+/// use compressors::{CompressorBuilder, Level, Resources};
+///
+/// // Settings that say nothing about the format, applied to one chosen at runtime.
+/// let settings = CompressorBuilder::new().level(Level::HIGH);
+/// let compressor = settings.build_format(Format::Gzip, Resources::global())?;
+/// # let _ = compressor;
+/// # }
+/// # Ok::<(), compressors::BuildError>(())
+/// ```
+#[derive(Debug, Clone)]
+pub struct CompressorBuilder {
+ pub(crate) level: Level,
+ pub(crate) chunk_size: NonZeroUsize,
+ /// The chosen format's own settings, and `()` until a format is chosen.
+ ///
+ /// The shared builder never reads this beyond handing it to the engine; the format's own module
+ /// adds the setters that populate it.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only a format module's build method reads the settings it owns")
+ )]
+ pub(crate) format: T,
+}
+
+impl CompressorBuilder {
+ /// Starts from the shared defaults, with one format's settings already chosen.
+ ///
+ /// Each format's module wraps this in its own [`Default`] implementation, which is why the
+ /// marker types need no public constructor.
+ pub(crate) fn with_format(format: T) -> Self {
+ Self {
+ level: Level::DEFAULT,
+ chunk_size: default_chunk_size(),
+ format,
+ }
+ }
+
+ /// Sets the compression level, mapped onto the format's native range.
+ #[must_use]
+ pub const fn level(mut self, level: Level) -> Self {
+ self.level = level;
+ self
+ }
+
+ /// Sets how much output a single `pull` produces before returning.
+ ///
+ /// This bounds how much output is buffered before `pull` returns it -- not total memory, which
+ /// also covers pending input and the engine's own state. Larger chunks reduce per-call
+ /// overhead; smaller chunks reduce peak output buffering and latency.
+ #[must_use]
+ pub const fn output_chunk_size(mut self, bytes: NonZeroUsize) -> Self {
+ self.chunk_size = bytes;
+ self
+ }
+}
+
+impl CompressorBuilder<()> {
+ /// Starts configuring a compressor whose format has not been chosen yet.
+ #[must_use]
+ #[inline]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Commits the format-independent settings to one format.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only a format module's build method commits a builder to a format")
+ )]
+ pub(crate) fn specialize(self, format: T) -> CompressorBuilder {
+ CompressorBuilder {
+ level: self.level,
+ chunk_size: self.chunk_size,
+ format,
+ }
+ }
+}
+
+impl Default for CompressorBuilder<()> {
+ #[inline]
+ fn default() -> Self {
+ Self::with_format(())
+ }
+}
+
+/// Configures a decompressor.
+///
+/// The type parameter selects the format, and defaults to `()` for a builder that has not chosen
+/// one yet: it carries only the settings every format shares, and gains a `build_gzip`-style method
+/// per enabled format plus [`build_format`][DecompressorBuilder::build_format]. Committing to a
+/// format -- which `gzip::Decompressor::builder` does -- adds
+/// that format's own settings and a `build` returning its concrete decompressor.
+///
+/// # Security
+///
+/// Compressed data can expand by orders of magnitude, so a decompressor pointed at untrusted input
+/// is a memory-exhaustion vector. What bounds that exposure is how much decompressed output is
+/// *retained*, not how much passes through: consumed through `CompressionStream` a decompressor
+/// hands back one bounded chunk at a time, so a consumer that processes and drops each chunk stays
+/// bounded however long the stream is.
+///
+/// Set [`limits`][DecompressorBuilder::limits] with
+/// [`max_output_len`][DecompressorLimits::max_output_len] to what you can afford whenever
+/// decompressed output is accumulated -- by the buffering conveniences, or by a consumer that keeps
+/// the chunks it is handed. Applying a cumulative cap to a pipeline that retains nothing only
+/// rejects legitimately long streams.
+#[derive(Debug, Clone)]
+pub struct DecompressorBuilder {
+ pub(crate) limits: DecompressorLimits,
+ pub(crate) chunk_size: NonZeroUsize,
+ pub(crate) multi_stream: Option,
+ pub(crate) trailing_data: TrailingData,
+ /// The chosen format's own settings, and `()` until a format is chosen.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only a format module's build method reads the settings it owns")
+ )]
+ pub(crate) format: T,
+}
+
+impl DecompressorBuilder {
+ /// Starts from the shared defaults, with one format's settings already chosen.
+ ///
+ /// Each format's module wraps this in its own [`Default`] implementation, which is why the
+ /// marker types need no public constructor.
+ pub(crate) fn with_format(format: T) -> Self {
+ Self {
+ limits: DecompressorLimits::new(),
+ chunk_size: default_chunk_size(),
+ multi_stream: None,
+ trailing_data: TrailingData::Reject,
+ format,
+ }
+ }
+
+ /// Overrides the bounds on how much data decompression may produce.
+ ///
+ /// Bounds left unset on the passed value keep the chosen format's own defaults. For most
+ /// formats that is a ratio and nothing else; brotli has no structural ceiling to derive one
+ /// from, so it defaults to no ratio bound either. The conveniences that buffer a whole result
+ /// add their own output and stream caps on top; see [`DecompressorLimits`].
+ ///
+ /// # Security
+ ///
+ /// Set [`max_output_len`][DecompressorLimits::max_output_len] to match your memory
+ /// budget whenever decompressed output is accumulated, rather than whenever the input is
+ /// untrusted -- retained output is what a cumulative cap protects. Do not rely on the format
+ /// default for brotli, which has none.
+ #[must_use]
+ pub const fn limits(mut self, limits: DecompressorLimits) -> Self {
+ self.limits = limits;
+ self
+ }
+
+ /// Sets how much output a single `pull` produces before returning.
+ #[must_use]
+ pub const fn output_chunk_size(mut self, bytes: NonZeroUsize) -> Self {
+ self.chunk_size = bytes;
+ self
+ }
+
+ /// Sets whether consecutive streams decompress as one logical stream.
+ ///
+ /// Left unset, each format keeps its own default: enabled for gzip and zstd, matching `gzip(1)`
+ /// and the `zstd` command line tool, and disabled for the rest, where concatenation is not an
+ /// established convention.
+ ///
+ /// When enabled, bytes after a complete stream must begin another valid stream, so trailing
+ /// padding is reported as corrupt data. When disabled,
+ /// [`trailing_data`][DecompressorBuilder::trailing_data] decides what happens to those bytes.
+ #[must_use]
+ pub const fn multi_stream(mut self, enabled: bool) -> Self {
+ self.multi_stream = Some(enabled);
+ self
+ }
+
+ /// Sets how a single-stream decompressor handles bytes after the compressed stream.
+ ///
+ /// Defaults to [`TrailingData::Reject`], so a stream that does not end exactly at end of input
+ /// is an error rather than a silent truncation of what the caller was given. Pass
+ /// [`TrailingData::Ignore`] for a container whose framing legitimately puts other data after
+ /// the compressed stream.
+ ///
+ /// In multi-stream mode, subsequent bytes are interpreted as another compressed stream
+ /// regardless of this setting.
+ #[must_use]
+ pub const fn trailing_data(mut self, trailing_data: TrailingData) -> Self {
+ self.trailing_data = trailing_data;
+ self
+ }
+}
+
+impl DecompressorBuilder<()> {
+ /// Starts configuring a decompressor whose format has not been chosen yet.
+ #[must_use]
+ #[inline]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Commits the format-independent settings to one format.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only a format module's build method commits a builder to a format")
+ )]
+ pub(crate) fn specialize(self, format: T) -> DecompressorBuilder {
+ DecompressorBuilder {
+ limits: self.limits,
+ chunk_size: self.chunk_size,
+ multi_stream: self.multi_stream,
+ trailing_data: self.trailing_data,
+ format,
+ }
+ }
+}
+
+impl Default for DecompressorBuilder<()> {
+ #[inline]
+ fn default() -> Self {
+ Self::with_format(())
+ }
+}
+
+const fn default_chunk_size() -> NonZeroUsize {
+ // Evaluated by the compiler: if `DEFAULT_CHUNK_SIZE` were ever zero, this constant would fail
+ // to build rather than panicking at runtime, so there is no runtime branch to cover here.
+ const CHUNK_SIZE: NonZeroUsize = match NonZeroUsize::new(DEFAULT_CHUNK_SIZE) {
+ Some(size) => size,
+ None => panic!("DEFAULT_CHUNK_SIZE must not be zero"),
+ };
+
+ CHUNK_SIZE
+}
diff --git a/crates/compressors/src/core/destination.rs b/crates/compressors/src/core/destination.rs
new file mode 100644
index 000000000..7bda29f94
--- /dev/null
+++ b/crates/compressors/src/core/destination.rs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/// What the caller does with the output it is handed.
+///
+/// A decompressor's exposure depends on this and on nothing else it can see: handing every chunk
+/// straight on keeps nothing, so a stream of any length is safe, while accumulating means what the
+/// engine produces is what the caller holds. Telling the engine which it is doing is what lets it
+/// bound its own output against its own counter -- and stop producing at the bound, rather than
+/// producing freely and leaving each caller to notice afterwards.
+///
+/// `pub` only to keep it off
+/// [`CompressionInternal::pull`][super::CompressionInternal::pull]'s `private_interfaces` warning,
+/// the same way [`Output`][super::Output] is: this module is private, so the name is unreachable
+/// outside the crate.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Destination {
+ /// Each chunk is consumed and dropped, so only the chunk size is retained.
+ Stream,
+
+ /// The whole result is accumulated, so the engine's cumulative output is what is retained.
+ Buffer,
+}
diff --git a/crates/compressors/src/core/mod.rs b/crates/compressors/src/core/mod.rs
new file mode 100644
index 000000000..b3029f22d
--- /dev/null
+++ b/crates/compressors/src/core/mod.rs
@@ -0,0 +1,287 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! The contract every compressor and decompressor implements.
+//!
+//! [`Compression`] is what makes the formats interchangeable: the same push/pull state machine
+//! whichever engine is behind it, with the [`Mode`][Compression::Mode] associated type recording
+//! which direction an implementation runs in.
+//!
+//! One trait covers both directions. [`Compress`] and [`Decompress`] are what an API names when it
+//! needs one of them -- `Compression` accepts any compressor and no decompressor.
+//!
+//! What one step of that contract reports is a crate-private detail, as are the methods that drive
+//! it: this module publishes the names an API is written against, and nothing else.
+
+use bytesbuf::{BytesBuf, BytesView};
+
+use crate::error::Result;
+
+mod destination;
+mod output;
+
+pub(crate) use destination::Destination;
+pub(crate) use output::Output;
+
+pub(crate) mod internal {
+ use std::fmt;
+
+ use bytesbuf::BytesView;
+
+ use super::{Destination, Output};
+ use crate::error::Result;
+
+ /// The push/pull mechanics behind every [`Compression`][super::Compression] implementation.
+ ///
+ /// This is deliberately kept off [`Compression`][super::Compression], and this module is
+ /// `pub(crate)`, so none of it reaches the public API. What that trait is for is *naming* an
+ /// engine; these methods are how this crate drives one.
+ ///
+ /// Being unnameable outside the crate is also what seals [`Compression`][super::Compression]:
+ /// a downstream crate cannot implement a supertrait it cannot refer to, so formats and methods
+ /// can be added here without breaking anyone. Every implementation is `Send + Sync`.
+ pub trait CompressionInternal: fmt::Debug + Send + Sync {
+ /// Supplies more input.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if input is still pending or end of input has been signaled.
+ fn push(&mut self, input: BytesView) -> Result<()>;
+
+ /// Signals that no further input will be supplied.
+ fn end_input(&mut self);
+
+ /// Produces the next output chunk.
+ ///
+ /// `into` says what the caller will do with it, and the engine bounds its own output
+ /// accordingly: a decompressor left unbounded applies the shared 64 MiB ceiling for
+ /// [`Destination::Buffer`], because that is the case where its cumulative output is what
+ /// the caller retains. Compressors ignore it -- compressed output tracks input the caller
+ /// already holds.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying engine fails, the input is invalid, or a bound that
+ /// applies to this destination has been reached.
+ fn pull(&mut self, into: Destination) -> Result;
+
+ /// The number of bytes consumed from the input so far.
+ fn total_in(&self) -> u64;
+
+ /// The number of bytes produced so far.
+ fn total_out(&self) -> u64;
+
+ /// Requests a resumable flush of everything supplied so far.
+ ///
+ /// Drain [`pull`][CompressionInternal::pull] until it reports [`Output::NeedInput`] before
+ /// pushing more input. Flushing ends a compressed block early, which can cost compression
+ /// ratio, so use it only when the bytes have to reach the far end before the stream does.
+ ///
+ /// Decompression has nothing to flush -- output is already produced as soon as the input
+ /// allows -- so this does nothing there, which is what the default implementation is.
+ ///
+ /// # Errors
+ ///
+ /// Returns an invalid-state error after end of input or a previous failure.
+ fn flush(&mut self) -> Result<()> {
+ Ok(())
+ }
+ }
+}
+
+pub(crate) use internal::CompressionInternal;
+
+/// Marks a [`Compression`] implementation that compresses its input.
+///
+/// This marker cannot be constructed outside this crate.
+#[derive(Debug)]
+#[non_exhaustive]
+pub struct Compress;
+
+/// Marks a [`Compression`] implementation that decompresses its input.
+///
+/// This marker cannot be constructed outside this crate.
+#[derive(Debug)]
+#[non_exhaustive]
+pub struct Decompress;
+
+/// A streaming compression or decompression engine.
+///
+/// Every format's compressor and decompressor implements this contract. The `Mode` associated type
+/// records which direction an implementation compresses in without changing how callers drive it. This
+/// allows shared processing code to accept any `Compression`, while APIs that require one direction
+/// can use `Compression` or `Compression`.
+///
+/// The trait is sealed so formats and methods can be added without breaking downstream code, and
+/// it is `Sized` so no `dyn Compression` object can be formed. Every implementation is
+/// `Send + Sync`.
+///
+/// # The mechanics are an internal detail
+///
+/// What this trait is *for* is naming an engine: `impl Compression` accepts any
+/// compressor and no decompressor. How this crate actually drives one -- pushing input, pulling
+/// output, ending input -- lives on a crate-private supertrait that no downstream crate can name,
+/// let alone implement. Those mechanics are therefore not public API and can change freely.
+///
+/// The `Sized` bound is what makes that true rather than merely intended. A trait object resolves
+/// supertrait methods as inherent candidates, with no need for the supertrait to be nameable or in
+/// scope, so a `dyn Compression` vtable would hand every downstream crate the push/pull mechanics
+/// and values of the crate-private step type. Boxing a concrete compressor stays available and is
+/// how to hold one without naming its type.
+///
+/// Reach for [`compress`][crate::compress] and [`decompress`][crate::decompress] for a complete
+/// buffer, or `CompressionStream` (behind the `futures-stream` feature) for data that arrives over time.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "gzip")]
+/// # {
+/// use bytesbuf::BytesView;
+/// use bytesbuf::mem::GlobalPool;
+/// use compressors::core::{Compress, Compression};
+/// use compressors::{Resources, gzip};
+///
+/// fn compress(
+/// compression: impl Compression,
+/// input: BytesView,
+/// ) -> compressors::Result {
+/// compressors::compress(input, compression)
+/// }
+///
+/// let memory = GlobalPool::new();
+/// let compressed = compress(
+/// gzip::Compressor::new(&Resources::default()),
+/// BytesView::copied_from_slice(b"format agnostic", &memory),
+/// )?;
+///
+/// assert_eq!(compressed.range(0..2).to_vec(), vec![0x1f, 0x8b]);
+/// # }
+/// # Ok::<(), compressors::Error>(())
+/// ```
+pub trait Compression: CompressionInternal + Sized {
+ /// Whether this implementation compresses or decompresses its input.
+ type Mode;
+}
+
+/// Drives one complete input through `engine` and returns the whole result.
+///
+/// This is [`push`][Compression::push], [`end_input`][Compression::end_input] and draining
+/// [`pull`][Compression::pull] in one call. It ends the engine, so an engine serves one call,
+/// and it buffers the entire result: drive `pull` directly to stay bounded by the chunk size.
+///
+/// Every `pull` here says [`Destination::Buffer`], which is what tells a decompressor that its
+/// cumulative output is what the caller will retain. The engine applies its own ceiling on that
+/// basis, so this loop does no bounding of its own.
+///
+/// # Errors
+///
+/// Returns an error if the underlying engine fails, the input is invalid, or the engine's bounds
+/// for a buffered destination are reached.
+pub(crate) fn process(mut engine: impl Compression, input: BytesView) -> Result {
+ engine.push(input)?;
+ engine.end_input();
+
+ let mut collected = BytesBuf::new();
+ loop {
+ match engine.pull(Destination::Buffer)? {
+ Output::Data(chunk) => collected.put_bytes(chunk),
+ Output::Progress => {}
+ Output::Done => break,
+ Output::NeedInput => {
+ return Err(crate::Error::invalid_state("the engine requested input after end of input"));
+ }
+ }
+ }
+
+ Ok(collected.consume_all())
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::testing::view;
+
+ #[test]
+ fn process_forwards_progress_without_producing_data() {
+ #[derive(Debug)]
+ struct ProgressOnceThenDone {
+ done: bool,
+ }
+ impl Compression for ProgressOnceThenDone {
+ type Mode = Compress;
+ }
+
+ impl CompressionInternal for ProgressOnceThenDone {
+ fn push(&mut self, _input: BytesView) -> Result<()> {
+ Ok(())
+ }
+
+ fn end_input(&mut self) {}
+
+ fn pull(&mut self, _into: Destination) -> Result {
+ if self.done {
+ return Ok(Output::Done);
+ }
+
+ self.done = true;
+ Ok(Output::Progress)
+ }
+ // No caller on the path under test asks for the byte counters; they exist only because
+ // the trait requires them.
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_in(&self) -> u64 {
+ 0
+ }
+
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_out(&self) -> u64 {
+ 0
+ }
+ }
+
+ let result = process(ProgressOnceThenDone { done: false }, view(b"ignored")).unwrap();
+
+ assert!(result.is_empty(), "the fixture never reports data");
+ }
+
+ #[test]
+ fn process_rejects_a_pull_that_still_requests_input_after_end() {
+ #[derive(Debug)]
+ struct NeedsMoreForever;
+ impl Compression for NeedsMoreForever {
+ type Mode = Compress;
+ }
+
+ impl CompressionInternal for NeedsMoreForever {
+ fn push(&mut self, _input: BytesView) -> Result<()> {
+ Ok(())
+ }
+
+ fn end_input(&mut self) {}
+
+ fn pull(&mut self, _into: Destination) -> Result {
+ Ok(Output::NeedInput)
+ }
+ // No caller on the path under test asks for the byte counters; they exist only because
+ // the trait requires them.
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_in(&self) -> u64 {
+ 0
+ }
+
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_out(&self) -> u64 {
+ 0
+ }
+ }
+
+ let error = process(NeedsMoreForever, view(b"ignored")).unwrap_err();
+ assert!(error.is_invalid_state());
+ }
+}
diff --git a/crates/compressors/src/core/output.rs b/crates/compressors/src/core/output.rs
new file mode 100644
index 000000000..b41c20d61
--- /dev/null
+++ b/crates/compressors/src/core/output.rs
@@ -0,0 +1,146 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use bytesbuf::BytesView;
+
+/// What a single engine step produced.
+///
+/// This is the state machine a caller drives: keep calling `pull` until it reports
+/// [`Output::NeedInput`], supply more data, and stop at [`Output::Done`]. When
+/// [`Output::Progress`] is returned, call `pull` again without pushing input.
+///
+/// It is an enum rather than an `Option` plus a separate `is_finished()` because
+/// "no bytes right now" and "no bytes ever again" require different responses from the caller, and
+/// conflating them turns a missing check into an infinite loop.
+///
+/// It is deliberately *not* `#[non_exhaustive]`. These states describe a complete engine step,
+/// and a caller that fails to handle one has a bug. Forcing a wildcard arm would convert that bug
+/// from a compile error into silent misbehavior, which is the opposite of what a wildcard is for.
+#[derive(Debug)]
+#[expect(
+ clippy::large_enum_variant,
+ reason = "a BytesView is ~272 bytes because it stores its first spans inline; boxing it would add an allocation per chunk on the hot path, which is exactly what this crate exists to avoid"
+)]
+pub enum Output {
+ /// Bytes are available now.
+ ///
+ /// Never empty.
+ Data(BytesView),
+
+ /// The engine advanced without producing bytes.
+ ///
+ /// Call `pull` again before supplying more input. This bounds how much CPU work one `pull`
+ /// performs even when highly compressible input produces very little output.
+ Progress,
+
+ /// More input is required before more output can be produced.
+ NeedInput,
+
+ /// The stream ended cleanly and no further output will ever be produced.
+ Done,
+}
+
+impl Output {
+ /// Returns the bytes, if this is [`Output::Data`].
+ #[must_use]
+ pub fn into_data(self) -> Option {
+ match self {
+ Self::Data(data) => Some(data),
+ _ => None,
+ }
+ }
+
+ /// Whether bytes are available.
+ #[must_use]
+ pub fn is_data(&self) -> bool {
+ matches!(*self, Self::Data(_))
+ }
+
+ /// Returns the bytes without consuming them, if this is [`Output::Data`].
+ #[must_use]
+ pub fn as_data(&self) -> Option<&BytesView> {
+ match *self {
+ Self::Data(ref data) => Some(data),
+ _ => None,
+ }
+ }
+
+ /// 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)
+ }
+
+ /// Whether the engine made progress and should be pulled again.
+ #[must_use]
+ pub fn is_progress(&self) -> bool {
+ matches!(*self, Self::Progress)
+ }
+
+ /// Whether the stream has ended.
+ #[must_use]
+ pub fn is_done(&self) -> bool {
+ matches!(*self, Self::Done)
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use bytesbuf::mem::GlobalPool;
+
+ use super::*;
+
+ #[test]
+ fn into_data_returns_bytes_only_for_data() {
+ let memory = GlobalPool::new();
+ let view = BytesView::copied_from_slice(b"hello", &memory);
+
+ assert_eq!(Output::Data(view).into_data().map(|d| d.to_vec()), Some(b"hello".to_vec()));
+ assert!(Output::Progress.into_data().is_none());
+ assert!(Output::NeedInput.into_data().is_none());
+ assert!(Output::Done.into_data().is_none());
+ }
+
+ #[test]
+ fn predicates_identify_exactly_one_variant() {
+ let memory = GlobalPool::new();
+ let data = Output::Data(BytesView::copied_from_slice(b"x", &memory));
+
+ assert!(data.is_data() && !data.is_progress() && !data.is_need_input() && !data.is_done());
+ assert!(
+ Output::Progress.is_progress()
+ && !Output::Progress.is_data()
+ && !Output::Progress.is_need_input()
+ && !Output::Progress.is_done()
+ );
+ assert!(
+ Output::NeedInput.is_need_input()
+ && !Output::NeedInput.is_data()
+ && !Output::NeedInput.is_progress()
+ && !Output::NeedInput.is_done()
+ );
+ assert!(Output::Done.is_done() && !Output::Done.is_data() && !Output::Done.is_progress() && !Output::Done.is_need_input());
+ }
+
+ #[test]
+ fn data_can_be_inspected_without_being_consumed() {
+ let memory = GlobalPool::new();
+ let output = Output::Data(BytesView::copied_from_slice(b"peek", &memory));
+
+ assert_eq!(output.as_data().map(BytesView::len), Some(4));
+ assert_eq!(output.into_data().unwrap().to_vec(), b"peek".to_vec());
+ }
+
+ #[test]
+ fn as_data_returns_nothing_for_non_data_variants() {
+ assert!(Output::Progress.as_data().is_none());
+ assert!(Output::NeedInput.as_data().is_none());
+ assert!(Output::Done.as_data().is_none());
+ }
+
+ #[test]
+ fn debug_is_available_for_diagnostics() {
+ assert!(format!("{:?}", Output::NeedInput).contains("NeedInput"));
+ }
+}
diff --git a/crates/compressors/src/deflate/mod.rs b/crates/compressors/src/deflate/mod.rs
new file mode 100644
index 000000000..fb1309bd2
--- /dev/null
+++ b/crates/compressors/src/deflate/mod.rs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Raw deflate (RFC 1951): the compressed payload with no header and no checksum.
+//!
+//! Use this only where the surrounding format supplies its own framing and integrity check, such as
+//! inside a ZIP archive. Without a checksum, corruption is not reliably detected, so prefer `zlib`
+//! or `gzip` for data in transit. PNG is not an example of this: its `IDAT` payloads concatenate
+//! into a single zlib stream, so reach for `zlib` there.
+//!
+//! # Examples
+//!
+//! ```
+//! use compressors::{Resources, deflate};
+//!
+//! let compressed = deflate::compress(b"the quick brown fox", &Resources::default())?;
+//!
+//! assert_eq!(
+//! deflate::decompress(compressed, &Resources::default())?.to_vec(),
+//! b"the quick brown fox".to_vec()
+//! );
+//! # Ok::<(), compressors::Error>(())
+//! ```
+
+use crate::flate::codec::{FlateCompress, FlateDecompress};
+use crate::flate::{DEFAULT_LIMITS, Wrapper};
+use crate::macros::define_format;
+
+/// Selects raw deflate as the format of a [`CompressorBuilder`][crate::CompressorBuilder] or [`DecompressorBuilder`][crate::DecompressorBuilder].
+///
+/// Raw deflate has no settings beyond the ones every format shares, so this type carries none. It
+/// exists to name the format in the builder's type parameter, which is what gives that builder a
+/// `build` method producing this module's [`Compressor`] and [`Decompressor`].
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct Deflate;
+
+impl Deflate {
+ /// The settings a deflate builder starts with. Raw deflate has none of its own.
+ pub(crate) const fn new() -> Self {
+ Self
+ }
+}
+
+define_format! {
+ name = "deflate",
+ format = Deflate,
+ build_method = build_deflate,
+ compressor_codec = FlateCompress,
+ compressor_build = infallible,
+ new_compressor = |level, _format, pool| FlateCompress::new(Wrapper::Raw, level, pool),
+ decompressor_codec = FlateDecompress,
+ decompressor_build = infallible,
+ default_limits = DEFAULT_LIMITS,
+ new_decompressor = |limits, multi_stream, trailing_data, _format, pool| {
+ FlateDecompress::new(Wrapper::Raw, limits, multi_stream, trailing_data, pool)
+ },
+ multi_stream_default = false,
+}
diff --git a/crates/compressors/src/documentation.rs b/crates/compressors/src/documentation.rs
new file mode 100644
index 000000000..f1b840026
--- /dev/null
+++ b/crates/compressors/src/documentation.rs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Longer form documentation for [`compressors`](crate).
+//!
+//! These guides cover the decisions that span several APIs, which no single item's documentation
+//! can carry. They live as Markdown beside the crate so they read on GitHub as well as here.
+//!
+//! * [DESIGN.md] -- the user-visible policies: format selection, what is uniform across formats and
+//! what is not, how decompression is bounded, stream framing, and why the public surface is
+//! sealed.
+//! * [SECURITY.md] -- the threat model for untrusted compressed data: what makes input untrusted,
+//! which resource each budget bounds and which it does not, which defaults apply to which
+//! consumption mode, what the caller still owns, and how those claims are verified.
+//! * [IMPLEMENTATION.md] -- the mechanisms behind them: the pump state machine, the unsafe
+//! initialized-output contract every backend adapter must honour, engine pooling and why some
+//! engines are excluded, and the async driving rules.
+//!
+//! [DESIGN.md]: https://github.com/microsoft/oxidizer/blob/main/crates/compressors/docs/DESIGN.md
+//! [SECURITY.md]: https://github.com/microsoft/oxidizer/blob/main/crates/compressors/docs/SECURITY.md
+//! [IMPLEMENTATION.md]: https://github.com/microsoft/oxidizer/blob/main/crates/compressors/docs/IMPLEMENTATION.md
diff --git a/crates/compressors/src/engine.rs b/crates/compressors/src/engine.rs
new file mode 100644
index 000000000..673c81c88
--- /dev/null
+++ b/crates/compressors/src/engine.rs
@@ -0,0 +1,1511 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::mem::MaybeUninit;
+use std::num::{NonZeroU64, NonZeroUsize};
+
+use bytesbuf::mem::OpaqueMemory;
+use bytesbuf::{BytesBuf, BytesView};
+
+use crate::core::{Destination, Output};
+use crate::error::{Error, Result};
+use crate::limits::BufferedFallbacks;
+
+/// Maximum input consumed by one public `pull` call.
+///
+/// A megabyte, written as a plain literal rather than as `1024 * 1024` so the constant carries no
+/// arithmetic: this is tuning that bounds how much work one call does, never what it produces, so a
+/// mutation of that arithmetic would change no observable result.
+///
+/// Chosen as a conservative starting point rather than from measurement, and it is free to move.
+/// The rule it has to satisfy is that one `pull` returns soon enough not to monopolize the caller's
+/// thread or task, while staying far enough above a typical chunk size that ordinary messages are
+/// never split across calls for want of budget.
+const MAX_INPUT_PER_PULL: usize = 1_048_576;
+
+/// Maximum engine calls made by one public `pull` call.
+///
+/// The same rule as [`MAX_INPUT_PER_PULL`], for the case where an engine consumes little per step:
+/// a pathological input must not turn one `pull` into an unbounded loop. Small enough to yield
+/// promptly, large enough that a well-behaved engine never hits it. Also a starting point rather
+/// than a measured optimum.
+const MAX_STEPS_PER_PULL: usize = 64;
+
+/// Enough room for the largest deflate sync-flush marker plus one spare byte.
+const MIN_FLUSH_OUTPUT: usize = 7;
+
+/// What the encoder should do with the input supplied to one engine step.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Operation {
+ Process,
+ Flush,
+ Finish,
+}
+
+/// The outcome of a single engine step.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Step {
+ /// The engine can do more work, given more input or more output space.
+ Continue,
+ /// A requested resumable flush completed.
+ FlushComplete,
+ /// The engine reached the end of a compressed stream.
+ StreamEnd,
+}
+
+/// What a decoder wants to do after one compressed stream ends.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum StreamEnd {
+ /// The logical input is complete; preserve any unconsumed bytes as a remainder.
+ Complete,
+ /// The stream is complete only when the caller confirms EOF.
+ AwaitEof,
+ /// Reset the engine and accept another compressed stream.
+ NextStream,
+}
+
+/// Drives one direction of a compression algorithm, step by step, as the [`Pump`] calls it.
+///
+/// # Safety
+///
+/// [`Codec::step`] reports how many output bytes it wrote, and that count is load-bearing:
+/// [`Pump::pull`] hands back the engine's uninitialized spare capacity and then declares exactly
+/// that many bytes initialized. An implementation must therefore leave `output[..produced]`
+/// genuinely initialized when it returns, and must never read from `output`, write past
+/// `output.len()`, or touch memory outside the two slices it was given. Reporting a count it did not
+/// write exposes uninitialized memory to the caller.
+///
+/// Every implementation must write through an API that accepts uninitialized memory, or initialize
+/// the slice before writing. The three backend families in this crate split two to one: `flate`
+/// writes through `flate2`'s `*_uninit` entry points and zstd through `zstd_safe::WriteBuf`, while
+/// brotli initializes the slice before handing it to an encoder that takes `&mut [u8]`. Each family
+/// implements this trait twice, once for compression and once for decompression.
+pub(crate) unsafe trait Codec {
+ /// Runs a single engine step.
+ ///
+ /// Returns the step outcome, the number of input bytes consumed, and the number of output
+ /// bytes written to the front of `output`.
+ ///
+ /// `operation` is only `Flush` or `Finish` on the final slice of the currently pending input.
+ /// A [`BytesView`] is a chain of segments, so signaling either operation on an earlier segment
+ /// would flush or finalize at the wrong boundary.
+ ///
+ /// The reported output count carries the safety obligation described on the trait.
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)>;
+
+ /// Called when [`Codec::step`] reported [`Step::StreamEnd`].
+ ///
+ /// Defaults to a complete, single-stream container; engines whose containers can continue
+ /// (trailing data, concatenated streams, strict EOF checks) override this.
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::Complete)
+ }
+
+ /// Validates the cumulative byte counts, for engines that enforce limits.
+ // Equivalent mutant: the body already reduces to `Ok(())` for every input; `mutants::skip`
+ // documents that no test can distinguish this default from a mutant that also always
+ // returns `Ok(())`, since the two are behaviorally identical (the only difference is
+ // dropping the parameter-discarding `let` binding, which has no runtime effect).
+ #[cfg_attr(test, mutants::skip)]
+ fn check_limits(&self, total_in: u64, total_out: u64, streams: u64) -> Result<()> {
+ let _ = (total_in, total_out, streams);
+ Ok(())
+ }
+
+ /// Returns the remaining absolute output budget, if one is configured.
+ // Excluded for the same reason as the implementations that override it: a mutant answering a
+ // small constant is not wrong, only slow. Every codec that keeps this default -- which is every
+ // compressor -- would then be offered one byte per step, so the suite runs orders of magnitude
+ // slower and the harness's timeout expires before the failing assertion is reached. That
+ // timeout is derived from the suite's own runtime, so it is now ~20s and there is no margin.
+ #[cfg_attr(test, mutants::skip)]
+ fn remaining_output(&self, _total_out: u64) -> Option {
+ None
+ }
+
+ /// Returns the maximum number of streams this engine may decode.
+ fn max_streams(&self) -> Option {
+ None
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum State {
+ /// Accepting input.
+ Open,
+ /// Draining a resumable flush. `end_after` queues finalization behind it.
+ Flushing { end_after: bool },
+ /// The caller signaled end of input; drain the engine.
+ Finishing,
+ /// A compressed stream ended and the decoder is waiting for another one or EOF.
+ BetweenStreams,
+ /// A single stream ended and strict trailing-data validation is waiting for EOF.
+ AwaitingEof,
+ /// The configured stream-count limit was reached; only EOF is now valid.
+ AtStreamLimit { maximum: u64 },
+ /// The engine reported end of stream.
+ Done,
+ /// A fatal engine error occurred. Native state must never be entered again.
+ Failed,
+}
+
+/// What a step loop iteration should do next, after a stream end updates [`State`].
+///
+/// Computed alongside the new `State` in the same match so the loop never needs a second,
+/// redundant match over `State` (which would otherwise need an unreachable catch-all arm).
+enum StreamContinuation {
+ /// Keep pumping without returning to the caller.
+ Loop,
+ /// Report [`Output::NeedInput`] to the caller.
+ NeedInput,
+ /// Report [`Output::Done`] to the caller.
+ Done,
+}
+
+/// Moves bytes between a [`BytesView`] source and a [`BytesBuf`] sink through a [`Codec`].
+///
+/// This is where the impedance match happens: `BytesView` is a chain of segments with no
+/// contiguous representation, and `BytesBuf` exposes its spare capacity one uninitialized segment
+/// at a time. Both are fed to the engine a segment at a time, so no intermediate copy is needed
+/// and no `std::io` trait is involved.
+#[derive(Debug)]
+pub(crate) struct Pump {
+ memory: OpaqueMemory,
+ chunk_size: usize,
+ input: BytesView,
+ output: BytesBuf,
+ total_in: u64,
+ total_out: u64,
+ streams: u64,
+ state: State,
+ buffered: BufferedFallbacks,
+}
+
+/// Whether one `pull` has done enough work and should hand control back.
+///
+/// Answering `false` unconditionally lets a single call run until the stream ends; answering `true`
+/// unconditionally makes it report progress without ever advancing.
+// Either answer leaves every drain loop spinning, so those mutants hang rather than failing and
+// mutation testing records a timeout instead of a verdict.
+#[cfg_attr(test, mutants::skip)]
+fn yields_to_the_caller(steps: usize, input_work: usize) -> bool {
+ steps >= MAX_STEPS_PER_PULL || input_work >= MAX_INPUT_PER_PULL
+}
+
+/// Whether an engine step moved neither input nor output, which means it is stuck.
+///
+/// This is the engine's only guard against an engine that can never finish.
+// Answering `false` unconditionally removes that guard, so the mutant hangs rather than failing.
+#[cfg_attr(test, mutants::skip)]
+fn made_no_progress(consumed: usize, produced: usize) -> bool {
+ consumed == 0 && produced == 0
+}
+
+/// The tighter of two optional bounds, where absent means unbounded.
+// Excluded for the same reason as its output-budget caller: a mutant answering a small constant is
+// not wrong, only slow. Through `remaining_output` it shrinks the per-step slice to the probe byte,
+// so the pump produces the right bytes one at a time and the harness times out instead of reaching
+// a verdict. Nothing is hidden by this -- `tighter => None`, the answer that actually loosens a
+// bound, is caught, and `Pump::max_streams`, its other caller, is mutated and caught in its own
+// right.
+#[cfg_attr(test, mutants::skip)]
+fn tighter(engine: Option, buffered: Option) -> Option {
+ match (engine, buffered) {
+ (Some(engine), Some(buffered)) => Some(engine.min(buffered)),
+ (bound, None) | (None, bound) => bound,
+ }
+}
+
+impl Pump {
+ pub(crate) fn new(memory: OpaqueMemory, chunk_size: NonZeroUsize) -> Self {
+ Self {
+ memory,
+ chunk_size: chunk_size.get(),
+ input: BytesView::new(),
+ // Reserved on the first pull rather than here. `pull` calls `ensure_output_capacity`
+ // before every engine step anyway, so starting empty makes the same single reservation
+ // at the same point, while a codec that is built and never driven -- including a
+ // fallible build that fails after this field is evaluated -- costs no output capacity.
+ output: BytesBuf::new(),
+ total_in: 0,
+ total_out: 0,
+ streams: 0,
+ state: State::Open,
+ buffered: BufferedFallbacks::default(),
+ }
+ }
+
+ /// Sets the bounds that apply while a caller accumulates the whole output.
+ ///
+ /// Separate from the engine's own limits because they are not properties of the format: the
+ /// same decompressor is safe to stream unbounded and unsafe to buffer unbounded, so these
+ /// exist only for [`Destination::Buffer`] and the engine's own bounds apply either way.
+ pub(crate) const fn with_buffered_fallbacks(mut self, fallbacks: BufferedFallbacks) -> Self {
+ self.buffered = fallbacks;
+ self
+ }
+
+ /// The buffering fallbacks in force for this destination.
+ ///
+ /// Empty for [`Destination::Stream`]: each chunk is consumed and dropped, so neither cumulative
+ /// output nor stream count measures anything the caller retains, and bounding them would cut
+ /// off long streams for no benefit.
+ const fn buffered(&self, into: Destination) -> BufferedFallbacks {
+ match into {
+ Destination::Buffer => self.buffered,
+ Destination::Stream => BufferedFallbacks {
+ output_len: None,
+ streams: None,
+ },
+ }
+ }
+
+ /// The output budget still available, narrowing the engine's own bound by the buffering ceiling.
+ ///
+ /// Whichever bound is tighter is the one that decides, and either may be absent.
+ // A mutant that answers a small constant is not wrong, only slow: the step loop always offers
+ // at least the probe byte, so the pump still produces exactly the right bytes, one per step.
+ // Every drain loop then needs one step per byte and mutation testing records a timeout instead
+ // of a verdict. `None` -- the answer that actually loosens the bound -- is caught.
+ #[cfg_attr(test, mutants::skip)]
+ fn remaining_output(&self, codec: &impl Codec, into: Destination) -> Option {
+ let buffered = self
+ .buffered(into)
+ .output_len
+ .map(|ceiling| ceiling.get().saturating_sub(self.total_out));
+
+ tighter(codec.remaining_output(self.total_out), buffered)
+ }
+
+ /// The stream cap in force, narrowing the engine's own bound by the buffering fallback.
+ ///
+ /// The counterpart of [`Pump::remaining_output`] for concatenated streams. A buffering caller
+ /// needs this bound even when the output ceiling is generous: many tiny members each pay engine
+ /// setup while producing almost no output, so the output cap never trips.
+ fn max_streams(&self, codec: &impl Codec, into: Destination) -> Option {
+ tighter(codec.max_streams(), self.buffered(into).streams.map(NonZeroU64::get))
+ }
+
+ /// Validates the cumulative counts against the engine's own limits and the buffering ceiling.
+ ///
+ /// One method so the two call sites below cannot drift apart on which bounds they test. The
+ /// output bound is tested the same way the engines test theirs -- against a count that the probe
+ /// byte in the step loop lets exceed the bound by one, which is what proves the stream needed
+ /// more output than the bound allows rather than ending exactly at it.
+ ///
+ /// The stream bound needs no such test. It is enforced by narrowing
+ /// [`max_streams`][Pump::max_streams], which stops the pump at the bound and refuses the *next*
+ /// stream before it starts, so the count can never run past it the way output can.
+ fn check_limits(&self, codec: &impl Codec, into: Destination) -> Result<()> {
+ codec.check_limits(self.total_in, self.total_out, self.streams)?;
+
+ if let Some(ceiling) = self.buffered(into).output_len
+ && self.total_out > ceiling.get()
+ {
+ return Err(Error::output_limit_exceeded(self.total_out, ceiling.get()));
+ }
+
+ Ok(())
+ }
+
+ pub(crate) fn push(&mut self, input: BytesView) -> Result<()> {
+ if !self.input.is_empty() {
+ return Err(Error::invalid_state(
+ "cannot push more input while previously pushed input is still pending",
+ ));
+ }
+
+ match self.state {
+ State::Open => {}
+ State::BetweenStreams | State::AwaitingEof | State::AtStreamLimit { .. } if input.is_empty() => return Ok(()),
+ State::BetweenStreams => self.state = State::Open,
+ State::AwaitingEof => {
+ let error = Error::corrupt_data("trailing data followed the compressed stream");
+ return Err(self.fail(error));
+ }
+ State::AtStreamLimit { maximum } => {
+ let error = Error::stream_limit_exceeded(self.streams.saturating_add(1), maximum);
+ return Err(self.fail(error));
+ }
+ State::Flushing { .. } => {
+ return Err(Error::invalid_state("cannot push more input while a flush is still pending"));
+ }
+ State::Finishing | State::Done => {
+ return Err(Error::invalid_state("cannot push more input after end of input was signaled"));
+ }
+ State::Failed => {
+ return Err(Error::invalid_state("cannot push more input after the codec failed"));
+ }
+ }
+
+ self.input = input;
+ Ok(())
+ }
+
+ pub(crate) fn flush(&mut self) -> Result<()> {
+ match self.state {
+ State::Open => self.state = State::Flushing { end_after: false },
+ State::Flushing { end_after: false } => {}
+ State::Flushing { end_after: true }
+ | State::Finishing
+ | State::BetweenStreams
+ | State::AwaitingEof
+ | State::AtStreamLimit { .. }
+ | State::Done => {
+ return Err(Error::invalid_state("cannot flush after end of input was signaled"));
+ }
+ State::Failed => {
+ return Err(Error::invalid_state("cannot flush after the codec failed"));
+ }
+ }
+
+ Ok(())
+ }
+
+ // Emptying this leaves every drain loop, including the crate's own `process`, asking for input
+ // that will never end the stream. The mutant hangs rather than failing, so the harness records a
+ // timeout instead of a verdict; it is not a mutation any test can report on.
+ #[cfg_attr(test, mutants::skip)]
+ pub(crate) fn end_input(&mut self) {
+ self.state = match self.state {
+ State::Open => State::Finishing,
+ State::Flushing { .. } => State::Flushing { end_after: true },
+ State::BetweenStreams | State::AwaitingEof | State::AtStreamLimit { .. } => State::Done,
+ State::Finishing | State::Done => self.state,
+ State::Failed => State::Failed,
+ };
+ }
+
+ pub(crate) fn total_in(&self) -> u64 {
+ self.total_in
+ }
+
+ pub(crate) fn total_out(&self) -> u64 {
+ self.total_out
+ }
+
+ fn fail(&mut self, error: Error) -> Error {
+ self.state = State::Failed;
+ error
+ }
+
+ /// Hands over whatever output has accumulated, if any.
+ // Answering `Some(empty)` unconditionally hands every drain loop an endless supply of empty
+ // chunks, so the mutant hangs rather than failing and mutation testing records a timeout
+ // instead of a verdict.
+ #[cfg_attr(test, mutants::skip)]
+ fn take_output(&mut self) -> Option {
+ if self.output.is_empty() {
+ return None;
+ }
+
+ Some(self.output.consume(self.output.len().min(self.chunk_size)))
+ }
+
+ /// Ensures the output buffer has at least `additional_bytes` of spare capacity.
+ // Equivalent mutant: `BytesBuf::reserve` already re-derives `additional_bytes <=
+ // remaining_capacity()` internally and returns immediately once that holds, so calling it
+ // one comparison earlier (right at the `remaining_capacity() == additional_bytes` boundary)
+ // is indistinguishable from skipping the call: either way `reserve` does nothing. Takes the
+ // output buffer and memory provider by reference, rather than `&mut self`, so it borrows
+ // disjointly from `self.input` at the call site.
+ #[cfg_attr(test, mutants::skip)]
+ fn ensure_output_capacity(output: &mut BytesBuf, memory: &OpaqueMemory, additional_bytes: usize) {
+ if output.remaining_capacity() < additional_bytes {
+ output.reserve(additional_bytes, memory);
+ }
+ }
+
+ #[expect(
+ clippy::too_many_lines,
+ reason = "keeping the state transitions in one loop makes their ordering and terminal paths explicit"
+ )]
+ pub(crate) fn pull(&mut self, codec: &mut impl Codec, into: Destination) -> Result {
+ match self.state {
+ State::Done => {
+ if let Some(data) = self.take_output() {
+ return Ok(Output::Data(data));
+ }
+
+ return Ok(Output::Done);
+ }
+ State::Failed => {
+ return Err(Error::invalid_state("cannot continue after a previous codec failure"));
+ }
+ State::BetweenStreams | State::AwaitingEof | State::AtStreamLimit { .. } if self.input.is_empty() => {
+ return Ok(self.take_output().map_or(Output::NeedInput, Output::Data));
+ }
+ _ => {}
+ }
+
+ let mut steps = 0;
+ let mut input_work = 0;
+
+ loop {
+ // Hand over a full chunk rather than growing the buffer, so the working set stays
+ // bounded no matter how long the stream is.
+ if self.output.len() >= self.chunk_size
+ && let Some(data) = self.take_output()
+ {
+ return Ok(Output::Data(data));
+ }
+
+ if yields_to_the_caller(steps, input_work) {
+ return Ok(self.take_output().map_or(Output::Progress, Output::Data));
+ }
+
+ // A memory provider may hand back more capacity than asked for, so the chunk bound has
+ // to be applied to the slice itself rather than to the reservation. This also bounds
+ // the cost of the engine's zero-fill of the uninitialized output slice.
+ let budget = self.chunk_size - self.output.len();
+ let pending = self.input.len();
+ let input_budget = MAX_INPUT_PER_PULL - input_work;
+ // Read before the borrows below, since it needs all of `self` and neither it nor
+ // anything it reads changes inside the step block.
+ let remaining = self.remaining_output(codec, into);
+ let (step, consumed, produced, supplied, provided_output) = {
+ let first = self.input.first_slice();
+ let input = &first[..first.len().min(input_budget)];
+ let supplied = input.len();
+ let last_slice = input.len() == pending;
+ let operation = match self.state {
+ State::Flushing { .. } if last_slice => Operation::Flush,
+ State::Finishing if last_slice => Operation::Finish,
+ State::Open | State::Flushing { .. } | State::Finishing => Operation::Process,
+ State::BetweenStreams | State::AwaitingEof | State::AtStreamLimit { .. } | State::Done | State::Failed => {
+ non_driving_state_reached_the_step_loop()
+ }
+ };
+ let engine_budget = if operation == Operation::Flush {
+ budget.max(MIN_FLUSH_OUTPUT)
+ } else {
+ budget
+ };
+ Self::ensure_output_capacity(&mut self.output, &self.memory, engine_budget);
+ let spare = self.output.first_unfilled_slice();
+ let limit_budget = remaining.map_or(usize::MAX, |remaining| usize::try_from(remaining).unwrap_or(usize::MAX));
+ // One probe byte lets the engine prove that a stream ending exactly at the limit
+ // needs no more output, while bounding any overshoot to a byte that is never
+ // returned to the caller.
+ let take = spare.len().min(engine_budget).min(limit_budget.max(1));
+ match codec.step(input, &mut spare[..take], operation) {
+ Ok((step, consumed, produced)) => (step, consumed, produced, supplied, take),
+ Err(error) => return Err(self.fail(error)),
+ }
+ };
+
+ if consumed > supplied || produced > provided_output {
+ return Err(self.fail(Error::invalid_state("the compression engine reported invalid byte counts")));
+ }
+
+ self.input.advance(consumed);
+
+ // SAFETY: the engine reported writing `produced` bytes to the front of the slice
+ // returned by `first_unfilled_slice`, and the check above rejected any count past that
+ // slice. `Codec::step` documents this count as load-bearing; see its contract for why
+ // every implementation can be trusted to have written what it claims.
+ unsafe { self.output.advance(produced) };
+
+ self.total_in = self.total_in.saturating_add(u64::try_from(consumed).unwrap_or(u64::MAX));
+ self.total_out = self.total_out.saturating_add(u64::try_from(produced).unwrap_or(u64::MAX));
+ input_work = input_work.saturating_add(consumed);
+ steps += 1;
+
+ if let Err(error) = self.check_limits(codec, into) {
+ return Err(self.fail(error));
+ }
+
+ if step == Step::FlushComplete {
+ self.state = match self.state {
+ State::Flushing { end_after: true } => State::Finishing,
+ State::Flushing { end_after: false } => State::Open,
+ _ => {
+ return Err(self.fail(Error::invalid_state("the compression engine completed an unrequested flush")));
+ }
+ };
+
+ if let Some(data) = self.take_output() {
+ return Ok(Output::Data(data));
+ }
+
+ if self.state == State::Open {
+ return Ok(Output::NeedInput);
+ }
+
+ continue;
+ }
+
+ if step == Step::StreamEnd {
+ self.streams = self.streams.saturating_add(1);
+ if let Err(error) = self.check_limits(codec, into) {
+ return Err(self.fail(error));
+ }
+
+ let end_of_input = self.state == State::Finishing;
+ let stream_end = match codec.stream_ended() {
+ Ok(stream_end) => stream_end,
+ Err(error) => return Err(self.fail(error)),
+ };
+
+ // One read of the codec's stream cap, already narrowed to "and we are at it", so the
+ // three arms below cannot drift apart on how the limit is tested and none of them
+ // has to re-open the `Option` it just matched on.
+ let stream_limit = self.max_streams(codec, into).filter(|maximum| self.streams >= *maximum);
+
+ // Paired with the state so the match below stays exhaustive over exactly the
+ // states this match can actually produce, with no catch-all for a state this
+ // engine step can never reach.
+ let (new_state, continuation) = match stream_end {
+ StreamEnd::Complete => (State::Done, StreamContinuation::Done),
+ StreamEnd::AwaitEof if !self.input.is_empty() => {
+ return Err(self.fail(Error::corrupt_data("trailing data followed the compressed stream")));
+ }
+ StreamEnd::AwaitEof if end_of_input => (State::Done, StreamContinuation::Done),
+ StreamEnd::AwaitEof => (State::AwaitingEof, StreamContinuation::NeedInput),
+ StreamEnd::NextStream
+ if let Some(maximum) = stream_limit
+ && !self.input.is_empty() =>
+ {
+ return Err(self.fail(Error::stream_limit_exceeded(self.streams.saturating_add(1), maximum)));
+ }
+ StreamEnd::NextStream if stream_limit.is_some() && end_of_input => (State::Done, StreamContinuation::Done),
+ StreamEnd::NextStream if let Some(maximum) = stream_limit => {
+ (State::AtStreamLimit { maximum }, StreamContinuation::NeedInput)
+ }
+ StreamEnd::NextStream if !self.input.is_empty() && end_of_input => (State::Finishing, StreamContinuation::Loop),
+ StreamEnd::NextStream if !self.input.is_empty() => (State::Open, StreamContinuation::Loop),
+ StreamEnd::NextStream if end_of_input => (State::Done, StreamContinuation::Done),
+ StreamEnd::NextStream => (State::BetweenStreams, StreamContinuation::NeedInput),
+ };
+
+ self.state = new_state;
+
+ if let Some(data) = self.take_output() {
+ return Ok(Output::Data(data));
+ }
+
+ return Ok(match continuation {
+ StreamContinuation::Done => Output::Done,
+ StreamContinuation::Loop => continue,
+ StreamContinuation::NeedInput => Output::NeedInput,
+ });
+ }
+
+ if made_no_progress(consumed, produced) {
+ if self.state == State::Finishing {
+ // Reaching here means the codec wants more input and there is none: the stream
+ // ended earlier than its own framing promised. Whether an earlier member
+ // completed says nothing about that, and data the codec knows to be malformed
+ // has already failed through its own error path.
+ return Err(self.fail(Error::unexpected_end_of_stream()));
+ }
+
+ if self.input.is_empty() && self.state == State::Open {
+ return Ok(self.take_output().map_or(Output::NeedInput, Output::Data));
+ }
+
+ return Err(self.fail(Error::invalid_state("the compression engine could not make progress")));
+ }
+ }
+ }
+}
+
+/// `Pump::pull` returns before entering the step loop for every state that does not drive the
+/// engine (`Done`, `Failed`, and, whenever no input is pending, `BetweenStreams`, `AwaitingEof`,
+/// and `AtStreamLimit`); `push` never leaves input pending for the latter three otherwise. So the
+/// step loop can only ever run in `Open`, `Flushing`, or `Finishing` when driven through the
+/// public API. Exercised directly by a white-box test that violates the invariant through private
+/// field access, so no coverage exclusion is needed here.
+#[cold]
+fn non_driving_state_reached_the_step_loop() -> ! {
+ unreachable!("non-driving states return before stepping")
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroU32;
+
+ use bytesbuf::mem::GlobalPool;
+ use bytesbuf::mem::testing::FixedBlockMemory;
+
+ use super::*;
+ use crate::testing::{chunk, view};
+
+ /// An engine that copies input to output verbatim, so pump behaviour can be tested on its own.
+ #[derive(Debug, Default)]
+ struct Passthrough {
+ ended: bool,
+ }
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for Passthrough {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)> {
+ let count = input.len().min(output.len());
+ for (slot, byte) in output.iter_mut().zip(input.iter().take(count)) {
+ slot.write(*byte);
+ }
+
+ if operation == Operation::Finish && count == input.len() {
+ self.ended = true;
+ return Ok((Step::StreamEnd, count, count));
+ }
+
+ if operation == Operation::Flush && count == input.len() {
+ return Ok((Step::FlushComplete, count, count));
+ }
+
+ Ok((Step::Continue, count, count))
+ }
+ }
+
+ #[test]
+ fn reports_need_input_when_empty() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ let output = pump.pull(&mut Passthrough::default(), Destination::Stream).unwrap();
+
+ assert!(output.is_need_input());
+ }
+
+ #[test]
+ fn round_trips_data_through_the_codec() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"hello world")).unwrap();
+
+ let data = pump
+ .pull(&mut Passthrough::default(), Destination::Stream)
+ .unwrap()
+ .into_data()
+ .unwrap();
+
+ assert_eq!(data.to_vec(), b"hello world".to_vec());
+ assert_eq!(pump.total_in(), 11);
+ assert_eq!(pump.total_out(), 11);
+ }
+
+ #[test]
+ fn bounds_each_chunk_to_the_configured_size() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(4));
+ pump.push(view(b"abcdefghij")).unwrap();
+
+ let data = pump
+ .pull(&mut Passthrough::default(), Destination::Stream)
+ .unwrap()
+ .into_data()
+ .unwrap();
+
+ assert!(data.len() <= 8, "chunk was {} bytes, expected it near 4", data.len());
+ }
+
+ #[test]
+ fn a_single_step_never_gets_more_room_than_the_chunk_has_left() {
+ // Writes only 2 bytes on the first call, regardless of how much output room is offered,
+ // then greedily fills the entire offered slice on every later call. A budget computed as
+ // `chunk_size + output.len()` (instead of `chunk_size - output.len()`) would hand this
+ // codec more than the chunk has left on the second call, overrunning `chunk_size`.
+ #[derive(Debug, Default)]
+ struct PartialThenGreedy {
+ calls: u32,
+ }
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for PartialThenGreedy {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ self.calls += 1;
+ let produced = if self.calls == 1 { output.len().min(2) } else { output.len() };
+ for byte in &mut output[..produced] {
+ byte.write(0);
+ }
+ Ok((Step::Continue, 1.min(input.len()), produced))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(10));
+ pump.push(view(&[0_u8; 10])).unwrap();
+
+ let data = pump
+ .pull(&mut PartialThenGreedy::default(), Destination::Stream)
+ .unwrap()
+ .into_data()
+ .unwrap();
+
+ // `take_output` itself caps a returned chunk at `chunk_size`, so a step that was handed
+ // too much room would not show up in `data.len()`; it shows up as extra bytes recorded in
+ // `total_out` that this pull was never entitled to produce.
+ assert!(data.len() <= 10, "a returned chunk must never exceed the configured chunk size");
+ assert_eq!(
+ pump.total_out(),
+ 10,
+ "a step must never be handed more room than the chunk still has left"
+ );
+ }
+
+ #[test]
+ fn a_single_pull_never_takes_more_engine_steps_than_its_cap_allows() {
+ // Echoes 1 byte of input to 1 byte of output per call, so input and output grow at
+ // identical, easily predictable rates. With a chunk size larger than
+ // `MAX_STEPS_PER_PULL`, the step count is the only thing that can end this pull before
+ // the output buffer fills, so a mutant that stops incrementing `steps` would let this
+ // single pull keep stepping until the chunk fills instead of stopping at the step cap.
+ #[derive(Debug, Default)]
+ struct OneByteEcho;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for OneByteEcho {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ let consumed = input.len().min(1);
+ if consumed == 1 {
+ output[0].write(input[0]);
+ }
+ Ok((Step::Continue, consumed, consumed))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(100));
+ pump.push(view(&[0_u8; 150])).unwrap();
+
+ let output = pump.pull(&mut OneByteEcho, Destination::Stream).unwrap();
+ assert!(output.is_data());
+ assert_eq!(
+ pump.total_in(),
+ 64,
+ "a single pull must stop after MAX_STEPS_PER_PULL engine steps, not fill the whole chunk"
+ );
+ }
+
+ #[test]
+ fn bounds_input_work_and_reports_progress() {
+ #[derive(Debug)]
+ struct SilentConsumer;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for SilentConsumer {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::Continue, input.len(), 0))
+ }
+ }
+
+ // Hardcoded literals (rather than `MAX_INPUT_PER_PULL`) so this test actually pins the
+ // budget's numeric value instead of trivially matching whatever the constant is set to.
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(&vec![0_u8; 1_048_577])).unwrap();
+
+ assert!(pump.pull(&mut SilentConsumer, Destination::Stream).unwrap().is_progress());
+ assert_eq!(
+ pump.total_in(),
+ 1_048_576,
+ "a single pull should stop at exactly one mebibyte of input"
+ );
+ }
+
+ #[test]
+ fn a_single_step_never_gets_more_input_than_the_pull_has_left() {
+ // Consumes only 3 bytes on the first call, regardless of how much input is offered, then
+ // greedily consumes everything offered on every later call. The pushed input is backed
+ // by a single 2,000,000-byte memory block, so `first_slice()` always returns the entire
+ // remaining input as one contiguous slice: the per-pull input budget, not a memory-block
+ // boundary, is the only thing that can ever limit how much a later call is offered. A
+ // budget computed as `MAX_INPUT_PER_PULL + input_work` (instead of
+ // `MAX_INPUT_PER_PULL - input_work`) would let this codec consume more than one mebibyte
+ // in a single pull.
+ #[derive(Debug, Default)]
+ struct SmallFirstThenGreedy {
+ calls: u32,
+ }
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for SmallFirstThenGreedy {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ self.calls += 1;
+ let consumed = if self.calls == 1 { input.len().min(3) } else { input.len() };
+ Ok((Step::Continue, consumed, 0))
+ }
+ }
+
+ let large_block = NonZeroU32::new(2_000_000).unwrap();
+ let single_block_memory = FixedBlockMemory::new(large_block);
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(BytesView::copied_from_slice(&vec![0_u8; 1_048_578], &single_block_memory))
+ .unwrap();
+
+ assert!(
+ pump.pull(&mut SmallFirstThenGreedy::default(), Destination::Stream)
+ .unwrap()
+ .is_progress()
+ );
+ assert_eq!(
+ pump.total_in(),
+ 1_048_576,
+ "a single pull must not consume more than one mebibyte of input"
+ );
+ }
+
+ #[test]
+ fn flush_returns_to_the_open_state() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"flush me")).unwrap();
+ pump.flush().unwrap();
+
+ let mut codec = Passthrough::default();
+ assert_eq!(
+ pump.pull(&mut codec, Destination::Stream).unwrap().into_data().unwrap().to_vec(),
+ b"flush me".to_vec()
+ );
+ assert!(pump.pull(&mut codec, Destination::Stream).unwrap().is_need_input());
+ pump.push(view(b"more")).unwrap();
+ }
+
+ #[test]
+ fn flush_is_idempotent_while_still_pending() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.flush().unwrap();
+ pump.flush().unwrap();
+
+ assert!(pump.pull(&mut Passthrough::default(), Destination::Stream).unwrap().is_need_input());
+ }
+
+ #[test]
+ fn empty_flush_completes_without_output() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.flush().unwrap();
+
+ assert!(pump.pull(&mut Passthrough::default(), Destination::Stream).unwrap().is_need_input());
+ pump.flush().unwrap();
+ }
+
+ #[test]
+ fn flush_with_queued_end_input_continues_to_finishing() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.flush().unwrap();
+ pump.end_input();
+
+ assert!(pump.pull(&mut Passthrough::default(), Destination::Stream).unwrap().is_done());
+ }
+
+ #[test]
+ fn drains_leftover_output_across_multiple_pulls_after_done() {
+ /// Reports `StreamEnd` on a `Flush` step and writes as much output as the flush's minimum
+ /// output budget allows, so the produced output can outgrow a small chunk size.
+ #[derive(Debug)]
+ struct FlushSizedStreamEnd;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for FlushSizedStreamEnd {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)> {
+ assert_eq!(
+ operation,
+ Operation::Flush,
+ "the pump only offers a flush-sized budget while flushing"
+ );
+
+ for slot in output.iter_mut() {
+ slot.write(0);
+ }
+
+ Ok((Step::StreamEnd, input.len(), output.len()))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(4));
+ pump.push(view(b"x")).unwrap();
+ pump.flush().unwrap();
+
+ let mut codec = FlushSizedStreamEnd;
+ let first = pump.pull(&mut codec, Destination::Stream).unwrap().into_data().unwrap();
+ assert_eq!(first.len(), 4, "the first pull hands over exactly one chunk");
+
+ let second = pump.pull(&mut codec, Destination::Stream).unwrap().into_data().unwrap();
+ assert!(!second.is_empty(), "the remainder must still be delivered");
+
+ assert!(pump.pull(&mut codec, Destination::Stream).unwrap().is_done());
+ }
+
+ #[test]
+ fn rejects_input_and_final_flush_while_flushing() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.flush().unwrap();
+
+ assert!(pump.push(view(b"late")).unwrap_err().is_invalid_state());
+ pump.end_input();
+ assert!(pump.flush().unwrap_err().is_invalid_state());
+ }
+
+ #[test]
+ fn failed_codecs_reject_every_later_operation() {
+ #[derive(Debug)]
+ struct Fails;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for Fails {
+ fn step(&mut self, _input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Err(Error::corrupt_data("failed"))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ assert!(pump.pull(&mut Fails, Destination::Stream).unwrap_err().is_corrupt_data());
+ pump.end_input();
+
+ assert!(pump.push(view(b"late")).unwrap_err().is_invalid_state());
+ assert!(pump.flush().unwrap_err().is_invalid_state());
+ assert!(pump.pull(&mut Fails, Destination::Stream).unwrap_err().is_invalid_state());
+ }
+
+ #[test]
+ fn rejects_an_unrequested_flush_completion() {
+ #[derive(Debug)]
+ struct SpuriousFlush;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for SpuriousFlush {
+ fn step(&mut self, _input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::FlushComplete, 0, 0))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ assert!(pump.pull(&mut SpuriousFlush, Destination::Stream).unwrap_err().is_invalid_state());
+ }
+
+ #[test]
+ fn propagates_stream_end_hook_failures() {
+ #[derive(Debug)]
+ struct BadEnd;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for BadEnd {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::StreamEnd, input.len(), 0))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Err(Error::invalid_state("cannot reset"))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"input")).unwrap();
+ assert!(pump.pull(&mut BadEnd, Destination::Stream).unwrap_err().is_invalid_state());
+ }
+
+ #[test]
+ fn await_eof_completes_when_end_was_already_signaled() {
+ #[derive(Debug)]
+ struct StrictEnd;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for StrictEnd {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::StreamEnd, input.len(), 0))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::AwaitEof)
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"input")).unwrap();
+ pump.end_input();
+
+ assert!(pump.pull(&mut StrictEnd, Destination::Stream).unwrap().is_done());
+ }
+
+ #[test]
+ fn a_recyclable_codec_waits_between_streams_by_default() {
+ /// Reports `NextStream` and relies on the `Codec` trait's default `max_streams` (no
+ /// limit) and default `check_limits` (always ok), so the pump waits between streams.
+ #[derive(Debug)]
+ struct Recyclable;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for Recyclable {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::StreamEnd, input.len(), 0))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::NextStream)
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"member")).unwrap();
+
+ assert!(pump.pull(&mut Recyclable, Destination::Stream).unwrap().is_need_input());
+
+ // A real `Some(n)` limit would put the pump in `State::AtStreamLimit` right here, and the
+ // next `push` would fail with `stream_limit_exceeded`. Succeeding proves the default is
+ // genuinely unbounded (`None`), not merely a limit this test happens not to reach.
+ pump.push(view(b"second member")).unwrap();
+ assert!(pump.pull(&mut Recyclable, Destination::Stream).unwrap().is_need_input());
+ }
+
+ #[test]
+ fn a_stream_end_with_more_input_already_pending_loops_without_waiting() {
+ // Consumes at most 5 bytes per call and always reports `NextStream`. Pushing two
+ // 5-byte members back to back in a single `push`, with no `end_input`, means the second
+ // member's bytes are already pending the instant the first stream ends. The pump must
+ // keep looping internally (`State::Open`) to drive the already-available next stream
+ // rather than surfacing `NeedInput` for data that never needed a caller round trip. A
+ // mutant that disables this guard would stop after the first member instead.
+ #[derive(Debug)]
+ struct FixedFrame;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for FixedFrame {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::StreamEnd, input.len().min(5), 0))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::NextStream)
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"firstsecond")).unwrap();
+
+ assert!(pump.pull(&mut FixedFrame, Destination::Stream).unwrap().is_need_input());
+ assert_eq!(
+ pump.total_in(),
+ 11,
+ "a single pull must drive every already-pending stream instead of stopping early"
+ );
+ }
+
+ #[test]
+ fn reaching_the_stream_limit_exactly_at_end_of_input_completes() {
+ /// Reports `NextStream` but caps at a single stream, so reaching the limit right as the
+ /// caller signals end of input completes the pump instead of returning an error or waiting.
+ #[derive(Debug)]
+ struct StreamLimited;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for StreamLimited {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::StreamEnd, input.len(), 0))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::NextStream)
+ }
+
+ fn max_streams(&self) -> Option {
+ Some(1)
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"member")).unwrap();
+ pump.end_input();
+
+ assert!(pump.pull(&mut StreamLimited, Destination::Stream).unwrap().is_done());
+ }
+
+ #[test]
+ fn check_limits_is_reevaluated_immediately_after_a_stream_ends() {
+ /// Rejects every check once at least one stream has completed, so the limit check the
+ /// pump repeats right after incrementing the stream count is what catches the failure.
+ #[derive(Debug)]
+ struct RejectsAnotherStream;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for RejectsAnotherStream {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::StreamEnd, input.len(), 0))
+ }
+
+ fn check_limits(&self, _total_in: u64, _total_out: u64, streams: u64) -> Result<()> {
+ if streams > 0 {
+ return Err(Error::stream_limit_exceeded(streams, 0));
+ }
+
+ Ok(())
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"member")).unwrap();
+
+ let error = pump.pull(&mut RejectsAnotherStream, Destination::Stream).unwrap_err();
+ assert!(error.is_limit_exceeded());
+ }
+
+ #[test]
+ fn violating_the_input_invariant_panics_before_stepping() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ // Bypass the public API (which never leaves input pending in a non-driving state) to
+ // exercise the engine's internal defensive check directly.
+ pump.input = view(b"stray input");
+ pump.state = State::BetweenStreams;
+
+ let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ let _ = pump.pull(&mut Passthrough::default(), Destination::Stream);
+ }));
+
+ assert!(
+ panicked.is_err(),
+ "a non-driving state with pending input must never reach the step loop"
+ );
+ }
+
+ #[test]
+ fn no_progress_with_pending_input_is_terminal() {
+ #[derive(Debug)]
+ struct Stalled;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for Stalled {
+ fn step(&mut self, _input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::Continue, 0, 0))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"input")).unwrap();
+
+ assert!(pump.pull(&mut Stalled, Destination::Stream).unwrap_err().is_invalid_state());
+ }
+
+ #[test]
+ fn rejects_a_second_push_while_input_is_pending() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"first")).unwrap();
+
+ let error = pump.push(view(b"second")).unwrap_err();
+ assert!(error.is_invalid_state());
+ }
+
+ #[test]
+ fn rejects_push_after_end_input() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.end_input();
+
+ let error = pump.push(view(b"late")).unwrap_err();
+ assert!(error.is_invalid_state());
+ }
+
+ #[test]
+ fn end_input_is_idempotent() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.end_input();
+ pump.end_input();
+
+ let output = pump.pull(&mut Passthrough::default(), Destination::Stream).unwrap();
+ assert!(output.is_done());
+ }
+
+ #[test]
+ fn reports_done_after_the_stream_ends() {
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"tail")).unwrap();
+ pump.end_input();
+
+ let mut codec = Passthrough::default();
+ let data = pump.pull(&mut codec, Destination::Stream).unwrap().into_data().unwrap();
+ assert_eq!(data.to_vec(), b"tail".to_vec());
+
+ assert!(pump.pull(&mut codec, Destination::Stream).unwrap().is_done());
+ assert!(pump.pull(&mut codec, Destination::Stream).unwrap().is_done());
+ }
+
+ #[test]
+ fn reports_truncation_when_the_codec_never_ends() {
+ /// Consumes input but never reports `StreamEnd`, imitating a truncated container.
+ #[derive(Debug)]
+ struct NeverEnds;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for NeverEnds {
+ fn step(&mut self, _input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::Continue, 0, 0))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.end_input();
+
+ let error = pump.pull(&mut NeverEnds, Destination::Stream).unwrap_err();
+ assert!(error.is_unexpected_end_of_stream());
+ }
+
+ #[test]
+ fn propagates_limit_failures() {
+ /// Produces output without consuming input, and rejects it via `check_limits`.
+ #[derive(Debug)]
+ struct Expanding;
+
+ // SAFETY: this fixture reports only what it wrote into the slice, which is usually nothing at all.
+
+ unsafe impl Codec for Expanding {
+ fn step(&mut self, _input: &[u8], output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ for slot in output.iter_mut() {
+ slot.write(0);
+ }
+
+ Ok((Step::Continue, 0, output.len()))
+ }
+
+ fn check_limits(&self, _total_in: u64, total_out: u64, _streams: u64) -> Result<()> {
+ Err(Error::output_limit_exceeded(total_out, 0))
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"seed")).unwrap();
+
+ let error = pump.pull(&mut Expanding, Destination::Stream).unwrap_err();
+ assert!(error.is_limit_exceeded());
+ }
+
+ #[test]
+ fn rejects_output_counts_beyond_the_provided_slice() {
+ #[derive(Debug)]
+ struct Overreports;
+
+ // SAFETY: this fixture over-reports on purpose -- it writes one byte and claims
+ // `output.len() + 1` -- which is exactly what the test below drives. Soundness does not
+ // rest on this impl: `Pump::pull` rejects `produced > provided_output` before it reaches
+ // `advance`, and the assertion on `total_out` is what proves the uninitialized tail is
+ // never handed out.
+
+ unsafe impl Codec for Overreports {
+ fn step(&mut self, _input: &[u8], output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ output[0].write(0);
+ Ok((Step::Continue, 0, output.len() + 1))
+ }
+
+ fn remaining_output(&self, _total_out: u64) -> Option {
+ Some(0)
+ }
+ }
+
+ let mut pump = Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64));
+ pump.push(view(b"input")).unwrap();
+
+ let error = pump.pull(&mut Overreports, Destination::Stream).unwrap_err();
+ assert!(error.is_invalid_state(), "got {error}");
+ assert_eq!(pump.total_out(), 0, "uninitialized bytes must never be advanced");
+ }
+
+ /// Drains a pump to completion the way a caller that accumulates the whole result does.
+ fn drain(pump: &mut Pump, codec: &mut impl Codec, into: Destination) -> Result> {
+ let mut collected = Vec::new();
+ loop {
+ match pump.pull(codec, into)? {
+ Output::Data(data) => collected.extend_from_slice(&data.to_vec()),
+ Output::Progress => {}
+ Output::Done => return Ok(collected),
+ Output::NeedInput => panic!("input was ended, so the pump must not ask for more"),
+ }
+ }
+ }
+
+ fn ceiling(pump: Pump, bytes: u64) -> Pump {
+ pump.with_buffered_fallbacks(BufferedFallbacks {
+ output_len: Some(NonZeroU64::new(bytes).unwrap()),
+ streams: None,
+ })
+ }
+
+ fn stream_cap(pump: Pump, streams: u64) -> Pump {
+ pump.with_buffered_fallbacks(BufferedFallbacks {
+ output_len: None,
+ streams: Some(NonZeroU64::new(streams).unwrap()),
+ })
+ }
+
+ #[test]
+ fn the_buffering_ceiling_rejects_output_past_the_bound() {
+ let mut pump = ceiling(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), 4);
+ pump.push(view(b"too much")).unwrap();
+ pump.end_input();
+
+ let error = drain(&mut pump, &mut Passthrough::default(), Destination::Buffer).unwrap_err();
+ assert!(error.is_limit_exceeded(), "got {error}");
+ assert_eq!(pump.total_out(), 5, "the probe byte is the only overshoot the bound allows");
+ }
+
+ #[test]
+ fn the_buffering_ceiling_does_not_bound_a_streaming_destination() {
+ // The same pump and the same over-long input as above: only the destination differs, which
+ // is what makes this the proof that the ceiling is not a property of the engine's config.
+ let mut pump = ceiling(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), 4);
+ pump.push(view(b"too much")).unwrap();
+ pump.end_input();
+
+ let output = drain(&mut pump, &mut Passthrough::default(), Destination::Stream).unwrap();
+ assert_eq!(output, b"too much".to_vec());
+ }
+
+ #[test]
+ fn the_buffering_ceiling_admits_output_ending_exactly_at_the_bound() {
+ let mut pump = ceiling(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), 8);
+ pump.push(view(b"exactly!")).unwrap();
+ pump.end_input();
+
+ let output = drain(&mut pump, &mut Passthrough::default(), Destination::Buffer).unwrap();
+ assert_eq!(output, b"exactly!".to_vec());
+ }
+
+ /// Reports `NextStream` and declares no stream bound of its own, consuming its input and
+ /// producing nothing -- the shape a stream cap exists for, since an output ceiling never trips.
+ #[derive(Debug)]
+ struct Members;
+
+ // SAFETY: this fixture never writes to the slice and reports producing nothing.
+
+ unsafe impl Codec for Members {
+ fn step(&mut self, input: &[u8], _output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ Ok((Step::StreamEnd, input.len(), 0))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::NextStream)
+ }
+ }
+
+ /// Feeds `count` one-member pushes, returning the first error.
+ fn feed_members(pump: &mut Pump, count: usize, into: Destination) -> Result<()> {
+ for _ in 0..count {
+ pump.push(view(b"member"))?;
+ pump.pull(&mut Members, into)?;
+ }
+
+ Ok(())
+ }
+
+ #[test]
+ fn the_buffered_stream_cap_rejects_more_members_than_the_bound() {
+ let mut pump = stream_cap(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), 2);
+
+ let error = feed_members(&mut pump, 3, Destination::Buffer).unwrap_err();
+ assert!(error.is_limit_exceeded(), "got {error}");
+ }
+
+ #[test]
+ fn the_buffered_stream_cap_admits_exactly_the_bound() {
+ let mut pump = stream_cap(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), 2);
+
+ feed_members(&mut pump, 2, Destination::Buffer).unwrap();
+ }
+
+ #[test]
+ fn the_buffered_stream_cap_does_not_bound_a_streaming_destination() {
+ // The same pump and a higher member count than the rejecting case above: only the
+ // destination differs, which is what makes this the proof that the cap is not a property
+ // of the engine.
+ let mut pump = stream_cap(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), 2);
+
+ feed_members(&mut pump, 5, Destination::Stream).unwrap();
+ }
+
+ #[test]
+ fn the_tighter_of_the_engine_stream_bound_and_the_buffering_cap_decides() {
+ /// Declares its own stream bound, the way configured decompressor limits do.
+ #[derive(Debug)]
+ struct BoundedMembers(u64);
+
+ // SAFETY: delegates to `Members`, which never writes and reports producing nothing.
+
+ unsafe impl Codec for BoundedMembers {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)> {
+ Members.step(input, output, operation)
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Members.stream_ended()
+ }
+
+ fn max_streams(&self) -> Option {
+ Some(self.0)
+ }
+ }
+
+ // Whichever side is tighter must be the one that stops it at two members.
+ for (engine, buffered) in [(2_u64, 4_u64), (4, 2)] {
+ let mut pump = stream_cap(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), buffered);
+ let mut codec = BoundedMembers(engine);
+
+ pump.push(view(b"member")).unwrap();
+ pump.pull(&mut codec, Destination::Buffer).unwrap();
+ pump.push(view(b"member")).unwrap();
+ pump.pull(&mut codec, Destination::Buffer).unwrap();
+
+ let error = pump.push(view(b"member")).unwrap_err();
+ assert!(error.is_limit_exceeded(), "got {error} for engine {engine} and buffered {buffered}");
+ }
+ }
+
+ #[test]
+ fn the_tighter_of_the_engine_bound_and_the_buffering_ceiling_decides() {
+ /// A passthrough that bounds its own output, the way configured decompressor limits do.
+ #[derive(Debug)]
+ struct Bounded {
+ inner: Passthrough,
+ maximum: u64,
+ }
+
+ // SAFETY: `step` delegates to `Passthrough`, whose reported counts are already sound.
+
+ unsafe impl Codec for Bounded {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)> {
+ self.inner.step(input, output, operation)
+ }
+
+ fn check_limits(&self, _total_in: u64, total_out: u64, _streams: u64) -> Result<()> {
+ if total_out > self.maximum {
+ return Err(Error::output_limit_exceeded(total_out, self.maximum));
+ }
+
+ Ok(())
+ }
+
+ fn remaining_output(&self, total_out: u64) -> Option {
+ Some(self.maximum.saturating_sub(total_out))
+ }
+ }
+
+ // Whichever side is tighter, the pump must stop at four bytes plus the probe byte. Reading
+ // a looser bound would let the engine produce past the tighter one before anything noticed.
+ for (maximum, buffered) in [(4, 8), (8, 4)] {
+ let mut pump = ceiling(Pump::new(OpaqueMemory::new(GlobalPool::new()), chunk(64)), buffered);
+ pump.push(view(b"far too much")).unwrap();
+ pump.end_input();
+
+ let mut codec = Bounded {
+ inner: Passthrough::default(),
+ maximum,
+ };
+ let error = drain(&mut pump, &mut codec, Destination::Buffer).unwrap_err();
+
+ assert!(
+ error.is_limit_exceeded(),
+ "got {error} for maximum {maximum} and ceiling {buffered}"
+ );
+ assert_eq!(
+ pump.total_out(),
+ 5,
+ "maximum {maximum} and ceiling {buffered} must stop at the tighter bound"
+ );
+ }
+ }
+}
diff --git a/crates/compressors/src/error.rs b/crates/compressors/src/error.rs
new file mode 100644
index 000000000..42ef4d769
--- /dev/null
+++ b/crates/compressors/src/error.rs
@@ -0,0 +1,591 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::borrow::Cow;
+use std::error::Error as StdError;
+use std::fmt;
+
+use recoverable::{Recovery, RecoveryInfo};
+
+/// The failure mode of an [`Error`].
+///
+/// Deliberately private: keeping the discriminants out of the public API means new failure modes
+/// can be added without a breaking change. Consumers branch on the `is_*` accessors instead.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Kind {
+ CorruptData,
+ UnexpectedEndOfStream,
+ LimitExceeded,
+ InvalidState,
+ InvalidConfiguration,
+ Source,
+}
+
+impl Kind {
+ /// How a failure of this kind is recovered from, absent anything more specific.
+ fn default_recovery(self) -> RecoveryInfo {
+ match self {
+ // Truncation is worth another attempt only if the bytes are fetched again, and this
+ // crate neither owns nor re-drives the source: the operation that raised this is a pure
+ // decode of bytes already in hand, so re-running it is guaranteed to fail identically.
+ // Whoever owns the transport is the layer that can classify this, exactly as for a
+ // foreign failure below.
+ Self::UnexpectedEndOfStream | Self::Source => RecoveryInfo::unknown(),
+ // Malformed input stays malformed, a bound stays exceeded, and misuse or a rejected
+ // setting needs a code change rather than another attempt.
+ Self::CorruptData | Self::LimitExceeded | Self::InvalidState | Self::InvalidConfiguration => RecoveryInfo::never(),
+ }
+ }
+}
+
+/// Classifies a foreign error by looking for an [`io::Error`][std::io::Error] in its chain.
+///
+/// An engine or transport failure is usually an IO failure wearing a wrapper, and `recoverable`
+/// already classifies every [`ErrorKind`][std::io::ErrorKind]. Anything else is unknown rather than
+/// guessed at.
+fn detect_recovery(source: &(dyn StdError + 'static)) -> RecoveryInfo {
+ let mut current = Some(source);
+
+ while let Some(error) = current {
+ if let Some(io) = error.downcast_ref::() {
+ return RecoveryInfo::from(io.kind());
+ }
+
+ current = error.source();
+ }
+
+ RecoveryInfo::unknown()
+}
+
+/// An error produced while compressing or decompressing.
+///
+/// This is a single canonical error type rather than an enum, so that new failure modes do not
+/// break downstream `match` statements. Classify a failure with the `is_*` accessors, or with
+/// [`recovery`][Recovery::recovery] when what matters is whether retrying could help.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "gzip")]
+/// # {
+/// use compressors::{Resources, gzip};
+///
+/// let error = gzip::decompress(b"definitely not gzip", &Resources::default()).unwrap_err();
+/// assert!(error.is_corrupt_data());
+/// # }
+/// ```
+#[derive(Debug)]
+pub struct Error {
+ kind: Kind,
+ message: Cow<'static, str>,
+ source: Option>,
+ recovery: RecoveryInfo,
+}
+
+#[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only the codecs construct these, and no format is enabled")
+)]
+impl Error {
+ pub(crate) fn new(kind: Kind, message: impl Into>) -> Self {
+ Self {
+ kind,
+ message: message.into(),
+ source: None,
+ recovery: kind.default_recovery(),
+ }
+ }
+
+ #[cfg_attr(
+ all(not(test), not(any(feature = "deflate", feature = "gzip", feature = "zlib"))),
+ expect(dead_code, reason = "only the flate codecs attach a source")
+ )]
+ pub(crate) fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self {
+ self.source = Some(Box::new(source));
+ self
+ }
+
+ pub(crate) fn corrupt_data(message: impl Into>) -> Self {
+ Self::new(Kind::CorruptData, message)
+ }
+
+ pub(crate) fn unexpected_end_of_stream() -> Self {
+ Self::new(Kind::UnexpectedEndOfStream, "compressed stream ended before the final block")
+ }
+
+ pub(crate) fn output_limit_exceeded(actual: u64, maximum: u64) -> Self {
+ Self::new(
+ Kind::LimitExceeded,
+ format!("decompressed output reached {actual} bytes, exceeding the limit of {maximum}"),
+ )
+ }
+
+ pub(crate) fn ratio_limit_exceeded(input: u64, output: u64, maximum: u32) -> Self {
+ Self::new(
+ Kind::LimitExceeded,
+ format!(
+ "decompressed output reached {output} bytes from {input} compressed bytes, \
+ exceeding the expansion limit of {maximum}x"
+ ),
+ )
+ }
+
+ pub(crate) fn stream_limit_exceeded(actual: u64, maximum: u64) -> Self {
+ Self::new(
+ Kind::LimitExceeded,
+ format!("decoded stream count reached {actual}, exceeding the limit of {maximum}"),
+ )
+ }
+
+ pub(crate) fn invalid_state(message: impl Into>) -> Self {
+ Self::new(Kind::InvalidState, message)
+ }
+
+ pub(crate) fn invalid_configuration(message: impl Into>) -> Self {
+ Self::new(Kind::InvalidConfiguration, message)
+ }
+}
+
+impl Error {
+ /// Wraps a foreign error, classifying it by inspecting it.
+ ///
+ /// Use this to carry a failure from something this crate drives -- a transport, a reader, an
+ /// engine binding -- through an API that returns this crate's [`Error`]. The wrapped error stays
+ /// reachable through [`source`][std::error::Error::source], and
+ /// [`is_source`][Self::is_source] reports the resulting error.
+ ///
+ /// The recovery information is detected rather than assumed: if an
+ /// [`io::Error`][std::io::Error] appears anywhere in `source`'s chain, its
+ /// [`ErrorKind`][std::io::ErrorKind] decides the classification. Anything else is
+ /// [`RecoveryInfo::unknown`]. Reach for [`other_with_recovery`][Self::other_with_recovery] when
+ /// you already know better than the heuristic.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use std::io;
+ ///
+ /// use compressors::Error;
+ /// use recoverable::{Recovery, RecoveryKind};
+ ///
+ /// let error = Error::other(
+ /// "reading the body failed",
+ /// io::Error::from(io::ErrorKind::TimedOut),
+ /// );
+ ///
+ /// assert!(error.is_source());
+ /// assert_eq!(error.recovery().kind(), RecoveryKind::Retry);
+ /// ```
+ #[must_use]
+ pub fn other(message: impl Into>, source: impl Into>) -> Self {
+ let source = source.into();
+ let recovery = detect_recovery(&*source);
+
+ Self::other_with_recovery(message, source, recovery)
+ }
+
+ /// Wraps a foreign error with recovery information you supply.
+ ///
+ /// Identical to [`other`][Self::other] except that `recovery` is attached as given, for when the
+ /// caller knows how the failure should be handled and the heuristic cannot.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use compressors::Error;
+ /// use recoverable::{Recovery, RecoveryInfo, RecoveryKind};
+ ///
+ /// #[derive(Debug)]
+ /// struct Throttled;
+ /// # impl std::fmt::Display for Throttled {
+ /// # fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ /// # f.write_str("throttled")
+ /// # }
+ /// # }
+ /// # impl std::error::Error for Throttled {}
+ ///
+ /// let error = Error::other_with_recovery(
+ /// "the backend throttled us",
+ /// Throttled,
+ /// RecoveryInfo::unavailable(),
+ /// );
+ ///
+ /// assert_eq!(error.recovery().kind(), RecoveryKind::Unavailable);
+ /// ```
+ #[must_use]
+ pub fn other_with_recovery(
+ message: impl Into>,
+ source: impl Into>,
+ recovery: RecoveryInfo,
+ ) -> Self {
+ let mut error = Self::new(Kind::Source, message);
+ error.source = Some(source.into());
+ error.recovery = recovery;
+ error
+ }
+
+ /// The compressed data is malformed, or its checksum does not match the decompressed bytes.
+ #[must_use]
+ pub fn is_corrupt_data(&self) -> bool {
+ self.kind == Kind::CorruptData
+ }
+
+ /// The input ended in the middle of a compressed stream.
+ ///
+ /// Input ended before the decoder reported a complete stream.
+ ///
+ /// That is all this establishes. It does not say why the input ended -- a producer stopping
+ /// early and a transport truncating the body are indistinguishable from here -- and it does not
+ /// certify the bytes already handed back: a framed decoder can emit output before the framing
+ /// or checksum that would have rejected the stream. Treat that output as provisional, exactly
+ /// as when the operation has not reported that it is done.
+ ///
+ /// It is distinct from [`is_corrupt_data`][Self::is_corrupt_data] in what it licenses: corrupt
+ /// data stays corrupt, whereas fetching the body again may produce a complete one. That is
+ /// advice for whoever owns the byte source, not for a retry of this call -- decompressing the
+ /// same buffer again is deterministic and fails the same way, which is why
+ /// [`recovery`][recoverable::Recovery::recovery] reports this as
+ /// [`Unknown`][recoverable::RecoveryKind::Unknown] rather than asserting a retry to middleware
+ /// that cannot re-drive the transport.
+ #[must_use]
+ pub fn is_unexpected_end_of_stream(&self) -> bool {
+ self.kind == Kind::UnexpectedEndOfStream
+ }
+
+ /// Decompression would have exceeded the configured [`DecompressorLimits`].
+ ///
+ /// [`DecompressorLimits`]: crate::DecompressorLimits
+ #[must_use]
+ pub fn is_limit_exceeded(&self) -> bool {
+ self.kind == Kind::LimitExceeded
+ }
+
+ /// The engine was driven in an order it does not support, such as pushing input after end of
+ /// input, or the underlying compression engine reported an internal failure.
+ #[must_use]
+ pub fn is_invalid_state(&self) -> bool {
+ self.kind == Kind::InvalidState
+ }
+
+ /// A configuration value was outside the range the format accepts.
+ ///
+ /// Produced by the `TryFrom` conversions on types such as `Level`, where the
+ /// value typically came from a configuration file or a command line.
+ #[must_use]
+ pub fn is_invalid_configuration(&self) -> bool {
+ self.kind == Kind::InvalidConfiguration
+ }
+
+ /// A failure that did not come from this crate.
+ ///
+ /// Carries whatever [`other`][Self::other] or
+ /// [`other_with_recovery`][Self::other_with_recovery] was given, which the adapters behind the
+ /// `futures-stream` feature use for a source stream's own error. Because those constructors
+ /// accept any foreign failure, this kind says only that the failure was foreign -- it does not
+ /// establish that a source stream was involved, nor that the compressed bytes seen so far were
+ /// valid. The original failure is available from [`source`][std::error::Error::source], and is
+ /// the only thing that can say more.
+ #[must_use]
+ pub fn is_source(&self) -> bool {
+ self.kind == Kind::Source
+ }
+}
+
+impl Recovery for Error {
+ /// Whether retrying could help, and how soon.
+ ///
+ /// Kinds this crate raises itself are classified by what they mean. Corrupt data, an exceeded
+ /// bound, misuse and a rejected setting are never worth another attempt. A truncated stream
+ /// reports [`Unknown`][recoverable::RecoveryKind::Unknown]: re-decoding the same bytes is
+ /// deterministic, so whether asking again helps depends on a transport this crate does not own.
+ /// A wrapped foreign error reports whatever [`other`][Self::other] detected or
+ /// [`other_with_recovery`][Self::other_with_recovery] was given.
+ fn recovery(&self) -> RecoveryInfo {
+ self.recovery.clone()
+ }
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.message)
+ }
+}
+
+impl StdError for Error {
+ fn source(&self) -> Option<&(dyn StdError + 'static)> {
+ self.source.as_ref().map(|source| &**source as &(dyn StdError + 'static))
+ }
+}
+
+/// A [`Result`][std::result::Result] whose error is this crate's [`Error`].
+pub type Result = std::result::Result;
+
+/// A compressor or decompressor could not be built from the settings it was given.
+///
+/// Most formats accept any combination the builders can express, so their `build` methods do not
+/// return this at all. The exception is zstd, whose native library validates the parameters this
+/// crate hands it and can therefore reject them.
+///
+/// This is a separate type from [`Error`] so that a failure to build is not something callers have
+/// to consider while streaming: once an engine exists, this error can no longer occur. It converts
+/// into [`Error`] for code that handles both in one place.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "zstd")]
+/// # {
+/// use compressors::{Resources, zstd};
+///
+/// let compressor = zstd::Compressor::builder().build(&Resources::default())?;
+/// # let _ = compressor;
+/// # }
+/// # Ok::<(), compressors::BuildError>(())
+/// ```
+#[derive(Debug)]
+pub struct BuildError {
+ message: Cow<'static, str>,
+}
+
+#[cfg_attr(
+ all(not(test), not(feature = "zstd")),
+ expect(dead_code, reason = "only the zstd engine validates a configuration")
+)]
+impl BuildError {
+ pub(crate) fn new(message: impl Into>) -> Self {
+ Self { message: message.into() }
+ }
+}
+
+impl fmt::Display for BuildError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.message)
+ }
+}
+
+impl StdError for BuildError {}
+
+impl Recovery for BuildError {
+ /// A rejected setting needs a code change rather than another attempt.
+ fn recovery(&self) -> RecoveryInfo {
+ RecoveryInfo::never()
+ }
+}
+
+impl From for Error {
+ fn from(error: BuildError) -> Self {
+ Self::new(Kind::InvalidConfiguration, error.message)
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use recoverable::RecoveryKind;
+
+ use super::*;
+
+ #[test]
+ fn a_build_failure_renders_and_converts_to_an_invalid_configuration() {
+ let error = BuildError::new("the engine rejected the window size");
+
+ assert_eq!(error.to_string(), "the engine rejected the window size");
+ assert!(error.source().is_none(), "a build failure has no cause to report");
+ assert!(format!("{error:?}").contains("BuildError"), "the kind should be visible");
+
+ let converted = Error::from(error);
+ assert!(converted.is_invalid_configuration(), "got {converted}");
+ assert_eq!(converted.to_string(), "the engine rejected the window size");
+ }
+
+ #[test]
+ fn accessors_report_exactly_one_kind() {
+ let cases = [
+ (Error::corrupt_data("bad"), [true, false, false, false, false, false]),
+ (Error::unexpected_end_of_stream(), [false, true, false, false, false, false]),
+ (Error::output_limit_exceeded(2, 1), [false, false, true, false, false, false]),
+ (Error::invalid_state("wrong order"), [false, false, false, true, false, false]),
+ (
+ Error::invalid_configuration("out of range"),
+ [false, false, false, false, true, false],
+ ),
+ ];
+
+ for (error, expected) in cases {
+ let actual = [
+ error.is_corrupt_data(),
+ error.is_unexpected_end_of_stream(),
+ error.is_limit_exceeded(),
+ error.is_invalid_state(),
+ error.is_invalid_configuration(),
+ error.is_source(),
+ ];
+ assert_eq!(actual, expected, "wrong classification for {error}");
+ }
+ }
+
+ #[test]
+ fn is_source_reports_only_the_source_kind() {
+ let error = Error::other("the underlying stream failed", std::io::Error::other("stream failed"));
+
+ assert!(error.is_source(), "got {error}");
+ assert!(!error.is_corrupt_data(), "got {error}");
+ assert!(!error.is_invalid_configuration(), "got {error}");
+ }
+
+ #[test]
+ fn display_messages_start_lowercase() {
+ let errors = [
+ Error::corrupt_data("bad gzip header"),
+ Error::unexpected_end_of_stream(),
+ Error::output_limit_exceeded(2, 1),
+ Error::invalid_state("input already ended"),
+ ];
+
+ for error in errors {
+ let rendered = error.to_string();
+ let first = rendered.chars().next().unwrap();
+ assert!(!first.is_uppercase(), "message should not start with a capital: {rendered}");
+ assert!(!rendered.contains("exception"), "say 'error', not 'exception': {rendered}");
+ }
+ }
+
+ #[test]
+ fn source_is_exposed_when_present() {
+ let inner = std::io::Error::other("inner failure");
+ let error = Error::corrupt_data("outer").with_source(inner);
+
+ let source = error.source().unwrap();
+ assert_eq!(source.to_string(), "inner failure");
+ }
+
+ #[test]
+ fn source_is_absent_by_default() {
+ assert!(Error::corrupt_data("no cause").source().is_none());
+ }
+
+ #[test]
+ fn debug_is_available_for_diagnostics() {
+ let rendered = format!("{:?}", Error::output_limit_exceeded(2, 1));
+ assert!(rendered.contains("LimitExceeded"), "kind should be visible: {rendered}");
+ }
+
+ #[test]
+ fn each_kind_classifies_its_own_recoverability() {
+ let cases = [
+ (Error::unexpected_end_of_stream(), RecoveryKind::Unknown),
+ (Error::corrupt_data("bad"), RecoveryKind::Never),
+ (Error::output_limit_exceeded(2, 1), RecoveryKind::Never),
+ (Error::invalid_state("wrong order"), RecoveryKind::Never),
+ (Error::invalid_configuration("out of range"), RecoveryKind::Never),
+ ];
+
+ for (error, expected) in cases {
+ assert_eq!(error.recovery().kind(), expected, "wrong recovery for {error}");
+ }
+ }
+
+ #[test]
+ fn a_build_failure_converts_to_an_unrecoverable_error() {
+ let build_error = BuildError::new("the engine rejected the window size");
+
+ assert_eq!(
+ build_error.recovery().kind(),
+ RecoveryKind::Never,
+ "a rejected setting needs a code change"
+ );
+
+ let error = Error::from(build_error);
+
+ assert_eq!(error.recovery().kind(), RecoveryKind::Never);
+ }
+
+ #[test]
+ fn other_classifies_a_wrapped_io_error_by_its_kind() {
+ let cases = [
+ (std::io::ErrorKind::TimedOut, RecoveryKind::Retry),
+ (std::io::ErrorKind::NetworkDown, RecoveryKind::Unavailable),
+ (std::io::ErrorKind::NotFound, RecoveryKind::Never),
+ ];
+
+ for (kind, expected) in cases {
+ let error = Error::other("the transport failed", std::io::Error::from(kind));
+
+ assert!(error.is_source(), "got {error}");
+ assert_eq!(error.to_string(), "the transport failed");
+ assert_eq!(error.recovery().kind(), expected, "wrong recovery for {kind:?}");
+ }
+ }
+
+ #[test]
+ fn other_finds_an_io_error_nested_inside_a_wrapper() {
+ // A transport rarely hands back a bare io::Error; the heuristic has to look through
+ // whatever wrapped it.
+ #[derive(Debug)]
+ struct Wrapper(std::io::Error);
+
+ impl fmt::Display for Wrapper {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("wrapped")
+ }
+ }
+
+ impl StdError for Wrapper {
+ fn source(&self) -> Option<&(dyn StdError + 'static)> {
+ Some(&self.0)
+ }
+ }
+
+ let error = Error::other(
+ "the transport failed",
+ Wrapper(std::io::Error::from(std::io::ErrorKind::WouldBlock)),
+ );
+
+ assert_eq!(error.recovery().kind(), RecoveryKind::Retry);
+ assert_eq!(
+ error.source().unwrap().to_string(),
+ "wrapped",
+ "the wrapper itself stays the reported cause, not the io::Error the heuristic reached through it"
+ );
+ }
+
+ #[test]
+ fn other_reports_unknown_when_nothing_in_the_chain_is_an_io_error() {
+ let error = Error::other("something else failed", "a plain message");
+
+ assert_eq!(error.recovery().kind(), RecoveryKind::Unknown);
+ assert_eq!(error.source().unwrap().to_string(), "a plain message");
+ }
+
+ #[test]
+ fn other_with_recovery_attaches_what_it_was_given() {
+ let supplied = RecoveryInfo::unavailable();
+ let error = Error::other_with_recovery("the backend is degraded", "a plain message", supplied.clone());
+
+ assert!(error.is_source(), "got {error}");
+ assert_eq!(error.recovery(), supplied, "the supplied information should win over the heuristic");
+ }
+
+ #[test]
+ fn other_with_recovery_overrides_what_the_heuristic_would_have_detected() {
+ // The caller knows the timeout is terminal for them even though the heuristic says retry.
+ let error = Error::other_with_recovery(
+ "the deadline is gone",
+ std::io::Error::from(std::io::ErrorKind::TimedOut),
+ RecoveryInfo::never(),
+ );
+
+ assert_eq!(error.recovery().kind(), RecoveryKind::Never);
+ }
+
+ #[test]
+ fn a_wrapped_cause_carries_its_own_recovery() {
+ let error = Error::other(
+ "the underlying stream failed",
+ std::io::Error::from(std::io::ErrorKind::ConnectionReset),
+ );
+
+ assert!(error.is_source(), "got {error}");
+ assert_eq!(error.recovery().kind(), RecoveryKind::Retry);
+ }
+}
diff --git a/crates/compressors/src/flate/codec.rs b/crates/compressors/src/flate/codec.rs
new file mode 100644
index 000000000..e79fbe0d1
--- /dev/null
+++ b/crates/compressors/src/flate/codec.rs
@@ -0,0 +1,359 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Binds the deflate family to the engine, driven segment by segment.
+
+use std::mem::MaybeUninit;
+
+use flate2::{Compress, Decompress, FlushCompress, FlushDecompress, Status};
+
+use crate::engine::{Codec, Operation, Step, StreamEnd};
+use crate::error::{Error, Result};
+use crate::flate::Wrapper;
+use crate::level::Level;
+use crate::limits::FormatLimits;
+use crate::pool::{EngineKey, Pool};
+use crate::trailing::TrailingData;
+
+/// Narrows one engine-counter delta to the `usize` the pump works in.
+///
+/// Both slices handed to the engine are bounded by `usize`, so a delta that does not fit means the
+/// engine's counters disagree with the buffers it was given. Saturating would be the wrong answer:
+/// `produced` is the count [`Pump::pull`][crate::engine::Pump::pull] declares initialized, so
+/// over-reporting it is a soundness question rather than a cosmetic one, and `consumed` decides
+/// whether the pump believes it made progress.
+///
+/// On a 64-bit target the conversion is a no-op and this cannot fail, which is why it is excluded
+/// from coverage; it earns its place on narrower targets.
+// Excluded from mutation testing for a related reason: this runs on every engine step, so a mutant
+// that fixes the result to a small constant makes every step report a byte-sized delta. The pump
+// then needs one step per byte and the harness times out before any assertion can fail.
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg_attr(test, mutants::skip)]
+fn step_count(delta: u64) -> Result {
+ usize::try_from(delta).map_err(|error| {
+ Error::invalid_state(format!(
+ "the flate engine reported a {delta}-byte step, which does not fit in a pointer-sized count"
+ ))
+ .with_source(error)
+ })
+}
+
+/// Drives `flate2`'s encoder for one compressed stream.
+///
+/// Owns the engine for the whole operation and hands it back to the pool on drop, so `compress` is
+/// `Some` until then. `key` records the container and level the engine was built with, because
+/// `reset` preserves both and a recycled engine is therefore only interchangeable with one built
+/// the same way. Nothing here spans more than one stream: the deflate family does not concatenate.
+#[derive(Debug)]
+pub(crate) struct FlateCompress {
+ /// `Some` until the engine is handed back in `drop`.
+ compress: Option,
+ recycle: Pool,
+ key: EngineKey,
+}
+
+impl FlateCompress {
+ pub(crate) fn new(wrapper: Wrapper, level: Level, pool: Pool) -> Self {
+ let key = EngineKey {
+ wrapper,
+ level: level.get(),
+ };
+ let compress = pool.take_compressor(key).unwrap_or_else(|| wrapper.compressor(level));
+
+ Self {
+ compress: Some(compress),
+ recycle: pool,
+ key,
+ }
+ }
+
+ fn engine(&mut self) -> &mut Compress {
+ self.compress.as_mut().expect("the engine is only taken in drop")
+ }
+}
+
+impl Drop for FlateCompress {
+ fn drop(&mut self) {
+ self.recycle.return_compressor(self.key, &mut self.compress);
+ }
+}
+
+// SAFETY: `step` writes through `flate2`'s `*_uninit` entry points, which take the uninitialized
+// slice and report what they filled, so the count is the engine's own.
+unsafe impl Codec for FlateCompress {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)> {
+ let flush = match operation {
+ Operation::Process => FlushCompress::None,
+ Operation::Flush => FlushCompress::Sync,
+ Operation::Finish => FlushCompress::Finish,
+ };
+
+ let compress = self.engine();
+ let before_in = compress.total_in();
+ let before_out = compress.total_out();
+
+ let status = compress
+ .compress_uninit(input, output, flush)
+ .map_err(|error| Error::invalid_state("the compression engine reported a failure").with_source(error))?;
+
+ let consumed = step_count(compress.total_in() - before_in)?;
+ let produced = step_count(compress.total_out() - before_out)?;
+
+ let step = match operation {
+ _ if status == Status::StreamEnd => Step::StreamEnd,
+ Operation::Flush if consumed == input.len() && produced < output.len() => Step::FlushComplete,
+ _ => Step::Continue,
+ };
+
+ Ok((step, consumed, produced))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ Ok(StreamEnd::Complete)
+ }
+}
+
+/// Drives `flate2`'s decoder, across as many concatenated streams as the configuration allows.
+///
+/// Splits into three lifetimes. `decompress` and `recycle` last the whole operation, the engine
+/// going back to the pool on drop. `limits`, `multi_stream` and `trailing_data` are the fixed
+/// policy the builder chose. `needs_reset` spans one stream: it is set when a stream ends and
+/// consumed before the next one starts, so the reset happens lazily and only if another stream
+/// actually arrives.
+///
+/// `recycle` is absent for gzip, whose decompressor cannot be pooled -- `flate2`'s reset takes a
+/// boolean that cannot express gzip framing, so a recycled engine would decode as raw deflate.
+#[derive(Debug)]
+pub(crate) struct FlateDecompress {
+ /// `Some` until the engine is handed back in `drop`.
+ decompress: Option,
+ wrapper: Wrapper,
+ limits: FormatLimits,
+ multi_stream: bool,
+ trailing_data: TrailingData,
+ needs_reset: bool,
+ /// Only present where some container's decompressor can actually be recycled.
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ recycle: Pool,
+}
+
+impl FlateDecompress {
+ pub(crate) fn new(wrapper: Wrapper, limits: FormatLimits, multi_stream: bool, trailing_data: TrailingData, pool: Pool) -> Self {
+ // Only containers whose reset restores their framing can be recycled.
+ let pool = if wrapper.reset_restores_framing() {
+ pool
+ } else {
+ Pool::disabled().clone()
+ };
+ let decompress = Self::checkout(wrapper, &pool);
+
+ #[cfg(not(any(test, feature = "deflate", feature = "zlib")))]
+ drop(pool);
+
+ Self {
+ decompress: Some(decompress),
+ wrapper,
+ limits,
+ multi_stream,
+ trailing_data,
+ needs_reset: false,
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ recycle: pool,
+ }
+ }
+
+ fn checkout(wrapper: Wrapper, pool: &Pool) -> Decompress {
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ if let Some(engine) = pool.take_decompressor(wrapper) {
+ return engine;
+ }
+
+ let _ = pool;
+ wrapper.decompressor()
+ }
+
+ fn engine(&mut self) -> &mut Decompress {
+ self.decompress.as_mut().expect("the engine is only taken in drop")
+ }
+}
+
+#[cfg(any(test, feature = "deflate", feature = "zlib"))]
+impl Drop for FlateDecompress {
+ fn drop(&mut self) {
+ self.recycle.return_decompressor(self.wrapper, &mut self.decompress);
+ }
+}
+
+// SAFETY: `step` writes through `flate2`'s `*_uninit` entry points, which take the uninitialized
+// slice and report what they filled, so the count is the engine's own.
+unsafe impl Codec for FlateDecompress {
+ fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> {
+ if self.needs_reset {
+ match self.wrapper {
+ #[cfg(any(test, feature = "deflate"))]
+ Wrapper::Raw => self.engine().reset(false),
+ #[cfg(any(test, feature = "zlib"))]
+ Wrapper::Zlib => self.engine().reset(true),
+ #[cfg(any(test, feature = "gzip"))]
+ Wrapper::Gzip => {
+ // `Decompress::reset` cannot express gzip framing.
+ self.decompress = Some(self.wrapper.decompressor());
+ }
+ }
+ self.needs_reset = false;
+ }
+
+ let wrapper = self.wrapper;
+ let decompress = self.engine();
+ let before_in = decompress.total_in();
+ let before_out = decompress.total_out();
+
+ let status = decompress
+ .decompress_uninit(input, output, FlushDecompress::None)
+ .map_err(|error| {
+ Error::corrupt_data(format!("the compressed data is not a valid {} stream", wrapper.name())).with_source(error)
+ })?;
+
+ let consumed = step_count(decompress.total_in() - before_in)?;
+ let produced = step_count(decompress.total_out() - before_out)?;
+
+ let step = if status == Status::StreamEnd {
+ Step::StreamEnd
+ } else {
+ Step::Continue
+ };
+
+ Ok((step, consumed, produced))
+ }
+
+ fn stream_ended(&mut self) -> Result {
+ if !self.multi_stream {
+ return Ok(match self.trailing_data {
+ TrailingData::Ignore => StreamEnd::Complete,
+ TrailingData::Reject => StreamEnd::AwaitEof,
+ });
+ }
+
+ self.needs_reset = true;
+ Ok(StreamEnd::NextStream)
+ }
+
+ fn check_limits(&self, total_in: u64, total_out: u64, streams: u64) -> Result<()> {
+ self.limits.check(total_in, total_out, streams)
+ }
+
+ // Delegates to `FormatLimits::remaining_output`, and is excluded for the same reason: a mutant
+ // that answers a small constant only shrinks the slice offered per step, so the pump crawls and
+ // the harness times out rather than reaching a verdict.
+ #[cfg_attr(test, mutants::skip)]
+ fn remaining_output(&self, total_out: u64) -> Option {
+ self.limits.remaining_output(total_out)
+ }
+
+ fn max_streams(&self) -> Option {
+ self.limits.max_streams()
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn wrappers_produce_distinguishable_headers() {
+ // Guards the framing: a zlib stream must not be mistaken for a gzip one, and raw deflate
+ // must carry no header at all.
+ let mut headers = Vec::new();
+
+ for wrapper in [Wrapper::Raw, Wrapper::Zlib, Wrapper::Gzip] {
+ let mut codec = FlateCompress::new(wrapper, Level::DEFAULT, Pool::disabled().clone());
+ let mut out = [MaybeUninit::uninit(); 64];
+ let (_, _, produced) = codec.step(b"header check", &mut out, Operation::Finish).unwrap();
+
+ // SAFETY: the engine reported initializing `produced` bytes.
+ let bytes = unsafe { std::slice::from_raw_parts(out.as_ptr().cast::(), produced) };
+ headers.push(bytes[..2].to_vec());
+ }
+
+ assert_eq!(headers[2], vec![0x1f, 0x8b], "gzip must carry its magic bytes");
+ assert_ne!(headers[0], headers[1], "raw deflate and zlib must differ");
+ assert_ne!(headers[1], headers[2], "zlib and gzip must differ");
+ }
+
+ #[test]
+ fn dropping_a_pooled_compressor_returns_its_engine() {
+ let pool = Pool::new();
+ let key = EngineKey {
+ wrapper: Wrapper::Gzip,
+ level: Level::DEFAULT.get(),
+ };
+
+ drop(FlateCompress::new(Wrapper::Gzip, Level::DEFAULT, pool.clone()));
+
+ assert!(
+ pool.take_compressor(key).is_some(),
+ "the engine should have been returned to the pool"
+ );
+ }
+
+ #[test]
+ fn dropping_a_pooled_decompressor_returns_its_engine() {
+ let pool = Pool::new();
+
+ drop(FlateDecompress::new(
+ Wrapper::Zlib,
+ FormatLimits::new(None, None, None),
+ false,
+ TrailingData::Reject,
+ pool.clone(),
+ ));
+
+ assert!(
+ pool.take_decompressor(Wrapper::Zlib).is_some(),
+ "the engine should have been returned to the pool"
+ );
+ }
+
+ #[test]
+ fn a_flush_reports_continue_until_a_small_output_buffer_catches_up() {
+ let mut codec = FlateCompress::new(Wrapper::Gzip, Level::DEFAULT, Pool::disabled().clone());
+ let mut scratch = [MaybeUninit::uninit(); 4096];
+
+ let payload = b"flush boundary check payload";
+ let (_, consumed, _) = codec.step(payload, &mut scratch, Operation::Process).unwrap();
+ assert_eq!(consumed, payload.len(), "the whole input should have been consumed");
+
+ // A one byte buffer cannot hold the whole flush in a single call, so the guard must
+ // report `Continue`, not `FlushComplete`, while output remains buffered.
+ let mut tiny = [MaybeUninit::uninit(); 1];
+ let (step, consumed, produced) = codec.step(&[], &mut tiny, Operation::Flush).unwrap();
+ assert_eq!(consumed, 0, "no new input was supplied");
+ assert_eq!(produced, 1, "the tiny buffer should be filled completely");
+ assert_eq!(step, Step::Continue, "the flush cannot be complete while output remains buffered");
+
+ // A generous buffer drains the rest of the same flush and reports completion. This must
+ // be a single call, not a retry loop: calling `Flush` again after it already completed
+ // would ask flate2 to insert another sync marker, so the test only issues exactly the
+ // calls this one flush needs.
+ let mut generous = [MaybeUninit::uninit(); 256];
+ let (step, consumed, _) = codec.step(&[], &mut generous, Operation::Flush).unwrap();
+ assert_eq!(consumed, 0, "no new input was supplied");
+ assert_eq!(step, Step::FlushComplete, "a generous buffer must drain the remainder of the flush");
+ }
+
+ #[test]
+ fn remaining_output_delegates_to_the_configured_limits() {
+ let codec = FlateDecompress::new(
+ Wrapper::Zlib,
+ FormatLimits::new(None, Some(100), None),
+ false,
+ TrailingData::Reject,
+ Pool::disabled().clone(),
+ );
+
+ assert_eq!(Codec::remaining_output(&codec, 40), Some(60));
+ assert_eq!(Codec::remaining_output(&codec, 100), Some(0));
+ }
+}
diff --git a/crates/compressors/src/flate/mod.rs b/crates/compressors/src/flate/mod.rs
new file mode 100644
index 000000000..31e36fbce
--- /dev/null
+++ b/crates/compressors/src/flate/mod.rs
@@ -0,0 +1,157 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! The deflate family: raw deflate, zlib and gzip.
+//!
+//! All three wrap the same deflate payload, differing only in framing, so the `deflate`, `zlib` and
+//! `gzip` modules share one engine implementation, parameterized by [`Wrapper`].
+
+pub(crate) mod codec;
+
+use flate2::{Compress, Compression, Decompress};
+
+use crate::level::Level;
+use crate::limits::FormatLimits;
+
+/// The deflate family's default bounds.
+///
+/// Deflate cannot expand its input by more than about `1032x` -- a structural property of the format,
+/// not a tuning choice -- so a single stream is inherently bounded. Measured worst case for 1 MiB of
+/// zeros is `1015x`, so this sits just above what the format can actually produce and never rejects
+/// data deflate could legitimately have generated. Total output and stream count are left open, so
+/// a stream of any length passes through; the buffering conveniences bound what they accumulate.
+pub(crate) const DEFAULT_LIMITS: FormatLimits = FormatLimits::new(Some(1_100), None, None);
+
+/// The deflate window size exponent: the maximum the backend accepts, for the best compression
+/// ratio.
+///
+/// Only the gzip container needs it explicitly; the raw and zlib constructors default to it.
+#[cfg(any(test, feature = "gzip"))]
+const WINDOW_BITS: u8 = 15;
+
+/// The container framing wrapped around a deflate payload.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum Wrapper {
+ /// Raw deflate (RFC 1951): no header and no checksum.
+ #[cfg(any(test, feature = "deflate"))]
+ Raw,
+ /// zlib (RFC 1950): a two byte header and an Adler-32 trailer.
+ #[cfg(any(test, feature = "zlib"))]
+ Zlib,
+ /// gzip (RFC 1952): a ten byte header and a CRC-32 plus length trailer.
+ #[cfg(any(test, feature = "gzip"))]
+ Gzip,
+}
+
+/// Maps the portable scale onto flate's.
+///
+/// The identity: both run 0 to 9 and mean the same thing at each step. Named and tested anyway,
+/// because "the scales happen to agree" is a mapping decision like any other, and this passthrough
+/// is the only thing standing between [`Level`] and the engine.
+fn compression_for(level: Level) -> Compression {
+ Compression::new(u32::from(level.get()))
+}
+
+impl Wrapper {
+ pub(crate) fn compressor(self, level: Level) -> Compress {
+ let compression = compression_for(level);
+
+ match self {
+ #[cfg(any(test, feature = "deflate"))]
+ Self::Raw => Compress::new(compression, false),
+ #[cfg(any(test, feature = "zlib"))]
+ Self::Zlib => Compress::new(compression, true),
+ #[cfg(any(test, feature = "gzip"))]
+ Self::Gzip => Compress::new_gzip(compression, WINDOW_BITS),
+ }
+ }
+
+ pub(crate) fn decompressor(self) -> Decompress {
+ match self {
+ #[cfg(any(test, feature = "deflate"))]
+ Self::Raw => Decompress::new(false),
+ #[cfg(any(test, feature = "zlib"))]
+ Self::Zlib => Decompress::new(true),
+ #[cfg(any(test, feature = "gzip"))]
+ Self::Gzip => Decompress::new_gzip(WINDOW_BITS),
+ }
+ }
+
+ /// Whether a recycled decompressor keeps this container's framing after a reset.
+ ///
+ /// `Decompress::reset` takes a boolean selecting raw deflate or zlib, so it cannot express
+ /// gzip, which the engine compresses as `window_bits + 16`. Recycling a gzip decompressor would
+ /// silently drop it to raw deflate, so gzip decompressors are never pooled.
+ pub(crate) fn reset_restores_framing(self) -> bool {
+ match self {
+ #[cfg(any(test, feature = "deflate"))]
+ Self::Raw => true,
+ #[cfg(any(test, feature = "zlib"))]
+ Self::Zlib => true,
+ #[cfg(any(test, feature = "gzip"))]
+ Self::Gzip => false,
+ }
+ }
+
+ /// The boolean `Decompress::reset` needs to restore this container.
+ ///
+ /// Only `Raw` and `Zlib` ever reach this call: [`Self::reset_restores_framing`] keeps a gzip
+ /// decompressor out of the pool, so `checkout` never asks it to reset. Written without a
+ /// dedicated `Gzip` arm so every branch stays reachable through that existing pooling test.
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ pub(crate) fn expects_zlib_header(self) -> bool {
+ #[cfg(any(test, feature = "zlib"))]
+ {
+ matches!(self, Self::Zlib)
+ }
+ #[cfg(not(any(test, feature = "zlib")))]
+ {
+ false
+ }
+ }
+
+ pub(crate) fn name(self) -> &'static str {
+ match self {
+ #[cfg(any(test, feature = "deflate"))]
+ Self::Raw => "deflate",
+ #[cfg(any(test, feature = "zlib"))]
+ Self::Zlib => "zlib",
+ #[cfg(any(test, feature = "gzip"))]
+ Self::Gzip => "gzip",
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_wrapper_has_a_name() {
+ assert_eq!(Wrapper::Raw.name(), "deflate");
+ assert_eq!(Wrapper::Zlib.name(), "zlib");
+ assert_eq!(Wrapper::Gzip.name(), "gzip");
+ }
+
+ #[test]
+ fn only_gzip_loses_its_framing_on_reset() {
+ assert!(Wrapper::Raw.reset_restores_framing());
+ assert!(Wrapper::Zlib.reset_restores_framing());
+ assert!(!Wrapper::Gzip.reset_restores_framing());
+ }
+
+ #[test]
+ fn the_level_maps_onto_flates_scale_unchanged() {
+ // Brotli and zstd pin the arithmetic of their mappings; this is flate's, and it being the
+ // identity is exactly why it is worth pinning -- a passthrough is the easiest thing to
+ // replace with a constant without anything noticing.
+ assert_eq!(compression_for(Level::MIN).level(), 0, "the floor must line up");
+ assert_eq!(compression_for(Level::HIGH).level(), 9, "the ceiling must line up");
+
+ for raw in 0..=Level::MAX.get() {
+ let level = Level::new(raw).unwrap();
+ assert_eq!(compression_for(level).level(), u32::from(raw), "every step must map to itself");
+ }
+ }
+}
diff --git a/crates/compressors/src/format.rs b/crates/compressors/src/format.rs
new file mode 100644
index 000000000..57287f1c9
--- /dev/null
+++ b/crates/compressors/src/format.rs
@@ -0,0 +1,1059 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Choosing a compression format at runtime.
+//!
+//! [`Format`] is the entry point, and this module carries the same shape every format module does
+//! -- a [`Compressor`], a [`Decompressor`], and the [`compress`], [`decompress`] and
+//! [`decompress_with_limits`] conveniences -- with the format threaded through at runtime rather
+//! than fixed at compile time. The `build_format` methods on the shared builders live here too,
+//! because this is the only place that has to know every format by name.
+
+use bytesbuf::BytesView;
+
+use crate::builder::{CompressorBuilder, DecompressorBuilder};
+use crate::core::{Compress, Compression, CompressionInternal, Decompress, Destination, Output};
+use crate::error::{BuildError, Result};
+use crate::limits::DecompressorLimits;
+use crate::resources::Resources;
+
+/// A compression format, selectable at runtime.
+///
+/// The format modules (`gzip` and friends) are the right choice when the format is
+/// known at compile time. This enum is for when it is not: encoding whatever a client asked for,
+/// or decoding whatever a peer declared it sent.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "gzip")]
+/// # {
+/// use compressors::format::{self, Format};
+/// use compressors::{CompressorBuilder, Level, Resources};
+///
+/// // The format arrives as a string, from an HTTP header.
+/// let format = Format::from_content_encoding("gzip").expect("a supported encoding");
+///
+/// let resources = Resources::global();
+/// let compressed = format::compress(format, b"payload", resources)?;
+///
+/// assert_eq!(compressed.range(0..2).to_vec(), vec![0x1f, 0x8b]);
+///
+/// // Or build the compressor yourself when the level or chunk size matters.
+/// let tuned = CompressorBuilder::new()
+/// .level(Level::HIGH)
+/// .build_format(format, resources)?;
+/// # let _ = tuned;
+/// # }
+/// # Ok::<(), compressors::Error>(())
+/// ```
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[non_exhaustive]
+pub enum Format {
+ /// Raw deflate, RFC 1951. See `deflate`. Requires the `deflate` feature.
+ #[cfg(any(test, feature = "deflate"))]
+ Deflate,
+ /// Zlib, RFC 1950. See `zlib`. Requires the `zlib` feature.
+ #[cfg(any(test, feature = "zlib"))]
+ Zlib,
+ /// Gzip, RFC 1952. See `gzip`. Requires the `gzip` feature.
+ #[cfg(any(test, feature = "gzip"))]
+ Gzip,
+ /// Brotli, RFC 7932. See `brotli`. Requires the `brotli` feature.
+ #[cfg(any(test, feature = "brotli"))]
+ Brotli,
+ /// Zstandard, RFC 8878. See `zstd`. Requires the `zstd` feature.
+ #[cfg(any(test, feature = "zstd"))]
+ Zstd,
+}
+
+impl Format {
+ /// Every format this build supports, in no particular order.
+ ///
+ /// The contents depend on which cargo features are enabled.
+ pub const ALL: &'static [Self] = &[
+ #[cfg(any(test, feature = "deflate"))]
+ Self::Deflate,
+ #[cfg(any(test, feature = "zlib"))]
+ Self::Zlib,
+ #[cfg(any(test, feature = "gzip"))]
+ Self::Gzip,
+ #[cfg(any(test, feature = "brotli"))]
+ Self::Brotli,
+ #[cfg(any(test, feature = "zstd"))]
+ Self::Zstd,
+ ];
+
+ /// The HTTP `Content-Encoding` token for this format, if it has one.
+ ///
+ /// Returns `None` for `Format::Deflate`: raw deflate has no HTTP token. Note that the HTTP
+ /// `deflate` token means a *zlib* stream, not raw deflate, so it maps to `Format::Zlib`.
+ #[must_use]
+ #[cfg_attr(
+ all(
+ not(any(test, feature = "deflate")),
+ any(feature = "brotli", feature = "gzip", feature = "zlib", feature = "zstd")
+ ),
+ expect(
+ clippy::unnecessary_wraps,
+ reason = "raw deflate is the only format without an HTTP token, and it is not enabled in this configuration"
+ )
+ )]
+ pub const fn content_encoding(self) -> Option<&'static str> {
+ match self {
+ #[cfg(any(test, feature = "deflate"))]
+ Self::Deflate => None,
+ #[cfg(any(test, feature = "zlib"))]
+ Self::Zlib => Some("deflate"),
+ #[cfg(any(test, feature = "gzip"))]
+ Self::Gzip => Some("gzip"),
+ #[cfg(any(test, feature = "brotli"))]
+ Self::Brotli => Some("br"),
+ #[cfg(any(test, feature = "zstd"))]
+ Self::Zstd => Some("zstd"),
+ }
+ }
+
+ /// Parses a single HTTP `Content-Encoding` token.
+ ///
+ /// Matching is case-insensitive, as HTTP requires. `deflate` maps to `Format::Zlib`, which is
+ /// what the token actually denotes; `x-gzip` is accepted as a legacy alias for `gzip`. Tokens
+ /// for formats this build does not support return `None`.
+ ///
+ /// This takes one bare token rather than parsing a complete HTTP header.
+ #[must_use]
+ pub fn from_content_encoding(token: &str) -> Option {
+ let token = token.trim();
+
+ #[cfg(any(test, feature = "gzip"))]
+ if token.eq_ignore_ascii_case("gzip") || token.eq_ignore_ascii_case("x-gzip") {
+ return Some(Self::Gzip);
+ }
+
+ #[cfg(any(test, feature = "zlib"))]
+ if token.eq_ignore_ascii_case("deflate") {
+ return Some(Self::Zlib);
+ }
+
+ #[cfg(any(test, feature = "brotli"))]
+ if token.eq_ignore_ascii_case("br") {
+ return Some(Self::Brotli);
+ }
+
+ #[cfg(any(test, feature = "zstd"))]
+ if token.eq_ignore_ascii_case("zstd") {
+ return Some(Self::Zstd);
+ }
+
+ #[cfg(not(any(test, feature = "brotli", feature = "gzip", feature = "zlib", feature = "zstd")))]
+ let _ = token;
+
+ None
+ }
+}
+
+/// Dispatches one method to whichever format's engine a runtime-format compressor or decompressor is holding.
+///
+/// With no format feature enabled the enum has no variants, so this expands to a match on an
+/// uninhabited value -- which is exactly right: there is then no way to construct one.
+macro_rules! dispatch {
+ ($kind:ident, $value:expr, $codec:ident => $call:expr) => {
+ match $value {
+ #[cfg(any(test, feature = "deflate"))]
+ $kind::Deflate($codec) => $call,
+ #[cfg(any(test, feature = "zlib"))]
+ $kind::Zlib($codec) => $call,
+ #[cfg(any(test, feature = "gzip"))]
+ $kind::Gzip($codec) => $call,
+ #[cfg(any(test, feature = "brotli"))]
+ $kind::Brotli($codec) => $call,
+ #[cfg(any(test, feature = "zstd"))]
+ $kind::Zstd($codec) => $call,
+ #[cfg(not(any(test, any_format)))]
+ #[expect(
+ clippy::uninhabited_references,
+ reason = "the variant cannot be constructed, so a reference to it cannot exist for this arm to reach"
+ )]
+ $kind::Impossible(never) => match *never {},
+ }
+ };
+}
+
+#[derive(Debug)]
+enum CompressorKind {
+ #[cfg(any(test, feature = "deflate"))]
+ Deflate(crate::deflate::Compressor),
+ #[cfg(any(test, feature = "zlib"))]
+ Zlib(crate::zlib::Compressor),
+ #[cfg(any(test, feature = "gzip"))]
+ Gzip(crate::gzip::Compressor),
+ #[cfg(any(test, feature = "brotli"))]
+ // Brotli's engine state dwarfs the others (roughly 6.5 KiB against 1 KiB), and this enum is
+ // returned by value, so the odd one out is boxed to keep the common cases cheap to move.
+ Brotli(Box),
+ #[cfg(any(test, feature = "zstd"))]
+ Zstd(crate::zstd::Compressor),
+ /// Keeps the dispatch below exhaustive when no format is enabled.
+ ///
+ /// [`Infallible`][core::convert::Infallible] cannot be constructed, so neither can this: the
+ /// type exists so a build with no format can still name it, not so it can be used.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "the placeholder exists to be matched, never constructed")
+ )]
+ #[cfg(not(any(test, any_format)))]
+ Impossible(core::convert::Infallible),
+}
+
+#[derive(Debug)]
+enum DecompressorKind {
+ #[cfg(any(test, feature = "deflate"))]
+ Deflate(crate::deflate::Decompressor),
+ #[cfg(any(test, feature = "zlib"))]
+ Zlib(crate::zlib::Decompressor),
+ #[cfg(any(test, feature = "gzip"))]
+ Gzip(crate::gzip::Decompressor),
+ #[cfg(any(test, feature = "brotli"))]
+ // Boxed for the same reason as the compressor above.
+ Brotli(Box),
+ #[cfg(any(test, feature = "zstd"))]
+ Zstd(crate::zstd::Decompressor),
+ /// Keeps the dispatch exhaustive when no format is enabled, exactly as for the compressor above.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "the placeholder exists to be matched, never constructed")
+ )]
+ #[cfg(not(any(test, any_format)))]
+ Impossible(core::convert::Infallible),
+}
+
+/// Compresses a stream of byte sequences into a format chosen at runtime.
+///
+/// The runtime-format counterpart of each format module's `Compressor`, and driven exactly the same
+/// way -- through [`Compression`]. The chosen format is held internally,
+/// so this is a concrete type rather than a trait object: it can be stored in a struct, returned
+/// from a function and handed to [`compress`][crate::compress] like any other compressor.
+///
+/// Reach it through [`CompressorBuilder::build_format`], or [`compress`] for a complete buffer.
+#[derive(Debug)]
+pub struct Compressor {
+ kind: CompressorKind,
+}
+
+impl Compressor {
+ /// Creates a compressor for `format` at `Level::DEFAULT`.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`] if the chosen format's engine rejects the
+ /// default configuration, which in practice it never does.
+ pub fn new(format: Format, resources: &Resources) -> ::core::result::Result {
+ Self::builder().build_format(format, resources)
+ }
+
+ /// Starts configuring a compressor whose format is chosen when it is built.
+ #[must_use]
+ pub fn builder() -> CompressorBuilder<()> {
+ CompressorBuilder::new()
+ }
+}
+
+impl Compression for Compressor {
+ type Mode = Compress;
+}
+
+impl CompressionInternal for Compressor {
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(unused_variables, reason = "the dispatch below diverges when no format is enabled")
+ )]
+ fn push(&mut self, input: BytesView) -> Result<()> {
+ dispatch!(CompressorKind, &mut self.kind, codec => codec.push(input))
+ }
+
+ fn end_input(&mut self) {
+ dispatch!(CompressorKind, &mut self.kind, codec => codec.end_input());
+ }
+
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(unused_variables, reason = "the dispatch below diverges when no format is enabled")
+ )]
+ fn pull(&mut self, into: Destination) -> Result {
+ dispatch!(CompressorKind, &mut self.kind, codec => codec.pull(into))
+ }
+
+ fn total_in(&self) -> u64 {
+ dispatch!(CompressorKind, &self.kind, codec => codec.total_in())
+ }
+
+ fn total_out(&self) -> u64 {
+ dispatch!(CompressorKind, &self.kind, codec => codec.total_out())
+ }
+
+ fn flush(&mut self) -> Result<()> {
+ dispatch!(CompressorKind, &mut self.kind, codec => codec.flush())
+ }
+}
+
+/// Decompresses a stream in a format chosen at runtime.
+///
+/// The runtime-format counterpart of each format module's `Decompressor`.
+///
+/// # Security
+///
+/// This carries whatever bounds it was built with and adds none of its own, exactly like every
+/// other format's decompressor. [`decompress`] is the bounded convenience.
+///
+/// Reach it through [`DecompressorBuilder::build_format`], or [`decompress`] for a complete stream.
+#[derive(Debug)]
+pub struct Decompressor {
+ kind: DecompressorKind,
+}
+
+impl Decompressor {
+ /// Creates a decompressor for `format` with that format's own default bounds.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`] if the chosen format's engine rejects the
+ /// default configuration, which in practice it never does.
+ pub fn new(format: Format, resources: &Resources) -> ::core::result::Result {
+ Self::builder().build_format(format, resources)
+ }
+
+ /// Starts configuring a decompressor whose format is chosen when it is built.
+ #[must_use]
+ pub fn builder() -> DecompressorBuilder<()> {
+ DecompressorBuilder::new()
+ }
+}
+
+impl Compression for Decompressor {
+ type Mode = Decompress;
+}
+
+impl CompressionInternal for Decompressor {
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(unused_variables, reason = "the dispatch below diverges when no format is enabled")
+ )]
+ fn push(&mut self, input: BytesView) -> Result<()> {
+ dispatch!(DecompressorKind, &mut self.kind, codec => codec.push(input))
+ }
+
+ fn end_input(&mut self) {
+ dispatch!(DecompressorKind, &mut self.kind, codec => codec.end_input());
+ }
+
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(unused_variables, reason = "the dispatch below diverges when no format is enabled")
+ )]
+ fn pull(&mut self, into: Destination) -> Result {
+ dispatch!(DecompressorKind, &mut self.kind, codec => codec.pull(into))
+ }
+
+ fn total_in(&self) -> u64 {
+ dispatch!(DecompressorKind, &self.kind, codec => codec.total_in())
+ }
+
+ fn total_out(&self) -> u64 {
+ dispatch!(DecompressorKind, &self.kind, codec => codec.total_out())
+ }
+}
+
+/// Compresses a complete byte sequence into `format`.
+///
+/// Uses `Level::DEFAULT`; for anything else, configure a
+/// [`CompressorBuilder`] and finish it with [`build_format`][CompressorBuilder::build_format].
+/// Prefer [`Compressor`] for data that arrives incrementally; this convenience buffers the entire
+/// result before returning.
+///
+/// # Errors
+///
+/// Returns an error if the underlying compression engine fails.
+pub fn compress(format: Format, input: impl crate::InputData, resources: &Resources) -> Result {
+ let input = crate::InputData::into_view(input, resources);
+
+ crate::compress(input, Compressor::new(format, resources)?)
+}
+
+/// Decompresses a complete stream in `format`.
+///
+/// Applies that format's default bounds plus this convenience's buffering caps, because it
+/// accumulates the whole result. Prefer [`Decompressor`] for data that arrives incrementally.
+///
+/// # Errors
+///
+/// Returns an error if the data is malformed, truncated, or exceeds those bounds.
+pub fn decompress(format: Format, input: impl crate::InputData, resources: &Resources) -> Result {
+ decompress_with_limits(format, input, resources, DecompressorLimits::new())
+}
+
+/// Decompresses a complete stream in `format` with explicit output limits.
+///
+/// # Errors
+///
+/// Returns an error if the data is malformed, truncated, or exceeds `limits`. Bounds left unset on
+/// `limits` still receive this convenience's buffering caps.
+pub fn decompress_with_limits(
+ format: Format,
+ input: impl crate::InputData,
+ resources: &Resources,
+ limits: DecompressorLimits,
+) -> Result {
+ let input = crate::InputData::into_view(input, resources);
+
+ crate::decompress(
+ input,
+ Decompressor::builder()
+ .limits(limits.for_buffered_output())
+ .build_format(format, resources)?,
+ )
+}
+
+impl CompressorBuilder<()> {
+ /// Builds a compressor for a format chosen at runtime.
+ ///
+ /// The result is this module's [`Compressor`], a concrete type like every other format's, so it
+ /// fits anywhere one of those does. The chosen format is an implementation detail of the value
+ /// rather than something the caller has to name.
+ ///
+ /// Everything this builder carries means the same thing in every format. A setting only one
+ /// format has -- brotli's quality, say -- needs that format's own builder.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`] if the chosen format's engine rejects the
+ /// configuration.
+ #[cfg_attr(
+ not(any(test, feature = "brotli", feature = "zstd")),
+ expect(
+ clippy::unnecessary_wraps,
+ reason = "brotli and zstd are the formats whose engines can reject a configuration, and neither is enabled"
+ )
+ )]
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(
+ unreachable_code,
+ unused_variables,
+ clippy::unused_self,
+ reason = "with no format enabled `Format` has no variants, so the match below diverges"
+ )
+ )]
+ pub fn build_format(self, format: Format, resources: &Resources) -> ::core::result::Result {
+ let kind = match format {
+ #[cfg(any(test, feature = "deflate"))]
+ Format::Deflate => CompressorKind::Deflate(self.build_deflate(resources)),
+ #[cfg(any(test, feature = "zlib"))]
+ Format::Zlib => CompressorKind::Zlib(self.build_zlib(resources)),
+ #[cfg(any(test, feature = "gzip"))]
+ Format::Gzip => CompressorKind::Gzip(self.build_gzip(resources)),
+ #[cfg(any(test, feature = "brotli"))]
+ Format::Brotli => CompressorKind::Brotli(Box::new(self.build_brotli(resources))),
+ #[cfg(any(test, feature = "zstd"))]
+ Format::Zstd => CompressorKind::Zstd(self.build_zstd(resources)?),
+ };
+
+ Ok(Compressor { kind })
+ }
+}
+
+impl DecompressorBuilder<()> {
+ /// Builds a decompressor for a format chosen at runtime.
+ ///
+ /// The result is this module's [`Decompressor`], a concrete type like every other format's.
+ ///
+ /// Bounds left unset on [`limits`][DecompressorBuilder::limits], and a
+ /// [`multi_stream`][DecompressorBuilder::multi_stream] left unset, keep whatever the chosen
+ /// format defaults to.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`] if the chosen format's engine rejects the
+ /// configuration.
+ #[cfg_attr(
+ not(feature = "zstd"),
+ expect(
+ clippy::unnecessary_wraps,
+ reason = "zstd is the only format whose decompressor engine can reject a configuration, and it is not enabled"
+ )
+ )]
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(
+ unreachable_code,
+ unused_variables,
+ clippy::unused_self,
+ reason = "with no format enabled `Format` has no variants, so the match below diverges"
+ )
+ )]
+ pub fn build_format(self, format: Format, resources: &Resources) -> ::core::result::Result {
+ let kind = match format {
+ #[cfg(any(test, feature = "deflate"))]
+ Format::Deflate => DecompressorKind::Deflate(self.build_deflate(resources)),
+ #[cfg(any(test, feature = "zlib"))]
+ Format::Zlib => DecompressorKind::Zlib(self.build_zlib(resources)),
+ #[cfg(any(test, feature = "gzip"))]
+ Format::Gzip => DecompressorKind::Gzip(self.build_gzip(resources)),
+ #[cfg(any(test, feature = "brotli"))]
+ Format::Brotli => DecompressorKind::Brotli(Box::new(self.build_brotli(resources))),
+ #[cfg(any(test, feature = "zstd"))]
+ Format::Zstd => DecompressorKind::Zstd(self.build_zstd(resources)?),
+ };
+
+ Ok(Decompressor { kind })
+ }
+}
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use std::num::{NonZeroU64, NonZeroUsize};
+
+ use bytesbuf::BytesBuf;
+
+ use super::*;
+ use crate::level::Level;
+ use crate::limits::FormatLimits;
+ use crate::testing::view;
+ use crate::trailing::TrailingData;
+
+ /// Caps every drain loop in this module.
+ ///
+ /// A conforming engine always terminates, so exceeding this means the code under test is
+ /// spinning. A hanging test reports nothing at all, so the cap turns a hang into a failure --
+ /// which also lets mutation testing reach a verdict instead of timing out.
+ ///
+ /// The cap has to stay tight enough for that verdict to arrive inside the mutation harness's
+ /// per-mutant timeout. No test here needs more than a few hundred steps, so this leaves well
+ /// over an order of magnitude of headroom while still failing a spinning mutant in under a
+ /// second. Matches the cap the other drain-loop tests use.
+ const MAX_STEPS: usize = 10_000;
+
+ /// Fails a spinning test instead of letting it hang.
+ ///
+ /// A conforming engine always terminates, so exceeding the cap means the code under test is
+ /// looping. A hanging test reports nothing at all, and mutation testing records a timeout rather
+ /// than a verdict, so every drain loop here counts its steps through this.
+ struct StepGuard(usize);
+
+ impl StepGuard {
+ fn new() -> Self {
+ Self(0)
+ }
+
+ fn step(&mut self) {
+ self.0 += 1;
+ assert!(self.0 < MAX_STEPS, "the operation did not finish within {MAX_STEPS} steps");
+ }
+ }
+
+ fn compressed_bytes(builder: CompressorBuilder<()>, format: Format, payload: &[u8]) -> Vec {
+ let mut compressor = builder.build_format(format, &Resources::default()).unwrap();
+ compressor.push(view(payload)).unwrap();
+ compressor.end_input();
+
+ let mut collected = Vec::new();
+ let mut finished = false;
+ for _ in 0..MAX_STEPS {
+ let output = compressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "compressor requested input after end");
+ let done = output.is_done();
+ if let Some(chunk) = output.into_data() {
+ collected.extend_from_slice(&chunk.to_vec());
+ }
+ if done {
+ finished = true;
+ break;
+ }
+ }
+ assert!(finished, "compression did not finish within {MAX_STEPS} steps");
+
+ collected
+ }
+
+ #[test]
+ fn the_runtime_format_codecs_report_their_byte_counters_and_flush() {
+ // The counters and `flush` forward to whichever codec the enum is holding, so they need
+ // driving for every format rather than only for whichever one happens to be first.
+ let payload = b"counted and flushed ".repeat(200);
+
+ for &format in Format::ALL {
+ let mut compressor = Compressor::new(format, &Resources::default()).unwrap();
+
+ compressor.push(view(&payload)).unwrap();
+ compressor.flush().unwrap();
+
+ let mut compressed = BytesBuf::new();
+ let mut guard = 0;
+ loop {
+ guard += 1;
+ assert!(guard < MAX_STEPS, "the flush did not settle for {format:?}");
+
+ let output = compressor.pull(Destination::Stream).unwrap();
+ if output.is_need_input() {
+ break;
+ }
+ if let Some(chunk) = output.into_data() {
+ compressed.put_bytes(chunk);
+ }
+ }
+
+ assert_eq!(
+ compressor.total_in(),
+ payload.len() as u64,
+ "{format:?} miscounted what it consumed"
+ );
+ assert_eq!(
+ compressor.total_out(),
+ compressed.len() as u64,
+ "{format:?} miscounted what it produced after a flush"
+ );
+
+ compressor.end_input();
+ loop {
+ guard += 1;
+ assert!(guard < MAX_STEPS, "compression did not finish for {format:?}");
+
+ let output = compressor.pull(Destination::Stream).unwrap();
+ let done = output.is_done();
+ if let Some(chunk) = output.into_data() {
+ compressed.put_bytes(chunk);
+ }
+ if done {
+ break;
+ }
+ }
+
+ let mut decompressor = Decompressor::new(format, &Resources::default()).unwrap();
+
+ let compressed_len = compressed.len() as u64;
+ decompressor.push(compressed.consume_all()).unwrap();
+ decompressor.flush().unwrap();
+ decompressor.end_input();
+
+ let mut plain = BytesBuf::new();
+ loop {
+ guard += 1;
+ assert!(guard < MAX_STEPS, "decompression did not finish for {format:?}");
+
+ let output = decompressor.pull(Destination::Stream).unwrap();
+ let done = output.is_done();
+ if let Some(chunk) = output.into_data() {
+ plain.put_bytes(chunk);
+ }
+ if done {
+ break;
+ }
+ }
+
+ assert_eq!(plain.consume_all().to_vec(), payload, "{format:?} did not round trip");
+ assert_eq!(
+ decompressor.total_out(),
+ payload.len() as u64,
+ "{format:?} miscounted what it produced"
+ );
+ assert_eq!(decompressor.total_in(), compressed_len, "{format:?} miscounted what it consumed");
+
+ // The `flush` above lands on a state that accepts one, so it cannot tell a forwarding
+ // `flush` from one that just answers `Ok`. Flushing after the stream is finished must
+ // be refused, and only the codec behind the enum knows that.
+ let refused = compressor.flush();
+ assert!(refused.is_err(), "{format:?} allowed a flush after end of input");
+ }
+ }
+
+ #[test]
+ fn every_format_round_trips_through_the_enum() {
+ let payload = b"runtime selected format ".repeat(200);
+
+ for &format in Format::ALL {
+ let compressed = crate::format::compress(format, view(&payload), &Resources::default()).unwrap();
+ let plain = crate::format::decompress(format, compressed, &Resources::default()).unwrap();
+
+ assert_eq!(plain.to_vec(), payload, "{format:?} failed to round trip");
+ }
+ }
+
+ #[test]
+ fn content_encoding_tokens_round_trip() {
+ for &format in Format::ALL {
+ let Some(token) = format.content_encoding() else {
+ continue;
+ };
+
+ assert_eq!(
+ Format::from_content_encoding(token),
+ Some(format),
+ "{format:?} did not survive its own token"
+ );
+ }
+ }
+
+ #[cfg(any(test, all(feature = "deflate", feature = "zlib")))]
+ #[test]
+ fn http_deflate_token_means_zlib() {
+ // The most common source of confusion in this area: the HTTP `deflate` token denotes a zlib
+ // stream, not raw deflate.
+ assert_eq!(Format::from_content_encoding("deflate"), Some(Format::Zlib));
+ assert_eq!(Format::Deflate.content_encoding(), None);
+ }
+
+ #[cfg(any(test, feature = "gzip"))]
+ #[test]
+ fn content_encoding_parsing_is_case_insensitive_and_trims() {
+ assert_eq!(Format::from_content_encoding("GZIP"), Some(Format::Gzip));
+ assert_eq!(Format::from_content_encoding(" gzip "), Some(Format::Gzip));
+ assert_eq!(Format::from_content_encoding("x-gzip"), Some(Format::Gzip));
+ assert_eq!(Format::from_content_encoding("identity"), None);
+ assert_eq!(Format::from_content_encoding(""), None);
+ }
+
+ #[cfg(any(test, feature = "brotli"))]
+ #[test]
+ fn brotli_uses_the_br_token() {
+ assert_eq!(Format::from_content_encoding("br"), Some(Format::Brotli));
+ assert_eq!(Format::Brotli.content_encoding(), Some("br"));
+ }
+
+ #[test]
+ fn unknown_tokens_are_rejected() {
+ // A token no format claims, and one that names a format this crate deliberately gives no
+ // content coding: raw deflate has none, because the HTTP `deflate` token means zlib.
+ assert_eq!(Format::from_content_encoding("compress"), None);
+ assert_eq!(Format::from_content_encoding("identity"), None);
+ }
+
+ /// A payload with real matching work in it, for distinguishing compression levels.
+ ///
+ /// A trivially repetitive payload cannot: every level finds the same single long match and
+ /// emits identical bytes, even though the level did reach the engine. Sixteen symbols over 64
+ /// KiB leaves enough redundancy to compress and enough structure for a stronger level to find
+ /// more of it.
+ fn varied_payload() -> Vec {
+ let mut payload = Vec::with_capacity(1 << 16);
+ let mut state = 0x2545_F491_4F6C_DD1D_u64;
+
+ while payload.len() < (1 << 16) {
+ state = state
+ .wrapping_mul(6_364_136_223_846_793_005)
+ .wrapping_add(1_442_695_040_888_963_407);
+ payload.push(b'a' + u8::try_from((state >> 33) & 0x0f).unwrap());
+ }
+
+ payload
+ }
+
+ #[test]
+ fn the_compressor_builder_applies_its_level() {
+ // Inequality of the bytes is the load-bearing assertion. A size comparison alone is
+ // satisfied by equality, so a backend that dropped the level and always compressed at its
+ // default would pass it while the `Level` knob silently did nothing.
+ let varied = varied_payload();
+ for &format in Format::ALL {
+ let fast = compressed_bytes(CompressorBuilder::new().level(Level::FAST), format, &varied);
+ let best = compressed_bytes(CompressorBuilder::new().level(Level::HIGH), format, &varied);
+
+ assert_ne!(best, fast, "{format:?}: the level did not reach the engine");
+ }
+
+ // The size property, kept as a secondary check and on its own payload. It needs one where a
+ // stronger level can actually pay off: on the high-entropy payload above, zstd's top level
+ // legitimately emits *more* bytes than its fast one, so this is not a property of the
+ // portable scale in general. Only `<=` even here -- strict monotonicity is not promised
+ // across backends and payloads.
+ let repetitive = b"the quick brown fox jumps over the lazy dog ".repeat(400);
+ for &format in Format::ALL {
+ let fast = compressed_bytes(CompressorBuilder::new().level(Level::FAST), format, &repetitive);
+ let best = compressed_bytes(CompressorBuilder::new().level(Level::HIGH), format, &repetitive);
+
+ assert!(
+ best.len() <= fast.len(),
+ "{format:?}: best={} should not exceed fast={}",
+ best.len(),
+ fast.len()
+ );
+ }
+ }
+
+ #[test]
+ fn the_compressor_builder_applies_its_chunk_size() {
+ let bound = NonZeroUsize::new(128).unwrap();
+
+ for &format in Format::ALL {
+ let mut compressor = CompressorBuilder::new()
+ .output_chunk_size(bound)
+ .build_format(format, &Resources::default())
+ .unwrap();
+ compressor.push(view(&b"chunked ".repeat(5_000))).unwrap();
+ compressor.end_input();
+
+ let mut finished = false;
+ for _ in 0..MAX_STEPS {
+ let output = compressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "compressor requested input after end");
+ let done = output.is_done();
+ if let Some(chunk) = output.as_data() {
+ assert!(chunk.len() <= bound.get(), "{format:?} produced a {} byte chunk", chunk.len());
+ }
+ if done {
+ finished = true;
+ break;
+ }
+ }
+ assert!(finished, "{format:?} compression did not finish within {MAX_STEPS} steps");
+ }
+ }
+
+ #[test]
+ fn the_decompressor_builder_applies_its_limits() {
+ for &format in Format::ALL {
+ let compressed = crate::format::compress(format, view(&vec![0_u8; 256 * 1024]), &Resources::default()).unwrap();
+
+ let mut decompressor = DecompressorBuilder::new()
+ .limits(
+ DecompressorLimits::new()
+ .unbounded_ratio()
+ .max_output_len(NonZeroU64::new(1024).unwrap()),
+ )
+ .output_chunk_size(NonZeroUsize::new(64).unwrap())
+ .build_format(format, &Resources::default())
+ .unwrap();
+ decompressor.push(compressed).unwrap();
+ decompressor.end_input();
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream) {
+ Ok(output) => {
+ assert!(
+ !output.is_done() && !output.is_need_input(),
+ "{format:?}: the cap should have fired"
+ );
+ }
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_limit_exceeded(), "{format:?}: got {error}");
+ }
+ }
+
+ #[test]
+ fn a_decompressor_without_an_explicit_chunk_size_defaults_to_64_kib() {
+ // Hardcoded literal (rather than `DEFAULT_CHUNK_SIZE`) so this test pins the actual byte
+ // count instead of trivially matching whatever the constant happens to be set to.
+ const EXPECTED_DEFAULT_CHUNK_SIZE: usize = 65_536;
+
+ for &format in Format::ALL {
+ let compressed = crate::format::compress(format, view(&vec![0_u8; 256 * 1024]), &Resources::default()).unwrap();
+
+ let mut decompressor = DecompressorBuilder::new().build_format(format, &Resources::default()).unwrap();
+ decompressor.push(compressed).unwrap();
+ decompressor.end_input();
+
+ let mut saw_a_full_size_chunk = false;
+ let mut finished = false;
+ for _ in 0..MAX_STEPS {
+ let output = decompressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "decompressor requested input after end");
+ let done = output.is_done();
+ if let Some(chunk) = output.as_data() {
+ let chunk_len = chunk.len();
+ assert!(
+ chunk_len <= EXPECTED_DEFAULT_CHUNK_SIZE,
+ "{format:?} produced a {chunk_len} byte chunk, larger than the 64 KiB default"
+ );
+ saw_a_full_size_chunk |= chunk_len == EXPECTED_DEFAULT_CHUNK_SIZE;
+ }
+ if done {
+ finished = true;
+ break;
+ }
+ }
+ assert!(finished, "{format:?} decompression did not finish within {MAX_STEPS} steps");
+
+ assert!(
+ saw_a_full_size_chunk,
+ "{format:?}: decompressing a quarter megabyte of zeros never produced a full 64 KiB chunk"
+ );
+ }
+ }
+
+ #[test]
+ fn the_decompressor_builder_applies_its_chunk_size() {
+ let bound = NonZeroUsize::new(128).unwrap();
+
+ for &format in Format::ALL {
+ let compressed = crate::format::compress(format, view(&b"chunked output ".repeat(5_000)), &Resources::default()).unwrap();
+ let mut decompressor = DecompressorBuilder::new()
+ .output_chunk_size(bound)
+ .build_format(format, &Resources::default())
+ .unwrap();
+ decompressor.push(compressed).unwrap();
+ decompressor.end_input();
+
+ let mut finished = false;
+ for _ in 0..MAX_STEPS {
+ let output = decompressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "decompressor requested input after end");
+ let done = output.is_done();
+ if let Some(chunk) = output.as_data() {
+ assert!(chunk.len() <= bound.get(), "{format:?} produced a {} byte chunk", chunk.len());
+ }
+ if done {
+ finished = true;
+ break;
+ }
+ }
+ assert!(finished, "{format:?} decompression did not finish within {MAX_STEPS} steps");
+ }
+ }
+
+ #[test]
+ fn the_decompressor_builder_applies_its_trailing_data_policy() {
+ for &format in Format::ALL {
+ let compressed = crate::format::compress(format, view(&b"payload ".repeat(4_096)), &Resources::default()).unwrap();
+ let joined = BytesView::from_views([compressed, view(b"trailing")]);
+ let mut decompressor = DecompressorBuilder::new()
+ .multi_stream(false)
+ .trailing_data(TrailingData::Reject)
+ .output_chunk_size(NonZeroUsize::new(64).unwrap())
+ .build_format(format, &Resources::default())
+ .unwrap();
+ decompressor.push(joined).unwrap();
+ decompressor.end_input();
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream) {
+ Ok(output) => {
+ assert!(
+ !output.is_done() && !output.is_need_input(),
+ "{format:?}: trailing input unexpectedly completed"
+ );
+ }
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_corrupt_data(), "{format:?}: got {error}");
+ }
+ }
+
+ #[test]
+ fn explicit_limits_are_available_on_the_one_shot_runtime_api() {
+ for &format in Format::ALL {
+ let compressed = crate::format::compress(format, view(&vec![0_u8; 4096]), &Resources::default()).unwrap();
+ let error = crate::format::decompress_with_limits(
+ format,
+ compressed,
+ &Resources::default(),
+ DecompressorLimits::new()
+ .unbounded_ratio()
+ .max_output_len(NonZeroU64::new(1024).unwrap()),
+ )
+ .unwrap_err();
+
+ assert!(error.is_limit_exceeded(), "{format:?}: got {error}");
+ }
+ }
+
+ #[test]
+ fn multi_stream_governs_every_format() {
+ // The generic half of the contract: whatever the format, setting this explicitly decides
+ // whether a second stream is decompressed or ignored.
+ let payload = b"member ".repeat(50);
+
+ for &format in Format::ALL {
+ let compressed = crate::format::compress(format, view(&payload), &Resources::default()).unwrap();
+ let joined = BytesView::from_views([compressed.clone(), compressed]);
+
+ let joined_len = decompressed_len(
+ decompressor_for(DecompressorBuilder::new().multi_stream(true), format),
+ joined.clone(),
+ );
+ assert_eq!(joined_len, payload.len() * 2, "{format:?} should join with multi_stream(true)");
+
+ let single_len = decompressed_len(
+ decompressor_for(
+ DecompressorBuilder::new().multi_stream(false).trailing_data(TrailingData::Ignore),
+ format,
+ ),
+ joined,
+ );
+ assert_eq!(single_len, payload.len(), "{format:?} should stop with multi_stream(false)");
+ }
+ }
+
+ #[test]
+ fn each_format_keeps_its_own_multi_stream_default() {
+ // The format-specific half: the runtime builder must preserve each format's own default
+ // rather than flattening every format to one behaviour. Gzip and zstd join, matching
+ // `gzip(1)` and the `zstd` tool; the rest stop at the first stream.
+ let payload = b"member ".repeat(50);
+
+ for &format in Format::ALL {
+ // Matching the variant by name keeps this free of the cfg gates the variants carry.
+ let joins_by_default = matches!(format!("{format:?}").as_str(), "Gzip" | "Zstd");
+
+ let compressed = crate::format::compress(format, view(&payload), &Resources::default()).unwrap();
+ let joined = BytesView::from_views([compressed.clone(), compressed]);
+
+ // `Ignore` isolates the multi-stream default under test from the trailing-data policy,
+ // which would otherwise reject the second member for the formats that do not join.
+ let len = decompressed_len(
+ decompressor_for(DecompressorBuilder::new().trailing_data(TrailingData::Ignore), format),
+ joined,
+ );
+ let expected = if joins_by_default { payload.len() * 2 } else { payload.len() };
+
+ assert_eq!(len, expected, "{format:?} did not keep its documented default");
+ }
+ }
+
+ fn decompressed_len(decompressor: Decompressor, input: BytesView) -> usize {
+ crate::decompress(input, decompressor).unwrap().len()
+ }
+
+ fn decompressor_for(builder: DecompressorBuilder<()>, format: Format) -> Decompressor {
+ builder.build_format(format, &Resources::default()).unwrap()
+ }
+
+ #[test]
+ fn no_format_bounds_output_or_stream_count_by_default() {
+ // A decompressor hands each chunk straight back and keeps nothing, so bounding its total
+ // output or member count would cut off long streams that never buffer more than one chunk.
+ // Cumulative bounds belong to the conveniences that accumulate, applied by
+ // `DecompressorLimits::for_buffered_output` and asserted in `limits`.
+ fn assert_uncapped(name: &str, limits: FormatLimits) {
+ const HUGE: u64 = 64 * 1024 * 1024 * 1024;
+
+ // Input matches output so the ratio guard, which differs per format, never fires here.
+ assert!(limits.check(HUGE, HUGE, 1).is_ok(), "{name} should not cap total output");
+ assert!(limits.check(1, 0, HUGE).is_ok(), "{name} should not cap stream count");
+ }
+
+ #[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+ assert_uncapped("flate", crate::flate::DEFAULT_LIMITS);
+ #[cfg(any(test, feature = "brotli"))]
+ assert_uncapped("brotli", crate::brotli::DEFAULT_LIMITS);
+ #[cfg(any(test, feature = "zstd"))]
+ assert_uncapped("zstd", crate::zstd::DEFAULT_LIMITS);
+ }
+
+ #[test]
+ fn all_lists_exactly_the_compiled_in_formats() {
+ let expected = usize::from(cfg!(any(test, feature = "deflate")))
+ + usize::from(cfg!(any(test, feature = "zlib")))
+ + usize::from(cfg!(any(test, feature = "gzip")))
+ + usize::from(cfg!(any(test, feature = "brotli")))
+ + usize::from(cfg!(any(test, feature = "zstd")));
+
+ assert_eq!(Format::ALL.len(), expected);
+ }
+}
diff --git a/crates/compressors/src/gzip/mod.rs b/crates/compressors/src/gzip/mod.rs
new file mode 100644
index 000000000..1e946a2fc
--- /dev/null
+++ b/crates/compressors/src/gzip/mod.rs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Gzip (RFC 1952): a deflate payload with a member header and a CRC-32 plus length trailer.
+//!
+//! The header is a ten byte fixed prefix followed by optional fields -- an original file name, a
+//! comment, an extra field, a header checksum -- so a member header is not a fixed size.
+//!
+//! This is the format behind HTTP `Content-Encoding: gzip` and the `.gz` file extension.
+//! Concatenated members decompress as one logical stream by default, matching `gzip(1)`.
+//!
+//! # Examples
+//!
+//! ```
+//! use compressors::{Resources, gzip};
+//!
+//! let compressed = gzip::compress(b"the quick brown fox", &Resources::default())?;
+//! assert_eq!(compressed.range(0..2).to_vec(), vec![0x1f, 0x8b]);
+//!
+//! assert_eq!(
+//! gzip::decompress(compressed, &Resources::default())?.to_vec(),
+//! b"the quick brown fox".to_vec()
+//! );
+//! # Ok::<(), compressors::Error>(())
+//! ```
+
+use crate::flate::codec::{FlateCompress, FlateDecompress};
+use crate::flate::{DEFAULT_LIMITS, Wrapper};
+use crate::macros::define_format;
+
+/// Selects gzip as the format of a [`CompressorBuilder`][crate::CompressorBuilder] or [`DecompressorBuilder`][crate::DecompressorBuilder].
+///
+/// Gzip has no settings beyond the ones every format shares, so this type carries none. It exists
+/// to name the format in the builder's type parameter, which is what gives that builder a `build`
+/// method producing this module's [`Compressor`] and [`Decompressor`].
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct Gzip;
+
+impl Gzip {
+ /// The settings a gzip builder starts with. Gzip has none of its own.
+ pub(crate) const fn new() -> Self {
+ Self
+ }
+}
+
+define_format! {
+ name = "gzip",
+ format = Gzip,
+ build_method = build_gzip,
+ compressor_codec = FlateCompress,
+ compressor_build = infallible,
+ new_compressor = |level, _format, pool| FlateCompress::new(Wrapper::Gzip, level, pool),
+ decompressor_codec = FlateDecompress,
+ decompressor_build = infallible,
+ default_limits = DEFAULT_LIMITS,
+ new_decompressor = |limits, multi_stream, trailing_data, _format, pool| {
+ FlateDecompress::new(Wrapper::Gzip, limits, multi_stream, trailing_data, pool)
+ },
+ multi_stream_default = true,
+}
diff --git a/crates/compressors/src/input.rs b/crates/compressors/src/input.rs
new file mode 100644
index 000000000..7bff0d747
--- /dev/null
+++ b/crates/compressors/src/input.rs
@@ -0,0 +1,114 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! What the whole-buffer conveniences accept as input.
+//!
+//! [`InputData`] is why `gzip::compress(b"hello", resources)` reads as well as
+//! `gzip::compress(view, resources)`: the conveniences already hold the [`Resources`] a
+//! [`BytesView`] needs to be built from, so a caller with a plain slice does not have to build one
+//! by hand first.
+
+use bytesbuf::BytesView;
+
+use crate::resources::Resources;
+
+pub(crate) mod sealed {
+ /// Restricts [`InputData`][super::InputData] to the inputs this crate defines.
+ ///
+ /// Living in a `pub(crate)` module makes it unnameable downstream, so the trait cannot be
+ /// implemented outside this crate even though its own method is public.
+ pub trait Sealed {}
+}
+
+/// A byte sequence one of this crate's whole-buffer conveniences can compress or decompress.
+///
+/// Implemented for [`BytesView`], which is passed through untouched, and for byte slices and
+/// arrays, which are copied into memory drawn from the [`Resources`] the convenience was given.
+///
+/// The trait is sealed: it names the inputs this crate accepts rather than being an extension
+/// point, so implementations can be added without that being a breaking change.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "gzip")]
+/// # {
+/// use bytesbuf::BytesView;
+/// use compressors::{Resources, gzip};
+///
+/// let resources = Resources::global();
+///
+/// // A slice, copied into the resources' memory on the way in.
+/// let from_slice = gzip::compress(b"hello", resources)?;
+///
+/// // A view that already exists, used as it is.
+/// let view = BytesView::copied_from_slice(b"hello", resources.memory());
+/// let from_view = gzip::compress(view, resources)?;
+///
+/// assert_eq!(from_slice.to_vec(), from_view.to_vec());
+/// # }
+/// # Ok::<(), compressors::Error>(())
+/// ```
+pub trait InputData: sealed::Sealed {
+ /// Produces the [`BytesView`] the engine consumes, allocating from `resources` if it has to.
+ ///
+ /// Taking `self` by value is what lets an existing view be forwarded without a copy.
+ fn into_view(self, resources: &Resources) -> BytesView;
+}
+
+impl sealed::Sealed for BytesView {}
+
+impl InputData for BytesView {
+ fn into_view(self, _resources: &Resources) -> BytesView {
+ self
+ }
+}
+
+impl sealed::Sealed for &[u8] {}
+
+impl InputData for &[u8] {
+ fn into_view(self, resources: &Resources) -> BytesView {
+ BytesView::copied_from_slice(self, resources.memory())
+ }
+}
+
+// A byte-string literal is `&[u8; N]`, which does not coerce to `&[u8]` while matching a generic
+// parameter, so the array carries its own implementation rather than making every caller write
+// `b"..."` followed by `.as_slice()`.
+impl sealed::Sealed for &[u8; N] {}
+
+impl InputData for &[u8; N] {
+ fn into_view(self, resources: &Resources) -> BytesView {
+ BytesView::copied_from_slice(self.as_slice(), resources.memory())
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_view_is_passed_through_unchanged() {
+ let resources = Resources::default();
+ let view = BytesView::copied_from_slice(b"already a view", resources.memory());
+
+ assert_eq!(view.clone().into_view(&resources).to_vec(), view.to_vec());
+ }
+
+ #[test]
+ fn a_slice_is_copied_into_the_supplied_memory() {
+ let resources = Resources::default();
+ let slice: &[u8] = b"a plain slice";
+
+ assert_eq!(slice.into_view(&resources).to_vec(), slice.to_vec());
+ }
+
+ #[test]
+ fn an_array_reference_is_copied_like_a_slice() {
+ // `b"..."` is `&[u8; N]`, so this is the shape most call sites actually have.
+ let resources = Resources::default();
+
+ assert_eq!(b"a literal".into_view(&resources).to_vec(), b"a literal".to_vec());
+ }
+}
diff --git a/crates/compressors/src/level.rs b/crates/compressors/src/level.rs
new file mode 100644
index 000000000..a83318014
--- /dev/null
+++ b/crates/compressors/src/level.rs
@@ -0,0 +1,164 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/// A portable compression-effort level from [`Level::MIN`] to [`Level::MAX`].
+///
+/// This is a newtype rather than a re-export of the underlying compression engine's level type.
+/// Exposing the engine's type would make the engine part of this crate's semver surface, so
+/// swapping or upgrading it would become a breaking change for every consumer.
+///
+/// The scale orders settings from lower effort and latency to higher effort and usually better
+/// compression. It does not promise that zero disables compression or that nine is the strongest
+/// setting a format supports; use a format-specific level type when exact native control matters.
+///
+/// # How the scale is calibrated
+///
+/// The `0..=9` range and the position of [`DEFAULT`][Self::DEFAULT] come from the deflate family,
+/// whose native scale this is; every other format is mapped onto it rather than the other way
+/// round. The anchor is meaning rather than arithmetic: each format's mapping is chosen so that
+/// [`DEFAULT`][Self::DEFAULT] lands on that format's own balanced setting -- zstd's native 3, for
+/// instance -- rather than on the midpoint of its native range. Where a format's own range climbs
+/// steeply at the top, the mapping stops short of it instead of stretching to reach it, which is
+/// why the top of this scale is not necessarily the top of a format's.
+///
+/// The scale is portable but its *cost* is not, and how much a step costs differs by format and by
+/// payload -- in time, and for some backends in working memory too. The mapping is chosen so that
+/// moving up the scale never lowers effort, and nothing stronger than that ordering is promised.
+/// Treat [`Level::HIGH`] as a deliberate choice to be measured on real payloads, not as a free
+/// improvement.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(any_format)]
+/// # {
+/// use compressors::Level;
+///
+/// assert_eq!(Level::default(), Level::DEFAULT);
+/// assert_eq!(Level::new(9), Some(Level::HIGH));
+/// assert_eq!(Level::new(10), None);
+/// # }
+/// ```
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct Level(u8);
+
+impl Level {
+ /// The lowest-effort setting on the portable scale.
+ pub const MIN: Self = Self(0);
+
+ /// The fastest level that still compresses.
+ pub const FAST: Self = Self(1);
+
+ /// A balanced trade-off between speed and compression ratio.
+ pub const DEFAULT: Self = Self(6);
+
+ /// A high-compression setting at a correspondingly high cost.
+ ///
+ /// See the note on [`Level`] before reaching for this.
+ pub const HIGH: Self = Self(9);
+
+ /// The top of the portable scale, the same as [`Level::HIGH`].
+ ///
+ /// This is a `Level` rather than a bare number, so it can be passed straight to a builder.
+ pub const MAX: Self = Self::HIGH;
+
+ /// Creates a level, or returns `None` if `level` exceeds [`Level::MAX`].
+ ///
+ /// This returns an `Option` rather than panicking because levels routinely arrive from
+ /// configuration files and command-line arguments, where an out-of-range value is a user
+ /// mistake to be reported rather than a bug to crash on. Use [`TryFrom`] when you want that
+ /// mistake as an [`Error`][crate::Error] to propagate with `?`.
+ #[must_use]
+ pub const fn new(level: u8) -> Option {
+ if level > Self::MAX.0 { None } else { Some(Self(level)) }
+ }
+
+ /// Returns the level as a number in `0..=9`.
+ #[must_use]
+ pub const fn get(self) -> u8 {
+ self.0
+ }
+}
+
+impl Default for Level {
+ fn default() -> Self {
+ Self::DEFAULT
+ }
+}
+
+impl TryFrom for Level {
+ type Error = crate::Error;
+
+ fn try_from(level: u8) -> Result {
+ Self::new(level).ok_or_else(|| {
+ crate::Error::invalid_configuration(format!(
+ "compression level {level} is out of range; expected {}..={}",
+ Self::MIN.0,
+ Self::MAX.0
+ ))
+ })
+ }
+}
+
+impl From for u8 {
+ #[inline]
+ fn from(level: Level) -> Self {
+ level.get()
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn new_accepts_the_whole_valid_range() {
+ for level in 0..=Level::MAX.get() {
+ let parsed = Level::new(level).unwrap();
+ assert_eq!(parsed.get(), level);
+ }
+ }
+
+ #[test]
+ fn new_rejects_out_of_range_without_panicking() {
+ assert_eq!(Level::new(10), None);
+ assert_eq!(Level::new(u8::MAX), None);
+ }
+
+ #[test]
+ fn bounds_are_levels_so_they_can_be_passed_to_a_builder() {
+ assert_eq!(Level::MIN.get(), 0);
+ assert_eq!(Level::MAX, Level::HIGH);
+ }
+
+ #[test]
+ fn conversions_follow_the_standard_traits() {
+ assert_eq!(Level::try_from(9).unwrap(), Level::HIGH);
+ assert_eq!(u8::from(Level::HIGH), 9);
+
+ let error = Level::try_from(10).unwrap_err();
+ assert!(error.is_invalid_configuration(), "got {error}");
+ assert!(error.to_string().contains("0..=9"), "the message should name the range: {error}");
+ }
+
+ #[test]
+ fn named_levels_have_the_expected_values() {
+ assert_eq!(Level::MIN.get(), 0);
+ assert_eq!(Level::FAST.get(), 1);
+ assert_eq!(Level::DEFAULT.get(), 6);
+ assert_eq!(Level::HIGH.get(), 9);
+ }
+
+ #[test]
+ fn default_matches_the_default_constant() {
+ assert_eq!(Level::default(), Level::DEFAULT);
+ }
+
+ #[test]
+ fn levels_order_by_strength() {
+ assert!(Level::MIN < Level::FAST);
+ assert!(Level::FAST < Level::DEFAULT);
+ assert!(Level::DEFAULT < Level::HIGH);
+ }
+}
diff --git a/crates/compressors/src/lib.rs b/crates/compressors/src/lib.rs
new file mode 100644
index 000000000..92ed70785
--- /dev/null
+++ b/crates/compressors/src/lib.rs
@@ -0,0 +1,354 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+#![cfg_attr(docsrs, feature(doc_cfg))]
+
+//! Streaming compression and decompression over [`bytesbuf`] byte sequences.
+//!
+//! Each supported format -- `deflate`, `zlib`, `gzip`, `brotli`, `zstd` -- lives in a module of its
+//! own behind a cargo feature of its own. What those modules share is uniform: `compress`,
+//! `decompress`, `Compressor` and `Decompressor` have the same shape in every one of them, so
+//! moving a call site between formats is a change of import. Their builders are not uniform:
+//! `brotli` and `zstd` add format-specific settings, and their compressor `build` returns a
+//! [`Result`], so switching a builder call site can take more than an import change.
+//!
+//! **Engine** below means a third-party format implementation (`flate2`, the `brotli` crate,
+//! `zstd-safe`) together with the working memory it allocates. A `Compressor` or a `Decompressor`
+//! owns one, configured and positioned in a single stream, and returns it to [`Resources`] on drop.
+//!
+//! This crate is distinguished by:
+//!
+//! * **It reads and writes [`bytesbuf`] sequences directly.** Input is read from a [`BytesView`]'s
+//! segments where they already sit, and output is written into the uninitialized spare capacity
+//! of a [`BytesBuf`][bytesbuf::BytesBuf]. Nothing is flattened into an intermediate buffer on the
+//! way in, and nothing is copied out of one on the way back.
+//! * **It recycles engine state.** [`Resources`] keeps the window and hash tables an engine
+//! allocates and hands them to the next compressor or decompressor that needs them. On a small
+//! message that setup costs about as much as the compression itself, so the saving is worth
+//! having.
+//! * **One API spans every format, at any size.** The same push/pull contract drives every engine,
+//! so code is written once and works with whichever one it is given. Because an engine is a state
+//! machine rather than a one-shot transform, a stream of any length passes through it while the
+//! pending output it buffers stays bounded by the configured chunk size.
+//!
+//! Secondarily, this is also why the engines are not driven through `std::io`. That route works --
+//! [`BytesView`] implements `BufRead` over its segments and `BytesBufWriter` implements `Write`
+//! into segmented storage, so nothing has to be flattened to use it. What the direct adapters buy
+//! is narrower: output goes straight into a [`BytesBuf`][bytesbuf::BytesBuf]'s uninitialized spare
+//! capacity rather than through an intermediate buffer the adapter owns, engine state stays
+//! reusable from one stream to the next, and flush and chunk boundaries remain under this crate's
+//! control.
+//!
+//! # Whole buffers
+//!
+//! Each format module has its own `compress` and `decompress` for the common case. The crate-level
+//! [`compress`] and [`decompress`] instead accept any engine implementing [`Compression`],
+//! however it was constructed.
+//!
+//! ```
+//! # #[cfg(feature = "gzip")]
+//! # {
+//! use compressors::{Resources, gzip};
+//!
+//! let resources = Resources::global();
+//! let compressed = gzip::compress(b"hello", resources)?;
+//!
+//! assert_eq!(
+//! gzip::decompress(compressed, resources)?.to_vec(),
+//! b"hello".to_vec()
+//! );
+//! # }
+//! # Ok::<(), compressors::Error>(())
+//! ```
+//!
+//! # Streaming
+//!
+//! An engine is a state machine rather than 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
+//! their size depends on the format and its configuration. `CompressionStream`, behind the
+//! `futures-stream` feature, is how to reach that -- it turns any stream of byte sequences into its
+//! compressed or decompressed counterpart:
+//!
+//! ```
+//! # #[cfg(all(feature = "futures-stream", feature = "gzip"))]
+//! # {
+//! use std::io::Error as IoError;
+//!
+//! use bytesbuf::BytesView;
+//! use compressors::{CompressionStream, Resources, gzip};
+//! use futures::{TryStreamExt, stream};
+//!
+//! # futures::executor::block_on(async {
+//! let resources = Resources::global();
+//! let body = stream::iter(vec![
+//! Ok::<_, IoError>(BytesView::copied_from_slice(b"a body ", resources.memory())),
+//! Ok(BytesView::copied_from_slice(
+//! b"in pieces",
+//! resources.memory(),
+//! )),
+//! ]);
+//!
+//! let mut compressed = CompressionStream::compress(body, gzip::Compressor::new(resources));
+//!
+//! // Each chunk is inspected and dropped as it arrives, so the caller stays bounded too --
+//! // collecting them all would put the whole encoded body back in memory.
+//! let mut magic = Vec::new();
+//! while let Some(chunk) = compressed.try_next().await? {
+//! if magic.is_empty() && chunk.len() >= 2 {
+//! magic = chunk.range(0..2).to_vec();
+//! }
+//! }
+//!
+//! assert_eq!(magic, vec![0x1f, 0x8b]);
+//! # Ok::<(), compressors::Error>(())
+//! # })
+//! # .expect("the in-memory source stream cannot fail");
+//! # }
+//! ```
+//!
+//! # Choosing a format
+//!
+//! When the format is only known at runtime -- from a `Content-Encoding` token, say -- the
+//! [`format`](mod@crate::format) module resolves the token and carries the same shape every other
+//! format module does: a `Compressor`, a `Decompressor`, and the whole-buffer conveniences. Use
+//! [`CompressorBuilder::build_format`] instead when a level or chunk size has to be set on the
+//! result.
+//!
+//! Note that the `deflate` feature and the HTTP `deflate` content coding are not the same thing.
+//! `Format::Deflate` is raw DEFLATE (RFC 1951), which has no content-coding token, so
+//! `Format::Deflate.content_encoding()` returns `None`. The HTTP `deflate` token denotes a
+//! zlib-wrapped stream (RFC 1950), so `Format::from_content_encoding("deflate")` resolves to
+//! `Format::Zlib` and needs the `zlib` feature, not the `deflate` one.
+//!
+//! ```
+//! # #[cfg(feature = "gzip")]
+//! # {
+//! use compressors::Resources;
+//! use compressors::format::{self, Format};
+//!
+//! let format = Format::from_content_encoding("gzip").expect("this build supports gzip");
+//!
+//! let resources = Resources::global();
+//! let compressed = format::compress(format, b"runtime selected", resources)?;
+//!
+//! assert_eq!(
+//! format::decompress(format, compressed, resources)?.to_vec(),
+//! b"runtime selected".to_vec()
+//! );
+//! # }
+//! # Ok::<(), compressors::Error>(())
+//! ```
+//!
+//! # Reusing engine state
+//!
+//! Building a compressor allocates and initializes a substantial amount of state -- on a small
+//! message, as much work as the compression itself. [`Resources`] recycles it: hold one, hand it to
+//! every compressor and decompressor, and each engine returns to it on drop. The saving is roughly
+//! fixed per message, so it matters most for small bodies.
+//!
+//! Recycling is on by default, which is why every API that builds an engine asks for resources rather
+//! than for a memory provider alone. Set the capacity to zero with
+//! [`with_pool_capacity`][Resources::with_pool_capacity] when compression is rare enough that
+//! retaining engine state costs more than rebuilding it.
+//!
+//! ```
+//! # #[cfg(feature = "gzip")]
+//! # {
+//! use compressors::{Level, Resources, gzip};
+//!
+//! // Held once by the application, cloned into whatever needs it.
+//! let resources = Resources::global();
+//!
+//! // Per request: cheap to build, recycles the engine on drop.
+//! let compressor = gzip::Compressor::builder()
+//! .level(Level::DEFAULT)
+//! .build(resources);
+//! # let _ = compressor;
+//! # }
+//! ```
+//!
+//! Recycling applies only to the engines whose state is expensive enough to be worth retaining and
+//! is skipped for the rest, so calling code never has to know which engines benefit.
+//!
+//! # Security
+//!
+//! Every one of these formats can expand its input by orders of magnitude, so a decompressor
+//! pointed at untrusted data is a memory-exhaustion vector. A decompressor consumed through
+//! [`CompressionStream`] never accumulates -- each chunk it hands back is bounded -- so the
+//! exposure is in what the caller keeps, which makes it the conveniences that buffer a whole result
+//! that need bounding. Those add a 64 MiB output cap and a 1024 concatenated-stream cap to whatever
+//! the caller did not set.
+//!
+//! When you buffer decompressed output yourself, set
+//! [`max_output_len`][DecompressorLimits::max_output_len] to what you can afford. That
+//! guardrail is for the common case, not a substitute for bounding how many bodies you decompress
+//! at once. [`DecompressorLimits`] documents what each format bounds by default, and why a ratio
+//! alone is not protection.
+//!
+//! Decompression can yield bytes before a checksum or trailer has rejected the stream, so treat
+//! them as provisional until the decompressor reports that it is done. A checksum match is not an
+//! authenticity claim either -- none of these formats is authenticated -- so decompressing does not
+//! make the content any more trustworthy than it was.
+//!
+//! See [`documentation`] for the full threat model.
+//!
+//! # Features
+//!
+//! Every format is a separate feature and none is on by default, so a build compiles only the
+//! engines it names:
+//!
+//! * `gzip` -- the `gzip` module and `Format::Gzip`, via `flate2`. Accepted by essentially every
+//! HTTP client and server, so it is the safe default when the peer's capabilities are unknown.
+//! * `deflate` -- the `deflate` module and `Format::Deflate`, via `flate2`. Raw DEFLATE, with no
+//! HTTP content-coding token of its own.
+//! * `zlib` -- the `zlib` module and `Format::Zlib`, via `flate2`. This is what the HTTP `deflate`
+//! content coding actually denotes.
+//! * `brotli` -- the `brotli` module and `Format::Brotli`, via the pure-Rust `brotli` crate.
+//! * `zstd` -- the `zstd` module and `Format::Zstd`, via `zstd-safe`.
+//! * `futures-stream` -- `CompressionStream`, presenting compression and decompression as a
+//! `futures_core::Stream` over any stream of byte sequences.
+//!
+//! The deflate-family features share one dependency, so enabling more than one of them costs no
+//! more than enabling one. A build that needs only `brotli` or only `zstd` never compiles `flate2`
+//! at all, and a build that names no format at all still gets [`Compression`], the builders and
+//! [`Resources`], which is what a crate that only passes compressors and decompressors around
+//! needs.
+//!
+//! # Further reading
+//!
+//! See [`documentation`] for the longer form guides.
+
+#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/compressors/logo.png")]
+#![doc(html_favicon_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/compressors/favicon.ico")]
+
+#[cfg(any(doc, test))]
+pub mod documentation;
+
+#[cfg(any(test, feature = "brotli"))]
+pub mod brotli;
+mod builder;
+pub mod core;
+#[cfg(any(test, feature = "deflate"))]
+pub mod deflate;
+#[cfg(any(test, any_format))]
+mod engine;
+mod error;
+#[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+mod flate;
+pub mod format;
+#[cfg(any(test, feature = "gzip"))]
+pub mod gzip;
+mod input;
+mod level;
+pub(crate) mod limits;
+#[cfg(any(test, any_format))]
+mod macros;
+mod pool;
+mod resources;
+mod trailing;
+#[cfg(any(test, feature = "zlib"))]
+pub mod zlib;
+#[cfg(any(test, feature = "zstd"))]
+pub mod zstd;
+
+#[cfg(any(test, feature = "futures-stream"))]
+mod stream;
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod testing;
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests;
+
+pub use builder::{CompressorBuilder, DecompressorBuilder};
+use bytesbuf::BytesView;
+pub use error::{BuildError, Error, Result};
+pub use input::InputData;
+pub use level::Level;
+pub use limits::DecompressorLimits;
+pub use resources::Resources;
+#[cfg(any(test, feature = "futures-stream"))]
+pub use stream::CompressionStream;
+pub use trailing::TrailingData;
+
+use crate::core::{Compress, Compression, Decompress, process};
+
+/// Compresses one complete byte sequence that is already in memory.
+///
+/// Takes any compressor: a concrete one such as a `gzip::Compressor`, or the
+/// `format::Compressor` that [`CompressorBuilder::build_format`] returns for a format chosen at
+/// runtime. The direction is part of the bound, so a decompressor will not compile here.
+///
+/// Prefer [`CompressionStream`] for data that arrives incrementally: this buffers the entire
+/// result before returning.
+///
+/// # Security
+///
+/// Unlike [`decompress`], this applies no ceiling. Compressed output tracks the size of the input
+/// the caller already holds, so it is not an amplification vector, and a cap would make a
+/// legitimately large compression impossible with no way to raise it.
+///
+/// # Errors
+///
+/// Returns an error if the underlying compression engine fails.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "gzip")]
+/// # {
+/// use bytesbuf::BytesView;
+/// use compressors::format::Format;
+/// use compressors::{CompressorBuilder, Resources, gzip};
+///
+/// let resources = Resources::global();
+/// let input = BytesView::copied_from_slice(b"either way", resources.memory());
+///
+/// // A compressor built by hand, or one for a format chosen at runtime.
+/// let by_hand = compressors::compress(input.clone(), gzip::Compressor::new(resources))?;
+/// let at_runtime = compressors::compress(
+/// input,
+/// CompressorBuilder::new().build_format(Format::Gzip, resources)?,
+/// )?;
+///
+/// assert_eq!(by_hand.to_vec(), at_runtime.to_vec());
+/// # }
+/// # Ok::<(), compressors::Error>(())
+/// ```
+pub fn compress(input: BytesView, compressor: impl Compression) -> Result {
+ process(compressor, input)
+}
+
+/// Decompresses one complete stream that is already in memory.
+///
+/// Takes any decompressor, exactly as [`compress`] takes any compressor.
+///
+/// # Errors
+///
+/// Returns an error if the data is malformed, truncated, or exceeds the limits the decompressor
+/// was built with.
+///
+/// # Security
+///
+/// Buffering the whole result is a memory-exhaustion vector on untrusted input, so a decompressor
+/// that was left unbounded is held to the same defaults every format's own `decompress` applies:
+/// a 64 MiB output ceiling and a 1024 concatenated-stream cap. Without the first, a decompressor
+/// for a format that declares no defaults -- brotli declares none -- would let a hundred
+/// compressed bytes expand without limit here. The second covers what the first cannot: many tiny
+/// members each pay a full engine setup while producing almost no output, so no output bound ever
+/// trips.
+///
+/// Both are fallbacks, not overrides, and each is decided independently. Whatever the caller asked
+/// for on the decompressor's [`limits`][DecompressorBuilder::limits] wins:
+///
+/// | That bound was built with | Applied here |
+/// |---|---|
+/// | nothing, or [`DecompressorLimits::new`] | 64 MiB output, 1024 streams |
+/// | [`max_output_len`][DecompressorLimits::max_output_len] or [`max_streams`][DecompressorLimits::max_streams] | the caller's value |
+/// | [`unbounded_output_len`][DecompressorLimits::unbounded_output_len] or [`unbounded_streams`][DecompressorLimits::unbounded_streams] | none for that bound |
+/// | [`DecompressorLimits::UNLIMITED`] | none for either bound |
+pub fn decompress(input: BytesView, decompressor: impl Compression) -> Result {
+ process(decompressor, input)
+}
diff --git a/crates/compressors/src/limits.rs b/crates/compressors/src/limits.rs
new file mode 100644
index 000000000..808f76737
--- /dev/null
+++ b/crates/compressors/src/limits.rs
@@ -0,0 +1,619 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::num::{NonZeroU32, NonZeroU64};
+
+use crate::error::{Error, Result};
+
+/// Cumulative output below this size is never rejected by the ratio guard.
+///
+/// A container carries a fixed header and trailer, and a short stream's compressed form can easily
+/// be larger than its payload. Without a floor, a legitimate two-byte stream would look like an
+/// infinitely bad expansion ratio and be rejected. 32 KiB is far below any size at which a
+/// decompression bomb becomes a memory-exhaustion risk.
+#[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only the decompressors resolve and enforce bounds, and no format is enabled")
+)]
+const RATIO_FLOOR_BYTES: u64 = 32 * 1024;
+
+/// The cap the buffering conveniences put on total decompressed output.
+///
+/// A ratio bound cannot tell a bomb from legitimate highly-compressible data, so an absolute cap is
+/// what actually bounds untrusted input. It applies where the crate accumulates a whole result --
+/// each format's `decompress` and `decompress_with_limits`, and the same pair on
+/// [`Format`][crate::format::Format] -- because those are the paths where a bomb exhausts the caller's
+/// memory. A decompressor driven incrementally hands every chunk straight back, so a cumulative
+/// bound there would cut off long streams that never buffer more than one chunk.
+///
+/// 64 MiB is a policy guardrail for the common case, not a universal safety guarantee: a server
+/// decompressing many bodies at once still has to bound its own concurrency. A caller who buffers
+/// more, or less, passes explicit [`DecompressorLimits`] to `decompress_with_limits`.
+#[cfg(not(test))]
+pub(crate) const DEFAULT_MAX_OUTPUT_LEN: u64 = SHIPPED_MAX_OUTPUT_LEN;
+
+/// The shipped cap is [`SHIPPED_MAX_OUTPUT_LEN`]; see the definition above for what it is for.
+///
+/// Lowered to 1 MiB for the crate's own tests. Every case that has to cross this bound builds a
+/// payload derived from it, so the shipped value costs the suite a 64 MiB allocation per such case
+/// -- and one brotli compression of that size -- for no added confidence. What those tests check is
+/// that the bound is applied, resolved and overridden correctly, and none of that depends on its
+/// magnitude. They state the bound through this constant rather than a literal, so they follow it
+/// down.
+#[cfg(test)]
+pub(crate) const DEFAULT_MAX_OUTPUT_LEN: u64 = 1024 * 1024;
+
+/// What a real build caps buffered output at, named separately from [`DEFAULT_MAX_OUTPUT_LEN`] so
+/// that a test build -- which lowers that constant -- can still assert the documented figure.
+pub(crate) const SHIPPED_MAX_OUTPUT_LEN: u64 = 64 * 1024 * 1024;
+
+/// The cap the buffering conveniences put on concatenated stream count.
+///
+/// Each stream costs engine setup that its own payload need not pay for, so a buffered input of
+/// many tiny members amplifies work out of proportion to its size. Like the output cap this binds
+/// only where output accumulates: formats that treat concatenated members as one logical stream are
+/// used incrementally for exactly the block-oriented archive workloads that run to many thousands
+/// of members, and those must keep passing through.
+///
+/// The rule the number encodes: comfortably above any plausible count for a single buffered HTTP
+/// body, and far enough below an archive's member count that the two cases stay distinguishable. It
+/// is a policy guardrail chosen for that separation rather than a measured threshold.
+pub(crate) const DEFAULT_MAX_STREAMS: u64 = 1024;
+
+/// Bounds on how much data decompression may produce.
+///
+/// Compressed data can expand by orders of magnitude, so a decompressor pointed at untrusted input is a
+/// memory-exhaustion vector.
+///
+/// This type carries *overrides*, not values. Each bound starts unset, meaning the format applies
+/// its own default. The only bound a format sets is the ratio, because the formats differ by orders
+/// of magnitude in what they can legitimately produce:
+///
+/// | Format | Default ratio bound | Why |
+/// |---|---|---|
+/// | `deflate`, `zlib`, `gzip` | `1100x` | deflate cannot expand further than about `1032x`; that is structural |
+/// | `brotli` | none | brotli has no structural ceiling, so any ratio bound rejects sufficiently compressible legitimate data |
+/// | `zstd` | `250 000x` | zstd has no structural ceiling either, so this is a very loose coarse backstop rather than a bound derived from the format |
+///
+/// Total output and stream count are not bounded by default, because a decompressor hands each
+/// chunk straight back and a stream of any length passes through it in bounded memory. The
+/// conveniences that buffer a whole result -- each format's `decompress` and
+/// `decompress_with_limits`, and the same pair on [`Format`][crate::format::Format] -- add a 64 MiB output
+/// cap and a 1024 stream cap to whichever of those bounds the caller left unset.
+///
+/// Each setter accepts only a non-zero bound. Use the corresponding explicitly named `unbounded_*`
+/// method to remove one bound, including any format default or buffering fallback.
+///
+/// # Security
+///
+/// A ratio bound is a coarse backstop, not real protection: in a format with no structural
+/// expansion ceiling it cannot separate a bomb from legitimate highly-compressible data. What
+/// bounds untrusted input is an absolute cap on what you buffer. Set
+/// [`max_output_len`][Self::max_output_len] to whatever the caller can afford whenever it
+/// accumulates decompressed output itself.
+///
+/// # Examples
+///
+/// ```
+/// use std::num::{NonZeroU32, NonZeroU64};
+///
+/// use compressors::DecompressorLimits;
+///
+/// // Tighten the shared 64 MiB cap to what this caller can actually buffer.
+/// let untrusted =
+/// DecompressorLimits::new().max_output_len(NonZeroU64::new(16 * 1024 * 1024).unwrap());
+///
+/// // Or override every bound.
+/// let strict = DecompressorLimits::new()
+/// .max_ratio(NonZeroU32::new(50).unwrap())
+/// .max_output_len(NonZeroU64::new(1024 * 1024).unwrap())
+/// .max_streams(NonZeroU64::new(16).unwrap());
+///
+/// // Remove only the ratio guard, keeping the output and stream caps.
+/// let no_ratio = strict.unbounded_ratio();
+/// # let _ = (untrusted, no_ratio);
+/// ```
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct DecompressorLimits {
+ ratio: Option,
+ output_len: Option,
+ streams: Option,
+}
+
+impl DecompressorLimits {
+ /// Overrides nothing: every bound is left to the format's own default.
+ ///
+ /// This is what [`Default`] returns.
+ #[must_use]
+ pub const fn new() -> Self {
+ Self {
+ ratio: None,
+ output_len: None,
+ streams: None,
+ }
+ }
+
+ /// Removes every bound, overriding whatever the format would have applied.
+ ///
+ /// # Security
+ ///
+ /// Only use this when the compressed data comes from a source you trust to the same degree you
+ /// trust your own process. It removes the caps the buffering conveniences would otherwise apply,
+ /// so a decompression bomb passed to one of those will consume memory until the allocator gives
+ /// up. Driving a decompressor directly still hands back one bounded chunk at a time, and there
+ /// the risk is only what the consumer chooses to keep.
+ pub const UNLIMITED: Self = Self {
+ ratio: Some(NonZeroU32::MAX),
+ output_len: Some(NonZeroU64::MAX),
+ streams: Some(NonZeroU64::MAX),
+ };
+
+ /// Bounds the ratio of decompressed to compressed bytes.
+ ///
+ /// The ratio is only enforced once cumulative output exceeds 32 KiB, so small streams are never
+ /// rejected for the fixed overhead of their container.
+ ///
+ /// [`NonZeroU32::MAX`] represents an unbounded ratio. Prefer
+ /// [`unbounded_ratio`][Self::unbounded_ratio] when removing the bound so that intent is explicit.
+ #[must_use]
+ pub const fn max_ratio(mut self, ratio: NonZeroU32) -> Self {
+ self.ratio = Some(ratio);
+ self
+ }
+
+ /// Removes the ratio bound, overriding the format's default.
+ #[must_use]
+ pub const fn unbounded_ratio(mut self) -> Self {
+ self.ratio = Some(NonZeroU32::MAX);
+ self
+ }
+
+ /// Bounds the total decompressed size, in bytes.
+ ///
+ /// This is the bound that actually protects a caller which buffers the output. It takes a
+ /// [`NonZeroU64`] for the same reason the ratio and stream bounds take non-zero types: a bound
+ /// of zero rejects every stream, which is a way of not decompressing rather than a limit.
+ ///
+ /// [`NonZeroU64::MAX`] represents an unbounded output length. Prefer
+ /// [`unbounded_output_len`][Self::unbounded_output_len] when removing the bound so that intent is
+ /// explicit.
+ #[must_use]
+ pub const fn max_output_len(mut self, bytes: NonZeroU64) -> Self {
+ self.output_len = Some(bytes);
+ self
+ }
+
+ /// Removes 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 const fn unbounded_output_len(mut self) -> Self {
+ self.output_len = Some(NonZeroU64::MAX);
+ self
+ }
+
+ /// Bounds how many compressed streams may be decompressed from one input.
+ ///
+ /// One independently framed compressed stream costs one count. A gzip member is one such
+ /// stream, so a file of concatenated members costs one per member even though multi-stream mode
+ /// joins them into a single logical output; the same holds for concatenated zstd frames.
+ ///
+ /// This limits work that produces little or no output, such as a file containing millions of
+ /// empty gzip members.
+ ///
+ /// [`NonZeroU64::MAX`] represents an unbounded stream count. Prefer
+ /// [`unbounded_streams`][Self::unbounded_streams] when removing the bound so that intent is explicit.
+ #[must_use]
+ pub const fn max_streams(mut self, streams: NonZeroU64) -> Self {
+ self.streams = Some(streams);
+ self
+ }
+
+ /// Removes the stream-count bound, including the buffering conveniences' default cap.
+ #[must_use]
+ pub const fn unbounded_streams(mut self) -> Self {
+ self.streams = Some(NonZeroU64::MAX);
+ self
+ }
+
+ /// Adds the bounds an API that buffers a whole result needs, wherever the caller left them open.
+ ///
+ /// A decompressor that hands each chunk back keeps nothing, so it carries no cumulative bounds
+ /// and a stream of any length passes through it. The conveniences that accumulate are a
+ /// different proposition: what they produce is what the caller holds, so they apply the shared
+ /// caps. Only bounds the caller left unset are filled -- an explicit value, or an explicit
+ /// removal, is the caller's decision and survives untouched.
+ pub(crate) const fn for_buffered_output(mut self) -> Self {
+ if self.output_len.is_none() {
+ self.output_len = NonZeroU64::new(DEFAULT_MAX_OUTPUT_LEN);
+ }
+
+ if self.streams.is_none() {
+ self.streams = NonZeroU64::new(DEFAULT_MAX_STREAMS);
+ }
+
+ self
+ }
+
+ /// The bounds a caller that buffers this decompressor's whole output should apply on top.
+ ///
+ /// Each is `Some` only when the caller left that bound unset, in which case the shared default
+ /// stands in. An explicit value -- or an explicit
+ /// [`UNLIMITED`][DecompressorLimits::UNLIMITED] -- is the caller's decision, and the
+ /// decompressor already enforces it, so nothing is added on top.
+ ///
+ /// Both travel together in one value on purpose. They were once two separate accessors, and
+ /// the entry point that retrofits them onto an already-built decompressor picked up the output
+ /// half and silently kept no stream bound at all -- which is the case the stream cap exists
+ /// for, since many tiny members cost engine setup while producing almost no output.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only a decompressor's pump carries these, and no format is enabled")
+ )]
+ pub(crate) const fn buffered_fallbacks(self) -> BufferedFallbacks {
+ BufferedFallbacks {
+ output_len: match self.output_len {
+ None => NonZeroU64::new(DEFAULT_MAX_OUTPUT_LEN),
+ Some(_) => None,
+ },
+ streams: match self.streams {
+ None => NonZeroU64::new(DEFAULT_MAX_STREAMS),
+ Some(_) => None,
+ },
+ }
+ }
+
+ /// Applies these overrides on top of a format's defaults.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only the decompressors resolve and enforce bounds, and no format is enabled")
+ )]
+ pub(crate) fn resolve(self, defaults: FormatLimits) -> FormatLimits {
+ FormatLimits {
+ ratio: match self.ratio {
+ None => defaults.ratio,
+ Some(NonZeroU32::MAX) => None,
+ Some(ratio) => Some(ratio.get()),
+ },
+ output_len: match self.output_len {
+ None => defaults.output_len,
+ Some(NonZeroU64::MAX) => None,
+ Some(output_len) => Some(output_len.get()),
+ },
+ streams: match self.streams {
+ None => defaults.streams,
+ Some(NonZeroU64::MAX) => None,
+ Some(streams) => Some(streams.get()),
+ },
+ }
+ }
+}
+
+/// The bounds added on top of a decompressor's own for a caller that buffers the whole result.
+///
+/// One value rather than two accessors so a caller that applies these cannot pick up one bound and
+/// silently drop the other.
+#[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only a decompressor's pump carries these, and no format is enabled")
+)]
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub(crate) struct BufferedFallbacks {
+ /// Stands in for an unset total-output bound.
+ pub(crate) output_len: Option,
+
+ /// Stands in for an unset concatenated-stream bound.
+ pub(crate) streams: Option,
+}
+
+/// A format's bounds after the caller's overrides have been applied.
+///
+/// Private: formats declare their defaults as constants of this type, and the decompressors enforce it.
+#[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only the decompressors resolve and enforce bounds, and no format is enabled")
+)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct FormatLimits {
+ ratio: Option,
+ output_len: Option,
+ streams: Option,
+}
+
+#[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only the decompressors resolve and enforce bounds, and no format is enabled")
+)]
+impl FormatLimits {
+ /// Declares a format's default bounds.
+ pub(crate) const fn new(max_ratio: Option, max_output_len: Option, max_streams: Option) -> Self {
+ Self {
+ ratio: max_ratio,
+ output_len: max_output_len,
+ streams: max_streams,
+ }
+ }
+
+ /// Fails if the totals so far violate either bound.
+ pub(crate) fn check(self, input_len: u64, output_len: u64, streams: u64) -> Result<()> {
+ if let Some(max) = self.output_len
+ && output_len > max
+ {
+ return Err(Error::output_limit_exceeded(output_len, max));
+ }
+
+ if let Some(ratio) = self.ratio
+ && output_len > RATIO_FLOOR_BYTES
+ && output_len > input_len.saturating_mul(u64::from(ratio))
+ {
+ return Err(Error::ratio_limit_exceeded(input_len, output_len, ratio));
+ }
+
+ if let Some(max) = self.streams
+ && streams > max
+ {
+ return Err(Error::stream_limit_exceeded(streams, max));
+ }
+
+ Ok(())
+ }
+
+ /// The output still allowed by the configured bound, if there is one.
+ // A mutant that answers a small constant is not wrong, only slow: it shrinks the slice the pump
+ // offers the engine without changing which bytes are produced or where `check` rejects, so
+ // every drain loop needs one step per byte and mutation testing records a timeout instead of a
+ // verdict. `check` is what enforces the bound, and its mutants are caught.
+ #[cfg_attr(test, mutants::skip)]
+ pub(crate) fn remaining_output(self, output_len: u64) -> Option {
+ self.output_len.map(|maximum| maximum.saturating_sub(output_len))
+ }
+
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "no decompression engine reads the stream limit when no format is enabled")
+ )]
+ pub(crate) fn max_streams(self) -> Option {
+ self.streams
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_shared_defaults_are_the_documented_values() {
+ // Pinned as literals rather than by reference to the constants, so moving either one is a
+ // deliberate edit here as well as there -- and so the doc table that quotes these numbers
+ // cannot drift away from them unnoticed. Asserted against the shipped constant because this
+ // is a test build, where `DEFAULT_MAX_OUTPUT_LEN` is deliberately lowered.
+ assert_eq!(SHIPPED_MAX_OUTPUT_LEN, 64 * 1024 * 1024, "the shared output cap is 64 MiB");
+ assert_eq!(DEFAULT_MAX_STREAMS, 1024, "the shared stream cap is 1024");
+ assert_eq!(DEFAULT_MAX_OUTPUT_LEN, 1024 * 1024, "test builds lower the output cap to 1 MiB");
+ }
+
+ #[test]
+ fn buffering_fills_only_the_bounds_the_caller_left_open() {
+ // The trap this guards: a caller who overrides one bound must not silently lose the others.
+ let ratio_only = DecompressorLimits::new().max_ratio(ratio(7)).for_buffered_output();
+
+ assert_eq!(ratio_only.resolve(ALL_BOUNDS).output_len, Some(DEFAULT_MAX_OUTPUT_LEN));
+ assert_eq!(ratio_only.resolve(ALL_BOUNDS).streams, Some(DEFAULT_MAX_STREAMS));
+ assert_eq!(ratio_only.resolve(ALL_BOUNDS).ratio, Some(7), "the caller's own bound survives");
+ }
+
+ #[test]
+ fn buffering_leaves_an_explicit_choice_alone() {
+ let chosen = DecompressorLimits::new()
+ .max_output_len(NonZeroU64::new(99).unwrap())
+ .max_streams(NonZeroU64::new(3).unwrap())
+ .for_buffered_output();
+
+ assert_eq!(
+ chosen.resolve(ALL_BOUNDS).output_len,
+ Some(99),
+ "an explicit cap is not overwritten"
+ );
+ assert_eq!(chosen.resolve(ALL_BOUNDS).streams, Some(3), "an explicit cap is not overwritten");
+ }
+
+ #[test]
+ fn buffering_respects_an_explicit_removal() {
+ let removed = DecompressorLimits::new()
+ .unbounded_output_len()
+ .unbounded_streams()
+ .for_buffered_output();
+
+ assert_eq!(removed.resolve(ALL_BOUNDS).output_len, None, "opting out is the caller's decision");
+ assert_eq!(removed.resolve(ALL_BOUNDS).streams, None, "opting out is the caller's decision");
+ assert_eq!(
+ DecompressorLimits::UNLIMITED.for_buffered_output().resolve(ALL_BOUNDS),
+ FormatLimits::new(None, None, None),
+ "UNLIMITED removes every bound, buffering or not"
+ );
+ }
+
+ /// Stands in for a format's declared defaults.
+ const DEFAULTS: FormatLimits = FormatLimits::new(Some(1_000), None, None);
+
+ /// Stands in for defaults that set every bound.
+ ///
+ /// Real formats declare only a ratio, so resolving against [`DEFAULTS`] cannot tell "the caller
+ /// removed this bound" from "there was nothing to remove". Tests about removal use this instead.
+ const ALL_BOUNDS: FormatLimits = FormatLimits::new(Some(1_000), Some(4_096), Some(8));
+
+ fn ratio(value: u32) -> NonZeroU32 {
+ NonZeroU32::new(value).unwrap()
+ }
+
+ fn resolved(limits: DecompressorLimits) -> FormatLimits {
+ limits.resolve(DEFAULTS)
+ }
+
+ #[test]
+ fn default_overrides_nothing() {
+ assert_eq!(DecompressorLimits::default(), DecompressorLimits::new());
+ assert_eq!(resolved(DecompressorLimits::default()), DEFAULTS);
+ }
+
+ #[test]
+ fn an_unset_bound_defers_to_the_format() {
+ // The whole point of the override model: a caller who cares about one bound must not
+ // silently clobber the other with a value calibrated for a different format.
+ let limits = DecompressorLimits::new().max_output_len(NonZeroU64::new(4096).unwrap());
+ let resolved = resolved(limits);
+
+ assert_eq!(resolved.ratio, DEFAULTS.ratio, "the format's ratio must survive");
+ assert_eq!(resolved.output_len, Some(4096));
+ }
+
+ #[test]
+ fn unlimited_removes_the_formats_defaults() {
+ let resolved = DecompressorLimits::UNLIMITED.resolve(ALL_BOUNDS);
+
+ assert_eq!(resolved.ratio, None);
+ assert_eq!(resolved.output_len, None);
+ assert_eq!(resolved.streams, None);
+ resolved.check(1, u64::MAX, u64::MAX).unwrap();
+ }
+
+ #[test]
+ fn each_bound_can_be_removed_independently() {
+ let no_ratio = DecompressorLimits::new().unbounded_ratio().resolve(ALL_BOUNDS);
+ assert_eq!(no_ratio.ratio, None);
+ assert_eq!(no_ratio.output_len, ALL_BOUNDS.output_len, "the others are untouched");
+ assert_eq!(no_ratio.streams, ALL_BOUNDS.streams, "the others are untouched");
+
+ let no_len = DecompressorLimits::new().unbounded_output_len().resolve(ALL_BOUNDS);
+ assert_eq!(no_len.ratio, ALL_BOUNDS.ratio, "the others are untouched");
+ assert_eq!(no_len.output_len, None);
+ assert_eq!(no_len.streams, ALL_BOUNDS.streams, "the others are untouched");
+
+ let no_streams = DecompressorLimits::new().unbounded_streams().resolve(ALL_BOUNDS);
+ assert_eq!(no_streams.ratio, ALL_BOUNDS.ratio, "the others are untouched");
+ assert_eq!(no_streams.output_len, ALL_BOUNDS.output_len, "the others are untouched");
+ assert_eq!(no_streams.streams, None);
+ }
+
+ #[test]
+ fn maximum_values_represent_unbounded_limits() {
+ let maximums = DecompressorLimits::new()
+ .max_ratio(NonZeroU32::MAX)
+ .max_output_len(NonZeroU64::MAX)
+ .max_streams(NonZeroU64::MAX);
+
+ assert_eq!(maximums, DecompressorLimits::UNLIMITED);
+ assert_eq!(maximums.resolve(ALL_BOUNDS), FormatLimits::new(None, None, None));
+ }
+
+ #[test]
+ fn a_later_call_replaces_an_earlier_override() {
+ let bounded = DecompressorLimits::new()
+ .max_ratio(ratio(7))
+ .max_output_len(NonZeroU64::new(99).unwrap())
+ .max_streams(NonZeroU64::new(3).unwrap());
+ let unlimited = bounded.unbounded_ratio().unbounded_output_len().unbounded_streams();
+
+ assert_eq!(unlimited.resolve(ALL_BOUNDS), FormatLimits::new(None, None, None));
+
+ let restored = unlimited
+ .max_ratio(ratio(11))
+ .max_output_len(NonZeroU64::new(42).unwrap())
+ .max_streams(NonZeroU64::new(5).unwrap());
+
+ assert_eq!(restored.resolve(ALL_BOUNDS), FormatLimits::new(Some(11), Some(42), Some(5)));
+ }
+
+ #[test]
+ fn an_explicit_bound_overrides_the_format() {
+ let resolved = resolved(DecompressorLimits::new().max_ratio(ratio(7)));
+
+ assert_eq!(resolved.ratio, Some(7));
+ }
+
+ #[test]
+ fn ratio_guard_rejects_a_bomb() {
+ let error = DEFAULTS.check(1_000, 100 * 1024 * 1024, 1).unwrap_err();
+
+ assert!(error.is_limit_exceeded());
+ }
+
+ #[test]
+ fn ratio_guard_allows_multi_gigabyte_streams() {
+ // An absolute cap would reject this; a ratio guard must not.
+ DEFAULTS.check(64 * 1024 * 1024 * 1024, 640 * 1024 * 1024 * 1024, 1).unwrap();
+ }
+
+ #[test]
+ fn ratio_guard_ignores_output_below_the_floor() {
+ DEFAULTS.check(0, RATIO_FLOOR_BYTES, 1).unwrap();
+ }
+
+ #[test]
+ fn the_ratio_floor_is_exactly_32_kib() {
+ // Pinned as a literal (not `32 * 1024`) so a mutated multiplication in the constant's
+ // definition cannot hide behind a test that recomputes the same expression.
+ assert_eq!(RATIO_FLOOR_BYTES, 32_768);
+ }
+
+ #[test]
+ fn ratio_guard_engages_immediately_above_the_floor() {
+ let error = DEFAULTS.check(0, RATIO_FLOOR_BYTES + 1, 1).unwrap_err();
+
+ assert!(error.is_limit_exceeded());
+ }
+
+ #[test]
+ fn ratio_guard_allows_exactly_the_configured_ratio() {
+ let limits = resolved(DecompressorLimits::new().max_ratio(ratio(2)));
+ let input = RATIO_FLOOR_BYTES;
+
+ limits.check(input, input * 2, 1).unwrap();
+ let error = limits.check(input, input * 2 + 1, 1).unwrap_err();
+
+ assert!(error.is_limit_exceeded());
+ }
+
+ #[test]
+ fn absolute_bound_rejects_beyond_the_cap() {
+ let limits = resolved(DecompressorLimits::new().max_output_len(NonZeroU64::new(100).unwrap()));
+ let error = limits.check(1_000_000, 101, 1).unwrap_err();
+
+ assert!(error.is_limit_exceeded());
+ }
+
+ #[test]
+ fn absolute_bound_allows_exactly_the_cap() {
+ let limits = resolved(DecompressorLimits::new().max_output_len(NonZeroU64::new(100).unwrap()));
+
+ limits.check(1_000_000, 100, 1).unwrap();
+ }
+
+ #[test]
+ fn ratio_multiplication_saturates_instead_of_overflowing() {
+ let limits = resolved(DecompressorLimits::new().max_ratio(ratio(u32::MAX)));
+
+ limits.check(u64::MAX, u64::MAX, 1).unwrap();
+ }
+
+ #[test]
+ fn stream_count_is_bounded() {
+ let limits = resolved(DecompressorLimits::new().max_streams(NonZeroU64::new(2).unwrap()));
+
+ limits.check(100, 100, 2).unwrap();
+ let error = limits.check(100, 100, 3).unwrap_err();
+
+ assert!(error.is_limit_exceeded());
+ }
+
+ #[test]
+ fn remaining_output_saturates_at_zero() {
+ let limits = resolved(DecompressorLimits::new().max_output_len(NonZeroU64::new(100).unwrap()));
+
+ assert_eq!(limits.remaining_output(40), Some(60));
+ assert_eq!(limits.remaining_output(100), Some(0));
+ assert_eq!(limits.remaining_output(101), Some(0));
+ assert_eq!(DEFAULTS.remaining_output(100), None);
+ }
+}
diff --git a/crates/compressors/src/macros.rs b/crates/compressors/src/macros.rs
new file mode 100644
index 000000000..69b58a935
--- /dev/null
+++ b/crates/compressors/src/macros.rs
@@ -0,0 +1,533 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! The macros that generate each format module's public surface.
+//!
+//! Every format exposes the same types and functions, differing only in which engine they drive and
+//! in their documentation. Generating them keeps the modules honest -- a change to the contract
+//! cannot drift between formats -- without collapsing them into one type that would lose the
+//! compile-time distinction between, say, a gzip and a brotli compressor.
+//!
+//! # What a format module exposes
+//!
+//! A `Compressor` is reached through its builder and driven through
+//! [`Compression`][crate::core::Compression]; it has no inherent
+//! methods of its own. That is what lets code be written once against the trait and used with
+//! any format, including the runtime-format `Compressor` the `format` module provides.
+//!
+//! # Format-specific settings
+//!
+//! The builders themselves are shared: [`CompressorBuilder`][crate::CompressorBuilder] and
+//! [`DecompressorBuilder`][crate::DecompressorBuilder] carry every setting that means the same
+//! thing in every format. What differs is the type parameter, which names the format and carries
+//! whatever settings only that format has -- brotli's quality, window and content mode, zstd's
+//! native levels and decompressor window limit. A format with no extra settings uses a unit struct.
+//!
+//! Each format's module owns that type, the setters that populate it, and the `build` method that
+//! consumes it, so nothing outside the module has to know which formats a build enabled.
+//!
+//! # Fallible builds
+//!
+//! Most engines take their configuration without validating it, so their builders cannot fail.
+//! zstd's native library validates as it applies it, so its compressor build returns a
+//! [`BuildError`][crate::BuildError]. Each format declares which of the two it is and gets the
+//! matching signatures.
+
+/// Generates one format's compressor builds and its whole-buffer `compress`.
+///
+/// The leading token selects whether the engine can reject its configuration.
+macro_rules! define_compressor_build {
+ (infallible, $name:literal, $format:ty, $build_method:ident, $new_compressor:expr) => {
+ impl Compressor {
+ #[doc = concat!("Creates a ", $name, " compressor at [`Level::DEFAULT`][crate::Level::DEFAULT].")]
+ #[must_use]
+ #[inline]
+ pub fn new(resources: &$crate::Resources) -> Self {
+ Self::builder().build(resources)
+ }
+ }
+
+ impl $crate::CompressorBuilder<$format> {
+ /// Builds the compressor, drawing its memory and engine state from `resources`.
+ #[must_use]
+ #[inline]
+ pub fn build(self, resources: &$crate::Resources) -> Compressor {
+ Compressor {
+ pump: Pump::new(resources.memory().clone(), self.chunk_size),
+ codec: $new_compressor(self.level, &self.format, resources.pool().clone()),
+ }
+ }
+ }
+
+ impl $crate::CompressorBuilder<()> {
+ #[doc = concat!("Builds a ", $name, " compressor from the format-independent settings.")]
+ ///
+ /// Everything this builder carries means the same thing in every format, so committing
+ /// to one here rather than up front costs nothing.
+ #[must_use]
+ pub fn $build_method(self, resources: &$crate::Resources) -> Compressor {
+ self.specialize(<$format>::new()).build(resources)
+ }
+ }
+
+ #[doc = concat!("Compresses a complete byte sequence into ", $name, ".")]
+ ///
+ /// Uses [`Level::DEFAULT`][crate::Level::DEFAULT], and recycles engine state through
+ /// `resources`; pass [`Resources::with_pool_capacity(0)`][crate::Resources::with_pool_capacity] to
+ /// recycle nothing. Prefer [`Compressor`] for data that arrives incrementally; this
+ /// convenience buffers the entire result before returning.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying compression engine fails.
+ pub fn compress(input: impl $crate::InputData, resources: &$crate::Resources) -> Result {
+ let input = $crate::InputData::into_view(input, resources);
+
+ $crate::compress(input, Compressor::new(resources))
+ }
+ };
+ (fallible, $name:literal, $format:ty, $build_method:ident, $new_compressor:expr) => {
+ impl Compressor {
+ #[doc = concat!("Creates a ", $name, " compressor at [`Level::DEFAULT`][crate::Level::DEFAULT].")]
+ ///
+ /// # Panics
+ ///
+ /// Never in practice: the default settings are inside the ranges the engine accepts, so
+ /// it has nothing to reject. Build through [`Compressor::builder`] to handle a
+ /// rejection of settings that are not the defaults.
+ #[must_use]
+ #[inline]
+ pub fn new(resources: &$crate::Resources) -> Self {
+ Self::builder()
+ .build(resources)
+ .expect("the default settings are inside the engine's own ranges, so it cannot reject them")
+ }
+ }
+
+ impl $crate::CompressorBuilder<$format> {
+ /// Builds the compressor, drawing its memory and engine state from `resources`.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`][crate::BuildError] if the engine rejects the configuration.
+ #[inline]
+ pub fn build(self, resources: &$crate::Resources) -> ::core::result::Result {
+ Ok(Compressor {
+ pump: Pump::new(resources.memory().clone(), self.chunk_size),
+ codec: $new_compressor(self.level, &self.format, resources.pool().clone())?,
+ })
+ }
+ }
+
+ impl $crate::CompressorBuilder<()> {
+ #[doc = concat!("Builds a ", $name, " compressor from the format-independent settings.")]
+ ///
+ /// Everything this builder carries means the same thing in every format, so committing
+ /// to one here rather than up front costs nothing.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`][crate::BuildError] if the engine rejects the configuration.
+ pub fn $build_method(self, resources: &$crate::Resources) -> ::core::result::Result {
+ self.specialize(<$format>::new()).build(resources)
+ }
+ }
+
+ #[doc = concat!("Compresses a complete byte sequence into ", $name, ".")]
+ ///
+ /// Uses [`Level::DEFAULT`][crate::Level::DEFAULT], and recycles engine state through
+ /// `resources`; pass [`Resources::with_pool_capacity(0)`][crate::Resources::with_pool_capacity] to
+ /// recycle nothing. Prefer [`Compressor`] for data that arrives incrementally; this
+ /// convenience buffers the entire result before returning.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the engine rejects the default configuration, or if compression
+ /// itself fails.
+ pub fn compress(input: impl $crate::InputData, resources: &$crate::Resources) -> Result {
+ let input = $crate::InputData::into_view(input, resources);
+ // Built through the fallible path rather than `Compressor::new`, which panics on a
+ // rejection: this function already returns `Result`, so a caller should never have to
+ // catch a panic for something it can be told about.
+ let compressor = Compressor::builder().build(resources)?;
+
+ $crate::compress(input, compressor)
+ }
+ };
+}
+
+/// Generates one format's decompressor builds, its `decompress` and its `decompress_with_limits`.
+macro_rules! define_decompressor_build {
+ (
+ infallible,
+ $name:literal,
+ $format:ty,
+ $build_method:ident,
+ $new_decompressor:expr,
+ $default_limits:expr,
+ $multi_stream_default:expr
+ ) => {
+ impl Decompressor {
+ #[doc = concat!("Creates a ", $name, " decompressor with this format's default bounds.")]
+ #[must_use]
+ #[inline]
+ pub fn new(resources: &$crate::Resources) -> Self {
+ Self::builder().build(resources)
+ }
+ }
+
+ impl $crate::DecompressorBuilder<$format> {
+ /// Builds the decompressor, drawing its memory and engine state from `resources`.
+ #[must_use]
+ #[inline]
+ pub fn build(self, resources: &$crate::Resources) -> Decompressor {
+ Decompressor {
+ pump: Pump::new(resources.memory().clone(), self.chunk_size).with_buffered_fallbacks(self.limits.buffered_fallbacks()),
+ codec: $new_decompressor(
+ self.limits.resolve($default_limits),
+ self.multi_stream.unwrap_or($multi_stream_default),
+ self.trailing_data,
+ &self.format,
+ resources.pool().clone(),
+ ),
+ }
+ }
+ }
+
+ impl $crate::DecompressorBuilder<()> {
+ #[doc = concat!("Builds a ", $name, " decompressor from the format-independent settings.")]
+ ///
+ /// Everything this builder carries means the same thing in every format, so committing
+ /// to one here rather than up front costs nothing. Bounds left unset, and a
+ /// multi-stream policy left unset, keep this format's own defaults.
+ #[must_use]
+ pub fn $build_method(self, resources: &$crate::Resources) -> Decompressor {
+ self.specialize(<$format>::new()).build(resources)
+ }
+ }
+
+ #[doc = concat!("Decompresses a complete ", $name, " stream that is already in memory.")]
+ ///
+ /// Applies this format's default bounds, and recycles engine state through `resources`; pass
+ /// [`Resources::with_pool_capacity(0)`][crate::Resources::with_pool_capacity] to recycle nothing. Prefer [`Decompressor`]
+ /// for data that arrives incrementally; this convenience buffers the entire result before
+ /// returning.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the data is malformed, truncated, or exceeds the bounds this convenience
+ /// applies: the format's own ratio, plus the shared output and stream caps that
+ /// [`DecompressorLimits`][crate::DecompressorLimits] documents, because it buffers the whole
+ /// result. Use `decompress_with_limits` to choose your own.
+ pub fn decompress(input: impl $crate::InputData, resources: &$crate::Resources) -> Result {
+ let input = $crate::InputData::into_view(input, resources);
+
+ // This convenience accumulates the whole result, so it is the caller's memory that a
+ // bomb would exhaust. Incremental decompressors hand each chunk straight back and are
+ // left uncapped, because a total-output bound there would cut off long streams that
+ // never buffer more than one chunk.
+ $crate::decompress(
+ input,
+ Decompressor::builder()
+ .limits($crate::DecompressorLimits::new().for_buffered_output())
+ .build(resources),
+ )
+ }
+
+ #[doc = concat!("Decompresses a complete ", $name, " stream with explicit limits.")]
+ ///
+ /// This is the convenient path for untrusted in-memory input.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the data is malformed, truncated, or exceeds `limits`. Bounds left unset
+ /// on `limits` still receive this convenience's buffering caps.
+ pub fn decompress_with_limits(
+ input: impl $crate::InputData,
+ resources: &$crate::Resources,
+ limits: DecompressorLimits,
+ ) -> Result {
+ let input = $crate::InputData::into_view(input, resources);
+
+ $crate::decompress(
+ input,
+ Decompressor::builder().limits(limits.for_buffered_output()).build(resources),
+ )
+ }
+ };
+ (
+ fallible,
+ $name:literal,
+ $format:ty,
+ $build_method:ident,
+ $new_decompressor:expr,
+ $default_limits:expr,
+ $multi_stream_default:expr
+ ) => {
+ impl Decompressor {
+ #[doc = concat!("Creates a ", $name, " decompressor with this format's default bounds.")]
+ ///
+ /// # Panics
+ ///
+ /// Never in practice: the default settings are inside the ranges the engine accepts, so
+ /// it has nothing to reject. Build through [`Decompressor::builder`] to handle a
+ /// rejection of settings that are not the defaults.
+ #[must_use]
+ #[inline]
+ pub fn new(resources: &$crate::Resources) -> Self {
+ Self::builder()
+ .build(resources)
+ .expect("the default settings are inside the engine's own ranges, so it cannot reject them")
+ }
+ }
+
+ impl $crate::DecompressorBuilder<$format> {
+ /// Builds the decompressor, drawing its memory and engine state from `resources`.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`][crate::BuildError] if the engine rejects the configuration.
+ #[inline]
+ pub fn build(self, resources: &$crate::Resources) -> ::core::result::Result {
+ Ok(Decompressor {
+ pump: Pump::new(resources.memory().clone(), self.chunk_size).with_buffered_fallbacks(self.limits.buffered_fallbacks()),
+ codec: $new_decompressor(
+ self.limits.resolve($default_limits),
+ self.multi_stream.unwrap_or($multi_stream_default),
+ self.trailing_data,
+ &self.format,
+ resources.pool().clone(),
+ )?,
+ })
+ }
+ }
+
+ impl $crate::DecompressorBuilder<()> {
+ #[doc = concat!("Builds a ", $name, " decompressor from the format-independent settings.")]
+ ///
+ /// Everything this builder carries means the same thing in every format, so committing
+ /// to one here rather than up front costs nothing. Bounds left unset, and a
+ /// multi-stream policy left unset, keep this format's own defaults.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`BuildError`][crate::BuildError] if the engine rejects the configuration.
+ pub fn $build_method(self, resources: &$crate::Resources) -> ::core::result::Result {
+ self.specialize(<$format>::new()).build(resources)
+ }
+ }
+
+ #[doc = concat!("Decompresses a complete ", $name, " stream that is already in memory.")]
+ ///
+ /// Applies this format's default bounds, and recycles engine state through `resources`; pass
+ /// [`Resources::with_pool_capacity(0)`][crate::Resources::with_pool_capacity] to recycle nothing. Prefer [`Decompressor`]
+ /// for data that arrives incrementally; this convenience buffers the entire result before
+ /// returning.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the decompressor cannot be built, or if the data is malformed,
+ /// truncated, or exceeds the bounds this convenience applies: the format's own ratio, plus
+ /// the shared output and stream caps that [`DecompressorLimits`][crate::DecompressorLimits]
+ /// documents, because it buffers the whole result. Use `decompress_with_limits` to choose
+ /// your own.
+ pub fn decompress(input: impl $crate::InputData, resources: &$crate::Resources) -> Result {
+ let input = $crate::InputData::into_view(input, resources);
+
+ // This convenience accumulates the whole result, so it is the caller's memory that a
+ // bomb would exhaust. Incremental decompressors hand each chunk straight back and are
+ // left uncapped, because a total-output bound there would cut off long streams that
+ // never buffer more than one chunk.
+ $crate::decompress(
+ input,
+ Decompressor::builder()
+ .limits($crate::DecompressorLimits::new().for_buffered_output())
+ .build(resources)?,
+ )
+ }
+
+ #[doc = concat!("Decompresses a complete ", $name, " stream with explicit limits.")]
+ ///
+ /// This is the convenient path for untrusted in-memory input.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the decompressor cannot be built, or if the data is malformed,
+ /// truncated, or exceeds `limits`. Bounds left unset on `limits` still receive this
+ /// convenience's buffering caps.
+ pub fn decompress_with_limits(
+ input: impl $crate::InputData,
+ resources: &$crate::Resources,
+ limits: DecompressorLimits,
+ ) -> Result {
+ let input = $crate::InputData::into_view(input, resources);
+
+ $crate::decompress(
+ input,
+ Decompressor::builder()
+ .limits(limits.for_buffered_output())
+ .build(resources)?,
+ )
+ }
+ };
+}
+
+/// Generates `Compressor`, `Decompressor`, their builder aliases, `compress`, `decompress`, and
+/// `decompress_with_limits` for one format.
+macro_rules! define_format {
+ (
+ name = $name:literal,
+ format = $format:ty,
+ build_method = $build_method:ident,
+ compressor_codec = $compressor_codec:ty,
+ compressor_build = $compressor_build:tt,
+ new_compressor = $new_compressor:expr,
+ decompressor_codec = $decompressor_codec:ty,
+ decompressor_build = $decompressor_build:tt,
+ default_limits = $default_limits:expr,
+ new_decompressor = $new_decompressor:expr,
+ multi_stream_default = $multi_stream_default:expr,
+ ) => {
+ use bytesbuf::BytesView;
+ use $crate::core::Output;
+ use $crate::engine::Pump;
+ use $crate::error::Result;
+ use $crate::limits::DecompressorLimits;
+
+ impl Default for $crate::CompressorBuilder<$format> {
+ fn default() -> Self {
+ $crate::CompressorBuilder::with_format(<$format>::new())
+ }
+ }
+
+ impl Default for $crate::DecompressorBuilder<$format> {
+ fn default() -> Self {
+ $crate::DecompressorBuilder::with_format(<$format>::new())
+ }
+ }
+
+ #[doc = concat!("Compresses a stream of byte sequences into ", $name, ".")]
+ ///
+ /// A push/pull state machine, driven through [`Compression`][crate::core::Compression]:
+ /// supply input with `push`, take output with `pull`, and call `end_input` when there is no
+ /// more input. Each pull returns at most one bounded chunk, so a stream of any length can be
+ /// compressed with a bounded working set.
+ ///
+ /// The methods live on the trait rather than here, so code written against it works with
+ /// every format, and with the runtime-format compressor that `build_format` returns.
+ #[derive(Debug)]
+ pub struct Compressor {
+ pump: Pump,
+ codec: $compressor_codec,
+ }
+
+ impl Compressor {
+ /// Starts configuring a compressor.
+ #[must_use]
+ #[inline]
+ pub fn builder() -> $crate::CompressorBuilder<$format> {
+ $crate::CompressorBuilder::default()
+ }
+ }
+ impl $crate::core::Compression for Compressor {
+ type Mode = $crate::core::Compress;
+ }
+
+ impl $crate::core::CompressionInternal for Compressor {
+ fn push(&mut self, input: BytesView) -> Result<()> {
+ self.pump.push(input)
+ }
+
+ fn end_input(&mut self) {
+ self.pump.end_input();
+ }
+
+ fn pull(&mut self, into: $crate::core::Destination) -> Result {
+ self.pump.pull(&mut self.codec, into)
+ }
+
+ fn total_in(&self) -> u64 {
+ self.pump.total_in()
+ }
+
+ fn total_out(&self) -> u64 {
+ self.pump.total_out()
+ }
+
+ fn flush(&mut self) -> Result<()> {
+ self.pump.flush()
+ }
+ }
+
+ $crate::macros::define_compressor_build! {
+ $compressor_build, $name, $format, $build_method, $new_compressor
+ }
+
+ #[doc = concat!("Decompresses a ", $name, " stream into a stream of byte sequences.")]
+ ///
+ /// Driven through [`Compression`][crate::core::Compression], like every other format's
+ /// decompressor.
+ ///
+ /// # Security
+ ///
+ /// Compressed data can expand by orders of magnitude, so a decompressor pointed at untrusted
+ /// input is a memory-exhaustion vector. This format's own default bounds apply unless
+ /// [`DecompressorBuilder::limits`][crate::DecompressorBuilder::limits] overrides them.
+ ///
+ /// Output is provisional until the decompressor reports that it is done, because a checksum or
+ /// trailer can reject the stream after earlier chunks have been returned.
+ #[derive(Debug)]
+ pub struct Decompressor {
+ pump: Pump,
+ codec: $decompressor_codec,
+ }
+
+ impl Decompressor {
+ /// Starts configuring a decompressor.
+ #[must_use]
+ #[inline]
+ pub fn builder() -> $crate::DecompressorBuilder<$format> {
+ $crate::DecompressorBuilder::default()
+ }
+ }
+ impl $crate::core::Compression for Decompressor {
+ type Mode = $crate::core::Decompress;
+ }
+
+ impl $crate::core::CompressionInternal for Decompressor {
+ fn push(&mut self, input: BytesView) -> Result<()> {
+ self.pump.push(input)
+ }
+
+ fn end_input(&mut self) {
+ self.pump.end_input();
+ }
+
+ fn pull(&mut self, into: $crate::core::Destination) -> Result {
+ self.pump.pull(&mut self.codec, into)
+ }
+
+ fn total_in(&self) -> u64 {
+ self.pump.total_in()
+ }
+
+ fn total_out(&self) -> u64 {
+ self.pump.total_out()
+ }
+ }
+
+ $crate::macros::define_decompressor_build! {
+ $decompressor_build,
+ $name,
+ $format,
+ $build_method,
+ $new_decompressor,
+ $default_limits,
+ $multi_stream_default
+ }
+ };
+}
+
+pub(crate) use define_compressor_build;
+pub(crate) use define_decompressor_build;
+pub(crate) use define_format;
diff --git a/crates/compressors/src/pool.rs b/crates/compressors/src/pool.rs
new file mode 100644
index 000000000..162f100ba
--- /dev/null
+++ b/crates/compressors/src/pool.rs
@@ -0,0 +1,587 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Reuse of compression engine state from one stream to the next.
+
+#[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+use std::collections::HashMap;
+use std::fmt;
+#[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib", feature = "zstd"))]
+use std::sync::Mutex;
+use std::sync::{Arc, OnceLock};
+
+#[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+use crate::flate::Wrapper;
+
+/// How many idle engines the pool keeps per interchangeable group, unless told otherwise.
+///
+/// Sized for a service handling a moderate number of concurrent messages: high enough that an
+/// ordinary request burst is served from the pool rather than rebuilding, low enough that idle
+/// engine state is not retained indefinitely for a workload that has gone quiet. A conservative
+/// starting point rather than a measured optimum -- a caller who knows its concurrency should say
+/// so with [`Resources::with_pool_capacity`][crate::Resources::with_pool_capacity].
+const DEFAULT_CAPACITY: usize = 16;
+
+/// Identifies engines that are interchangeable with one another.
+///
+/// An engine can only be reused for the configuration it was built with: resetting a compressor
+/// preserves its container and its level, so a gzip level-9 engine cannot serve a zlib level-1
+/// request.
+#[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) struct EngineKey {
+ pub(crate) wrapper: Wrapper,
+ pub(crate) level: u8,
+}
+
+/// A shared, cloneable pool of reusable compression engine state.
+///
+/// Building an engine allocates and initializes a substantial amount of state -- on a small message,
+/// as much work as the compression itself -- so recycling removes a cost that is roughly fixed per
+/// engine and therefore matters most for small messages.
+///
+/// Reached through [`Resources`][crate::Resources]; a capacity of zero recycles nothing.
+///
+/// # What is pooled
+///
+/// | Engine | Reused? |
+/// |---|---|
+/// | `deflate` / `zlib` / `gzip` compressor | yes -- `reset` preserves its container and level |
+/// | `deflate` / `zlib` decompressor | yes -- `reset` restores the framing |
+/// | `gzip` decompressor | no -- see below |
+/// | `zstd` compressor and decompressor | yes -- `reset` keeps the context's allocations, which is where most of the cost is |
+/// | `brotli` compressor and decompressor | no -- upstream exposes no reset, and recycling its buffers through a custom allocator was measured and did not pay for itself |
+///
+/// The gzip decompressor is the gap worth explaining. `flate2`'s reset takes a boolean that cannot
+/// express gzip framing, so a recycled engine would silently decompress as raw deflate. Taking that
+/// framing over here would let gzip decompressors join the pool, but only by owning header parsing
+/// and checksum validation permanently to route around an upstream API gap. If `flate2` gains a
+/// reset that can express gzip framing, they can start being pooled with no change elsewhere.
+///
+/// # Bounds
+///
+/// At most [`Pool::capacity`] idle engines per distinct configuration, so a burst of concurrent
+/// requests cannot make it grow without limit. Engines beyond that are dropped when returned.
+#[derive(Clone)]
+pub(crate) struct Pool {
+ inner: Arc,
+}
+
+/// The shared state every [`Pool`] clone points at.
+///
+/// A `Pool` is a handle: cloning one shares this, which is what lets a `Resources` be handed around
+/// while every compressor and decompressor built from it draws on the same idle engines.
+///
+/// Each engine class gets its own [`Mutex`] rather than one lock over everything, so a compressor
+/// being returned never waits on a decompressor being taken, and poisoning is contained to the one
+/// class whose critical section panicked -- every checkout treats a poisoned lock as "nothing to
+/// reuse" and builds a fresh engine instead.
+struct Inner {
+ #[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+ compressors: Mutex>>,
+ /// Decompressors carry no level, so the container alone identifies them.
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ decompressors: Mutex>>,
+ /// Zstd contexts allocate their working memory lazily, so recycling them saves far more than
+ /// their construction cost suggests.
+ ///
+ /// Not keyed by level: checkout resets with `SessionAndParameters` and the compressor then
+ /// applies its level unconditionally, so any idle context serves any level. Keying by level
+ /// would only fragment reuse and let a caller-chosen level grow the map.
+ #[cfg(any(test, feature = "zstd"))]
+ zstd_compressors: Mutex>>,
+ #[cfg(any(test, feature = "zstd"))]
+ zstd_decompressors: Mutex>>,
+ capacity: usize,
+}
+
+impl Pool {
+ /// Creates a pool that keeps up to 16 idle engines per configuration.
+ #[must_use]
+ pub(crate) fn new() -> Self {
+ Self::with_capacity(DEFAULT_CAPACITY)
+ }
+
+ /// Creates a pool that keeps up to `capacity` idle engines per configuration.
+ ///
+ /// Sized to the number of messages expected to be in flight at once. A capacity of zero
+ /// disables recycling.
+ #[must_use]
+ pub(crate) fn with_capacity(capacity: usize) -> Self {
+ Self {
+ inner: Arc::new(Inner {
+ #[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+ compressors: Mutex::new(HashMap::new()),
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ decompressors: Mutex::new(HashMap::new()),
+ #[cfg(any(test, feature = "zstd"))]
+ zstd_compressors: Mutex::new(Vec::new()),
+ #[cfg(any(test, feature = "zstd"))]
+ zstd_decompressors: Mutex::new(Vec::new()),
+ capacity,
+ }),
+ }
+ }
+
+ /// A shared pool that recycles nothing.
+ ///
+ /// Every API that builds an engine asks for a pool, so going without recycling has to be an
+ /// explicit choice. This is that choice: one process-wide pool of capacity zero, so passing it
+ /// costs no more than cloning a handle.
+ #[must_use]
+ pub(crate) fn disabled() -> &'static Self {
+ static DISABLED: OnceLock = OnceLock::new();
+
+ DISABLED.get_or_init(|| Self::with_capacity(0))
+ }
+
+ /// The most idle engines this pool keeps per distinct configuration.
+ #[must_use]
+ pub(crate) fn capacity(&self) -> usize {
+ self.inner.capacity
+ }
+
+ /// Whether this pool stores nothing, so that every access can return without locking.
+ ///
+ /// A pool of capacity zero can neither hand an engine out nor keep one, so the locks it would
+ /// take are pure overhead.
+ #[cfg_attr(
+ not(any(test, feature = "deflate", feature = "gzip", feature = "zlib", feature = "zstd")),
+ expect(dead_code, reason = "only the pooled formats ask, and none of them is enabled")
+ )]
+ // Answering `false` here is unobservable: every caller then takes the lock and reaches a
+ // capacity check that a pool of zero fails anyway, handing back the same engine and keeping
+ // the same nothing. Only the lock traffic differs, so no test can hold this to account.
+ #[cfg_attr(test, mutants::skip)]
+ fn is_disabled(&self) -> bool {
+ self.capacity() == 0
+ }
+
+ /// Takes an idle compressor for `key`, or reports that one must be built.
+ ///
+ /// The engine is reset before it is handed over, so an engine dropped part-way through a stream
+ /// cannot leak its state into the next user.
+ #[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+ pub(crate) fn take_compressor(&self, key: EngineKey) -> Option {
+ if self.is_disabled() {
+ return None;
+ }
+
+ // A poisoned pool is not worth propagating: recycling is an optimisation, so building a
+ // fresh engine is always preferable to failing the caller's compression.
+ let mut engine = self.inner.compressors.lock().ok()?.get_mut(&key).and_then(Vec::pop)?;
+
+ engine.reset();
+ Some(engine)
+ }
+
+ /// Takes `engine` for reuse, leaving it in place when the pool cannot keep it.
+ ///
+ /// The engine is borrowed rather than consumed so that a pool which will not store it never
+ /// takes ownership. Every caller is a [`Drop`] implementation, and dropping the engine there
+ /// would free it while the value being destroyed is still borrowed, which the aliasing rules
+ /// forbid.
+ #[cfg(any(test, feature = "deflate", feature = "gzip", feature = "zlib"))]
+ pub(crate) fn return_compressor(&self, key: EngineKey, engine: &mut Option) {
+ if self.is_disabled() {
+ return;
+ }
+
+ if let Ok(mut guard) = self.inner.compressors.lock() {
+ // Probe before inserting. `entry(..).or_default()` leaves an empty bucket behind for
+ // every key it is asked about, so a full pool would still grow the map without bound.
+ if guard.get(&key).map_or(0, Vec::len) < self.inner.capacity
+ && let Some(engine) = engine.take()
+ {
+ guard.entry(key).or_default().push(engine);
+ }
+ }
+ }
+
+ /// Takes an idle decompressor for `wrapper`, or reports that one must be built.
+ ///
+ /// Only called for containers whose reset restores the framing; see
+ /// [`Wrapper::reset_restores_framing`].
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ pub(crate) fn take_decompressor(&self, wrapper: Wrapper) -> Option {
+ if self.is_disabled() {
+ return None;
+ }
+
+ let mut engine = self.inner.decompressors.lock().ok()?.get_mut(&wrapper).and_then(Vec::pop)?;
+
+ engine.reset(wrapper.expects_zlib_header());
+ Some(engine)
+ }
+
+ /// Takes `engine` for reuse, leaving it in place when the pool cannot keep it; see [`Self::return_compressor`].
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ pub(crate) fn return_decompressor(&self, wrapper: Wrapper, engine: &mut Option) {
+ if self.is_disabled() {
+ return;
+ }
+
+ if let Ok(mut guard) = self.inner.decompressors.lock() {
+ // Probe before inserting, for the reason given on `return_compressor`.
+ if guard.get(&wrapper).map_or(0, Vec::len) < self.inner.capacity
+ && let Some(engine) = engine.take()
+ {
+ guard.entry(wrapper).or_default().push(engine);
+ }
+ }
+ }
+
+ /// Takes an idle zstd compressor built for `level`, or reports that one must be built.
+ ///
+ /// Resetting the session drops any half-written frame while keeping the context's allocations,
+ /// which is where the saving comes from.
+ #[cfg(any(test, feature = "zstd"))]
+ pub(crate) fn take_zstd_compressor(&self) -> Option> {
+ if self.is_disabled() {
+ return None;
+ }
+
+ let mut context = self.inner.zstd_compressors.lock().ok()?.pop()?;
+
+ context.reset(zstd_safe::ResetDirective::SessionAndParameters).ok()?;
+ Some(context)
+ }
+
+ /// Takes `context` for reuse, leaving it in place when the pool cannot keep it, for the reason given on `return_compressor`.
+ #[cfg(any(test, feature = "zstd"))]
+ pub(crate) fn return_zstd_compressor(&self, context: &mut Option>) {
+ if self.is_disabled() {
+ return;
+ }
+
+ if let Ok(mut guard) = self.inner.zstd_compressors.lock()
+ && guard.len() < self.inner.capacity
+ && let Some(context) = context.take()
+ {
+ guard.push(context);
+ }
+ }
+
+ /// Takes an idle zstd decompressor, or reports that one must be built.
+ #[cfg(any(test, feature = "zstd"))]
+ pub(crate) fn take_zstd_decompressor(&self) -> Option> {
+ if self.is_disabled() {
+ return None;
+ }
+
+ let mut context = self.inner.zstd_decompressors.lock().ok()?.pop()?;
+
+ context.reset(zstd_safe::ResetDirective::SessionAndParameters).ok()?;
+ Some(context)
+ }
+
+ /// Takes `context` for reuse, leaving it in place when the pool cannot keep it, for the reason given on `return_compressor`.
+ #[cfg(any(test, feature = "zstd"))]
+ pub(crate) fn return_zstd_decompressor(&self, context: &mut Option>) {
+ if self.is_disabled() {
+ return;
+ }
+
+ if let Ok(mut guard) = self.inner.zstd_decompressors.lock()
+ && guard.len() < self.inner.capacity
+ && let Some(context) = context.take()
+ {
+ guard.push(context);
+ }
+ }
+}
+
+impl Default for Pool {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl fmt::Debug for Pool {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("Pool")
+ .field("capacity", &self.inner.capacity)
+ .finish_non_exhaustive()
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_capacity_is_applied() {
+ assert_eq!(Pool::new().capacity(), DEFAULT_CAPACITY);
+ assert_eq!(Pool::default().capacity(), DEFAULT_CAPACITY);
+ assert_eq!(Pool::with_capacity(3).capacity(), 3);
+ }
+
+ #[test]
+ fn clones_share_one_pool() {
+ let pool = Pool::new();
+ let clone = pool.clone();
+
+ assert!(Arc::ptr_eq(&pool.inner, &clone.inner), "cloning must not fork the pool");
+ }
+
+ #[test]
+ fn debug_reports_capacity() {
+ assert!(format!("{:?}", Pool::with_capacity(4)).contains("capacity: 4"));
+ }
+
+ #[cfg(any(test, feature = "gzip"))]
+ mod pooling {
+ use super::*;
+ use crate::Level;
+
+ fn key(level: u8) -> EngineKey {
+ EngineKey {
+ wrapper: Wrapper::Gzip,
+ level,
+ }
+ }
+
+ fn engine() -> flate2::Compress {
+ Wrapper::Gzip.compressor(Level::DEFAULT)
+ }
+
+ /// Counts what the pool is holding, which the public API deliberately does not expose.
+ fn idle(pool: &Pool, key: EngineKey) -> usize {
+ pool.inner.compressors.lock().unwrap().get(&key).map_or(0, Vec::len)
+ }
+
+ #[test]
+ fn an_engine_survives_a_round_trip_through_the_pool() {
+ let pool = Pool::new();
+ assert!(pool.take_compressor(key(6)).is_none(), "an empty pool has nothing to give");
+
+ pool.return_compressor(key(6), &mut Some(engine()));
+ assert_eq!(idle(&pool, key(6)), 1);
+
+ assert!(pool.take_compressor(key(6)).is_some(), "the returned engine should come back");
+ assert_eq!(idle(&pool, key(6)), 0, "taking an engine removes it from the pool");
+ }
+
+ #[test]
+ fn engines_are_not_shared_between_configurations() {
+ let pool = Pool::new();
+ pool.return_compressor(key(6), &mut Some(engine()));
+
+ assert!(
+ pool.take_compressor(key(9)).is_none(),
+ "a level-9 request must not receive a level-6 engine"
+ );
+ }
+
+ #[test]
+ fn capacity_bounds_what_is_retained() {
+ let pool = Pool::with_capacity(2);
+ for _ in 0..5 {
+ pool.return_compressor(key(6), &mut Some(engine()));
+ }
+
+ assert_eq!(idle(&pool, key(6)), 2, "only `capacity` engines are kept");
+ }
+
+ #[test]
+ fn zero_capacity_disables_recycling() {
+ let pool = Pool::with_capacity(0);
+ pool.return_compressor(key(6), &mut Some(engine()));
+
+ assert_eq!(idle(&pool, key(6)), 0);
+ assert!(pool.take_compressor(key(6)).is_none());
+ }
+
+ #[test]
+ fn a_returned_engine_is_reset_before_reuse() {
+ // An engine abandoned mid-stream must not leak its state into the next user.
+ let mut dirty = engine();
+ let mut scratch = [0_u8; 256];
+ dirty.compress(b"half a stream", &mut scratch, flate2::FlushCompress::None).unwrap();
+ assert!(dirty.total_in() > 0, "the engine should be dirty");
+
+ let pool = Pool::new();
+ pool.return_compressor(key(6), &mut Some(dirty));
+
+ let clean = pool.take_compressor(key(6)).unwrap();
+ assert_eq!(clean.total_in(), 0, "checkout must reset the engine");
+ assert_eq!(clean.total_out(), 0);
+ }
+
+ #[test]
+ fn a_poisoned_pool_silently_drops_a_returned_compressor() {
+ let pool = Pool::new();
+
+ // Poison the compressors mutex the same way a panicking holder would.
+ let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ let _guard = pool.inner.compressors.lock().unwrap();
+ panic!("poisoning the mutex for the test");
+ }));
+ assert!(poisoned.is_err(), "the panic should have been caught");
+ assert!(pool.inner.compressors.lock().is_err(), "the mutex must now be poisoned");
+
+ // Recycling is an optimisation, so a poisoned pool must not panic the caller.
+ pool.return_compressor(key(6), &mut Some(engine()));
+ assert!(pool.take_compressor(key(6)).is_none(), "a poisoned pool has nothing to give");
+ }
+ }
+
+ #[cfg(any(test, feature = "deflate", feature = "zlib"))]
+ mod flate_decompressor_pooling {
+ use super::*;
+
+ #[cfg(any(test, feature = "deflate"))]
+ fn wrapper() -> Wrapper {
+ Wrapper::Raw
+ }
+
+ #[cfg(all(not(test), feature = "zlib", not(feature = "deflate")))]
+ fn wrapper() -> Wrapper {
+ Wrapper::Zlib
+ }
+
+ fn engine() -> flate2::Decompress {
+ wrapper().decompressor()
+ }
+
+ /// Counts what the pool is holding, which the public API deliberately does not expose.
+ fn idle(pool: &Pool, wrapper: Wrapper) -> usize {
+ pool.inner.decompressors.lock().unwrap().get(&wrapper).map_or(0, Vec::len)
+ }
+
+ #[test]
+ fn an_engine_survives_a_round_trip_through_the_pool() {
+ let pool = Pool::new();
+ assert!(pool.take_decompressor(wrapper()).is_none(), "an empty pool has nothing to give");
+
+ pool.return_decompressor(wrapper(), &mut Some(engine()));
+ assert_eq!(idle(&pool, wrapper()), 1);
+
+ assert!(pool.take_decompressor(wrapper()).is_some(), "the returned engine should come back");
+ assert_eq!(idle(&pool, wrapper()), 0, "taking an engine removes it from the pool");
+ }
+
+ #[test]
+ fn capacity_bounds_what_is_retained() {
+ let pool = Pool::with_capacity(2);
+ for _ in 0..5 {
+ pool.return_decompressor(wrapper(), &mut Some(engine()));
+ }
+
+ assert_eq!(idle(&pool, wrapper()), 2, "only `capacity` engines are kept");
+ }
+
+ #[test]
+ fn zero_capacity_disables_decompressor_recycling() {
+ let pool = Pool::with_capacity(0);
+ pool.return_decompressor(wrapper(), &mut Some(engine()));
+
+ assert!(pool.take_decompressor(wrapper()).is_none());
+ }
+
+ #[test]
+ fn a_poisoned_pool_silently_drops_a_returned_decompressor() {
+ let pool = Pool::new();
+
+ let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ let _guard = pool.inner.decompressors.lock().unwrap();
+ panic!("poisoning the mutex for the test");
+ }));
+ assert!(poisoned.is_err(), "the panic should have been caught");
+ assert!(pool.inner.decompressors.lock().is_err(), "the mutex must now be poisoned");
+
+ pool.return_decompressor(wrapper(), &mut Some(engine()));
+ assert!(pool.take_decompressor(wrapper()).is_none(), "a poisoned pool has nothing to give");
+ }
+ }
+
+ #[cfg(any(test, feature = "zstd"))]
+ mod zstd_pooling {
+ use super::*;
+
+ /// Counts what the pool is holding, which the public API deliberately does not expose.
+ fn idle_compressors(pool: &Pool) -> usize {
+ pool.inner.zstd_compressors.lock().unwrap().len()
+ }
+
+ fn idle_decompressors(pool: &Pool) -> usize {
+ pool.inner.zstd_decompressors.lock().unwrap().len()
+ }
+
+ #[test]
+ fn a_compressor_survives_a_round_trip_through_the_pool() {
+ let pool = Pool::new();
+ assert!(pool.take_zstd_compressor().is_none(), "an empty pool has nothing to give");
+
+ pool.return_zstd_compressor(&mut Some(zstd_safe::CCtx::create()));
+ assert_eq!(idle_compressors(&pool), 1);
+
+ assert!(pool.take_zstd_compressor().is_some(), "the returned engine should come back");
+ assert_eq!(idle_compressors(&pool), 0, "taking an engine removes it from the pool");
+ }
+
+ #[test]
+ fn compressor_capacity_bounds_what_is_retained() {
+ let pool = Pool::with_capacity(2);
+ for _ in 0..5 {
+ pool.return_zstd_compressor(&mut Some(zstd_safe::CCtx::create()));
+ }
+
+ assert_eq!(idle_compressors(&pool), 2, "only `capacity` engines are kept");
+ }
+
+ #[test]
+ fn zero_capacity_disables_zstd_compressor_recycling() {
+ let pool = Pool::with_capacity(0);
+ pool.return_zstd_compressor(&mut Some(zstd_safe::CCtx::create()));
+
+ assert!(pool.take_zstd_compressor().is_none());
+ }
+
+ #[test]
+ fn a_poisoned_pool_silently_drops_a_returned_zstd_compressor() {
+ let pool = Pool::new();
+
+ let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ let _guard = pool.inner.zstd_compressors.lock().unwrap();
+ panic!("poisoning the mutex for the test");
+ }));
+ assert!(poisoned.is_err(), "the panic should have been caught");
+ assert!(pool.inner.zstd_compressors.lock().is_err(), "the mutex must now be poisoned");
+
+ pool.return_zstd_compressor(&mut Some(zstd_safe::CCtx::create()));
+ assert!(pool.take_zstd_compressor().is_none(), "a poisoned pool has nothing to give");
+ }
+
+ #[test]
+ fn a_decompressor_survives_a_round_trip_through_the_pool() {
+ let pool = Pool::new();
+ assert!(pool.take_zstd_decompressor().is_none(), "an empty pool has nothing to give");
+
+ pool.return_zstd_decompressor(&mut Some(zstd_safe::DCtx::create()));
+ assert_eq!(idle_decompressors(&pool), 1);
+
+ assert!(pool.take_zstd_decompressor().is_some(), "the returned engine should come back");
+ assert_eq!(idle_decompressors(&pool), 0, "taking an engine removes it from the pool");
+ }
+
+ #[test]
+ fn decompressor_capacity_bounds_what_is_retained() {
+ let pool = Pool::with_capacity(2);
+ for _ in 0..5 {
+ pool.return_zstd_decompressor(&mut Some(zstd_safe::DCtx::create()));
+ }
+
+ assert_eq!(idle_decompressors(&pool), 2, "only `capacity` engines are kept");
+ }
+
+ #[test]
+ fn zero_capacity_disables_zstd_decompressor_recycling() {
+ let pool = Pool::with_capacity(0);
+ pool.return_zstd_decompressor(&mut Some(zstd_safe::DCtx::create()));
+
+ assert!(pool.take_zstd_decompressor().is_none());
+ }
+ }
+}
diff --git a/crates/compressors/src/resources.rs b/crates/compressors/src/resources.rs
new file mode 100644
index 000000000..ac68323d9
--- /dev/null
+++ b/crates/compressors/src/resources.rs
@@ -0,0 +1,228 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! What an engine draws on, rather than what it is configured to do.
+//!
+//! Compression needs two things from its caller that have nothing to do with the format: somewhere
+//! to allocate output buffers, and somewhere to keep engine state between messages. Both are
+//! shared, both are cloneable handles, and both belong to the calling application rather than to
+//! any one message -- so they travel together, as [`Resources`].
+
+use std::sync::OnceLock;
+
+use bytesbuf::mem::{GlobalPool, MemoryShared, OpaqueMemory};
+use thread_aware::{Thread, ThreadAware};
+
+use crate::pool::Pool;
+
+/// The memory and engine state a compressor or decompressor draws on.
+///
+/// Everything a builder carries describes what to do; this describes what to do it with. Hold one
+/// per application -- or per subsystem that wants its own memory accounting -- and hand it to every
+/// compressor and decompressor. Cloning is cheap, and every clone draws on the same memory and the same engines.
+///
+/// # Recycling
+///
+/// Building a compressor allocates and initializes a substantial amount of state: on a small
+/// message, as much work as the compression itself. These resources recycle that state between
+/// messages, so a service that compresses many small bodies spends its budget compressing rather
+/// than getting ready to compress. It is on by default, and
+/// [`with_pool_capacity(0)`][Resources::with_pool_capacity] turns it off for the rare caller that
+/// wants to measure what it is worth, or that compresses one message and exits.
+///
+/// Recycling is transparent: it applies to the engines that benefit and quietly skips the rest, so
+/// calling code never has to know which is which, and which engines those are can change without
+/// any change to calling code.
+///
+/// # Relocation
+///
+/// [`ThreadAware`] is implemented, so a runtime that moves work between threads can tell these
+/// resources where they now run. The two halves are treated differently on purpose: the memory
+/// provider is relocated, since a NUMA-aware or per-thread provider will want to allocate from the
+/// destination's memory, while the engine pool is left untouched. Every clone shares one pool, an
+/// idle engine is plain memory with no affinity to where it was built, and discarding it on a move
+/// would throw away exactly what this type exists to retain.
+///
+/// # Examples
+///
+/// ```
+/// # #[cfg(feature = "gzip")]
+/// # {
+/// use compressors::{Resources, gzip};
+///
+/// // One shared instance: the process-wide memory provider and process-wide engine recycling.
+/// let resources = Resources::global();
+///
+/// let compressed = gzip::compress(b"hello", resources)?;
+/// assert_eq!(
+/// gzip::decompress(compressed, resources)?.to_vec(),
+/// b"hello".to_vec()
+/// );
+/// # }
+/// # Ok::<(), compressors::Error>(())
+/// ```
+#[derive(Clone, Debug)]
+pub struct Resources {
+ memory: OpaqueMemory,
+ pool: Pool,
+}
+
+impl Resources {
+ /// Draws output buffers from `memory`, recycling engine state between messages.
+ ///
+ /// The engines belong to the returned value, so every clone of it shares them, and separately
+ /// constructed resources have independent engine pools. Whether they also share memory is a
+ /// property of the [`MemoryShared`] provider handed in, not of this type.
+ #[must_use]
+ pub fn new(memory: impl MemoryShared) -> Self {
+ Self {
+ memory: OpaqueMemory::new(memory),
+ pool: Pool::new(),
+ }
+ }
+
+ /// Sets how many idle engines are kept per interchangeable group, where zero stops recycling.
+ ///
+ /// What counts as a group depends on the engine. The deflate family's compressors are keyed by
+ /// container and level, because a reset preserves both; zstd contexts are keyed by nothing at
+ /// all, because a reset lets any idle context serve any level. So the ceiling on retained
+ /// engines is this capacity times the number of groups a workload actually reaches, not this
+ /// capacity alone.
+ ///
+ /// Recycling is already on after [`new`][Resources::new] at a capacity that suits ordinary
+ /// request traffic. Set this to the number of messages expected to be in flight at once, or to
+ /// zero when compression is rare enough that retaining engine state costs more than rebuilding
+ /// it.
+ ///
+ /// The capacity bounds what is kept, not what can be used: a burst beyond it still compresses,
+ /// building engines it then drops instead of storing.
+ #[must_use]
+ pub fn with_pool_capacity(mut self, capacity: usize) -> Self {
+ self.pool = match capacity {
+ 0 => Pool::disabled().clone(),
+ capacity => Pool::with_capacity(capacity),
+ };
+ self
+ }
+
+ /// The shared resources of the process: one memory provider, one set of recycled engines.
+ ///
+ /// This is the right answer for an application that has no reason to account for memory per
+ /// subsystem, and it is what makes recycling the easy path rather than the deliberate one.
+ #[must_use]
+ pub fn global() -> &'static Self {
+ static GLOBAL: OnceLock = OnceLock::new();
+
+ GLOBAL.get_or_init(|| Self::new(global_memory().clone()))
+ }
+
+ /// The memory provider output buffers are drawn from.
+ ///
+ /// Also the provider to build input with, so that a message is allocated out of the same memory
+ /// it is compressed into.
+ #[must_use]
+ pub fn memory(&self) -> &OpaqueMemory {
+ &self.memory
+ }
+
+ /// The engines a compressor or decompressor built from these resources checks out of, and back into.
+ #[cfg_attr(
+ not(any(test, any_format)),
+ expect(dead_code, reason = "only a format module's build method checks an engine out")
+ )]
+ pub(crate) fn pool(&self) -> &Pool {
+ &self.pool
+ }
+}
+
+impl Default for Resources {
+ /// The [`global`][Resources::global] resources, as an owned handle.
+ fn default() -> Self {
+ Self::global().clone()
+ }
+}
+
+/// Written out rather than derived, because the two halves want opposite treatment and the
+/// difference is the interesting part.
+impl ThreadAware for Resources {
+ fn relocate(&mut self, source: Option<&Thread>, destination: &Thread) {
+ // The memory provider is the half that can act on a move: a NUMA-aware or per-thread
+ // provider wants to allocate from the destination's memory from here on. Forwarding is all
+ // this type has to do -- what that means is the provider's decision, not ours.
+ self.memory.relocate(source, destination);
+
+ // The pool deliberately does nothing. Every clone of these resources shares one pool, by
+ // design, so a pool is not owned by the thread that happens to be moving and has no
+ // per-thread state to migrate. An idle engine is a window and a set of hash tables -- plain
+ // memory, reachable from anywhere, with no affinity to where it was built. Draining or
+ // re-homing it on relocation would discard exactly the state this type exists to retain,
+ // and would do so on every move.
+ }
+}
+
+/// The one global memory provider this crate creates, shared by every [`Resources`] that does not
+/// name its own.
+fn global_memory() -> &'static GlobalPool {
+ static MEMORY: OnceLock = OnceLock::new();
+
+ MEMORY.get_or_init(GlobalPool::new)
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn recycling_is_on_by_default_and_its_capacity_is_adjustable() {
+ let recycling = Resources::new(GlobalPool::new());
+ assert!(recycling.pool().capacity() > 0, "recycling should be the default");
+
+ let plain = recycling.with_pool_capacity(0);
+ assert_eq!(plain.pool().capacity(), 0, "a capacity of zero must stop recycling");
+
+ assert_eq!(plain.with_pool_capacity(4).pool().capacity(), 4, "the capacity must be honoured");
+ }
+
+ #[test]
+ fn the_global_resources_are_one_instance() {
+ assert!(
+ std::ptr::eq(Resources::global(), Resources::global()),
+ "every caller must see the same global resources"
+ );
+ assert!(
+ Resources::default().pool().capacity() > 0,
+ "the default is the global handle, which recycles"
+ );
+ }
+
+ #[test]
+ fn memory_is_available_for_input_as_well_as_output() {
+ let resources = Resources::new(GlobalPool::new());
+
+ assert!(
+ resources.memory().reserve(16).remaining_capacity() >= 16,
+ "the memory provider must be reachable"
+ );
+ assert!(format!("{resources:?}").contains("Resources"));
+ }
+
+ #[test]
+ fn relocating_moves_the_memory_provider_and_leaves_the_pool_alone() {
+ use thread_aware::Relocator;
+
+ use crate::testing::counting_memory;
+
+ let (memory, activity) = counting_memory();
+ let mut resources = Resources::new(memory).with_pool_capacity(4);
+
+ _ = Relocator::between_threads().relocate(&mut resources);
+
+ assert_eq!(activity.relocations(), 1, "the memory provider must be told where it now runs");
+ assert_eq!(
+ resources.pool().capacity(),
+ 4,
+ "relocation must not disturb the pool, whose whole purpose is to outlive a move"
+ );
+ }
+}
diff --git a/crates/compressors/src/stream.rs b/crates/compressors/src/stream.rs
new file mode 100644
index 000000000..19a7f2c0b
--- /dev/null
+++ b/crates/compressors/src/stream.rs
@@ -0,0 +1,646 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Compression and decompression as a [`futures_core::Stream`].
+//!
+//! [`CompressionStream`] wraps a stream of byte sequences and yields converted chunks as they
+//! become available, so a body of any size passes through in bounded memory. Both the source and
+//! compression engine remain generic. Requires the `futures-stream` cargo feature.
+
+use std::pin::Pin;
+use std::task::{Context, Poll};
+
+use bytesbuf::BytesView;
+use futures_core::Stream;
+use pin_project_lite::pin_project;
+
+use crate::core::{Compress, Compression, Decompress, Destination, Output};
+use crate::error::{Error, Result};
+
+/// Bounds the amount of immediately-ready work one `poll_next` performs.
+///
+/// A stream whose source is always ready would otherwise let one poll run until the data ends,
+/// starving the executor's other tasks. The rule is to yield often enough to stay fair while
+/// amortizing the wake machinery over more than a single chunk; the value is a conservative
+/// starting point, not a measured optimum, and matches the engine's per-`pull` step budget so the
+/// two layers bound work on the same scale.
+const MAX_OPERATIONS_PER_POLL: usize = 64;
+
+/// Drives one poll of a compression stream, whichever direction it runs in.
+///
+/// The source is polled only when the engine has nothing left to give, so a slow consumer never
+/// causes unbounded buffering.
+///
+/// `finished` latches once the stream has yielded its last item. Without it, a failing engine would
+/// report the same error on every subsequent poll, and a caller that collects the stream would
+/// accumulate errors until it ran out of memory.
+fn poll_compression(
+ mut source: Pin<&mut S>,
+ compression: &mut C,
+ finished: &mut bool,
+ cx: &mut Context<'_>,
+) -> Poll>>
+where
+ S: Stream- >,
+ C: Compression,
+ E: Into
>,
+{
+ if *finished {
+ return Poll::Ready(None);
+ }
+
+ // Latches when the source has run dry and `end_input` has been signalled. A conforming codec
+ // answers the next `pull` with output or `Done`, never with another request for input, so a
+ // second `NeedInput` means the codec is not honouring the contract. Without this the loop would
+ // poll the exhausted source again, re-signal end of input, and keep waking itself: bounded per
+ // poll, but a livelock across them. `process` rejects the same sequence outright.
+ let mut input_ended = false;
+
+ for _ in 0..MAX_OPERATIONS_PER_POLL {
+ match compression.pull(Destination::Stream) {
+ Err(error) => {
+ *finished = true;
+ return Poll::Ready(Some(Err(error)));
+ }
+ Ok(Output::Data(data)) => return Poll::Ready(Some(Ok(data))),
+ Ok(Output::Progress) => {
+ cx.waker().wake_by_ref();
+ return Poll::Pending;
+ }
+ Ok(Output::Done) => {
+ *finished = true;
+ return Poll::Ready(None);
+ }
+ Ok(Output::NeedInput) if input_ended => {
+ *finished = true;
+ return Poll::Ready(Some(Err(Error::invalid_state("the operation requested input after end of input"))));
+ }
+ Ok(Output::NeedInput) => match source.as_mut().poll_next(cx) {
+ Poll::Pending => return Poll::Pending,
+ Poll::Ready(None) => {
+ compression.end_input();
+ input_ended = true;
+ }
+ Poll::Ready(Some(Ok(chunk))) => {
+ if let Err(error) = compression.push(chunk) {
+ *finished = true;
+ return Poll::Ready(Some(Err(error)));
+ }
+ }
+ Poll::Ready(Some(Err(error))) => {
+ *finished = true;
+ return Poll::Ready(Some(Err(Error::other("the underlying stream failed", error))));
+ }
+ },
+ }
+ }
+
+ cx.waker().wake_by_ref();
+ Poll::Pending
+}
+
+pin_project! {
+ /// Compresses or decompresses a stream of [`BytesView`] values.
+ ///
+ /// Construct it with [`CompressionStream::compress`] or [`CompressionStream::decompress`].
+ /// Both the source and the engine retain their concrete types; this adapter performs no boxing.
+ ///
+ /// The source yields `Result` rather than bare views, for any `E` that converts
+ /// into a boxed `std::error::Error + Send + Sync`. A source failure ends the stream, reported as
+ /// an [`Error`][crate::Error] for which [`is_source`][crate::Error::is_source] is true and whose
+ /// [`source`][std::error::Error::source] is the original. The constructors accept any `S`, so a
+ /// source of plain views compiles and only fails to satisfy [`Stream`] when it is polled.
+ ///
+ /// The stream ends after its first error rather than reporting the same failure repeatedly.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # #[cfg(feature = "gzip")]
+ /// # {
+ /// use bytesbuf::BytesView;
+ /// use bytesbuf::mem::GlobalPool;
+ /// use compressors::{CompressionStream, Resources, gzip};
+ /// use futures::{StreamExt, stream};
+ ///
+ /// # futures::executor::block_on(async {
+ /// let memory = GlobalPool::new();
+ /// let source = stream::iter(vec![
+ /// Ok::<_, std::io::Error>(BytesView::copied_from_slice(b"first ", &memory)),
+ /// Ok(BytesView::copied_from_slice(b"second", &memory)),
+ /// ]);
+ ///
+ /// let chunks: Vec<_> =
+ /// CompressionStream::compress(source, gzip::Compressor::new(&Resources::default())).collect().await;
+ /// let gzip = BytesView::from_views(chunks.into_iter().map(|c| c.unwrap()));
+ ///
+ /// assert_eq!(gzip.range(0..2).to_vec(), vec![0x1f, 0x8b]);
+ /// # });
+ /// # }
+ /// ```
+ #[derive(Debug)]
+ pub struct CompressionStream {
+ #[pin]
+ source: S,
+ compression: C,
+ finished: bool,
+ }
+}
+
+impl CompressionStream
+where
+ C: Compression,
+{
+ /// Compresses `source` with `compression`.
+ ///
+ /// # Examples
+ ///
+ /// See [`CompressionStream`] for a complete example.
+ #[must_use]
+ pub fn compress(source: S, compression: C) -> Self {
+ Self {
+ source,
+ compression,
+ finished: false,
+ }
+ }
+}
+
+impl CompressionStream
+where
+ C: Compression,
+{
+ /// Decompresses `source` with `compression`.
+ ///
+ /// # Security
+ ///
+ /// This adapter adds no bounds of its own; it keeps whatever the supplied decompressor was
+ /// built with, and hands every chunk straight back rather than accumulating. A decompressor
+ /// built with its format's `new` therefore carries only that format's ratio bound. If the
+ /// consumer buffers what this yields, build the decompressor with its `builder` and set
+ /// [`max_output_len`][crate::DecompressorLimits::max_output_len] to what that consumer
+ /// can afford.
+ ///
+ /// An output cap is not the whole story for a format that joins concatenated streams -- gzip
+ /// and zstd do so by default. Cumulative output is what
+ /// [`max_output_len`][crate::DecompressorLimits::max_output_len] bounds, so a long run
+ /// of small or empty members can keep decoding without ever reaching it. Bound the member count
+ /// as well with [`max_streams`][crate::DecompressorLimits::max_streams], or turn
+ /// joining off with [`multi_stream(false)`][crate::DecompressorBuilder::multi_stream].
+ ///
+ /// Output chunks are provisional until the stream ends, because a checksum or trailer can
+ /// reject the compressed stream after earlier bytes have been returned.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # #[cfg(feature = "gzip")]
+ /// # {
+ /// use std::num::NonZeroU64;
+ ///
+ /// use bytesbuf::BytesView;
+ /// use compressors::{CompressionStream, DecompressorLimits, Resources, gzip};
+ /// use futures::{StreamExt, stream};
+ ///
+ /// # futures::executor::block_on(async {
+ /// let compressed = gzip::compress(b"payload", &Resources::default()).unwrap();
+ ///
+ /// // Deliver the gzip stream one byte at a time, the worst case for a decompressor.
+ /// let source = stream::iter(
+ /// (0..compressed.len())
+ /// .map(|i| Ok::<_, std::io::Error>(compressed.range(i..i + 1)))
+ /// .collect::>(),
+ /// );
+ ///
+ /// // This consumer collects every chunk, so it caps the total rather than relying on the
+ /// // adapter's bounded working set.
+ /// let decompressor = gzip::Decompressor::builder()
+ /// .limits(DecompressorLimits::new().max_output_len(NonZeroU64::new(1 << 20).unwrap()))
+ /// .build(&Resources::default());
+ ///
+ /// let chunks: Vec<_> = CompressionStream::decompress(source, decompressor)
+ /// .collect()
+ /// .await;
+ /// let plain = BytesView::from_views(chunks.into_iter().map(|c| c.unwrap()));
+ ///
+ /// assert_eq!(plain.to_vec(), b"payload".to_vec());
+ /// # });
+ /// # }
+ /// ```
+ #[must_use]
+ pub fn decompress(source: S, compression: C) -> Self {
+ Self {
+ source,
+ compression,
+ finished: false,
+ }
+ }
+}
+
+impl Stream for CompressionStream
+where
+ S: Stream- >,
+ C: Compression,
+ E: Into
>,
+{
+ type Item = Result;
+
+ fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
+ let this = self.project();
+ poll_compression(this.source, this.compression, this.finished, cx)
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroU64;
+ use std::pin::pin;
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ use bytesbuf::BytesBuf;
+ use futures::executor::block_on;
+ use futures::task::{ArcWake, noop_waker};
+ use futures::{StreamExt, stream};
+
+ use super::*;
+ use crate::format::Format;
+ use crate::testing::{ProgressCompression, view};
+ use crate::{DecompressorLimits, Level, Resources, gzip};
+
+ fn ok_stream(chunks: Vec) -> impl Stream- > {
+ stream::iter(chunks.into_iter().map(Ok))
+ }
+
+ /// Caps the polls any test here may need.
+ ///
+ /// A conforming stream terminates, so exceeding this means the code under test is spinning or
+ /// emitting endlessly. Draining without a bound would hang or exhaust memory instead, and a
+ /// hanging test reports nothing at all -- which is also what stops mutation testing from
+ /// reaching a verdict rather than a timeout.
+ const MAX_POLLS: usize = 10_000;
+
+ /// A waker that records whether it was asked to wake anything.
+ #[derive(Debug, Default)]
+ struct CountingWaker(AtomicUsize);
+
+ impl ArcWake for CountingWaker {
+ fn wake_by_ref(arc_self: &Arc
) {
+ arc_self.0.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+
+ /// Drives `stream` to completion and returns everything it yielded.
+ ///
+ /// Polls directly rather than through an executor, so the number of polls is bounded and a
+ /// `Pending` that arranged no wake is a failure rather than a hang.
+ fn drain(stream: impl Stream- >) -> Result
{
+ let scheduled = Arc::new(CountingWaker::default());
+ let waker = futures::task::waker(Arc::clone(&scheduled));
+ let mut context = Context::from_waker(&waker);
+ let mut stream = pin!(stream);
+ let mut collected = BytesBuf::new();
+
+ for poll in 0..MAX_POLLS {
+ match stream.as_mut().poll_next(&mut context) {
+ Poll::Ready(Some(item)) => collected.put_bytes(item?),
+ Poll::Ready(None) => return Ok(collected.consume_all()),
+ Poll::Pending => assert!(
+ scheduled.0.load(Ordering::Relaxed) > 0,
+ "the stream returned Pending on poll {poll} without arranging a wake"
+ ),
+ }
+ }
+
+ panic!("the stream did not finish within {MAX_POLLS} polls");
+ }
+
+ #[test]
+ fn round_trips_through_both_directions() {
+ let payload = b"streaming round trip ".repeat(500);
+
+ let source = ok_stream(payload.chunks(97).map(view).collect());
+ let gzip = drain(CompressionStream::compress(source, gzip::Compressor::new(&Resources::default()))).unwrap();
+
+ let plain = drain(CompressionStream::decompress(
+ ok_stream(vec![gzip]),
+ gzip::Decompressor::new(&Resources::default()),
+ ))
+ .unwrap();
+
+ assert_eq!(plain.to_vec(), payload);
+ }
+
+ /// Every format must support both directions.
+ #[test]
+ fn every_format_round_trips_through_the_stream() {
+ let payload = b"every format ".repeat(300);
+ let chunks = || ok_stream(payload.chunks(89).map(view).collect());
+
+ // Every format reaches the stream through `Format`, so this needs no per-format arm and
+ // cannot fall out of step when a format is added.
+ for &format in Format::ALL {
+ let compressor = crate::CompressorBuilder::new().build_format(format, &Resources::default()).unwrap();
+ let compressed = drain(CompressionStream::compress(chunks(), compressor)).unwrap();
+
+ let decompressor = crate::DecompressorBuilder::new()
+ .build_format(format, &Resources::default())
+ .unwrap();
+ let plain = drain(CompressionStream::decompress(ok_stream(vec![compressed]), decompressor)).unwrap();
+
+ assert_eq!(plain.to_vec(), payload, "{format:?} failed to round trip");
+ }
+ }
+
+ #[test]
+ fn compresses_an_empty_source() {
+ let source = ok_stream(Vec::new());
+ let gzip = drain(CompressionStream::compress(source, gzip::Compressor::new(&Resources::default()))).unwrap();
+
+ assert_eq!(gzip.range(0..2).to_vec(), vec![0x1f, 0x8b]);
+ }
+
+ #[test]
+ fn decompresses_a_byte_at_a_time() {
+ let compressed = crate::gzip::compress(view(b"one byte at a time"), &Resources::default()).unwrap();
+ let single_bytes = (0..compressed.len()).map(|i| compressed.range(i..=i)).collect();
+
+ let plain = drain(CompressionStream::decompress(
+ ok_stream(single_bytes),
+ gzip::Decompressor::new(&Resources::default()),
+ ))
+ .unwrap();
+
+ assert_eq!(plain.to_vec(), b"one byte at a time".to_vec());
+ }
+
+ #[test]
+ fn decompresses_members_delivered_as_separate_source_items() {
+ let first = crate::gzip::compress(view(b"first"), &Resources::default()).unwrap();
+ let second = crate::gzip::compress(view(b"second"), &Resources::default()).unwrap();
+
+ let plain = drain(CompressionStream::decompress(
+ ok_stream(vec![first, second]),
+ gzip::Decompressor::new(&Resources::default()),
+ ))
+ .unwrap();
+
+ assert_eq!(plain.to_vec(), b"firstsecond".to_vec());
+ }
+
+ #[test]
+ fn reports_a_failing_source_as_a_source_error() {
+ let failing = stream::iter(vec![Err(std::io::Error::other("transport died"))]);
+
+ let error = drain(CompressionStream::compress(failing, gzip::Compressor::new(&Resources::default()))).unwrap_err();
+
+ assert!(error.is_source(), "got {error}");
+ assert_eq!(
+ std::error::Error::source(&error).map(ToString::to_string),
+ Some("transport died".to_owned()),
+ "the original failure should remain reachable"
+ );
+ }
+
+ #[test]
+ fn accepts_source_errors_convertible_to_a_boxed_error() {
+ let failing = stream::iter(vec![Err("transport died".to_owned())]);
+
+ let error = drain(CompressionStream::compress(failing, gzip::Compressor::new(&Resources::default()))).unwrap_err();
+
+ assert!(error.is_source(), "got {error}");
+ assert_eq!(
+ std::error::Error::source(&error).map(ToString::to_string),
+ Some("transport died".to_owned())
+ );
+ }
+
+ #[test]
+ fn reports_a_push_rejection_as_an_error() {
+ use crate::testing::RejectsPush;
+
+ let source = ok_stream(vec![view(b"chunk")]);
+ let error = drain(CompressionStream::compress(source, RejectsPush)).unwrap_err();
+
+ assert!(error.is_invalid_state(), "got {error}");
+ }
+
+ #[test]
+ fn reports_a_request_for_input_after_end_of_input_as_an_error() {
+ // The fixture asks for input forever. Once the source is exhausted there is none left to
+ // give, so the adapter has to end the stream rather than keep waking itself to ask again.
+ use crate::testing::RejectsPush;
+
+ let source = ok_stream(Vec::new());
+ let error = drain(CompressionStream::compress(source, RejectsPush)).unwrap_err();
+
+ assert!(error.is_invalid_state(), "got {error}");
+ }
+
+ #[test]
+ fn rejects_push_fixture_end_input_is_a_no_op() {
+ use crate::core::CompressionInternal as _;
+ use crate::testing::RejectsPush;
+
+ let mut operation = RejectsPush;
+ operation.end_input();
+ }
+
+ #[test]
+ fn ends_after_the_first_error_instead_of_repeating_it() {
+ // A stream that keeps yielding the same error is unbounded: a caller that collects it
+ // accumulates errors until it runs out of memory.
+ let source = ok_stream(vec![view(b"this is not gzip")]);
+ let mut stream = Box::pin(CompressionStream::decompress(
+ source,
+ gzip::Decompressor::new(&Resources::default()),
+ ));
+
+ block_on(async {
+ let first = stream.next().await.unwrap();
+ assert!(first.unwrap_err().is_corrupt_data());
+
+ assert!(stream.next().await.is_none(), "the stream must end after an error");
+ assert!(stream.next().await.is_none(), "and stay ended");
+ });
+ }
+
+ #[test]
+ fn stays_ended_after_completion() {
+ let gzip = crate::gzip::compress(view(b"done"), &Resources::default()).unwrap();
+ let mut stream = pin!(CompressionStream::decompress(
+ ok_stream(vec![gzip]),
+ gzip::Decompressor::new(&Resources::default()),
+ ));
+
+ let waker = noop_waker();
+ let mut context = Context::from_waker(&waker);
+
+ let mut ended = false;
+ for _ in 0..MAX_POLLS {
+ if matches!(stream.as_mut().poll_next(&mut context), Poll::Ready(None)) {
+ ended = true;
+ break;
+ }
+ }
+ assert!(ended, "the stream did not end within {MAX_POLLS} polls");
+
+ assert!(
+ matches!(stream.as_mut().poll_next(&mut context), Poll::Ready(None)),
+ "a completed stream stays ended"
+ );
+ }
+
+ #[test]
+ fn reports_corrupt_input_from_decompression() {
+ let source = ok_stream(vec![view(b"this is not gzip")]);
+
+ let error = drain(CompressionStream::decompress(
+ source,
+ gzip::Decompressor::new(&Resources::default()),
+ ))
+ .unwrap_err();
+
+ assert!(error.is_corrupt_data(), "got {error}");
+ }
+
+ #[test]
+ fn honours_a_pre_configured_decompressor() {
+ let gzip = crate::gzip::compress(view(&vec![0_u8; 4 * 1024 * 1024]), &Resources::default()).unwrap();
+
+ let decompressor = gzip::Decompressor::builder()
+ .limits(DecompressorLimits::new().max_output_len(NonZeroU64::new(1024).unwrap()))
+ .build(&Resources::default());
+
+ let error = drain(CompressionStream::decompress(ok_stream(vec![gzip]), decompressor)).unwrap_err();
+
+ assert!(error.is_limit_exceeded(), "got {error}");
+ }
+
+ #[test]
+ fn honours_a_pre_configured_compressor() {
+ let payload = b"the quick brown fox ".repeat(400);
+
+ let compressor = gzip::Compressor::builder().level(Level::HIGH).build(&Resources::default());
+ let gzip = drain(CompressionStream::compress(ok_stream(vec![view(&payload)]), compressor)).unwrap();
+
+ let plain = drain(CompressionStream::decompress(
+ ok_stream(vec![gzip]),
+ gzip::Decompressor::new(&Resources::default()),
+ ))
+ .unwrap();
+
+ assert_eq!(plain.to_vec(), payload);
+ }
+
+ #[test]
+ fn tolerates_empty_chunks_from_the_source() {
+ let source = ok_stream(vec![BytesView::new(), view(b"data"), BytesView::new()]);
+
+ let gzip = drain(CompressionStream::compress(source, gzip::Compressor::new(&Resources::default()))).unwrap();
+ let plain = drain(CompressionStream::decompress(
+ ok_stream(vec![gzip]),
+ gzip::Decompressor::new(&Resources::default()),
+ ))
+ .unwrap();
+
+ assert_eq!(plain.to_vec(), b"data".to_vec());
+ }
+
+ #[test]
+ fn waits_for_a_pending_source() {
+ // Exercises the `Poll::Pending` arm: the source stalls once before ending.
+ let mut stalled = false;
+ let source = stream::poll_fn(move |cx| -> Poll>> {
+ if stalled {
+ return Poll::Ready(None);
+ }
+
+ stalled = true;
+ cx.waker().wake_by_ref();
+ Poll::Pending
+ });
+
+ let gzip = drain(CompressionStream::compress(source, gzip::Compressor::new(&Resources::default()))).unwrap();
+
+ assert_eq!(gzip.range(0..2).to_vec(), vec![0x1f, 0x8b]);
+ }
+
+ #[test]
+ fn bounds_immediately_ready_empty_source_items_per_poll() {
+ struct ReadyEmpty {
+ polls: Arc,
+ }
+
+ impl Stream for ReadyEmpty {
+ type Item = std::result::Result;
+
+ fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> {
+ self.polls.fetch_add(1, Ordering::Relaxed);
+ Poll::Ready(Some(Ok(BytesView::new())))
+ }
+ }
+
+ let polls = Arc::new(AtomicUsize::new(0));
+ let source = ReadyEmpty { polls: Arc::clone(&polls) };
+ let mut stream = Box::pin(CompressionStream::compress(source, gzip::Compressor::new(&Resources::default())));
+ let waker = noop_waker();
+ let mut cx = Context::from_waker(&waker);
+
+ let mut yielded = false;
+ for _ in 0..4 {
+ if stream.as_mut().poll_next(&mut cx).is_pending() {
+ yielded = true;
+ break;
+ }
+ }
+
+ assert!(yielded, "the adapter must yield after a bounded amount of ready work");
+ assert_eq!(polls.load(Ordering::Relaxed), MAX_OPERATIONS_PER_POLL);
+ }
+
+ #[test]
+ fn progress_yields_after_one_codec_pull() {
+ let pulls = Arc::new(AtomicUsize::new(0));
+ let source = stream::pending::>();
+ let mut stream = Box::pin(CompressionStream::compress(source, ProgressCompression::new(Arc::clone(&pulls))));
+ let waker = noop_waker();
+ let mut cx = Context::from_waker(&waker);
+
+ assert!(stream.as_mut().poll_next(&mut cx).is_pending());
+ assert_eq!(pulls.load(Ordering::Relaxed), 1);
+ }
+
+ #[test]
+ fn the_progress_fixture_accepts_push_and_end_input_directly() {
+ use crate::core::CompressionInternal as _;
+
+ let mut operation = ProgressCompression::new(Arc::new(AtomicUsize::new(0)));
+ operation.push(view(b"ignored")).unwrap();
+ operation.end_input();
+ }
+
+ #[test]
+ fn streams_are_send_so_they_can_cross_task_boundaries() {
+ // `!Send` is infectious: a stream that cannot move between tasks is unusable in most async
+ // runtimes.
+ fn assert_send(_: &T) {}
+ assert_send(&CompressionStream::compress(
+ ok_stream(Vec::new()),
+ gzip::Compressor::new(&Resources::default()),
+ ));
+ assert_send(&CompressionStream::decompress(
+ ok_stream(Vec::new()),
+ gzip::Decompressor::new(&Resources::default()),
+ ));
+ }
+
+ #[test]
+ fn debug_is_available_for_diagnostics() {
+ let empty = stream::iter(Vec::>::new());
+ let stream = CompressionStream::compress(empty, gzip::Compressor::new(&Resources::default()));
+
+ assert!(format!("{stream:?}").contains("CompressionStream"));
+ }
+}
diff --git a/crates/compressors/src/testing.rs b/crates/compressors/src/testing.rs
new file mode 100644
index 000000000..1b6e12a14
--- /dev/null
+++ b/crates/compressors/src/testing.rs
@@ -0,0 +1,179 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Shared fixtures and helpers for this crate's own tests.
+//!
+//! Anything used by more than one test module lives here, so the production modules carry only
+//! production code. Module-local helpers stay where they are used.
+
+use std::num::NonZeroUsize;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use bytesbuf::mem::{CallbackMemory, GlobalPool};
+use bytesbuf::{BytesBuf, BytesView};
+use thread_aware::{Thread, ThreadAware};
+
+use crate::core::{Compress, Compression, CompressionInternal, Destination, Output};
+use crate::{Error, Result};
+
+/// What a [`counting_memory`] provider has been asked to do.
+///
+/// A cloneable handle onto the counters, so a test can hold one after the provider has been moved
+/// into a [`Resources`][crate::Resources].
+#[derive(Clone, Debug, Default)]
+pub(crate) struct MemoryActivity {
+ reservations: Arc,
+ relocations: Arc,
+}
+
+impl MemoryActivity {
+ /// How many times a buffer has been reserved from the provider.
+ pub(crate) fn reservations(&self) -> usize {
+ self.reservations.load(Ordering::SeqCst)
+ }
+
+ /// How many times the provider has been told it moved.
+ pub(crate) fn relocations(&self) -> usize {
+ self.relocations.load(Ordering::SeqCst)
+ }
+}
+
+/// The state a [`counting_memory`] provider carries: the wrapped provider and the counters.
+///
+/// `CallbackMemory`'s reservation function is a bare `fn` pointer and cannot capture, so everything
+/// it needs lives here.
+#[derive(Clone, Debug)]
+pub(crate) struct CountingData {
+ inner: GlobalPool,
+ activity: MemoryActivity,
+}
+
+impl ThreadAware for CountingData {
+ fn relocate(&mut self, source: Option<&Thread>, destination: &Thread) {
+ self.activity.relocations.fetch_add(1, Ordering::SeqCst);
+ self.inner.relocate(source, destination);
+ }
+}
+
+fn count_reserve(data: &CountingData, min_bytes: usize) -> BytesBuf {
+ data.activity.reservations.fetch_add(1, Ordering::SeqCst);
+ data.inner.reserve(min_bytes)
+}
+
+/// A memory provider that records what is asked of it.
+///
+/// For tests that need to prove the crate drew its buffers from the caller's provider rather than
+/// one of its own, or that a relocation reached it. Built on `bytesbuf`'s own `CallbackMemory`
+/// rather than a hand-written [`Memory`] impl, which is the extension point that exists for this.
+pub(crate) fn counting_memory() -> (CallbackMemory, MemoryActivity) {
+ let activity = MemoryActivity::default();
+ let data = CountingData {
+ inner: GlobalPool::new(),
+ activity: activity.clone(),
+ };
+
+ (CallbackMemory::new(data, count_reserve), activity)
+}
+
+/// A view over `bytes`, allocated from a throwaway pool.
+pub(crate) fn view(bytes: &[u8]) -> BytesView {
+ BytesView::copied_from_slice(bytes, &GlobalPool::new())
+}
+
+/// A view over `bytes` split into `segment`-sized spans, exercising the multi-segment paths.
+pub(crate) fn fragmented(bytes: &[u8], segment: usize) -> BytesView {
+ let memory = GlobalPool::new();
+ BytesView::from_views(bytes.chunks(segment).map(|chunk| BytesView::copied_from_slice(chunk, &memory)))
+}
+
+/// A chunk size, for the builders and pumps that require a non-zero one.
+///
+/// # Panics
+///
+/// Panics if `size` is zero, which is a mistake in the calling test.
+pub(crate) fn chunk(size: usize) -> NonZeroUsize {
+ NonZeroUsize::new(size).unwrap()
+}
+
+/// A fixture that only ever reports progress, for exercising callers that must keep polling rather
+/// than treat a progress step as output.
+#[derive(Debug)]
+pub(crate) struct ProgressCompression {
+ pulls: Arc,
+}
+
+impl ProgressCompression {
+ pub(crate) fn new(pulls: Arc) -> Self {
+ Self { pulls }
+ }
+}
+
+impl Compression for ProgressCompression {
+ type Mode = Compress;
+}
+
+impl CompressionInternal for ProgressCompression {
+ fn push(&mut self, _input: BytesView) -> Result<()> {
+ Ok(())
+ }
+
+ fn end_input(&mut self) {}
+
+ fn pull(&mut self, _into: Destination) -> Result {
+ self.pulls.fetch_add(1, Ordering::Relaxed);
+ Ok(Output::Progress)
+ }
+
+ // No caller on the path this fixture exists for asks for the byte counters; they are here only
+ // because the trait requires them.
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_in(&self) -> u64 {
+ 0
+ }
+
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_out(&self) -> u64 {
+ 0
+ }
+}
+
+/// A fixture that always asks for input and always rejects it, for exercising callers that must
+/// propagate a `push` failure rather than the specific reasons a real engine's `push` can fail.
+#[derive(Debug)]
+pub(crate) struct RejectsPush;
+
+impl Compression for RejectsPush {
+ type Mode = Compress;
+}
+
+impl CompressionInternal for RejectsPush {
+ // Accepting input would make this fixture, whose whole purpose is to reject it, ask for input
+ // endlessly instead. The mutant hangs rather than failing, so no verdict is available.
+ #[cfg_attr(test, mutants::skip)]
+ fn push(&mut self, _input: BytesView) -> Result<()> {
+ Err(Error::invalid_state("this fixture always rejects pushed input"))
+ }
+
+ fn end_input(&mut self) {}
+
+ fn pull(&mut self, _into: Destination) -> Result {
+ Ok(Output::NeedInput)
+ }
+
+ // No caller on the path this fixture exists for asks for the byte counters; they are here only
+ // because the trait requires them.
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_in(&self) -> u64 {
+ 0
+ }
+
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ #[cfg_attr(test, mutants::skip)]
+ fn total_out(&self) -> u64 {
+ 0
+ }
+}
diff --git a/crates/compressors/src/tests/fixtures/system_gzip.gz b/crates/compressors/src/tests/fixtures/system_gzip.gz
new file mode 100644
index 000000000..764ac5cca
Binary files /dev/null and b/crates/compressors/src/tests/fixtures/system_gzip.gz differ
diff --git a/crates/compressors/src/tests/format_contract.rs b/crates/compressors/src/tests/format_contract.rs
new file mode 100644
index 000000000..6c8ebdce8
--- /dev/null
+++ b/crates/compressors/src/tests/format_contract.rs
@@ -0,0 +1,2074 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! One contract, applied to every format.
+//!
+//! These tests exist to keep the abstraction honest. Every format goes through the same scenarios,
+//! so a format that behaves differently from its siblings -- or an abstraction that quietly only
+//! fits the deflate family -- fails here rather than surprising a consumer.
+
+use std::num::{NonZeroU32, NonZeroU64};
+use std::sync::OnceLock;
+
+use bytesbuf::mem::GlobalPool;
+use bytesbuf::{BytesBuf, BytesView};
+
+use crate::core::{Compress, Compression, CompressionInternal, Decompress, Destination, Output};
+use crate::format::Format;
+use crate::testing::{chunk, fragmented, view};
+use crate::{CompressorBuilder, DecompressorBuilder, DecompressorLimits, Level, Resources, TrailingData};
+
+/// The resources every test here draws on: one memory provider, one set of recycled engines.
+///
+/// Shared for the whole file on purpose. Recycling must be invisible, so tests that would fail if
+/// an engine came back dirty are exactly the tests that should be sharing one.
+fn resources() -> &'static Resources {
+ static RESOURCES: OnceLock = OnceLock::new();
+
+ RESOURCES.get_or_init(|| Resources::new(GlobalPool::new()))
+}
+
+/// Erases the difference between a build that can fail and one that cannot.
+///
+/// zstd's native library validates its configuration as it applies it, so its compressor build
+/// returns a [`Result`]; every other format's cannot fail and returns the compressor directly. The
+/// contract below is the same either way, so it goes through this to stay one test.
+trait Built {
+ type Codec;
+
+ fn built(self) -> Self::Codec;
+}
+
+impl Built for Result {
+ type Codec = T;
+
+ fn built(self) -> T {
+ self.unwrap()
+ }
+}
+
+/// Drives any compression engine to completion, feeding the input in `feed`-sized pieces.
+/// Caps every drain loop in this file.
+///
+/// A conforming engine always terminates, so exceeding this means the code under test is
+/// spinning. A hanging test reports nothing at all, so the cap turns a hang into a failure --
+/// which also lets mutation testing reach a verdict instead of timing out.
+///
+/// The cap has to stay tight enough for that verdict to arrive inside the mutation harness's
+/// per-mutant timeout. No test here needs more than a few hundred steps, so this leaves well over
+/// an order of magnitude of headroom while still failing a spinning mutant in under a second.
+const MAX_STEPS: usize = 10_000;
+
+/// Fails a spinning test instead of letting it hang.
+///
+/// A conforming engine always terminates, so exceeding the cap means the code under test is
+/// looping. A hanging test reports nothing at all, and mutation testing records a timeout rather
+/// than a verdict, so every drain loop below counts its steps through this.
+struct StepGuard(usize);
+
+impl StepGuard {
+ fn new() -> Self {
+ Self(0)
+ }
+
+ fn step(&mut self) {
+ self.0 += 1;
+ assert!(self.0 < MAX_STEPS, "the operation did not finish within {MAX_STEPS} steps");
+ }
+}
+
+fn process(compression: &mut C, input: &BytesView, feed: usize) -> crate::Result {
+ let mut offset = 0;
+ let mut collected = BytesBuf::new();
+
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match compression.pull(Destination::Stream)? {
+ Output::Data(data) => collected.put_bytes(data),
+ Output::Progress => {}
+ Output::Done => return Ok(collected.consume_all()),
+ Output::NeedInput => {
+ if offset >= input.len() {
+ compression.end_input();
+ continue;
+ }
+
+ let end = (offset + feed).min(input.len());
+ compression.push(input.range(offset..end))?;
+ offset = end;
+ }
+ }
+ }
+}
+
+fn compress>(compressor: &mut C, input: &BytesView, feed: usize) -> crate::Result {
+ process(compressor, input, feed)
+}
+
+fn decompress>(decompressor: &mut C, input: &BytesView, feed: usize) -> crate::Result {
+ process(decompressor, input, feed)
+}
+
+/// Generates the shared contract for one format, using its concrete module so the builders are
+/// exercised too, not just the runtime `Format` factories.
+macro_rules! format_contract {
+ ($module:ident, $format:expr) => {
+ mod $module {
+ use super::*;
+ use crate::$module;
+
+ const FORMAT: Format = $format;
+
+ impl Built for $module::Compressor {
+ type Codec = Self;
+
+ fn built(self) -> Self {
+ self
+ }
+ }
+
+ impl Built for $module::Decompressor {
+ type Codec = Self;
+
+ fn built(self) -> Self {
+ self
+ }
+ }
+
+ fn payload() -> Vec {
+ b"the quick brown fox jumps over the lazy dog; pack my box with five dozen liquor jugs. ".repeat(300)
+ }
+
+ #[test]
+ fn round_trips_a_payload() {
+ let data = payload();
+
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ assert!(compressed.len() < data.len(), "the payload should compress");
+
+ let plain = $module::decompress(compressed, resources()).unwrap();
+ assert_eq!(plain.to_vec(), data);
+ }
+
+ #[test]
+ fn compress_matches_driving_the_operation_by_hand() {
+ // The convenience must be exactly the manual loop, not an approximation of it.
+ let data = payload();
+
+ let convenient = crate::compress(view(&data), $module::Compressor::new(resources())).unwrap();
+
+ let mut by_hand = $module::Compressor::new(resources());
+ by_hand.push(view(&data)).unwrap();
+ CompressionInternal::end_input(&mut by_hand);
+ let mut collected = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match CompressionInternal::pull(&mut by_hand, Destination::Stream).unwrap() {
+ Output::Data(chunk) => collected.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => panic!("compressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ assert_eq!(convenient.to_vec(), collected.consume_all().to_vec());
+
+ let plain = crate::decompress(convenient, $module::Decompressor::new(resources())).unwrap();
+
+ assert_eq!(plain.to_vec(), data);
+ }
+
+ #[test]
+ fn compress_and_decompress_work_through_the_runtime_format_codec() {
+ // The runtime-format codec is the type-erasing path, so drive it the same way.
+ let data = payload();
+
+ let compressor = crate::format::Compressor::new(FORMAT, resources()).unwrap();
+ let compressed = crate::compress(view(&data), compressor).unwrap();
+
+ let decompressor = crate::format::Decompressor::new(FORMAT, resources()).unwrap();
+
+ assert_eq!(crate::decompress(compressed, decompressor).unwrap().to_vec(), data);
+ }
+
+ #[test]
+ fn round_trips_empty_input() {
+ let compressed = $module::compress(BytesView::new(), resources()).unwrap();
+ let plain = $module::decompress(compressed, resources()).unwrap();
+
+ assert!(plain.is_empty());
+ }
+
+ #[test]
+ fn round_trips_a_multi_segment_view() {
+ // The reason this crate exists: input arrives as a chain of spans, never as one
+ // contiguous slice.
+ for (segment, repeats) in [(1_usize, 40_usize), (7, 200), (1024, 2_000)] {
+ let data = b"multi segment ".repeat(repeats);
+
+ let compressed = $module::compress(fragmented(&data, segment), resources()).unwrap();
+ let plain = $module::decompress(compressed, resources()).unwrap();
+
+ assert_eq!(plain.to_vec(), data, "failed at {segment} byte segments");
+ }
+ }
+
+ #[test]
+ fn round_trips_when_driven_one_byte_at_a_time() {
+ // Worst case for a push/pull codec: minimal input pieces and minimal output chunks.
+ let data = b"drip fed".repeat(20);
+
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(1))
+ .build(resources())
+ .built();
+ let compressed = compress(&mut compressor, &view(&data), 1).unwrap();
+
+ let mut decompressor = $module::Decompressor::builder()
+ .output_chunk_size(chunk(1))
+ .build(resources())
+ .built();
+ let plain = decompress(&mut decompressor, &compressed, 1).unwrap();
+
+ assert_eq!(plain.to_vec(), data);
+ }
+
+ #[test]
+ fn honours_the_output_chunk_size() {
+ let data = payload();
+
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(256))
+ .build(resources())
+ .built();
+ compressor.push(view(&data)).unwrap();
+ CompressionInternal::end_input(&mut compressor);
+
+ let mut compressed = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match CompressionInternal::pull(&mut compressor, Destination::Stream).unwrap() {
+ Output::Data(piece) => {
+ assert!(piece.len() <= 256, "chunk of {} bytes exceeded the bound", piece.len());
+ compressed.put_bytes(piece);
+ }
+ Output::Progress => {}
+ Output::NeedInput => panic!("compressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ let mut decompressor = $module::Decompressor::builder()
+ .output_chunk_size(chunk(256))
+ .build(resources())
+ .built();
+ decompressor.push(compressed.consume_all()).unwrap();
+ CompressionInternal::end_input(&mut decompressor);
+
+ let mut plain = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match CompressionInternal::pull(&mut decompressor, Destination::Stream).unwrap() {
+ Output::Data(piece) => {
+ assert!(piece.len() <= 256, "chunk of {} bytes exceeded the bound", piece.len());
+ plain.put_bytes(piece);
+ }
+ Output::Progress => {}
+ Output::NeedInput => panic!("decompressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ assert_eq!(plain.consume_all().to_vec(), data);
+ }
+
+ #[test]
+ fn every_level_produces_a_decodable_stream() {
+ let data = payload();
+
+ for raw in 0..=Level::MAX.get() {
+ let level = Level::new(raw).unwrap();
+
+ let mut compressor = $module::Compressor::builder().level(level).build(resources()).built();
+ let compressed = compress(&mut compressor, &view(&data), usize::MAX).unwrap();
+
+ let plain = $module::decompress(compressed, resources()).unwrap();
+ assert_eq!(plain.to_vec(), data, "level {raw} did not round trip");
+ }
+ }
+
+ #[test]
+ fn tracks_byte_counts() {
+ let data = payload();
+
+ let mut compressor = $module::Compressor::new(resources());
+ let compressed = compress(&mut compressor, &view(&data), usize::MAX).unwrap();
+
+ assert_eq!(compressor.total_in(), data.len() as u64);
+ assert_eq!(compressor.total_out(), compressed.len() as u64);
+
+ let mut decompressor = $module::Decompressor::new(resources());
+ let plain = decompress(&mut decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(decompressor.total_in(), compressed.len() as u64);
+ assert_eq!(decompressor.total_out(), plain.len() as u64);
+ }
+
+ #[test]
+ fn rejects_a_truncated_stream() {
+ let compressed = $module::compress(view(&payload()), resources()).unwrap();
+
+ for cut in [1, compressed.len() / 3, compressed.len() - 1] {
+ let error = $module::decompress(compressed.range(0..cut), resources()).unwrap_err();
+
+ assert!(
+ error.is_unexpected_end_of_stream() || error.is_corrupt_data(),
+ "truncating at {cut} gave an unexpected classification: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn rejects_input_after_end_input() {
+ let mut compressor = $module::Compressor::new(resources());
+ CompressionInternal::end_input(&mut compressor);
+
+ let error = compressor.push(view(b"late")).unwrap_err();
+ assert!(error.is_invalid_state());
+
+ let mut decompressor = $module::Decompressor::new(resources());
+ CompressionInternal::end_input(&mut decompressor);
+
+ let error = decompressor.push(view(b"late")).unwrap_err();
+ assert!(error.is_invalid_state());
+ }
+
+ #[test]
+ fn asks_for_more_input_before_end_input() {
+ let mut compressor = $module::Compressor::new(resources());
+ compressor.push(view(b"partial")).unwrap();
+
+ let mut guard = StepGuard::new();
+ let output = loop {
+ guard.step();
+ match CompressionInternal::pull(&mut compressor, Destination::Stream).unwrap() {
+ Output::Data(_) | Output::Progress => {}
+ other => break other,
+ }
+ };
+
+ assert!(output.is_need_input(), "an unfinished compressor must ask for more input");
+ }
+
+ #[test]
+ fn enforces_a_configured_expansion_limit() {
+ // A ratio the data is guaranteed to exceed, so the mechanism itself is tested
+ // rather than whichever default the format happens to carry. A quarter megabyte of
+ // zeros clears the guard's 32 KiB floor several times over while staying cheap.
+ let bomb = $module::compress(view(&vec![0_u8; 256 * 1024]), resources()).unwrap();
+
+ let mut decompressor = $module::Decompressor::builder()
+ .limits(DecompressorLimits::new().max_ratio(NonZeroU32::new(4).unwrap()))
+ .build(resources())
+ .built();
+ decompressor.push(bomb).unwrap();
+ CompressionInternal::end_input(&mut decompressor);
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match CompressionInternal::pull(&mut decompressor, Destination::Stream) {
+ Ok(Output::Data(_) | Output::Progress) => {}
+ Ok(_) => panic!("the bomb decompressed fully instead of being rejected"),
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_limit_exceeded(), "got {error}");
+ assert!(
+ decompressor.total_out() < 256 * 1024,
+ "the guard should fire before the full expansion"
+ );
+ }
+
+ #[test]
+ fn default_limits_accept_ordinary_highly_compressible_data() {
+ // The formats' expansion characteristics differ by orders of magnitude: deflate has a
+ // structural ceiling around `1032x` while brotli legitimately reaches tens of thousands.
+ // A default calibrated for one therefore rejects ordinary repetitive input under
+ // another, which is why each format carries its own. These cases are what "ordinary"
+ // means -- repeated text and JSON, not contrived payloads.
+
+ let cases: [(&str, Vec); 3] = [
+ ("repeated short string", b"windowed ".repeat(20_000)),
+ (
+ "repeated sentence",
+ b"the quick brown fox jumps over the lazy dog. ".repeat(20_000),
+ ),
+ (
+ "repetitive json",
+ br#"{"id":1,"name":"widget","tags":["a","b"]},"#.repeat(12_000),
+ ),
+ ];
+
+ for (label, data) in cases {
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ let ratio = data.len() / compressed.len().max(1);
+
+ let plain = $module::decompress(compressed, resources())
+ .unwrap_or_else(|error| panic!("default limits rejected {label} at {ratio}x expansion: {error}"));
+
+ assert_eq!(plain.to_vec(), data, "{label} did not round trip");
+ }
+ }
+
+ #[test]
+ fn an_absolute_cap_is_enforced() {
+ let compressed = $module::compress(view(&vec![0_u8; 256 * 1024]), resources()).unwrap();
+
+ let mut decompressor = $module::Decompressor::builder()
+ .limits(
+ DecompressorLimits::new()
+ .unbounded_ratio()
+ .max_output_len(NonZeroU64::new(1024).unwrap()),
+ )
+ .build(resources())
+ .built();
+ decompressor.push(compressed).unwrap();
+ CompressionInternal::end_input(&mut decompressor);
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match CompressionInternal::pull(&mut decompressor, Destination::Stream) {
+ Ok(Output::Data(_) | Output::Progress) => {}
+ Ok(_) => panic!("the cap should have fired"),
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_limit_exceeded(), "got {error}");
+ }
+
+ #[test]
+ fn known_good_data_can_opt_out_of_the_limits() {
+ // What justifies UNLIMITED is knowing the input's expansion, or bounding it
+ // elsewhere -- not knowing the caller. A trusted caller can still be relaying an
+ // attacker's bytes. Here the data is generated locally, which is the precondition
+ // the opt-out actually needs.
+ let data = vec![0_u8; 256 * 1024];
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+
+ let mut decompressor = $module::Decompressor::builder()
+ .limits(DecompressorLimits::UNLIMITED)
+ .build(resources())
+ .built();
+ let plain = decompress(&mut decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(plain.len(), data.len());
+ }
+
+ #[test]
+ fn corruption_is_detected_or_changes_the_output() {
+ // Formats with a checksum report corruption; raw deflate has none, so the honest
+ // universal guarantee is only that corrupt input does not silently reproduce the
+ // original bytes.
+ let data = payload();
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ let original = compressed.to_vec();
+
+ for index in [0, original.len() / 2, original.len() - 1] {
+ let mut corrupted = original.clone();
+ corrupted[index] ^= 0xff;
+
+ match $module::decompress(view(&corrupted), resources()) {
+ Ok(plain) => assert_ne!(plain.to_vec(), data, "corruption at {index} went unnoticed"),
+ Err(error) => assert!(
+ error.is_corrupt_data() || error.is_unexpected_end_of_stream() || error.is_limit_exceeded(),
+ "corruption at {index} gave an unexpected classification: {error}"
+ ),
+ }
+ }
+ }
+
+ #[test]
+ fn the_runtime_factory_matches_the_module() {
+ // `Format` must produce codecs equivalent to the concrete modules, or runtime
+ // selection would silently behave differently from compile-time selection.
+ let data = payload();
+
+ let via_module = $module::compress(view(&data), resources()).unwrap();
+ let via_format = crate::format::compress(FORMAT, view(&data), resources()).unwrap();
+
+ assert_eq!(
+ via_module.to_vec(),
+ via_format.to_vec(),
+ "runtime and compile-time selection diverged"
+ );
+
+ // Either output must decompress through either path.
+ assert_eq!(
+ crate::format::decompress(FORMAT, via_module, resources()).unwrap().to_vec(),
+ data
+ );
+ assert_eq!($module::decompress(via_format, resources()).unwrap().to_vec(), data);
+ }
+
+ #[test]
+ fn works_through_the_runtime_format_codec() {
+ let data = payload();
+
+ let mut compressor = CompressorBuilder::new().build_format(FORMAT, resources()).built();
+ let compressed = compress(&mut compressor, &view(&data), usize::MAX).unwrap();
+
+ let mut decompressor = DecompressorBuilder::new().build_format(FORMAT, resources()).built();
+ let plain = decompress(&mut decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(plain.to_vec(), data);
+ }
+
+ #[test]
+ fn pooling_does_not_change_the_output() {
+ // Reuse is an optimisation, so it must change nothing a caller can observe. That is
+ // what this section asserts, and it is why it stays in the universal contract even
+ // though the formats differ in what they actually recycle -- brotli recycles nothing
+ // and a gzip decompressor is deliberately never recycled.
+ //
+ // Naming these for the observable property rather than for recycling is deliberate:
+ // a test that claimed to exercise a recycled engine would be a lie for brotli, and
+ // gating them on which formats currently pool would mean editing this suite every
+ // time an engine gains or loses a reset. The property holds either way, so the tests
+ // keep passing unchanged when pooling arrives for a format that lacks it today.
+ //
+ // The exact pool mechanics -- retention, keying, capacity, poisoning -- are tested
+ // next to `Pool` itself. What lives here is the caller-visible contract.
+ //
+ // The baseline and the pooled runs share one input view on purpose: some engines
+ // legitimately vary with input segmentation (zstd records the content size in its
+ // frame header only when the whole input arrives in one call), so a fresh view per
+ // run would compare allocator behaviour rather than pooling.
+ let input = view(&payload());
+ let baseline = {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ compress(&mut compressor, &input, usize::MAX).unwrap()
+ };
+
+ // Several rounds: the first compressor always misses the pool, so only later rounds
+ // exercise a recycled engine.
+ for round in 0..5 {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ let pooled = compress(&mut compressor, &input, usize::MAX).unwrap();
+ drop(compressor);
+
+ assert_eq!(pooled.to_vec(), baseline.to_vec(), "round {round}: pooled output diverged");
+
+ let mut decompressor = $module::Decompressor::builder().build(resources()).built();
+ let plain = decompress(&mut decompressor, &pooled, usize::MAX).unwrap();
+
+ assert_eq!(plain.to_vec(), payload(), "round {round}: pooled decompressor lost data");
+ }
+ }
+
+ #[test]
+ fn an_abandoned_engine_does_not_affect_the_next_stream() {
+ // A request cancelled part-way through returns a half-used engine.
+ let input = view(&payload());
+ let baseline = {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ compress(&mut compressor, &input, usize::MAX).unwrap()
+ };
+
+ for round in 0..4 {
+ {
+ let mut abandoned = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ abandoned.push(input.clone()).unwrap();
+ let _ = CompressionInternal::pull(&mut abandoned, Destination::Stream).unwrap();
+ // Dropped without finishing, so its engine is mid-frame.
+ }
+
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ let recovered = compress(&mut compressor, &input, usize::MAX).unwrap();
+
+ assert_eq!(recovered.to_vec(), baseline.to_vec(), "round {round}: a dirty engine leaked");
+ }
+ }
+
+ #[test]
+ fn a_failed_decompression_does_not_affect_the_next_one() {
+ let compressed = $module::compress(view(&payload()), resources()).unwrap();
+ let garbage = view(&b"definitely not a valid stream".repeat(20));
+
+ for round in 0..4 {
+ {
+ let mut failing = $module::Decompressor::builder().build(resources()).built();
+ let _ = decompress(&mut failing, &garbage, usize::MAX);
+ }
+
+ let mut decompressor = $module::Decompressor::builder().build(resources()).built();
+ let plain = decompress(&mut decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(
+ plain.to_vec(),
+ payload(),
+ "round {round}: a failed decompress poisoned the pool"
+ );
+ }
+ }
+
+ #[test]
+ fn each_level_still_produces_its_own_output() {
+ // Resetting a compressor preserves its level, so engines must be keyed by it.
+ let input = view(&payload());
+ let levels = [Level::MIN, Level::FAST, Level::DEFAULT, Level::HIGH];
+
+ let baselines: Vec<_> = levels
+ .iter()
+ .map(|&level| {
+ let mut compressor = $module::Compressor::builder()
+ .level(level)
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ compress(&mut compressor, &input, usize::MAX).unwrap().to_vec()
+ })
+ .collect();
+
+ for round in 0..4 {
+ for (index, &level) in levels.iter().enumerate() {
+ let mut compressor = $module::Compressor::builder()
+ .level(level)
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ let pooled = compress(&mut compressor, &input, usize::MAX).unwrap();
+
+ assert_eq!(
+ pooled.to_vec(),
+ baselines[index],
+ "round {round}: a pooled engine came back at the wrong level"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn two_live_codecs_do_not_interfere() {
+ // All three compressors are driven by exactly the same sequence, so any difference in
+ // their output is the engine and nothing else.
+ fn run(compressor: &mut $module::Compressor, input: &BytesView) -> Vec {
+ compressor.push(input.clone()).unwrap();
+ CompressionInternal::end_input(compressor);
+
+ let mut collected = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match CompressionInternal::pull(compressor, Destination::Stream).unwrap() {
+ Output::Data(chunk) => collected.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => panic!("compressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ collected.consume_all().to_vec()
+ }
+
+ fn build(resources: &Resources) -> $module::Compressor {
+ $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources)
+ .built()
+ }
+
+ let shared = Resources::new(GlobalPool::new());
+ let input = view(&payload());
+ let baseline = run(&mut build(&Resources::new(GlobalPool::new()).with_pool_capacity(0)), &input);
+
+ // Prime the pool so there is exactly one idle engine for two codecs to want.
+ drop(run(&mut build(&shared), &input));
+
+ let mut first = build(&shared);
+ let mut second = build(&shared);
+
+ // Interleave: both are live before either finishes, so they cannot be sharing.
+ first.push(input.clone()).unwrap();
+ second.push(input.clone()).unwrap();
+ CompressionInternal::end_input(&mut first);
+ CompressionInternal::end_input(&mut second);
+
+ for (label, compressor) in [("first", &mut first), ("second", &mut second)] {
+ let mut collected = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match CompressionInternal::pull(compressor, Destination::Stream).unwrap() {
+ Output::Data(chunk) => collected.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => panic!("compressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ assert_eq!(
+ collected.consume_all().to_vec(),
+ baseline,
+ "{label} compressor was corrupted by sharing"
+ );
+ }
+ }
+
+ #[test]
+ fn a_codec_outliving_its_pool_handle_still_works() {
+ let input = view(&payload());
+ let baseline = {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ compress(&mut compressor, &input, usize::MAX).unwrap()
+ };
+
+ let mut compressor = {
+ let owned = Resources::new(GlobalPool::new());
+ let compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(&owned)
+ .built();
+ drop(owned);
+ compressor
+ };
+
+ let pooled = compress(&mut compressor, &input, usize::MAX).unwrap();
+
+ assert_eq!(
+ pooled.to_vec(),
+ baseline.to_vec(),
+ "dropping the pool handle changed the output"
+ );
+ }
+
+ #[test]
+ fn pool_capacity_does_not_change_the_output() {
+ let input = view(&payload());
+ let baseline = {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ compress(&mut compressor, &input, usize::MAX).unwrap()
+ };
+
+ for capacity in [0_usize, 1, 4] {
+ let bounded = Resources::new(GlobalPool::new()).with_pool_capacity(capacity);
+
+ for round in 0..12 {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(&bounded)
+ .built();
+ let pooled = compress(&mut compressor, &input, usize::MAX).unwrap();
+
+ assert_eq!(
+ pooled.to_vec(),
+ baseline.to_vec(),
+ "capacity {capacity} round {round}: output changed"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn empty_input_round_trips_through_a_pool() {
+ let baseline = {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ compress(&mut compressor, &BytesView::new(), usize::MAX).unwrap()
+ };
+
+ for round in 0..4 {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(4096))
+ .build(resources())
+ .built();
+ let pooled = compress(&mut compressor, &BytesView::new(), usize::MAX).unwrap();
+ drop(compressor);
+
+ assert_eq!(pooled.to_vec(), baseline.to_vec(), "round {round}: empty framing changed");
+
+ let mut decompressor = $module::Decompressor::builder().build(resources()).built();
+ let plain = decompress(&mut decompressor, &pooled, usize::MAX).unwrap();
+
+ assert!(plain.is_empty(), "round {round}: empty input produced bytes");
+ }
+ }
+
+ #[test]
+ fn truncation_is_still_detected_when_pooled() {
+ let compressed = $module::compress(view(&payload()), resources()).unwrap();
+
+ for round in 0..3 {
+ // A healthy decompress first, so the next decompressor is guaranteed to be recycled.
+ let mut healthy = $module::Decompressor::builder().build(resources()).built();
+ decompress(&mut healthy, &compressed, usize::MAX).unwrap();
+ drop(healthy);
+
+ let mut decompressor = $module::Decompressor::builder().build(resources()).built();
+ let error = decompress(&mut decompressor, &compressed.range(0..compressed.len() - 1), usize::MAX).unwrap_err();
+
+ assert!(
+ error.is_unexpected_end_of_stream() || error.is_corrupt_data(),
+ "round {round}: unexpected classification {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn flushing_a_decompressor_does_nothing() {
+ // Decompression produces output as soon as the input allows, so there is nothing
+ // buffered to release early. The default must be a no-op, not an error and not an
+ // end of stream.
+ let data = payload();
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ let mut decompressor = $module::Decompressor::new(resources());
+
+ decompressor.flush().unwrap();
+
+ let plain = decompress(&mut decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(plain.to_vec(), data, "the flush must leave the stream untouched");
+ }
+
+ #[test]
+ fn a_flush_makes_supplied_input_decompressible_without_ending_the_stream() {
+ let data = b"flush this data now ".repeat(20_000);
+ let mut compressor = $module::Compressor::new(resources());
+ compressor.push(view(&data)).unwrap();
+ compressor.flush().unwrap();
+
+ let mut compressed = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match compressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => compressed.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => break,
+ Output::Done => panic!("a flush must not end the stream"),
+ }
+ }
+
+ let mut decompressor = $module::Decompressor::new(resources());
+ decompressor.push(compressed.consume_all()).unwrap();
+
+ let mut plain = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => plain.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => break,
+ Output::Done => panic!("the compressor has not ended the stream"),
+ }
+ }
+
+ assert_eq!(plain.consume_all().to_vec(), data);
+ }
+
+ #[test]
+ fn end_input_can_be_queued_behind_a_flush() {
+ let data = b"flush and finish ".repeat(200);
+ let mut compressor = $module::Compressor::new(resources());
+ compressor.push(view(&data)).unwrap();
+ compressor.flush().unwrap();
+ compressor.end_input();
+ let error = compressor.flush().unwrap_err();
+ assert!(error.is_invalid_state(), "got {error}");
+
+ let mut compressed = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match compressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => compressed.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => panic!("end of input is already queued"),
+ Output::Done => break,
+ }
+ }
+
+ let plain = $module::decompress(compressed.consume_all(), resources()).unwrap();
+ assert_eq!(plain.to_vec(), data);
+ }
+
+ #[test]
+ fn flush_terminates_with_tiny_output_chunks() {
+ let data = b"tiny flush chunks ".repeat(100);
+
+ for size in 1..=7 {
+ let mut compressor = $module::Compressor::builder()
+ .output_chunk_size(chunk(size))
+ .build(resources())
+ .built();
+ compressor.push(view(&data)).unwrap();
+ compressor.flush().unwrap();
+
+ let mut compressed = BytesBuf::new();
+ // One guard, not two: the shared `StepGuard` would have tripped first and
+ // reported the generic message, leaving this bound unreachable. This one keeps
+ // the chunk size in the diagnostic, which is what the test is varying.
+ let mut pulls = 0;
+ loop {
+ pulls += 1;
+ assert!(pulls < MAX_STEPS, "flush did not terminate at chunk size {size}");
+
+ match compressor.pull(Destination::Stream).unwrap() {
+ Output::Data(piece) => {
+ assert!(piece.len() <= size);
+ compressed.put_bytes(piece);
+ }
+ Output::Progress => {}
+ Output::NeedInput => break,
+ Output::Done => panic!("flush ended the stream"),
+ }
+ }
+
+ compressor.end_input();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match compressor.pull(Destination::Stream).unwrap() {
+ Output::Data(piece) => {
+ assert!(piece.len() <= size);
+ compressed.put_bytes(piece);
+ }
+ Output::Progress => {}
+ Output::NeedInput => panic!("compressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ let plain = $module::decompress(compressed.consume_all(), resources())
+ .unwrap_or_else(|error| panic!("chunk size {size} did not round trip: {error}"));
+ assert_eq!(plain.to_vec(), data);
+ }
+ }
+
+ #[test]
+ fn multi_stream_decompression_crosses_push_boundaries() {
+ let first_plain = b"first stream ".repeat(40);
+ let second_plain = b"second stream ".repeat(40);
+ let first = $module::compress(view(&first_plain), resources()).unwrap();
+ let second = $module::compress(view(&second_plain), resources()).unwrap();
+ let mut decompressor = $module::Decompressor::builder().multi_stream(true).build(resources()).built();
+ let mut plain = BytesBuf::new();
+
+ decompressor.push(first).unwrap();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => plain.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => break,
+ Output::Done => panic!("decompressor ended before the next pushed stream"),
+ }
+ }
+
+ decompressor.push(second).unwrap();
+ decompressor.end_input();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => plain.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => panic!("decompressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ assert_eq!(plain.consume_all().to_vec(), [first_plain, second_plain].concat());
+ }
+
+ #[test]
+ fn single_stream_decompression_stops_at_the_end_of_its_stream() {
+ let data = payload();
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ let trailing = view(b"next protocol message");
+ let joined = BytesView::from_views([compressed, trailing]);
+ let mut decompressor = $module::Decompressor::builder()
+ .multi_stream(false)
+ .trailing_data(TrailingData::Ignore)
+ .build(resources())
+ .built();
+ decompressor.push(joined).unwrap();
+
+ let mut plain = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => plain.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => panic!("single stream was complete"),
+ Output::Done => break,
+ }
+ }
+
+ // The bytes after the stream are not decompressed, and not mistaken for more of it.
+ assert_eq!(plain.consume_all().to_vec(), data);
+ }
+
+ #[test]
+ fn trailing_bytes_after_a_single_stream_are_rejected_by_default() {
+ // Silently dropping them is a parser differential, so it has to be opted into.
+ let data = payload();
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ let joined = BytesView::from_views([compressed, view(b"next protocol message")]);
+ let mut decompressor = $module::Decompressor::builder()
+ .multi_stream(false)
+ .build(resources())
+ .built();
+ decompressor.push(joined).unwrap();
+ decompressor.end_input();
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream) {
+ Ok(Output::Done) => panic!("the trailing bytes were accepted"),
+ Ok(_) => {}
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_corrupt_data(), "got {error}");
+ }
+
+ #[test]
+ fn an_empty_push_does_not_create_a_phantom_stream() {
+ let data = b"one stream only".repeat(20);
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ let mut decompressor = $module::Decompressor::builder().multi_stream(true).build(resources()).built();
+ decompressor.push(compressed).unwrap();
+
+ let mut plain = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => plain.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => break,
+ Output::Done => panic!("multi-stream decompressor must wait for EOF"),
+ }
+ }
+
+ decompressor.push(BytesView::new()).unwrap();
+ assert!(decompressor.pull(Destination::Stream).unwrap().is_need_input());
+ decompressor.end_input();
+ assert!(decompressor.pull(Destination::Stream).unwrap().is_done());
+ assert_eq!(plain.consume_all().to_vec(), data);
+ }
+
+ #[test]
+ fn multi_stream_end_input_handles_an_internal_stream_boundary() {
+ let first_plain = b"AAAAAAAAAA";
+ let second_plain = b"BBBBBBBBBB";
+ let first = $module::compress(view(first_plain), resources()).unwrap();
+ let second = $module::compress(view(second_plain), resources()).unwrap();
+ let split = first.len().saturating_sub(1);
+ let joined = BytesView::from_views([first.range(0..split), first.range(split..), second]);
+ let mut decompressor = $module::Decompressor::builder()
+ .multi_stream(true)
+ .output_chunk_size(chunk(first_plain.len()))
+ .build(resources())
+ .built();
+ decompressor.push(joined).unwrap();
+ decompressor.end_input();
+
+ let mut plain = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(chunk) => plain.put_bytes(chunk),
+ Output::Progress => {}
+ Output::NeedInput => panic!("all input was already supplied"),
+ Output::Done => break,
+ }
+ }
+
+ assert_eq!(
+ plain.consume_all().to_vec(),
+ [first_plain.as_slice(), second_plain.as_slice()].concat()
+ );
+ }
+
+ #[test]
+ fn strict_trailing_data_is_rejected_across_push_boundaries() {
+ let compressed = $module::compress(view(&payload()), resources()).unwrap();
+ let mut decompressor = $module::Decompressor::builder()
+ .multi_stream(false)
+ .trailing_data(TrailingData::Reject)
+ .build(resources())
+ .built();
+ decompressor.push(compressed).unwrap();
+
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(_) | Output::Progress => {}
+ Output::NeedInput => break,
+ Output::Done => panic!("strict trailing validation must wait for EOF"),
+ }
+ }
+
+ let error = decompressor.push(view(b"trailing")).unwrap_err();
+ assert!(error.is_corrupt_data(), "got {error}");
+ }
+
+ #[test]
+ fn strict_trailing_data_is_rejected_in_the_same_push() {
+ let compressed = $module::compress(view(&payload()), resources()).unwrap();
+ let joined = BytesView::from_views([compressed, view(b"trailing")]);
+ let mut decompressor = $module::Decompressor::builder()
+ .multi_stream(false)
+ .trailing_data(TrailingData::Reject)
+ .build(resources())
+ .built();
+ decompressor.push(joined).unwrap();
+ decompressor.end_input();
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream) {
+ Ok(Output::Data(_) | Output::Progress) => {}
+ Ok(_) => panic!("trailing input unexpectedly completed"),
+ Err(error) => break error,
+ }
+ };
+ assert!(error.is_corrupt_data(), "got {error}");
+ }
+
+ #[test]
+ fn a_truncated_later_stream_reads_as_a_short_stream() {
+ // A caller retrying a partial transfer needs to tell "it stopped early" from "these
+ // bytes are wrong". A stream that starts and then runs out is the former, however
+ // many streams decoded cleanly before it.
+ let compressed = $module::compress(view(&payload()), resources()).unwrap();
+ let whole = compressed.to_vec();
+ let truncated = &whole[..whole.len() - 1];
+ let joined = BytesView::from_views([compressed, view(truncated)]);
+
+ let mut decompressor = $module::Decompressor::builder().multi_stream(true).build(resources()).built();
+ decompressor.push(joined).unwrap();
+ CompressionInternal::end_input(&mut decompressor);
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match CompressionInternal::pull(&mut decompressor, Destination::Stream) {
+ Ok(Output::Data(_) | Output::Progress) => {}
+ Ok(_) => panic!("a truncated stream unexpectedly completed"),
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_unexpected_end_of_stream(), "got {error}");
+ }
+
+ #[test]
+ fn stream_count_limit_rejects_before_decompressing_the_next_stream() {
+ let data = payload();
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+ let joined = BytesView::from_views([compressed.clone(), compressed]);
+ let mut decompressor = $module::Decompressor::builder()
+ .multi_stream(true)
+ .limits(DecompressorLimits::new().max_streams(NonZeroU64::new(1).unwrap()))
+ .build(resources())
+ .built();
+ decompressor.push(joined).unwrap();
+ decompressor.end_input();
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream) {
+ Ok(Output::Data(_) | Output::Progress) => {}
+ Ok(_) => panic!("the second stream should exceed the limit"),
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_limit_exceeded(), "got {error}");
+ assert!(error.to_string().contains("reached 2"), "got {error}");
+ assert!(error.to_string().contains("limit of 1"), "got {error}");
+ assert_eq!(decompressor.total_out(), data.len() as u64);
+ }
+
+ #[test]
+ fn stream_count_limit_rejects_a_later_push() {
+ let compressed = $module::compress(view(&payload()), resources()).unwrap();
+ let mut decompressor = $module::Decompressor::builder()
+ .multi_stream(true)
+ .limits(DecompressorLimits::new().max_streams(NonZeroU64::new(1).unwrap()))
+ .build(resources())
+ .built();
+ decompressor.push(compressed.clone()).unwrap();
+
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream).unwrap() {
+ Output::Data(_) | Output::Progress => {}
+ Output::NeedInput => break,
+ Output::Done => panic!("multi-stream decompressor must wait for EOF"),
+ }
+ }
+
+ let error = decompressor.push(compressed).unwrap_err();
+ assert!(error.is_limit_exceeded(), "got {error}");
+ }
+
+ #[test]
+ fn absolute_output_limit_is_exact() {
+ let data = payload();
+ let compressed = $module::compress(view(&data), resources()).unwrap();
+
+ let exact = $module::decompress_with_limits(
+ compressed.clone(),
+ resources(),
+ DecompressorLimits::new()
+ .unbounded_ratio()
+ .max_output_len(NonZeroU64::new(data.len() as u64).unwrap()),
+ )
+ .unwrap();
+ assert_eq!(exact.to_vec(), data);
+
+ let maximum = data.len() as u64 - 1;
+ let error = $module::decompress_with_limits(
+ compressed,
+ resources(),
+ DecompressorLimits::new()
+ .unbounded_ratio()
+ .max_output_len(NonZeroU64::new(maximum).unwrap()),
+ )
+ .unwrap_err();
+
+ assert!(error.is_limit_exceeded(), "got {error}");
+ assert!(error.to_string().contains(&(maximum + 1).to_string()), "got {error}");
+ }
+
+ #[test]
+ fn a_fatal_error_makes_the_decompressor_terminal() {
+ let mut decompressor = $module::Decompressor::new(resources());
+ decompressor.push(view(b"not a valid stream")).unwrap();
+ decompressor.end_input();
+
+ let mut guard = StepGuard::new();
+ let first = loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream) {
+ Ok(Output::Data(_) | Output::Progress) => {}
+ Ok(_) => panic!("invalid input unexpectedly completed"),
+ Err(error) => break error,
+ }
+ };
+ assert!(first.is_corrupt_data() || first.is_unexpected_end_of_stream(), "got {first}");
+
+ let second = decompressor.pull(Destination::Stream).unwrap_err();
+ assert!(second.is_invalid_state(), "got {second}");
+ }
+
+ #[test]
+ fn works_through_generic_format_agnostic_code() {
+ /// Code written once, against the traits, with no knowledge of the format.
+ fn transcode(
+ mut compressor: impl Compression,
+ mut decompressor: impl Compression,
+ data: &[u8],
+ ) -> Vec {
+ let compressed = compress(&mut compressor, &view(data), 64).unwrap();
+ decompress(&mut decompressor, &compressed, 64).unwrap().to_vec()
+ }
+ let data = payload();
+
+ assert_eq!(
+ transcode(
+ $module::Compressor::new(resources()),
+ $module::Decompressor::new(resources()),
+ &data
+ ),
+ data
+ );
+ }
+ }
+ };
+}
+
+#[cfg(any(test, feature = "deflate"))]
+format_contract!(deflate, Format::Deflate);
+#[cfg(any(test, feature = "zlib"))]
+format_contract!(zlib, Format::Zlib);
+#[cfg(any(test, feature = "gzip"))]
+format_contract!(gzip, Format::Gzip);
+#[cfg(any(test, feature = "brotli"))]
+format_contract!(brotli, Format::Brotli);
+#[cfg(any(test, feature = "zstd"))]
+format_contract!(zstd, Format::Zstd);
+
+#[test]
+fn every_compiled_format_satisfies_the_contract() {
+ // Guards against a format being added to `Format::ALL` without being added to the suite above.
+ let covered = usize::from(cfg!(any(test, feature = "deflate")))
+ + usize::from(cfg!(any(test, feature = "zlib")))
+ + usize::from(cfg!(any(test, feature = "gzip")))
+ + usize::from(cfg!(any(test, feature = "brotli")))
+ + usize::from(cfg!(any(test, feature = "zstd")));
+
+ assert_eq!(
+ Format::ALL.len(),
+ covered,
+ "a format was added without extending the contract suite"
+ );
+}
+
+#[test]
+fn one_formats_output_never_decodes_to_the_original_through_another() {
+ // The guarantee this can enforce is weaker than "wrong format always errors", and deliberately
+ // so: a checksum-free or permissive decoder may accept unrelated bytes without that implying
+ // the formats are compatible. What must never happen is a wrong-format decode reproducing the
+ // payload, which would mean the two formats are not distinct.
+ let data = b"cross format check ".repeat(200);
+
+ for &produced_by in Format::ALL {
+ let compressed = crate::format::compress(produced_by, view(&data), resources()).unwrap();
+
+ for &decompressed_by in Format::ALL {
+ if produced_by == decompressed_by {
+ continue;
+ }
+
+ if let Ok(plain) = crate::format::decompress(decompressed_by, compressed.clone(), resources()) {
+ assert_ne!(
+ plain.to_vec(),
+ data,
+ "{decompressed_by:?} decompressed a {produced_by:?} stream as if it were its own"
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn a_decompressor_can_be_chosen_from_a_declared_encoding() {
+ // The end-to-end runtime scenario: a peer declares its encoding in a header, and the decompressor is
+ // chosen from that string.
+ let data = b"declared encoding ".repeat(100);
+
+ for &format in Format::ALL {
+ let Some(token) = format.content_encoding() else {
+ continue;
+ };
+
+ let compressed = crate::format::compress(format, view(&data), resources()).unwrap();
+
+ let declared = Format::from_content_encoding(token).unwrap();
+ let plain = crate::format::decompress(declared, compressed, resources()).unwrap();
+
+ assert_eq!(plain.to_vec(), data, "{format:?} did not decompress via its declared token");
+ }
+}
+
+/// Format-specific settings: how a format extends the shared builder without breaking the contract.
+#[cfg(any(test, feature = "brotli"))]
+mod format_specific_settings {
+ use super::*;
+ use crate::brotli;
+ use crate::brotli::{Mode, Quality, WindowSize};
+
+ #[test]
+ fn default_limits_accept_the_compressors_own_high_ratio_output() {
+ // Half a megabyte of zeros clears the ratio guard's 32 KiB floor and still reaches an
+ // expansion far past deflate's structural `1032x`, which is the ratio a portable default
+ // would have been calibrated on. Brotli's own default must accept it.
+ let data = vec![0_u8; 512 * 1024];
+ let compressed = brotli::compress(view(&data), resources()).unwrap();
+
+ let plain = brotli::decompress(compressed, resources()).unwrap();
+
+ assert_eq!(plain.to_vec(), data);
+ }
+
+ #[test]
+ fn a_format_specific_setting_still_produces_a_conforming_stream() {
+ // Whatever brotli-only knobs are set, the result must still satisfy the shared contract.
+ let data = b"format specific settings ".repeat(400);
+
+ let mut tuned = brotli::Compressor::builder()
+ .level(Level::HIGH)
+ .quality(Quality::new(3).unwrap())
+ .mode(Mode::Text)
+ .window_size(WindowSize::new(20).unwrap())
+ .build(resources())
+ .built();
+
+ let compressed = compress(&mut tuned, &view(&data), usize::MAX).unwrap();
+ let plain = brotli::decompress(compressed, resources()).unwrap();
+
+ assert_eq!(plain.to_vec(), data);
+ }
+
+ #[test]
+ fn window_size_rejects_values_outside_brotlis_range() {
+ // Configuration input must report a mistake, not panic.
+ assert_eq!(WindowSize::new(9), None);
+ assert_eq!(WindowSize::new(25), None);
+ assert_eq!(WindowSize::new(10), Some(WindowSize::MIN));
+ assert_eq!(WindowSize::new(24), Some(WindowSize::MAX));
+ assert_eq!(WindowSize::default(), WindowSize::DEFAULT);
+ }
+
+ #[test]
+ fn a_smaller_window_still_round_trips() {
+ let data = b"windowed ".repeat(20_000);
+
+ for exponent in [10, 16, 24] {
+ let window = WindowSize::new(exponent).unwrap();
+ let mut tuned = brotli::Compressor::builder().window_size(window).build(resources()).built();
+
+ let compressed = compress(&mut tuned, &view(&data), usize::MAX).unwrap();
+ let plain = brotli::decompress(compressed, resources()).unwrap();
+
+ assert_eq!(plain.to_vec(), data, "window 2^{exponent} did not round trip");
+ }
+ }
+
+ #[test]
+ fn a_runtime_chosen_format_can_still_reach_format_specific_settings() {
+ // The documented escape hatch: a runtime `Format` builder cannot carry a brotli-only
+ // setting, so branch on the format and use that format's own builder for the odd one out.
+ let data = b"escape hatch ".repeat(200);
+
+ for &format in Format::ALL {
+ let compressed = match format {
+ Format::Brotli => {
+ let mut tuned = brotli::Compressor::builder().mode(Mode::Text).build(resources()).built();
+ compress(&mut tuned, &view(&data), usize::MAX).unwrap()
+ }
+ // With brotli as the only enabled format there is no other variant to reach, so
+ // the fallback is dead in that configuration rather than wrong.
+ #[cfg_attr(
+ not(any(test, feature = "deflate", feature = "gzip", feature = "zlib", feature = "zstd")),
+ expect(unreachable_patterns, reason = "brotli is the only enabled format, so it is the only variant")
+ )]
+ other => {
+ let mut tuned = CompressorBuilder::new().build_format(other, resources()).built();
+ compress(&mut tuned, &view(&data), usize::MAX).unwrap()
+ }
+ };
+
+ let plain = crate::format::decompress(format, compressed, resources()).unwrap();
+ assert_eq!(plain.to_vec(), data, "{format:?} failed through the escape hatch");
+ }
+ }
+
+ #[test]
+ fn text_mode_does_not_change_the_decompressed_bytes() {
+ // The mode is a compressor-side hint only: it must never alter what comes back out.
+ let data = b"the quick brown fox jumps over the lazy dog ".repeat(300);
+
+ for mode in [Mode::Generic, Mode::Text, Mode::Font] {
+ let mut tuned = brotli::Compressor::builder().mode(mode).build(resources()).built();
+ let compressed = compress(&mut tuned, &view(&data), usize::MAX).unwrap();
+
+ let plain = brotli::decompress(compressed, resources()).unwrap();
+ assert_eq!(plain.to_vec(), data, "{mode:?} changed the decompressed bytes");
+ }
+ }
+}
+
+#[cfg(any(test, feature = "zstd"))]
+mod zstd_specific_settings {
+ use super::*;
+ use crate::zstd;
+ use crate::zstd::{CompressionLevel, WindowLog};
+
+ #[test]
+ fn native_level_and_decompressor_window_limit_are_wired() {
+ // Large enough that the frame has to declare a window well above the smallest zstd offers.
+ // A short payload would not: zstd sizes the window to the content, so the restricted
+ // decompressor below would accept it and prove nothing.
+ let data = b"zstd format-specific settings ".repeat(200_000);
+ let compressor = zstd::Compressor::builder()
+ .compression_level(CompressionLevel::min())
+ .build(resources())
+ .built();
+ let compressed = crate::compress(view(&data), compressor).unwrap();
+
+ // The limit has to be observable, or this test would pass even if the setting were dropped
+ // on the floor: the default decompressor accepts this frame either way.
+ let restricted = zstd::Decompressor::builder()
+ .max_window_log(WindowLog::MIN)
+ .build(resources())
+ .built();
+ let error = crate::decompress(compressed.clone(), restricted).unwrap_err();
+ assert!(error.is_corrupt_data(), "got {error}");
+
+ let decompressor = zstd::Decompressor::builder()
+ .max_window_log(WindowLog::DEFAULT)
+ // The payload has to be large for the window limit above to be observable at all, which
+ // puts it past the buffering ceiling. Stating the output bound explicitly keeps this
+ // test about the window setting rather than about the ceiling.
+ .limits(DecompressorLimits::UNLIMITED)
+ .build(resources())
+ .built();
+ let plain = crate::decompress(compressed, decompressor).unwrap();
+
+ assert_eq!(plain.to_vec(), data);
+ }
+}
+
+/// Engine reuse must be invisible: a recycled compressor has to behave exactly like a fresh one.
+#[cfg(any(test, feature = "gzip"))]
+mod pooling {
+ use super::*;
+ use crate::gzip;
+
+ /// Resources whose engines are recycled, shared by the tests in this module.
+ ///
+ /// Only for tests that do not depend on *which* engine they get: a shared pool cannot promise
+ /// that the engine one test dirtied is the engine the next call receives, because tests run
+ /// concurrently. Anything asserting a specific engine history uses [`solo_resources`].
+ fn pooled_resources() -> &'static Resources {
+ static POOLED: OnceLock = OnceLock::new();
+
+ POOLED.get_or_init(|| Resources::new(GlobalPool::new()))
+ }
+
+ /// A private pool holding exactly one idle engine.
+ ///
+ /// This is what makes a reuse sequence deterministic. Capacity one means the engine returned by
+ /// a drop is the engine the next build receives, and a pool of its own means no concurrently
+ /// running test can take it in between. Dirtying through one `Resources` and verifying through
+ /// another proves nothing at all -- each owns its own pool, so the engine under test would be a
+ /// fresh one and the assertion would pass however broken the reset was.
+ fn solo_resources() -> Resources {
+ Resources::new(GlobalPool::new()).with_pool_capacity(1)
+ }
+
+ /// Resources that recycle nothing, for the baseline a pooled run has to match.
+ fn unpooled_resources() -> Resources {
+ Resources::new(GlobalPool::new()).with_pool_capacity(0)
+ }
+
+ fn compress_with(resources: &Resources, level: Level, data: &[u8]) -> BytesView {
+ let mut compressor = gzip::Compressor::builder().level(level).build(resources).built();
+ compress(&mut compressor, &view(data), usize::MAX).unwrap()
+ }
+
+ #[test]
+ fn a_recycled_engine_produces_byte_identical_output() {
+ // The whole safety argument for pooling: reset state must leave no trace of the previous
+ // stream. A private capacity-one pool means every round after the first genuinely receives
+ // the engine the previous round returned, rather than whichever one a shared pool happened
+ // to hold.
+ let solo = solo_resources();
+ let payloads = [
+ b"first request body".repeat(50),
+ b"a completely different second body, longer".repeat(80),
+ b"third".repeat(500),
+ ];
+
+ for round in 0..4 {
+ for payload in &payloads {
+ let pooled = compress_with(&solo, Level::DEFAULT, payload);
+ let fresh = compress_with(&unpooled_resources(), Level::DEFAULT, payload);
+
+ assert_eq!(
+ pooled.to_vec(),
+ fresh.to_vec(),
+ "round {round}: pooled output diverged from a fresh engine"
+ );
+ assert_eq!(gzip::decompress(pooled, resources()).unwrap().to_vec(), *payload);
+ }
+ }
+ }
+
+ #[test]
+ fn a_compressor_abandoned_mid_stream_does_not_poison_the_pool() {
+ // A request cancelled part-way through returns a dirty engine. The next user must still
+ // get a clean stream -- and must actually receive *that* engine, which is what one private
+ // capacity-one pool used for both halves guarantees.
+ let solo = solo_resources();
+
+ {
+ let mut abandoned = gzip::Compressor::builder().build(&solo).built();
+ abandoned.push(view(&b"half a stream ".repeat(100))).unwrap();
+ let _ = CompressionInternal::pull(&mut abandoned, Destination::Stream).unwrap();
+ // Dropped without `end_input`, so its engine goes back to the pool mid-stream.
+ }
+
+ let recovered = compress_with(&solo, Level::DEFAULT, b"a fresh stream");
+ let fresh = compress_with(&unpooled_resources(), Level::DEFAULT, b"a fresh stream");
+
+ assert_eq!(recovered.to_vec(), fresh.to_vec(), "a recycled dirty engine must be reset");
+ assert_eq!(
+ gzip::decompress(recovered, resources()).unwrap().to_vec(),
+ b"a fresh stream".to_vec()
+ );
+ }
+
+ #[test]
+ fn levels_do_not_share_engines() {
+ // Reset preserves the level, so a level-9 request must never receive a level-1 engine. The
+ // private capacity-one pool is what makes that a real test: with one slot, the level-9
+ // request is offered the engine the level-1 run just returned, so a pool that ignored the
+ // level would hand it over here rather than merely being able to.
+ let solo = solo_resources();
+ let payload = b"the quick brown fox jumps over the lazy dog ".repeat(200);
+
+ let fast = compress_with(&solo, Level::FAST, &payload);
+ let best = compress_with(&solo, Level::HIGH, &payload);
+
+ assert_eq!(fast.to_vec(), compress_with(&unpooled_resources(), Level::FAST, &payload).to_vec());
+ assert_eq!(best.to_vec(), compress_with(&unpooled_resources(), Level::HIGH, &payload).to_vec());
+ // Inequality of the bytes is what proves the level reached the engine at all: the size
+ // comparison below is satisfied by equality, so a backend that dropped the level would
+ // pass it, and the `assert_eq!` pair above would still hold because pooled and fresh
+ // engines would agree on the same wrong output.
+ assert_ne!(best.to_vec(), fast.to_vec(), "the level did not reach the engine");
+ assert!(best.len() <= fast.len(), "Level::HIGH must still out-compress Level::FAST");
+ }
+
+ #[test]
+ fn a_pool_is_shared_across_threads() {
+ // The point of the design: one handle lives in a client and is cloned per request.
+ let payload = b"concurrent body ".repeat(200);
+
+ std::thread::scope(|scope| {
+ for _ in 0..8 {
+ let payload = payload.clone();
+ scope.spawn(move || {
+ for _ in 0..10 {
+ let compressed = compress_with(pooled_resources(), Level::DEFAULT, &payload);
+ assert_eq!(gzip::decompress(compressed, resources()).unwrap().to_vec(), payload);
+ }
+ });
+ }
+ });
+ }
+
+ #[test]
+ fn a_pooled_decompressor_round_trips_every_format() {
+ // Whether or not a format's engine is actually recycled is an implementation detail; the
+ // decompressed bytes must be identical either way.
+ let payloads = [b"first response body".repeat(60), b"a different second body".repeat(90)];
+
+ for &format in Format::ALL {
+ for round in 0..4 {
+ for payload in &payloads {
+ let compressed = crate::format::compress(format, view(payload), resources()).unwrap();
+
+ let mut decompressor = DecompressorBuilder::new().build_format(format, resources()).built();
+ let plain = decompress(&mut decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(plain.to_vec(), *payload, "{format:?} round {round} diverged when pooled");
+ }
+ }
+ }
+ }
+
+ #[cfg(any(test, feature = "zlib"))]
+ #[test]
+ fn a_decompressor_abandoned_mid_stream_does_not_poison_the_pool() {
+ use crate::zlib;
+
+ // zlib rather than gzip, because gzip decompressors are deliberately not recycled -- there
+ // would be no dirty engine to hand back. One private capacity-one pool for both halves, so
+ // the engine cut short is provably the engine that then has to decode a whole stream.
+ let solo = solo_resources();
+ let payload = b"a stream that gets cut short ".repeat(200);
+ let compressed = zlib::compress(view(&payload), &solo).unwrap();
+
+ {
+ let mut abandoned = zlib::Decompressor::builder().build(&solo).built();
+ abandoned.push(compressed.range(0..compressed.len() / 2)).unwrap();
+ let _ = CompressionInternal::pull(&mut abandoned, Destination::Stream).unwrap();
+ // Dropped mid-stream, so its engine goes back dirty.
+ }
+
+ let mut recovered = zlib::Decompressor::builder().build(&solo).built();
+ let plain = decompress(&mut recovered, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(plain.to_vec(), payload, "a recycled dirty decompressor must be reset");
+ }
+
+ #[test]
+ fn gzip_decompressors_are_not_recycled() {
+ // `Decompress::reset` takes a boolean that cannot express gzip framing, so a recycled gzip
+ // decompressor would silently decompress as raw deflate. It must therefore never be pooled --
+ // and the caller must not be able to tell the difference.
+ let payload = b"gzip stays correct ".repeat(200);
+ let compressed = gzip::compress(view(&payload), resources()).unwrap();
+
+ for round in 0..5 {
+ let mut decompressor = gzip::Decompressor::builder().build(resources()).built();
+ let plain = decompress(&mut decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(plain.to_vec(), payload, "gzip round {round} decompressed incorrectly");
+ }
+ }
+
+ #[test]
+ fn resources_without_recycling_still_work() {
+ let plain = Resources::new(GlobalPool::new()).with_pool_capacity(0);
+ let payload = b"no recycling here".repeat(20);
+
+ let compressed = compress_with(&plain, Level::DEFAULT, &payload);
+
+ assert_eq!(gzip::decompress(compressed, resources()).unwrap().to_vec(), payload);
+ }
+}
+
+/// The riskiest pooling bug: deflate, zlib and gzip share one engine type, so a mis-keyed pool
+/// would hand a zlib compressor to a gzip request and emit a well-formed stream in the wrong
+/// format. Nothing else in the suite would catch that.
+#[test]
+fn formats_never_share_pooled_engines() {
+ let data = b"interleaved through one pool ".repeat(200);
+ let input = view(&data);
+
+ let baselines: Vec<_> = Format::ALL
+ .iter()
+ .map(|&format| {
+ let mut compressor = CompressorBuilder::new()
+ .output_chunk_size(chunk(4096))
+ .build_format(format, resources())
+ .built();
+ let bytes = compress(&mut compressor, &input, usize::MAX).unwrap().to_vec();
+ (format, bytes)
+ })
+ .collect();
+
+ // Interleave, so every format has had a turn before any is asked again.
+ for round in 0..6 {
+ for (format, baseline) in &baselines {
+ let mut compressor = CompressorBuilder::new()
+ .output_chunk_size(chunk(4096))
+ .build_format(*format, resources())
+ .built();
+ let pooled = compress(&mut compressor, &input, usize::MAX).unwrap();
+ drop(compressor);
+
+ assert_eq!(
+ &pooled.to_vec(),
+ baseline,
+ "{format:?} round {round}: interleaving formats through one pool changed the output"
+ );
+
+ // And the bytes really are this format's, not a sibling's that happens to decompress.
+ for (other, _) in &baselines {
+ let mut reader = DecompressorBuilder::new().build_format(*other, resources()).built();
+ let decompressed = decompress(&mut reader, &pooled, usize::MAX);
+
+ if other == format {
+ assert_eq!(
+ decompressed.unwrap().to_vec(),
+ data,
+ "{format:?} round {round}: own decompressor failed"
+ );
+ } else if let Ok(plain) = decompressed {
+ assert_ne!(plain.to_vec(), data, "{other:?} decompressed a {format:?} stream as its own");
+ }
+ }
+ }
+ }
+}
+
+/// One pool shared by many threads, the way a client would actually use it.
+#[test]
+fn a_shared_pool_is_correct_under_concurrency() {
+ let data = b"concurrent request body ".repeat(150);
+
+ let baselines: Vec<_> = Format::ALL
+ .iter()
+ .map(|&format| {
+ let input = view(&data);
+ let mut compressor = CompressorBuilder::new()
+ .output_chunk_size(chunk(4096))
+ .build_format(format, resources())
+ .built();
+ let bytes = compress(&mut compressor, &input, usize::MAX).unwrap().to_vec();
+ (format, bytes)
+ })
+ .collect();
+
+ std::thread::scope(|scope| {
+ for _ in 0..8 {
+ let data = data.clone();
+ let baselines = baselines.clone();
+
+ scope.spawn(move || {
+ // Each thread builds its own view, so segmentation is stable within the thread.
+ let input = view(&data);
+
+ for round in 0..10 {
+ for (format, baseline) in &baselines {
+ let mut compressor = CompressorBuilder::new()
+ .output_chunk_size(chunk(4096))
+ .build_format(*format, resources())
+ .built();
+ let pooled = compress(&mut compressor, &input, usize::MAX).unwrap();
+ drop(compressor);
+
+ assert_eq!(&pooled.to_vec(), baseline, "{format:?} round {round}: concurrent pooling diverged");
+
+ let mut decompressor = DecompressorBuilder::new().build_format(*format, resources()).built();
+ let plain = decompress(&mut decompressor, &pooled, usize::MAX).unwrap();
+
+ assert_eq!(plain.to_vec(), data, "{format:?} round {round}: concurrent decompress lost data");
+ }
+ }
+ });
+ }
+ });
+}
+
+/// A long run must not drift: however many messages a pooled engine serves, each must match the first.
+#[test]
+fn pooled_output_does_not_drift_over_many_reuses() {
+ let data = b"steady state ".repeat(120);
+
+ for &format in Format::ALL {
+ let input = view(&data);
+ let mut first: Option> = None;
+
+ for round in 0..60 {
+ let mut compressor = CompressorBuilder::new()
+ .output_chunk_size(chunk(4096))
+ .build_format(format, resources())
+ .built();
+ let pooled = compress(&mut compressor, &input, usize::MAX).unwrap().to_vec();
+ drop(compressor);
+
+ match first {
+ None => first = Some(pooled),
+ Some(ref expected) => assert_eq!(&pooled, expected, "{format:?} round {round}: output drifted"),
+ }
+ }
+ }
+}
+
+/// The trait contract itself, exercised through one format and through a runtime-selected one.
+///
+/// These live here rather than beside the traits because driving them needs a concrete format, and
+/// `core` deliberately knows about none.
+#[cfg(any(test, feature = "gzip"))]
+mod trait_contract {
+ use super::*;
+ use crate::gzip;
+
+ #[test]
+ fn round_trips_through_the_trait_alone() {
+ let mut compressor = gzip::Compressor::new(resources());
+ CompressionInternal::push(&mut compressor, view(b"driven through the trait")).unwrap();
+ CompressionInternal::end_input(&mut compressor);
+
+ let mut collected = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ let output = CompressionInternal::pull(&mut compressor, Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "compressor requested input after end");
+ let done = output.is_done();
+ if let Some(chunk) = output.into_data() {
+ collected.put_bytes(chunk);
+ }
+ if done {
+ break;
+ }
+ }
+
+ let mut decompressor = gzip::Decompressor::new(resources());
+ CompressionInternal::push(&mut decompressor, collected.consume_all()).unwrap();
+ CompressionInternal::end_input(&mut decompressor);
+
+ let mut plain = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ let output = CompressionInternal::pull(&mut decompressor, Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "decompressor requested input after end");
+ let done = output.is_done();
+ if let Some(chunk) = output.into_data() {
+ plain.put_bytes(chunk);
+ }
+ if done {
+ break;
+ }
+ }
+
+ assert_eq!(plain.consume_all().to_vec(), b"driven through the trait".to_vec());
+ }
+
+ #[test]
+ fn a_boxed_operation_reports_the_same_counters_as_the_one_it_wraps() {
+ // Boxing is how a runtime-selected format reaches the same contract, so the counters must
+ // survive the indirection rather than reporting the box's own idea of progress.
+ let data = b"counted through the box ".repeat(50);
+ let input = view(&data);
+
+ let mut boxed = gzip::Compressor::new(resources());
+ assert_eq!(boxed.total_in(), 0, "nothing has been consumed yet");
+ assert_eq!(boxed.total_out(), 0, "nothing has been produced yet");
+
+ let compressed = compress(&mut boxed, &input, usize::MAX).unwrap();
+
+ assert_eq!(boxed.total_in(), data.len() as u64, "every input byte should be accounted for");
+ assert_eq!(
+ boxed.total_out(),
+ compressed.len() as u64,
+ "every output byte should be accounted for"
+ );
+ }
+
+ #[test]
+ fn trait_objects_are_send_sync_and_debug() {
+ fn assert_send_sync(_: &T) {}
+ let compressor = gzip::Compressor::new(resources());
+ let decompressor = gzip::Decompressor::new(resources());
+
+ assert_send_sync(&compressor);
+ assert_send_sync(&decompressor);
+ assert_send_sync(&gzip::Compressor::new(resources()));
+ assert_send_sync(&gzip::Decompressor::new(resources()));
+ assert!(format!("{compressor:?}").contains("Compressor"));
+ assert!(format!("{decompressor:?}").contains("Decompressor"));
+ }
+
+ #[test]
+ fn direction_specific_traits_work_for_concrete_and_runtime_operations() {
+ let input = view(b"direction-specific capabilities");
+
+ let mut concrete = gzip::Compressor::new(resources());
+ concrete.push(input.clone()).unwrap();
+ concrete.flush().unwrap();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ let output = concrete.pull(Destination::Stream).unwrap();
+ assert!(!output.is_done(), "flush ended the stream");
+ if output.is_need_input() {
+ break;
+ }
+ }
+
+ let mut compressor = CompressorBuilder::new().build_format(Format::Gzip, resources()).unwrap();
+ compressor.push(input).unwrap();
+ let mut compressed = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ let output = compressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_done(), "flush ended the stream");
+ let need_input = output.is_need_input();
+ if let Some(chunk) = output.into_data() {
+ compressed.put_bytes(chunk);
+ }
+ if need_input {
+ break;
+ }
+ }
+
+ // The header alone is already non-empty, so the flush's contribution must be measured
+ // against this baseline rather than against emptiness.
+ let before_flush = compressed.len();
+
+ compressor.flush().unwrap();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ let output = compressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_done(), "flush ended the stream");
+ let need_input = output.is_need_input();
+ if let Some(chunk) = output.into_data() {
+ compressed.put_bytes(chunk);
+ }
+ if need_input {
+ break;
+ }
+ }
+
+ assert!(
+ compressed.len() > before_flush,
+ "boxed flush should have released a sync-flush chunk beyond the header before end_input"
+ );
+
+ compressor.end_input();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ let output = compressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "compressor requested input after end");
+ let done = output.is_done();
+ if let Some(chunk) = output.into_data() {
+ compressed.put_bytes(chunk);
+ }
+ if done {
+ break;
+ }
+ }
+
+ let joined = BytesView::from_views([compressed.consume_all(), view(b"trailing")]);
+ let mut decompressor = DecompressorBuilder::new()
+ .multi_stream(false)
+ .trailing_data(TrailingData::Ignore)
+ .build_format(Format::Gzip, resources())
+ .unwrap();
+ decompressor.push(joined).unwrap();
+
+ let mut plain = BytesBuf::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ let output = decompressor.pull(Destination::Stream).unwrap();
+ assert!(!output.is_need_input(), "complete stream requested more input");
+ let done = output.is_done();
+ if let Some(chunk) = output.into_data() {
+ plain.put_bytes(chunk);
+ }
+ if done {
+ break;
+ }
+ }
+
+ assert_eq!(plain.consume_all().to_vec(), b"direction-specific capabilities".to_vec());
+ }
+}
diff --git a/crates/compressors/src/tests/mod.rs b/crates/compressors/src/tests/mod.rs
new file mode 100644
index 000000000..dd368791f
--- /dev/null
+++ b/crates/compressors/src/tests/mod.rs
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! White-box behaviour tests for the crate's cross-cutting contracts.
+//!
+//! These drive concrete engines through the crate-private push/pull mechanics and inspect the
+//! private step outcomes, which is what lets them assert the state machine's transitions rather
+//! than only its end results. That access is why they live inside the crate: a separate test crate
+//! cannot name those items, and exposing them so it could would defeat the sealing they exist to
+//! verify.
+
+#[cfg(any(test, any_format))]
+mod format_contract;
+
+#[cfg(any(test, feature = "gzip"))]
+mod round_trip;
diff --git a/crates/compressors/src/tests/round_trip.rs b/crates/compressors/src/tests/round_trip.rs
new file mode 100644
index 000000000..2a32c4dcc
--- /dev/null
+++ b/crates/compressors/src/tests/round_trip.rs
@@ -0,0 +1,445 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! End-to-end behaviour of gzip, including the parts no other format exercises.
+//!
+//! Gzip-specific: interop fixtures produced by the system `gzip`, and the concatenated-stream
+//! behaviour that only gzip enables by default. Drives the crate-private mechanics where a
+//! transition has to be observed rather than inferred from the final bytes.
+
+use std::num::NonZeroU64;
+
+use bytesbuf::{BytesBuf, BytesView};
+
+use crate::core::{CompressionInternal as _, Destination, Output};
+use crate::format::Format;
+use crate::limits::{DEFAULT_MAX_OUTPUT_LEN, DEFAULT_MAX_STREAMS};
+use crate::testing::{chunk, fragmented, view};
+use crate::{DecompressorLimits, Resources, gzip};
+
+/// The payload behind `fixtures/system_gzip.gz`, compressed by the system `gzip -9 -n`.
+const FIXTURE_PLAINTEXT: &[u8] = b"The quick brown fox jumps over the lazy dog.\nPack my box with five dozen liquor jugs.\n";
+
+const SYSTEM_GZIP: &[u8] = include_bytes!("fixtures/system_gzip.gz");
+
+/// Caps every drain loop in this file.
+///
+/// A conforming engine always terminates, so exceeding this means the code under test is
+/// spinning. A hanging test reports nothing at all, so the cap turns a hang into a failure --
+/// which also lets mutation testing reach a verdict instead of timing out.
+///
+/// The cap has to stay tight enough for that verdict to arrive inside the mutation harness's
+/// per-mutant timeout. No test here needs more than a few hundred steps, so this leaves well over
+/// an order of magnitude of headroom while still failing a spinning mutant in under a second.
+const MAX_STEPS: usize = 10_000;
+
+/// Fails a spinning test instead of letting it hang.
+///
+/// A conforming engine always terminates, so exceeding the cap means the code under test is
+/// looping. A hanging test reports nothing at all, and mutation testing records a timeout rather
+/// than a verdict, so every drain loop below counts its steps through this.
+struct StepGuard(usize);
+
+impl StepGuard {
+ fn new() -> Self {
+ Self(0)
+ }
+
+ fn step(&mut self) {
+ self.0 += 1;
+ assert!(self.0 < MAX_STEPS, "the operation did not finish within {MAX_STEPS} steps");
+ }
+}
+
+/// Drives an engine to completion over an input delivered in `feed`-sized pieces.
+fn drive_decompressor(mut decompressor: gzip::Decompressor, input: &BytesView, feed: usize) -> crate::Result {
+ let mut offset = 0;
+ let mut collected = BytesBuf::new();
+
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream)? {
+ Output::Data(data) => collected.put_bytes(data),
+ Output::Progress => {}
+ Output::Done => return Ok(collected.consume_all()),
+ Output::NeedInput => {
+ if offset >= input.len() {
+ decompressor.end_input();
+ continue;
+ }
+
+ let end = (offset + feed).min(input.len());
+ decompressor.push(input.range(offset..end))?;
+ offset = end;
+ }
+ }
+ }
+}
+
+#[test]
+fn decompresses_a_stream_produced_by_the_system_gzip() {
+ let plain = gzip::decompress(view(SYSTEM_GZIP), &Resources::default()).unwrap();
+
+ assert_eq!(plain.to_vec(), FIXTURE_PLAINTEXT);
+}
+
+#[test]
+fn decompresses_concatenated_members_produced_by_the_system_gzip() {
+ // Concatenation is what is under test, not a second producer: both members of the packaged
+ // two-member file were byte-identical copies of this one, so assembling the input here keeps
+ // one independently generated fixture as the source of truth.
+ let two_members = [SYSTEM_GZIP, SYSTEM_GZIP].concat();
+
+ let plain = gzip::decompress(view(&two_members), &Resources::default()).unwrap();
+
+ assert_eq!(plain.to_vec(), [FIXTURE_PLAINTEXT, FIXTURE_PLAINTEXT].concat());
+}
+
+#[test]
+fn our_framing_matches_an_independent_gzip_reader() {
+ // Cross-checks our container against flate2's own gzip framing, which parses the header,
+ // checksum and length trailer in Rust rather than in the compression engine.
+ use std::io::Read as _;
+
+ let payload = b"cross checked against an independent reader ".repeat(200);
+ let compressed = gzip::compress(fragmented(&payload, 71), &Resources::default()).unwrap();
+
+ let mut decompressed = Vec::new();
+ flate2::read::GzDecoder::new(compressed.to_vec().as_slice())
+ .read_to_end(&mut decompressed)
+ .unwrap();
+
+ assert_eq!(decompressed, payload);
+}
+
+#[test]
+fn round_trips_a_multi_segment_view() {
+ // Regression guard. `BytesView` is a chain of segments, and the engine is fed one segment at a
+ // time. Signalling end of input on the first segment rather than the last silently truncated the
+ // stream at the first segment boundary, which single-segment tests could not catch.
+ // Tiny segments are quadratic to build, so scale the payload down as the segment shrinks.
+ for (segment, repeats) in [(1, 200), (7, 500), (64, 5_000), (1024, 20_000), (65_536, 20_000)] {
+ let payload = b"multi segment payload ".repeat(repeats);
+
+ let compressed = gzip::compress(fragmented(&payload, segment), &Resources::default()).unwrap();
+ let plain = gzip::decompress(compressed, &Resources::default()).unwrap();
+
+ assert_eq!(plain.to_vec(), payload, "round trip failed for {segment} byte segments");
+ }
+}
+
+#[test]
+fn round_trips_when_input_arrives_one_byte_at_a_time() {
+ let payload = b"trickled in".repeat(50);
+ let compressed = gzip::compress(view(&payload), &Resources::default()).unwrap();
+
+ let decompressor = gzip::Decompressor::builder()
+ .output_chunk_size(chunk(1))
+ .build(&Resources::default());
+ let plain = drive_decompressor(decompressor, &compressed, 1).unwrap();
+
+ assert_eq!(plain.to_vec(), payload);
+}
+
+#[test]
+fn streams_a_large_payload_with_a_bounded_working_set() {
+ // The point of the push/pull design: a long stream must never require a buffer proportional to
+ // its length. Every chunk handed back stays within the configured bound.
+ const CHUNK: usize = 16 * 1024;
+
+ let payload = b"large streamed payload, compressible but not trivially so; ".repeat(20_000);
+ assert!(payload.len() > 1024 * 1024, "the payload should be large enough to matter");
+
+ let mut compressor = gzip::Compressor::builder()
+ .output_chunk_size(chunk(CHUNK))
+ .build(&Resources::default());
+ compressor.push(fragmented(&payload, 4096)).unwrap();
+ compressor.end_input();
+
+ let mut compressed = Vec::new();
+ let mut guard = StepGuard::new();
+ loop {
+ guard.step();
+ match compressor.pull(Destination::Stream).unwrap() {
+ Output::Data(piece) => {
+ assert!(
+ piece.len() <= CHUNK,
+ "chunk of {} bytes exceeded the {CHUNK} byte bound",
+ piece.len()
+ );
+ compressed.push(piece);
+ }
+ Output::Progress => {}
+ Output::NeedInput => panic!("compressor requested input after end"),
+ Output::Done => break,
+ }
+ }
+
+ let gz = BytesView::from_views(compressed);
+ assert!(gz.len() < payload.len() / 10, "the payload should compress well");
+
+ let decompressor = gzip::Decompressor::builder()
+ .output_chunk_size(chunk(CHUNK))
+ .build(&Resources::default());
+ let plain = drive_decompressor(decompressor, &gz, 8192).unwrap();
+
+ assert_eq!(plain.len(), payload.len());
+ assert_eq!(plain.to_vec(), payload);
+}
+
+#[test]
+fn rejects_a_bomb_before_materialising_it() {
+ // A megabyte of zeros compresses to a few hundred bytes. The guard must fire long before the
+ // output is fully materialised, so the cap is set far below what the bomb would expand to.
+ //
+ // The cap is set explicitly rather than relying on the default: deflate cannot expand by more
+ // than about `1032x`, so its default ratio never fires on data the format could have produced.
+ // An absolute cap is what actually protects a caller that buffers the output.
+ let bomb = gzip::compress(view(&vec![0_u8; 1024 * 1024]), &Resources::default()).unwrap();
+ assert!(bomb.len() < 16 * 1024, "the bomb should be tiny: {} bytes", bomb.len());
+
+ let mut decompressor = gzip::Decompressor::builder()
+ .limits(DecompressorLimits::new().max_output_len(NonZeroU64::new(16 * 1024).unwrap()))
+ .build(&Resources::default());
+ decompressor.push(bomb).unwrap();
+ decompressor.end_input();
+
+ let mut guard = StepGuard::new();
+ let error = loop {
+ guard.step();
+ match decompressor.pull(Destination::Stream) {
+ Ok(Output::Data(_) | Output::Progress) => {}
+ Ok(_) => panic!("the bomb decompressed fully instead of being rejected"),
+ Err(error) => break error,
+ }
+ };
+
+ assert!(error.is_limit_exceeded(), "got {error}");
+ assert!(
+ decompressor.total_out() < 1024 * 1024,
+ "the guard should fire before the full expansion, stopped at {}",
+ decompressor.total_out()
+ );
+}
+
+#[test]
+fn the_default_limits_accept_maximally_compressible_deflate_data() {
+ // Deflate's structural ceiling is about `1032x`, so the gzip default must sit above it: data the
+ // format could legitimately have produced must never be rejected as a bomb. A megabyte of zeros
+ // reaches that ceiling and clears the ratio guard's 32 KiB floor, so the guard is genuinely
+ // active here rather than skipped as too small to judge.
+ let payload = vec![0_u8; 1024 * 1024];
+ let compressed = gzip::compress(view(&payload), &Resources::default()).unwrap();
+
+ let plain = gzip::decompress(compressed, &Resources::default()).unwrap();
+
+ assert_eq!(plain.len(), payload.len());
+}
+
+#[test]
+fn known_good_data_can_opt_out_of_the_limits() {
+ // The precondition is the data, not the caller: this payload is generated here, so its
+ // expansion is known. A trusted caller relaying an attacker's bytes would not qualify.
+ let payload = vec![0_u8; 1024 * 1024];
+ let compressed = gzip::compress(view(&payload), &Resources::default()).unwrap();
+
+ let decompressor = gzip::Decompressor::builder()
+ .limits(DecompressorLimits::UNLIMITED)
+ .build(&Resources::default());
+ let plain = drive_decompressor(decompressor, &compressed, usize::MAX).unwrap();
+
+ assert_eq!(plain.len(), payload.len());
+}
+
+#[test]
+fn detects_truncation_at_every_offset() {
+ let compressed = gzip::compress(view(&b"truncate me ".repeat(500)), &Resources::default()).unwrap();
+
+ for cut in [
+ 1,
+ compressed.len() / 4,
+ compressed.len() / 2,
+ compressed.len() - 8,
+ compressed.len() - 1,
+ ] {
+ let error = gzip::decompress(compressed.range(0..cut), &Resources::default()).unwrap_err();
+
+ assert!(
+ error.is_unexpected_end_of_stream() || error.is_corrupt_data(),
+ "truncating at {cut} gave an unexpected classification: {error}"
+ );
+ }
+}
+
+#[test]
+fn a_corrupted_byte_anywhere_is_detected() {
+ let payload = b"integrity checked payload ".repeat(100);
+ let compressed = gzip::compress(view(&payload), &Resources::default()).unwrap();
+ let original = compressed.to_vec();
+
+ for index in [0, 1, 2, original.len() / 2, original.len() - 5, original.len() - 1] {
+ let mut corrupted = original.clone();
+ corrupted[index] ^= 0xff;
+
+ let result = gzip::decompress(view(&corrupted), &Resources::default());
+
+ match result {
+ Ok(plain) => assert_ne!(plain.to_vec(), payload, "corruption at {index} went entirely unnoticed"),
+ Err(error) => assert!(
+ error.is_corrupt_data() || error.is_unexpected_end_of_stream(),
+ "corruption at {index} gave an unexpected classification: {error}"
+ ),
+ }
+ }
+}
+
+#[test]
+fn empty_input_round_trips() {
+ let compressed = gzip::compress(BytesView::new(), &Resources::default()).unwrap();
+ let plain = gzip::decompress(compressed, &Resources::default()).unwrap();
+
+ assert!(plain.is_empty());
+}
+
+#[test]
+fn a_custom_memory_provider_is_used_for_output() {
+ // Anything implementing `MemoryShared` works; the engine never reaches for a global allocator
+ // of its own. Counting the reservations is what proves it, rather than merely building a
+ // provider and hoping.
+ let (memory, activity) = crate::testing::counting_memory();
+ let resources = Resources::new(memory);
+
+ let compressed = gzip::compress(view(b"provider supplied"), &resources).unwrap();
+ let after_compress = activity.reservations();
+ assert!(after_compress > 0, "compression must draw its output from the caller's provider");
+
+ let plain = gzip::decompress(compressed, &resources).unwrap();
+
+ assert_eq!(plain.to_vec(), b"provider supplied".to_vec());
+ assert!(
+ activity.reservations() > after_compress,
+ "decompression must draw its output from the caller's provider too"
+ );
+}
+
+#[test]
+fn a_stream_of_many_tiny_members_is_rejected_without_the_caller_setting_any_limit() {
+ // Each member costs engine setup its own payload never pays for, so a stream of empty members
+ // amplifies work out of all proportion to its size. The default stream cap is what bounds it,
+ // so the count is derived from that constant rather than restated: this is the smallest input
+ // that crosses the boundary, whatever the boundary currently is.
+ let members = usize::try_from(crate::limits::DEFAULT_MAX_STREAMS).unwrap() + 1;
+
+ let member = gzip::compress(BytesView::new(), &Resources::default()).unwrap().to_vec();
+ let mut many = Vec::with_capacity(member.len() * members);
+ for _ in 0..members {
+ many.extend_from_slice(&member);
+ }
+
+ let error = gzip::decompress(view(&many), &Resources::default()).unwrap_err();
+
+ assert!(error.is_limit_exceeded(), "expected a limit failure, got: {error}");
+ assert!(
+ error.to_string().contains("decoded stream count"),
+ "the stream cap should be what fired: {error}"
+ );
+}
+
+#[test]
+fn the_crate_level_decompress_applies_the_default_ceiling_unless_the_caller_decided() {
+ // `decompress` takes an already-built decompressor, so without a ceiling of its own it was
+ // bounded only by whatever that decompressor carried -- and brotli declares no defaults at all,
+ // so a hundred compressed bytes could expand without limit.
+ let over_the_cap = vec![0_u8; usize::try_from(DEFAULT_MAX_OUTPUT_LEN).unwrap() + 1];
+ let compressed = gzip::compress(&*over_the_cap, &Resources::default()).unwrap();
+
+ let decompress_with = |limits: DecompressorLimits| {
+ crate::decompress(
+ compressed.clone(),
+ gzip::Decompressor::builder().limits(limits).build(&Resources::default()),
+ )
+ };
+
+ // Bound left unset: our 64 MiB stands in, so this is refused.
+ let error = decompress_with(DecompressorLimits::new()).unwrap_err();
+ assert!(error.is_limit_exceeded(), "got {error}");
+
+ // Raised explicitly: the caller's number wins over ours.
+ let raised = DecompressorLimits::new().max_output_len(NonZeroU64::new(DEFAULT_MAX_OUTPUT_LEN * 2).unwrap());
+ assert_eq!(decompress_with(raised).unwrap().len(), over_the_cap.len());
+
+ // Removed explicitly: also the caller's decision, so nothing is added on top.
+ for removed in [DecompressorLimits::UNLIMITED, DecompressorLimits::new().unbounded_output_len()] {
+ assert_eq!(decompress_with(removed).unwrap().len(), over_the_cap.len());
+ }
+
+ // Lowered explicitly: still the caller's decision, in the other direction.
+ let lowered = DecompressorLimits::new().max_output_len(NonZeroU64::new(1024).unwrap());
+ assert!(decompress_with(lowered).unwrap_err().is_limit_exceeded());
+}
+
+#[test]
+fn the_crate_level_decompress_applies_the_default_stream_cap_unless_the_caller_decided() {
+ // The companion to the output ceiling, and the case it cannot cover: many tiny members each
+ // pay a full engine setup while producing almost no output, so no output bound ever trips.
+ // gzip decompresses concatenated members by default, so a default-built decompressor handed to
+ // `decompress` is exactly the exposed shape.
+ let member = gzip::compress(b"".as_slice(), &Resources::default()).unwrap();
+ let mut concatenated = BytesBuf::new();
+ for _ in 0..=DEFAULT_MAX_STREAMS {
+ concatenated.put_bytes(member.clone());
+ }
+ let over_the_cap = concatenated.consume_all();
+
+ let decompress_with = |limits: DecompressorLimits| {
+ crate::decompress(
+ over_the_cap.clone(),
+ gzip::Decompressor::builder().limits(limits).build(&Resources::default()),
+ )
+ };
+
+ // Bound left unset: our 1024 stands in, so this is refused.
+ let error = decompress_with(DecompressorLimits::new()).unwrap_err();
+ assert!(error.is_limit_exceeded(), "got {error}");
+
+ // Removed explicitly: the caller's decision, so nothing is added on top.
+ for removed in [DecompressorLimits::UNLIMITED, DecompressorLimits::new().unbounded_streams()] {
+ assert!(decompress_with(removed).unwrap().is_empty());
+ }
+
+ // Raised explicitly: also the caller's decision.
+ let raised = DecompressorLimits::new().max_streams(NonZeroU64::new(DEFAULT_MAX_STREAMS * 2).unwrap());
+ assert!(decompress_with(raised).unwrap().is_empty());
+
+ // Lowered explicitly: still the caller's decision, in the other direction.
+ let lowered = DecompressorLimits::new().max_streams(NonZeroU64::new(4).unwrap());
+ assert!(decompress_with(lowered).unwrap_err().is_limit_exceeded());
+}
+
+#[test]
+fn the_crate_level_decompress_applies_the_default_ceiling_to_runtime_formats_too() {
+ // The runtime-format decompressor is a separate `CompressionInternal` implementation, so the
+ // ceiling has to reach it the same way it reaches every other one. Brotli is the format that
+ // makes this observable: it declares no bounds of its own, so nothing but the ceiling can
+ // refuse this.
+ let over_the_cap = vec![0_u8; usize::try_from(DEFAULT_MAX_OUTPUT_LEN).unwrap() + 1];
+ let compressed = crate::format::compress(Format::Brotli, &*over_the_cap, &Resources::default()).unwrap();
+
+ let decompress_with = |limits: DecompressorLimits| {
+ crate::decompress(
+ compressed.clone(),
+ crate::format::Decompressor::builder()
+ .limits(limits)
+ .build_format(Format::Brotli, &Resources::default())
+ .unwrap(),
+ )
+ };
+
+ let error = decompress_with(DecompressorLimits::new()).unwrap_err();
+ assert!(error.is_limit_exceeded(), "got {error}");
+
+ for removed in [DecompressorLimits::UNLIMITED, DecompressorLimits::new().unbounded_output_len()] {
+ assert_eq!(decompress_with(removed).unwrap().len(), over_the_cap.len());
+ }
+}
diff --git a/crates/compressors/src/trailing.rs b/crates/compressors/src/trailing.rs
new file mode 100644
index 000000000..506f196f8
--- /dev/null
+++ b/crates/compressors/src/trailing.rs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/// How a single-stream decoder handles bytes after the compressed stream.
+///
+/// In multi-stream mode, subsequent bytes are always interpreted as another compressed stream and
+/// must be valid. This policy applies when multi-stream decoding is disabled.
+///
+/// [`Reject`][Self::Reject] is the default. Silently discarding trailing bytes is a parser
+/// differential: a proxy or scanner built on this crate would see only the benign prefix of a body
+/// whose tail something downstream still reads. Opt into [`Ignore`][Self::Ignore] when the input is
+/// a container whose framing legitimately puts other data after the compressed stream.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+#[non_exhaustive]
+pub enum TrailingData {
+ /// Stop after the compressed stream and ignore whatever follows it.
+ ///
+ /// The decoder reports that it is done at the end of the stream and never looks at the bytes
+ /// after it.
+ Ignore,
+
+ /// Require the compressed stream to end exactly at end of input.
+ ///
+ /// The decoder waits for `end_input` after the compressed stream and rejects any further
+ /// non-empty input.
+ #[default]
+ Reject,
+}
diff --git a/crates/compressors/src/zlib/mod.rs b/crates/compressors/src/zlib/mod.rs
new file mode 100644
index 000000000..1a4958083
--- /dev/null
+++ b/crates/compressors/src/zlib/mod.rs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Zlib (RFC 1950): a deflate payload with a two byte header and an Adler-32 trailer.
+//!
+//! This is the format behind HTTP `Content-Encoding: deflate`, which despite its name carries a
+//! zlib stream rather than raw deflate.
+//!
+//! # Examples
+//!
+//! ```
+//! use compressors::{Resources, zlib};
+//!
+//! let compressed = zlib::compress(b"the quick brown fox", &Resources::default())?;
+//!
+//! assert_eq!(
+//! zlib::decompress(compressed, &Resources::default())?.to_vec(),
+//! b"the quick brown fox".to_vec()
+//! );
+//! # Ok::<(), compressors::Error>(())
+//! ```
+
+use crate::flate::Wrapper;
+use crate::flate::codec::{FlateCompress, FlateDecompress};
+use crate::macros::define_format;
+
+/// Selects zlib as the format of a [`CompressorBuilder`][crate::CompressorBuilder] or [`DecompressorBuilder`][crate::DecompressorBuilder].
+///
+/// Zlib has no settings beyond the ones every format shares, so this type carries none. It exists
+/// to name the format in the builder's type parameter, which is what gives that builder a `build`
+/// method producing this module's [`Compressor`] and [`Decompressor`].
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct Zlib;
+
+impl Zlib {
+ /// The settings a zlib builder starts with. Zlib has none of its own.
+ pub(crate) const fn new() -> Self {
+ Self
+ }
+}
+
+define_format! {
+ name = "zlib",
+ format = Zlib,
+ build_method = build_zlib,
+ compressor_codec = FlateCompress,
+ compressor_build = infallible,
+ new_compressor = |level, _format, pool| FlateCompress::new(Wrapper::Zlib, level, pool),
+ decompressor_codec = FlateDecompress,
+ decompressor_build = infallible,
+ default_limits = crate::flate::DEFAULT_LIMITS,
+ new_decompressor = |limits, multi_stream, trailing_data, _format, pool| {
+ FlateDecompress::new(Wrapper::Zlib, limits, multi_stream, trailing_data, pool)
+ },
+ multi_stream_default = false,
+}
diff --git a/crates/compressors/src/zstd/codec.rs b/crates/compressors/src/zstd/codec.rs
new file mode 100644
index 000000000..f2fa73d88
--- /dev/null
+++ b/crates/compressors/src/zstd/codec.rs
@@ -0,0 +1,575 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Binds zstd to the engine, driven segment by segment.
+
+use std::mem::MaybeUninit;
+
+use zstd_safe::zstd_sys::ZSTD_EndDirective;
+use zstd_safe::{CCtx, CParameter, DCtx, DParameter, InBuffer, OutBuffer, ResetDirective};
+
+use crate::engine::{Codec, Operation, Step, StreamEnd};
+use crate::error::{BuildError, Error, Result};
+use crate::level::Level;
+use crate::limits::FormatLimits;
+use crate::pool::Pool;
+use crate::trailing::TrailingData;
+use crate::zstd::{CompressionLevel, Zstd};
+
+/// Maps the portable [`Level`] scale onto zstd's levels.
+///
+/// zstd's positive levels run to 22, but the top of that range is not a sensible destination for a
+/// portable "highest quality" setting: compression time rises steeply there while the ratio gain
+/// flattens out. The scale is therefore anchored on zstd's own default rather than stretched across
+/// the whole range, so [`Level::DEFAULT`] means what it says on every format -- a balanced
+/// trade-off.
+///
+/// Reach the levels outside this range, including zstd's negative fast levels, with
+/// [`CompressorBuilder::compression_level`][crate::zstd::CompressorBuilder::compression_level].
+fn compression_level(level: Level) -> i32 {
+ // Two fixed points and an interpolation, rather than a measured curve. `Level::DEFAULT` must
+ // land on zstd's own default of 3, and `Level::MAX` must stop short of the expensive top of the
+ // native range. The entries between them are chosen for a smooth, monotonic climb with the
+ // steps widening towards the top, where each additional level costs more; they are not
+ // individually derived from a benchmark, so retuning one is a policy change rather than a
+ // regression against a recorded measurement.
+ const MAPPING: [i32; 10] = [1, 1, 2, 2, 3, 3, 3, 6, 9, 12];
+
+ MAPPING[usize::from(level.get().min(9))]
+}
+
+/// Lends zstd an output slice that is still uninitialized.
+///
+/// Zstd writes into the output without ever reading it, and `WriteBuf` is the trait zstd-safe
+/// provides to say exactly that: the capacity may be uninitialized, and the callee reports what it
+/// filled. Handing over the engine's spare capacity directly is what keeps this engine from zeroing
+/// a whole output chunk before every step, which would defeat the point of reserving uninitialized
+/// memory in the first place.
+struct UninitOutput<'a> {
+ buffer: &'a mut [MaybeUninit],
+ /// How many bytes from the front zstd has reported writing.
+ filled: usize,
+}
+
+// SAFETY: `as_mut_ptr` returns a pointer to `capacity` writable bytes that stays valid for the
+// borrow, and `as_slice` never covers more than `filled`, which only ever advances through
+// `filled_until` -- whose own contract is that the caller initialized that many bytes, and which
+// refuses a count larger than the allocation.
+unsafe impl zstd_safe::WriteBuf for UninitOutput<'_> {
+ fn as_slice(&self) -> &[u8] {
+ // SAFETY: `filled_until` promised these bytes are initialized, and `u8` shares its layout
+ // with `MaybeUninit`.
+ unsafe { std::slice::from_raw_parts(self.buffer.as_ptr().cast::(), self.filled) }
+ }
+
+ // A mutant that reports a smaller capacity is not wrong, only slow: zstd writes no more than it
+ // is offered, so the same bytes are produced a step at a time and the harness times out instead
+ // of reaching a verdict.
+ #[cfg_attr(test, mutants::skip)]
+ fn capacity(&self) -> usize {
+ self.buffer.len()
+ }
+
+ fn as_mut_ptr(&mut self) -> *mut u8 {
+ self.buffer.as_mut_ptr().cast::()
+ }
+
+ // zstd is handed `capacity()` and reports back how much of it it wrote, so `n` is always within
+ // the buffer. This is checked rather than assumed, and in every build: `as_slice` hands out
+ // `filled` bytes as initialized, so a wrong `n` here is the difference between sound and
+ // unsound. Clamping would not be a recovery -- it would assert that the whole buffer was
+ // written, which is exactly what a nonsense report gives no reason to believe -- so an
+ // out-of-range count aborts instead.
+ unsafe fn filled_until(&mut self, n: usize) {
+ self.check_reported_count(n);
+ self.filled = n;
+ }
+}
+
+impl<'a> UninitOutput<'a> {
+ fn new(buffer: &'a mut [MaybeUninit