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 Logo + +# Compressors + +[![crate.io](https://img.shields.io/crates/v/compressors.svg)](https://crates.io/crates/compressors) +[![docs.rs](https://docs.rs/compressors/badge.svg)](https://docs.rs/compressors) +[![MSRV](https://img.shields.io/crates/msrv/compressors)](https://crates.io/crates/compressors) +[![CI](https://github.com/microsoft/oxidizer/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/oxidizer/actions/workflows/main.yml) +[![Coverage](https://codecov.io/gh/microsoft/oxidizer/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/oxidizer) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/microsoft/oxidizer/blob/main/LICENSE) +This crate was developed as part of the Oxidizer project + +
+ +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]) -> Self { + Self { buffer, filled: 0 } + } +} + +impl UninitOutput<'_> { + /// Rejects a reported write count that the buffer could not have held. + /// + /// Split out from [`filled_until`][zstd_safe::WriteBuf::filled_until] so this bound can be + /// tested without an unsafe call that could not honour its contract: that method requires the + /// caller to have initialized `n` bytes, and a count past the end of the buffer is by + /// definition one no caller could have initialized. Checking is safe on its own; recording the + /// count is not, which is why only the check moved. + fn check_reported_count(&self, n: usize) { + assert!( + n <= self.buffer.len(), + "zstd reported writing {n} bytes into a {}-byte buffer", + self.buffer.len() + ); + } +} + +/// Reads zstd's "bytes still buffered" answer as a step outcome. +/// +/// A zero remaining count means the epilogue is out and the flush or finish is complete; a non-zero +/// count means the engine has more to give and must be driven again. +// Treating a zero remaining count as anything else leaves a finish that never completes, so that +// mutant hangs rather than failing and mutation testing records a timeout instead of a verdict. +#[cfg_attr(test, mutants::skip)] +fn finish_step(operation: Operation, remaining: usize) -> Step { + match operation { + Operation::Finish if remaining == 0 => Step::StreamEnd, + Operation::Flush if remaining == 0 => Step::FlushComplete, + _ => Step::Continue, + } +} + +fn compression_failed(code: usize) -> Error { + Error::invalid_state(format!("zstd compression failed: {}", zstd_safe::get_error_name(code))) +} + +fn decompression_failed(code: usize) -> Error { + Error::corrupt_data(format!("zstd decompression failed: {}", zstd_safe::get_error_name(code))) +} + +/// zstd only rejects a compression level outside its own `min_c_level()..=max_c_level()` range, and +/// [`CompressionLevel::new`][crate::zstd::CompressionLevel::new] and [`compression_level`] both +/// stay inside exactly that range. +/// +/// This is therefore defensive against the bundled native library's contract rather than a check on +/// this crate's own invariants: the bounds are queried from `zstd_safe` at run time, so a future +/// zstd could narrow them or add validation `set_parameter` does not perform today. It is reported +/// rather than asserted for that reason -- a caller can act on a build failure from a dependency it +/// did not choose, whereas a panic would take the process down for an upstream change. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(test, mutants::skip)] +#[cold] +fn compression_level_rejected(level: i32, code: usize) -> BuildError { + BuildError::new(format!( + "zstd rejected compression level {level}: {}", + zstd_safe::get_error_name(code) + )) +} + +/// zstd clamps `WindowLogMax` to `ZSTD_WINDOWLOG_MIN..=ZSTD_WINDOWLOG_MAX`, and +/// [`WindowLog`][crate::zstd::WindowLog]'s own bounds are defined as exactly that range. +/// +/// Defensive against the bundled native library's contract rather than this crate's invariants, for +/// the same reason as [`compression_level_rejected`]: those limits come from the linked zstd, so a +/// different build of it could reject a value this one accepts. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(test, mutants::skip)] +#[cold] +fn window_log_rejected(window: u32, code: usize) -> BuildError { + BuildError::new(format!( + "zstd rejected maximum window log {window}: {}", + zstd_safe::get_error_name(code) + )) +} + +/// Resetting a session, with no parameters to validate, has no documented failure mode; this +/// guards against a native error the bundled zstd version has never been observed to return. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(test, mutants::skip)] +#[cold] +fn reset_for_next_frame_failed(code: usize) -> Error { + Error::invalid_state(format!( + "zstd failed to reset for the next frame: {}", + zstd_safe::get_error_name(code) + )) +} + +/// Drives zstd's compression context for one compressed stream. +/// +/// The context lasts the whole operation and returns to the pool on drop, so `context` is `Some` +/// until then. `level` is re-applied on every checkout rather than baked into the pooled context, +/// which is why the compressor pool is unkeyed: any idle context serves any level, so a +/// caller-chosen level cannot fragment reuse or grow the pool. +pub(crate) struct ZstdCompress { + /// `Some` until the context is handed back in `drop`. + context: Option>, + level: i32, + recycle: Pool, +} + +impl ZstdCompress { + pub(crate) fn new(level: Level, options: &Zstd, pool: Pool) -> ::core::result::Result { + let level = options.level.map_or_else(|| compression_level(level), CompressionLevel::get); + let mut context = pool.take_zstd_compressor().unwrap_or_else(CCtx::create); + + // Applied unconditionally: a recycled context comes back with its parameters cleared, so + // that a recycled compressor is indistinguishable from a fresh one. + context + .set_parameter(CParameter::CompressionLevel(level)) + .map_err(|code| compression_level_rejected(level, code))?; + + Ok(Self { + context: Some(context), + level, + recycle: pool, + }) + } + + fn engine(&mut self) -> &mut CCtx<'static> { + self.context.as_mut().expect("the context is only taken in drop") + } +} + +impl std::fmt::Debug for ZstdCompress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZstdCompress").field("level", &self.level).finish_non_exhaustive() + } +} + +impl Drop for ZstdCompress { + fn drop(&mut self) { + self.recycle.return_zstd_compressor(&mut self.context); + } +} + +// SAFETY: `step` writes through `zstd_safe::WriteBuf`, which takes the uninitialized slice and +// reports what zstd filled, so the count is the engine's own. +unsafe impl Codec for ZstdCompress { + fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], operation: Operation) -> Result<(Step, usize, usize)> { + let directive = match operation { + Operation::Process => ZSTD_EndDirective::ZSTD_e_continue, + Operation::Flush => ZSTD_EndDirective::ZSTD_e_flush, + Operation::Finish => ZSTD_EndDirective::ZSTD_e_end, + }; + + let mut out = UninitOutput::new(output); + let mut in_buffer = InBuffer::around(input); + let mut out_buffer = OutBuffer::around(&mut out); + + let remaining = self + .engine() + .compress_stream2(&mut out_buffer, &mut in_buffer, directive) + .map_err(compression_failed)?; + + // `ZSTD_e_end` reports zero only once the frame's epilogue has been flushed. + let step = finish_step(operation, remaining); + + Ok((step, in_buffer.pos(), out_buffer.pos())) + } + + fn stream_ended(&mut self) -> Result { + Ok(StreamEnd::Complete) + } +} + +/// Drives zstd's decompression context, across as many concatenated frames as the configuration +/// allows. +/// +/// `context` and `recycle` last the whole operation, the context going back to the pool on drop. +/// `limits`, `multi_stream` and `trailing_data` are the fixed policy the builder chose. +/// `needs_reset` spans one frame: zstd concatenates by default, so this is the common path rather +/// than the exception, and the reset is deferred until another frame actually arrives. +pub(crate) struct ZstdDecompress { + /// `Some` until the context is handed back in `drop`. + context: Option>, + limits: FormatLimits, + multi_stream: bool, + trailing_data: TrailingData, + recycle: Pool, + needs_reset: bool, +} + +impl ZstdDecompress { + pub(crate) fn new( + limits: FormatLimits, + multi_stream: bool, + trailing_data: TrailingData, + options: &Zstd, + pool: Pool, + ) -> ::core::result::Result { + let mut context = pool.take_zstd_decompressor().unwrap_or_else(DCtx::create); + + if let Some(window) = options.max_window_log { + context + .set_parameter(DParameter::WindowLogMax(window.get())) + .map_err(|code| window_log_rejected(window.get(), code))?; + } + + Ok(Self { + context: Some(context), + limits, + multi_stream, + trailing_data, + recycle: pool, + needs_reset: false, + }) + } + + fn engine(&mut self) -> &mut DCtx<'static> { + self.context.as_mut().expect("the context is only taken in drop") + } +} + +impl std::fmt::Debug for ZstdDecompress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZstdDecompress") + .field("limits", &self.limits) + .field("multi_stream", &self.multi_stream) + .field("trailing_data", &self.trailing_data) + .finish_non_exhaustive() + } +} + +impl Drop for ZstdDecompress { + fn drop(&mut self) { + self.recycle.return_zstd_decompressor(&mut self.context); + } +} + +// SAFETY: `step` writes through `zstd_safe::WriteBuf`, which takes the uninitialized slice and +// reports what zstd filled, so the count is the engine's own. +unsafe impl Codec for ZstdDecompress { + fn step(&mut self, input: &[u8], output: &mut [MaybeUninit], _operation: Operation) -> Result<(Step, usize, usize)> { + if self.needs_reset { + self.engine() + .reset(ResetDirective::SessionOnly) + .map_err(reset_for_next_frame_failed)?; + self.needs_reset = false; + } + + let mut out = UninitOutput::new(output); + let mut in_buffer = InBuffer::around(input); + let mut out_buffer = OutBuffer::around(&mut out); + + let hint = self + .engine() + .decompress_stream(&mut out_buffer, &mut in_buffer) + .map_err(decompression_failed)?; + + // Zero means the frame ended exactly here; anything else is a hint at the next read size. + let step = if hint == 0 { Step::StreamEnd } else { Step::Continue }; + + Ok((step, in_buffer.pos(), out_buffer.pos())) + } + + fn stream_ended(&mut self) -> Result { + if !self.multi_stream { + return Ok(match self.trailing_data { + TrailingData::Ignore => StreamEnd::Complete, + TrailingData::Reject => StreamEnd::AwaitEof, + }); + } + + // Reset only if another frame actually arrives, so the common single-frame path does no + // terminal cleanup work. + 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 the_level_mapping_is_monotonic_and_within_zstds_range() { + let mut previous = 0; + for raw in 0..=Level::MAX.get() { + let level = Level::new(raw).unwrap(); + let mapped = compression_level(level); + + assert!(mapped >= previous, "mapping must not decrease at level {raw}"); + assert!( + (CompressionLevel::min().get()..=CompressionLevel::max().get()).contains(&mapped), + "level {raw} mapped outside the range the bundled zstd accepts" + ); + previous = mapped; + } + } + + #[test] + fn the_default_level_maps_to_zstds_own_default() { + // The whole point of anchoring rather than stretching: `Level::DEFAULT` must mean + // "balanced" on every format, and zstd's balanced point is 3, not the middle of 1..=22. + assert_eq!(compression_level(Level::DEFAULT), 3); + assert_eq!(compression_level(Level::MIN), 1); + assert_eq!(compression_level(Level::HIGH), 12); + } + + #[test] + fn the_uninit_output_only_exposes_what_zstd_reported_writing() { + // The adapter's whole job is to hand out uninitialized capacity while never letting anyone + // read past the prefix zstd said it filled. + let mut raw = [MaybeUninit::new(0xff_u8); 8]; + let mut out = UninitOutput::new(&mut raw); + + assert_eq!(zstd_safe::WriteBuf::capacity(&out), 8); + assert!(zstd_safe::WriteBuf::as_slice(&out).is_empty(), "nothing is initialized yet"); + + // SAFETY: the eight bytes were initialized when `raw` was built. + unsafe { zstd_safe::WriteBuf::filled_until(&mut out, 3) }; + + assert_eq!(zstd_safe::WriteBuf::as_slice(&out), &[0xff_u8; 3]); + } + + #[test] + #[should_panic(expected = "zstd reported writing 9 bytes into a 8-byte buffer")] + fn the_uninit_output_refuses_a_count_past_its_capacity() { + // `as_slice` hands out `filled` bytes as initialized, so a count past the end is the + // difference between sound and unsound. It has to fail in release builds too, which is why + // this is an `assert!` rather than a `debug_assert!`, and why it does not clamp: clamping + // would claim the whole buffer was written, which a nonsense report gives no reason to + // believe. + // + // Driven through the safe check rather than through `filled_until`. That method requires + // its caller to have initialized `n` bytes, and nine bytes cannot be initialized in an + // eight-byte allocation, so calling it here could not have honoured its contract -- the + // assertion happening to run first does not make the call legitimate. + let mut raw = [MaybeUninit::new(0xff_u8); 8]; + let out = UninitOutput::new(&mut raw); + + out.check_reported_count(9); + } + + #[test] + fn native_error_helpers_keep_compression_and_decompression_distinct() { + assert!(compression_failed(0).is_invalid_state()); + assert!(decompression_failed(0).is_corrupt_data()); + } + + #[test] + fn every_expressible_configuration_is_accepted_by_the_engine() { + use crate::zstd::WindowLog; + + let mut settings = Zstd::new(); + for level in [CompressionLevel::min(), CompressionLevel::DEFAULT, CompressionLevel::max()] { + settings.level = Some(level); + ZstdCompress::new(Level::DEFAULT, &settings, Pool::disabled().clone()).unwrap(); + } + + for log in [WindowLog::MIN, WindowLog::DEFAULT, WindowLog::MAX] { + let mut settings = Zstd::new(); + settings.max_window_log = Some(log); + ZstdDecompress::new( + FormatLimits::new(None, None, None), + false, + TrailingData::Reject, + &settings, + Pool::disabled().clone(), + ) + .unwrap(); + } + } + + #[test] + fn decompressor_debug_includes_its_policies() { + let codec = ZstdDecompress::new( + FormatLimits::new(None, None, None), + false, + TrailingData::Reject, + &Zstd::new(), + Pool::disabled().clone(), + ) + .unwrap(); + let rendered = format!("{codec:?}"); + + assert!(rendered.contains("trailing_data")); + assert!(rendered.contains("Reject")); + } + + #[test] + fn compressor_debug_includes_its_level() { + let codec = ZstdCompress::new(Level::DEFAULT, &Zstd::new(), Pool::disabled().clone()).unwrap(); + let rendered = format!("{codec:?}"); + + assert!(rendered.contains("ZstdCompress")); + assert!(rendered.contains("level")); + } + + #[test] + fn dropping_a_pooled_compressor_returns_its_context() { + let pool = Pool::new(); + + drop(ZstdCompress::new(Level::DEFAULT, &Zstd::new(), pool.clone()).unwrap()); + + assert!( + pool.take_zstd_compressor().is_some(), + "the context should have been returned to the pool" + ); + } + + #[test] + fn dropping_a_pooled_decompressor_returns_its_context() { + let pool = Pool::new(); + + drop( + ZstdDecompress::new( + FormatLimits::new(None, None, None), + false, + TrailingData::Reject, + &Zstd::new(), + pool.clone(), + ) + .unwrap(), + ); + + assert!( + pool.take_zstd_decompressor().is_some(), + "the context should have been returned to the pool" + ); + } + + #[test] + fn a_flush_reports_continue_until_the_native_buffer_catches_up() { + let mut codec = ZstdCompress::new(Level::DEFAULT, &Zstd::new(), Pool::disabled().clone()).unwrap(); + let mut scratch = [MaybeUninit::uninit(); 4096]; + + let payload = b"zstd flush boundary check payload, repeated so the flush has real work to do. ".repeat(64); + 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 zstd still has buffered output. + 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 zstd to emit another empty flush frame, so the test only issues exactly the + // calls this one flush needs. + let mut generous = [MaybeUninit::uninit(); 4096]; + 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 = ZstdDecompress::new( + FormatLimits::new(None, Some(100), None), + false, + TrailingData::Reject, + &Zstd::new(), + Pool::disabled().clone(), + ) + .unwrap(); + + assert_eq!(Codec::remaining_output(&codec, 40), Some(60)); + assert_eq!(Codec::remaining_output(&codec, 100), Some(0)); + } +} diff --git a/crates/compressors/src/zstd/mod.rs b/crates/compressors/src/zstd/mod.rs new file mode 100644 index 000000000..a7a826bed --- /dev/null +++ b/crates/compressors/src/zstd/mod.rs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Zstandard (RFC 8878): fast compression with ratios well beyond the deflate family. +//! +//! The format behind HTTP `Content-Encoding: zstd`, and the usual choice when both compression time +//! and ratio matter -- though where it wins over the alternatives depends on the payload, so +//! benchmark a representative corpus. Requires the `zstd` cargo feature. +//! +//! Unlike this crate's other formats, zstd is currently provided by a C library compiled from +//! bundled sources, so enabling it requires a C compiler. Builds that leave the feature off stay +//! pure Rust. +//! +//! # Examples +//! +//! ``` +//! use compressors::{Resources, zstd}; +//! +//! let compressed = zstd::compress(b"the quick brown fox", &Resources::default())?; +//! assert_eq!( +//! compressed.range(0..4).to_vec(), +//! vec![0x28, 0xb5, 0x2f, 0xfd] +//! ); +//! +//! assert_eq!( +//! zstd::decompress(compressed, &Resources::default())?.to_vec(), +//! b"the quick brown fox".to_vec() +//! ); +//! # Ok::<(), compressors::Error>(()) +//! ``` + +mod codec; + +use crate::limits::FormatLimits; +use crate::macros::define_format; +use crate::zstd::codec::{ZstdCompress, ZstdDecompress}; + +/// Zstd's default bounds. +/// +/// Zstd has no structural expansion ceiling, so like brotli it needs a far looser ratio than the +/// deflate family. That ratio is a coarse backstop rather than real protection; what bounds +/// untrusted zstd is the cap the buffering conveniences apply, see +/// [`DecompressorLimits`][crate::DecompressorLimits]. +/// +/// The number itself is a policy choice, not a property of the format: any sufficiently +/// compressible input can legitimately expand by a very large factor, so no ratio both admits +/// legitimate data and excludes a bomb. The rule it encodes is "high enough that no realistic +/// payload trips it, low enough to still catch an obviously degenerate stream", and it is one +/// significant figure on purpose -- treat it as adjustable, not as a measured boundary. Anything of +/// the same order of magnitude would serve equally well. +pub(crate) const DEFAULT_LIMITS: FormatLimits = FormatLimits::new(Some(250_000), None, None); + +/// Selects zstd as the format of a [`CompressorBuilder`][crate::CompressorBuilder] or [`DecompressorBuilder`][crate::DecompressorBuilder], and carries +/// the settings only zstd 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 Zstd { + level: Option, + max_window_log: Option, +} + +impl Zstd { + /// The settings a zstd builder starts with: zstd's own defaults, and the portable + /// [`Level`][crate::Level] left in charge of the compression level. + pub(crate) const fn new() -> Self { + Self { + level: None, + max_window_log: None, + } + } +} + +define_format! { + name = "zstd", + format = Zstd, + build_method = build_zstd, + compressor_codec = ZstdCompress, + compressor_build = fallible, + new_compressor = ZstdCompress::new, + decompressor_codec = ZstdDecompress, + decompressor_build = fallible, + default_limits = DEFAULT_LIMITS, + new_decompressor = ZstdDecompress::new, + multi_stream_default = true, +} + +/// A level on zstd's own scale, for reaching settings the portable [`Level`][crate::Level] does not cover. +/// +/// The portable scale is anchored on zstd's default so that [`Level::DEFAULT`][crate::Level::DEFAULT] means the same +/// thing on every format, and it maps onto a positive subset of zstd's range. This type exposes the +/// whole range the bundled library reports, including the negative fast levels below `1` and the +/// strong levels above the portable maximum. +/// +/// Compression time rises steeply towards the strong end while the ratio gain flattens, and where +/// that trade stops being worthwhile depends on the payload and the hardware, so benchmark a +/// representative corpus rather than assuming a level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CompressionLevel(i32); + +impl CompressionLevel { + /// Zstd's own default, which the portable [`Level::DEFAULT`][crate::Level::DEFAULT] also maps to. + pub const DEFAULT: Self = Self(3); + + /// Creates a level in the range supported by the bundled zstd library. + #[must_use] + pub fn new(level: i32) -> Option { + if level < zstd_safe::min_c_level() || level > zstd_safe::max_c_level() { + return None; + } + + Some(Self(level)) + } + + /// The fastest level supported by the bundled zstd library. + #[must_use] + #[inline] + pub fn min() -> Self { + Self(zstd_safe::min_c_level()) + } + + /// The strongest level supported by the bundled zstd library. + #[must_use] + #[inline] + pub fn max() -> Self { + Self(zstd_safe::max_c_level()) + } + + /// Returns the level on zstd's scale. + #[must_use] + pub const fn get(self) -> i32 { + self.0 + } +} + +impl Default for CompressionLevel { + fn default() -> Self { + Self::DEFAULT + } +} + +impl TryFrom for CompressionLevel { + type Error = crate::Error; + + fn try_from(level: i32) -> core::result::Result { + Self::new(level).ok_or_else(|| { + crate::Error::invalid_configuration(format!( + "zstd compression level {level} is out of range; expected {}..={}", + Self::min().0, + Self::max().0 + )) + }) + } +} + +impl From for i32 { + #[inline] + fn from(level: CompressionLevel) -> Self { + level.get() + } +} + +/// The base-2 logarithm of the largest zstd window a decompressor will accept. +/// +/// The default allows windows up to 128 MiB. Lowering it limits decompressor memory at the cost of +/// rejecting streams produced with larger windows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WindowLog(u32); + +impl WindowLog { + /// The smallest zstd window, 1 KiB. + pub const MIN: Self = Self(10); + + /// Zstd's default decompressor limit, 128 MiB. + pub const DEFAULT: Self = Self(27); + + /// The largest supported window on this target. + pub const MAX: Self = Self(if usize::BITS == 32 { 30 } else { 31 }); + + /// Creates a window logarithm accepted by zstd on this target. + #[must_use] + pub const fn new(log: u32) -> Option { + if log < Self::MIN.0 || log > Self::MAX.0 { + None + } else { + Some(Self(log)) + } + } + + /// Returns the base-2 logarithm. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +impl Default for WindowLog { + fn default() -> Self { + Self::DEFAULT + } +} + +impl TryFrom for WindowLog { + type Error = crate::Error; + + fn try_from(log: u32) -> core::result::Result { + Self::new(log).ok_or_else(|| { + crate::Error::invalid_configuration(format!( + "zstd window log {log} is out of range; expected {}..={}", + Self::MIN.get(), + Self::MAX.get() + )) + }) + } +} + +/// Settings that only zstd has. +/// +/// # Examples +/// +/// ``` +/// use compressors::Resources; +/// use compressors::zstd::{self, CompressionLevel}; +/// +/// // A negative fast level -- below the portable scale, so only reachable here. +/// let compressor = zstd::Compressor::builder() +/// .compression_level(CompressionLevel::new(-3).expect("-3 is in range")) +/// .build(&Resources::default())?; +/// # let _ = compressor; +/// # Ok::<(), compressors::BuildError>(()) +/// ``` +impl crate::CompressorBuilder { + /// Sets the level on zstd's own scale, overriding any portable [`Level`][crate::Level]. + /// + /// Use this only when you need a level the portable scale does not reach; prefer + /// [`level`][crate::CompressorBuilder::level] otherwise, so the same configuration keeps working if the + /// format changes. + #[must_use] + pub const fn compression_level(mut self, level: CompressionLevel) -> Self { + self.format.level = Some(level); + self + } +} + +impl crate::DecompressorBuilder { + /// Limits the largest frame window this decompressor accepts. + #[must_use] + pub const fn max_window_log(mut self, max_window_log: WindowLog) -> Self { + self.format.max_window_log = Some(max_window_log); + self + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod configuration_tests { + use super::*; + + #[test] + fn accepts_the_full_native_level_range() { + assert!(CompressionLevel::min().get() < 0); + assert_eq!(CompressionLevel::new(CompressionLevel::min().get()), Some(CompressionLevel::min())); + assert_eq!(CompressionLevel::new(CompressionLevel::max().get()), Some(CompressionLevel::max())); + assert_eq!(CompressionLevel::default(), CompressionLevel::DEFAULT); + assert_eq!( + CompressionLevel::try_from(CompressionLevel::DEFAULT.get()).unwrap(), + CompressionLevel::DEFAULT + ); + assert_eq!(i32::from(CompressionLevel::DEFAULT), CompressionLevel::DEFAULT.get()); + + let error = CompressionLevel::try_from(CompressionLevel::max().get().saturating_add(1)).unwrap_err(); + assert!(error.is_invalid_configuration(), "got {error}"); + } + + #[test] + fn validates_window_logs() { + assert_eq!(WindowLog::new(WindowLog::MIN.get()), Some(WindowLog::MIN)); + assert_eq!(WindowLog::new(WindowLog::MAX.get()), Some(WindowLog::MAX)); + assert_eq!(WindowLog::new(WindowLog::MIN.get() - 1), None); + assert_eq!(WindowLog::new(WindowLog::MAX.get() + 1), None); + assert_eq!(WindowLog::default(), WindowLog::DEFAULT); + assert_eq!(WindowLog::try_from(WindowLog::DEFAULT.get()).unwrap(), WindowLog::DEFAULT); + + let error = WindowLog::try_from(WindowLog::MIN.get() - 1).unwrap_err(); + assert!(error.is_invalid_configuration(), "got {error}"); + } + + #[test] + fn max_window_log_matches_this_targets_pointer_width() { + // Computed independently of `WindowLog::MAX`'s own definition so a mutated comparison + // there cannot hide behind a test that recomputes the same expression. + let expected = if usize::BITS == 32 { 30 } else { 31 }; + assert_eq!(WindowLog::MAX.get(), expected); + } +} diff --git a/crates/compressors/tests/bolero_compressors.rs b/crates/compressors/tests/bolero_compressors.rs new file mode 100644 index 000000000..f4b6eb23e --- /dev/null +++ b/crates/compressors/tests/bolero_compressors.rs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A bounded state-machine campaign over the decompression surface. +//! +//! The deterministic suite covers carefully chosen cuts, chunk sizes and corruption offsets. What +//! it cannot cover is their product: a member boundary landing on a `BytesView` span boundary, on +//! output exhaustion, on an exact limit, or on trailing bytes, all at once. That space is +//! multiplicative, which is what this campaign is for. +//! +//! One target for every backend, deliberately. CI charges per target, and what is interesting here +//! is the interaction between this crate's framing and *some* engine rather than any one of them. +//! +//! Everything goes through the public API. The push/pull state machine is sealed, so this reaches +//! it the way callers do. That is also the surface an attacker reaches: the operation ordering the +//! sealed API would expose is chosen by trusted calling code, never by input bytes. +//! +//! # What is asserted +//! +//! That decompression is *safe*, not that it succeeds. Malformed input is expected to fail; what +//! must never happen is a panic, a non-terminating run, or a bound that quietly stops applying. + +#![expect( + clippy::expect_used, + reason = "a fuzz target reports a violated property by failing loudly, which is the point" +)] + +use std::num::{NonZeroU64, NonZeroUsize}; + +use bolero::TypeGenerator; +use bytesbuf::BytesView; +use compressors::format::Format; +use compressors::{CompressorBuilder, DecompressorBuilder, DecompressorLimits, Level, Resources, TrailingData}; + +/// Bounds the generated payload, so malformed cases stay cheap enough to run many of them. +const MAX_PAYLOAD: usize = 4096; + +/// Bounds how many copies a concatenating mutation makes. +const MAX_COPIES: usize = 4; + +/// A hard ceiling applied to every run regardless of what the scenario asked for. +/// +/// Without it a generated input that happens to be a small expansion bomb would spend the whole +/// campaign budget producing megabytes, and report nothing. The scenario's own bound is applied on +/// top of this, never instead of it. +const HARD_OUTPUT_CEILING: u64 = 1 << 20; + +/// What to do to a valid stream before decompressing it. +#[derive(Debug, Clone, Copy, TypeGenerator)] +enum Mutation { + /// Leave it alone. This is the round-trip case, and the only one with a required output. + None, + /// Cut it short, so the decoder meets end of input mid-stream. + Truncate(u16), + /// Flip one bit, so a checksum or a structural field disagrees with the data. + BitFlip(u16), + /// Append bytes, which is trailing data or another member depending on policy. + Append(u8), + /// Repeat it, which is a concatenated stream to the formats that decode them. + Concatenate(u8), +} + +/// Where to place a bound relative to the value it bounds. +/// +/// The boundary is where an off-by-one lives, so two of these sit exactly on it. +#[derive(Debug, Clone, Copy, TypeGenerator)] +enum LimitChoice { + /// Leave it to the format, and to the buffering fallback. + Unset, + /// Exactly the value being bounded, which must be accepted. + Exact, + /// One below, which must be refused. + JustUnder, + /// Comfortably above. + Generous, +} + +impl LimitChoice { + /// Resolves to a concrete bound around `actual`. + fn resolve(self, actual: u64) -> Option { + let value = match self { + Self::Unset => return None, + Self::Exact => actual, + Self::JustUnder => actual.saturating_sub(1), + Self::Generous => actual.saturating_mul(4).max(1024), + }; + + NonZeroU64::new(value.max(1)) + } +} + +/// One generated decompression. +#[derive(Debug, Clone, TypeGenerator)] +struct Scenario { + /// Selects a compiled format, reduced modulo the number available. + format: u8, + /// Compressed by this crate first when `valid`, otherwise fed in raw. + payload: Vec, + /// Whether `payload` is compressed before being decompressed, or used as it is. + valid: bool, + /// The level used when compressing, reduced into range. + level: u8, + /// Applied to the compressed bytes before decompression. + mutation: Mutation, + /// Splits the input across this many spans, so span and member boundaries interact. + fragments: u8, + /// The decompressor's output chunk size, reduced into range. + output_chunk: u16, + /// Selects an output bound relative to the payload's real size. + output_limit: LimitChoice, + /// Whether concatenated members are decoded. + multi_stream: bool, + /// Whether bytes after a complete stream are an error. + reject_trailing: bool, +} + +#[test] +fn decompression_survives_arbitrary_input() { + bolero::check!().with_type::().for_each(run); +} + +fn run(scenario: &Scenario) { + let Some(&format) = Format::ALL.get(usize::from(scenario.format) % Format::ALL.len().max(1)) else { + return; + }; + + let resources = Resources::default(); + let mut payload = scenario.payload.clone(); + payload.truncate(MAX_PAYLOAD); + + let Some(encoded) = encode(scenario, format, &payload, &resources) else { + return; + }; + + let mutated = mutate(scenario.mutation, &encoded); + + // An unmutated valid stream is the only case with a knowable answer, and only when nothing + // bounds it below its own size. Everything else is allowed to fail; what is checked there is + // that it fails rather than panics or runs away. + let pristine = scenario.valid && matches!(scenario.mutation, Mutation::None); + let natural = payload.len() as u64; + + let input = fragmented(&mutated, scenario.fragments, &resources); + let outcome = decompress(scenario, format, input, natural, &resources); + + match (pristine, scenario.output_limit) { + (true, LimitChoice::Exact | LimitChoice::Generous | LimitChoice::Unset) => { + let produced = outcome.expect("an unmutated stream within its bounds must decode"); + assert_eq!(produced, payload, "a round trip must return what went in"); + } + // `JustUnder` can only be genuinely under when there are at least two bytes to be under: + // a bound must be non-zero, so one less than a one-byte output is still one byte. + (true, LimitChoice::JustUnder) if payload.len() >= 2 => { + let error = outcome.expect_err("a bound below the real output must be refused"); + assert!(error.is_limit_exceeded(), "expected a limit error, got {error}"); + } + _ => { + // Malformed, mutated or degenerate: any outcome is legitimate, and reaching here at all + // is the property -- no panic, and no run that never returned. + drop(outcome); + } + } +} + +/// Produces the bytes to decompress: either a real stream, or the payload used as one. +fn encode(scenario: &Scenario, format: Format, payload: &[u8], resources: &Resources) -> Option> { + if !scenario.valid { + return Some(payload.to_vec()); + } + + let level = Level::new(scenario.level % (Level::MAX.get() + 1))?; + let compressor = CompressorBuilder::new().level(level).build_format(format, resources).ok()?; + + let view = BytesView::copied_from_slice(payload, resources.memory()); + + compressors::compress(view, compressor).ok().map(|encoded| encoded.to_vec()) +} + +/// Applies the generated corruption. +fn mutate(mutation: Mutation, encoded: &[u8]) -> Vec { + let mut bytes = encoded.to_vec(); + + match mutation { + Mutation::None => {} + Mutation::Append(byte) => bytes.push(byte), + Mutation::Concatenate(copies) => { + let original = bytes.clone(); + for _ in 0..(usize::from(copies) % MAX_COPIES) { + bytes.extend_from_slice(&original); + } + } + // The remaining two index into the stream, so an empty one is left as it is. + Mutation::Truncate(at) => { + if let Some(len) = NonZeroUsize::new(bytes.len()) { + bytes.truncate(usize::from(at) % len.get()); + } + } + Mutation::BitFlip(at) => { + if let Some(len) = NonZeroUsize::new(bytes.len()) { + let index = usize::from(at) % len.get(); + bytes[index] ^= 1 << (at % 8); + } + } + } + + bytes +} + +/// Splits `bytes` across spans, so span boundaries and member boundaries can coincide. +fn fragmented(bytes: &[u8], fragments: u8, resources: &Resources) -> BytesView { + let segments = usize::from(fragments) % 16 + 1; + let size = bytes.len().div_ceil(segments).max(1); + + BytesView::from_views( + bytes + .chunks(size) + .map(|chunk| BytesView::copied_from_slice(chunk, resources.memory())), + ) +} + +/// Drives one decompression through the collecting convenience. +fn decompress(scenario: &Scenario, format: Format, input: BytesView, natural: u64, resources: &Resources) -> compressors::Result> { + // The scenario's bound narrows the hard ceiling; it never widens it. + let ceiling = scenario + .output_limit + .resolve(natural) + .map_or(HARD_OUTPUT_CEILING, |chosen| chosen.get().min(HARD_OUTPUT_CEILING)); + + let limits = DecompressorLimits::new().max_output_len(NonZeroU64::new(ceiling.max(1)).expect("clamped to at least one")); + + let trailing = if scenario.reject_trailing { + TrailingData::Reject + } else { + TrailingData::Ignore + }; + + let chunk = NonZeroUsize::new(usize::from(scenario.output_chunk) % 4096 + 1).expect("at least one"); + + let decompressor = DecompressorBuilder::new() + .limits(limits) + .multi_stream(scenario.multi_stream) + .trailing_data(trailing) + .output_chunk_size(chunk) + .build_format(format, resources) + .map_err(|error| compressors::Error::other("the decompressor could not be built", error))?; + + compressors::decompress(input, decompressor).map(|view| view.to_vec()) +} diff --git a/crates/compressors/tests/format.rs b/crates/compressors/tests/format.rs new file mode 100644 index 000000000..cb029edd8 --- /dev/null +++ b/crates/compressors/tests/format.rs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Public format contract in a build with no compression backends enabled. + +#![cfg(not(any(feature = "brotli", feature = "deflate", feature = "gzip", feature = "zlib", feature = "zstd")))] + +use compressors::format::Format; + +#[test] +fn unknown_content_encoding_is_rejected_without_format_features() { + assert_eq!(Format::from_content_encoding("identity"), None); +}