Skip to content

Commit 01d346c

Browse files
committed
Prove compression stream finalization
1 parent ec94072 commit 01d346c

5 files changed

Lines changed: 79 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ behavior when the change improves security or project direction.
8484
values, and require trusted immutable MMDB file paths during loading.
8585
- Pin publishing actions, security-tool installs, and container base images to
8686
immutable reviewed versions and digests.
87-
- Bound Brotli, gzip, and Zstandard output before allocation growth, avoid a
88-
second body copy when draining codec buffers, and discard failed encoders.
87+
- Bound Brotli, gzip, and Zstandard logical output before accepting excess
88+
encoded bytes, avoid a second body copy when draining codec buffers, and
89+
discard failed encoders.
8990
- Fail malformed `Accept-Encoding` negotiation closed, honor explicit coding
9091
rejection over wildcard acceptance, and treat qualified `private` cache
9192
directives as compression-blocking security policy.

crates/fluxheim-compression/src/compression_tests.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use super::{
55
};
66

77
#[cfg(any(feature = "brotli", feature = "gzip", feature = "zstd"))]
8-
use std::io;
8+
use std::io::{self, Read as _};
99

1010
#[cfg(any(feature = "brotli", feature = "gzip", feature = "zstd"))]
1111
use bytes::Bytes;
@@ -97,6 +97,40 @@ fn gzip_encoder_releases_retained_output_between_chunks() {
9797
assert_eq!(gzip.get_ref().buffer_len(), 0);
9898
}
9999

100+
#[cfg(feature = "gzip")]
101+
#[test]
102+
fn gzip_encoder_finalizes_decodable_multi_chunk_stream() {
103+
let compressed = encode_multi_chunk_stream(ResponseCompressionEncoder::gzip(4, 64 * 1024));
104+
let mut decoded = Vec::new();
105+
flate2::read::GzDecoder::new(compressed.as_slice())
106+
.read_to_end(&mut decoded)
107+
.unwrap();
108+
109+
assert_eq!(decoded, multi_chunk_plaintext());
110+
}
111+
112+
#[cfg(feature = "brotli")]
113+
#[test]
114+
fn brotli_encoder_finalizes_decodable_multi_chunk_stream() {
115+
let compressed = encode_multi_chunk_stream(ResponseCompressionEncoder::brotli(5, 64 * 1024));
116+
let mut decoded = Vec::new();
117+
brotli::Decompressor::new(compressed.as_slice(), 4096)
118+
.read_to_end(&mut decoded)
119+
.unwrap();
120+
121+
assert_eq!(decoded, multi_chunk_plaintext());
122+
}
123+
124+
#[cfg(feature = "zstd")]
125+
#[test]
126+
fn zstd_encoder_finalizes_decodable_multi_chunk_stream() {
127+
let compressed =
128+
encode_multi_chunk_stream(ResponseCompressionEncoder::zstd(3, 64 * 1024).unwrap());
129+
let decoded = zstd::stream::decode_all(compressed.as_slice()).unwrap();
130+
131+
assert_eq!(decoded, multi_chunk_plaintext());
132+
}
133+
100134
#[cfg(feature = "gzip")]
101135
#[test]
102136
fn gzip_limit_failure_discards_encoder() {
@@ -139,3 +173,25 @@ fn assert_limit_failure_discards_encoder(encoder: &mut ResponseCompressionEncode
139173
assert_eq!(error.io_kind(), Some(io::ErrorKind::InvalidData));
140174
assert!(encoder.encode_chunk(Some(&input), true).unwrap().is_empty());
141175
}
176+
177+
#[cfg(any(feature = "brotli", feature = "gzip", feature = "zstd"))]
178+
fn encode_multi_chunk_stream(mut encoder: ResponseCompressionEncoder) -> Vec<u8> {
179+
let mut compressed = Vec::new();
180+
compressed.extend_from_slice(
181+
&encoder
182+
.encode_chunk(Some(b"first response segment; "), false)
183+
.unwrap(),
184+
);
185+
compressed.extend_from_slice(
186+
&encoder
187+
.encode_chunk(Some(b"second response segment; "), false)
188+
.unwrap(),
189+
);
190+
compressed.extend_from_slice(&encoder.encode_chunk(None, true).unwrap());
191+
compressed
192+
}
193+
194+
#[cfg(any(feature = "brotli", feature = "gzip", feature = "zstd"))]
195+
fn multi_chunk_plaintext() -> &'static [u8] {
196+
b"first response segment; second response segment; "
197+
}

docs/compression.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ The first codec implementation is intentionally bounded:
8888
- encoded output must stay within `compression.max_output_bytes`;
8989
- `compression.max_input_bytes` is capped at 64 MiB by config validation;
9090
- `compression.max_output_bytes` is capped at 128 MiB by config validation;
91-
- every codec writes through a bounded sink that rejects output before buffer
92-
growth would cross `compression.max_output_bytes`;
91+
- every codec writes through a bounded sink that rejects a write before the
92+
logical encoded length would cross `compression.max_output_bytes`;
9393
- emitted chunks transfer their owned allocation into `Bytes` without copying,
9494
and a codec is discarded permanently after an output or allocation failure;
9595
- gzip levels are restricted to `0..=9`;
@@ -101,6 +101,13 @@ The first codec implementation is intentionally bounded:
101101
A later implementation may add bounded compression worker pools, per-vhost
102102
concurrency, and precompressed static asset variants.
103103

104+
`max_output_bytes` is an encoded-byte limit, not an exact per-request RSS
105+
ceiling. The Rust allocator may reserve more `Vec` capacity than the current
106+
logical length, and each codec has internal CPU and working-memory costs that
107+
are separate from its output sink. Use route/vhost concurrency controls to
108+
bound aggregate request pressure until a dedicated compression worker pool is
109+
available.
110+
104111
## Cache Integration
105112

106113
Future shared-cache compression variants must be cache-isolated by:

docs/config-reference.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2006,10 +2006,12 @@ response rather than enabling an encoding.
20062006

20072007
`min_bytes` and `max_input_bytes` bound the original response size.
20082008
`max_output_bytes` bounds the encoded response size with a codec sink that
2009-
rejects writes before allocating beyond the limit. Emitted output allocations
2010-
are transferred without a second body copy, and an encoder is discarded after
2011-
any output-limit or allocation failure. The configured input maximum cannot
2012-
exceed 64 MiB and the output maximum cannot exceed 128 MiB.
2009+
rejects a write before its logical output length exceeds the limit. It is not
2010+
an exact RSS ceiling: the allocator may reserve additional `Vec` capacity and
2011+
codec-internal working memory is separate. Emitted output allocations are
2012+
transferred without a second body copy, and an encoder is discarded after any
2013+
output-limit or allocation failure. The configured input maximum cannot exceed
2014+
64 MiB and the output maximum cannot exceed 128 MiB.
20132015
`gzip_level` must be between `0` and `9`, `zstd_level` between `1` and `19`,
20142016
and `brotli_quality` between `0` and `11`.
20152017

release-notes/RELEASE_NOTES_1.7.7.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,10 @@ deterministic unsupported-call rejection.
9494
descriptor identity and reject config files modified during bounded reads.
9595
- Restore the all-feature config security suite, including tracing/privacy and
9696
dual-FIPS-backend feature combinations.
97-
- Bound Brotli, gzip, and Zstandard output before allocation growth, transfer
98-
emitted codec buffers into response bytes without copying, and permanently
99-
discard a codec after an output or allocation failure.
97+
- Bound Brotli, gzip, and Zstandard logical output before accepting excess
98+
encoded bytes, transfer emitted codec buffers into response bytes without
99+
copying, and permanently discard a codec after an output or allocation
100+
failure.
100101
- Fail response compression closed for malformed `Accept-Encoding` fields,
101102
honor explicit coding rejection over wildcard acceptance, and suppress
102103
compression for qualified `Cache-Control: private="..."` responses.

0 commit comments

Comments
 (0)