Skip to main content

trg/
inflate.rs

1//! Choosing between streaming and whole-buffer inflate.
2//!
3//! Whole-buffer (libdeflate) allocates the full decompressed size; streaming
4//! (flate2 on zlib-ng) stays around 10 MB. Buffering was once measured ~14x
5//! cheaper on CPU, against the gzip binary and before zlib-ng. Re-measured on
6//! 2026-10-23 (bench/inflate_modes.py) it never used less total CPU than
7//! streaming, so `auto` streams and `--inflate=buffer` is an explicit opt-in.
8//! The buffered path reads the gzip ISIZE trailer as a hint, but correctness
9//! comes from it growing and finally falling back, not from trusting the hint.
10//!
11//! `--inflate-budget` is a **hard ceiling in every mode**, not only `auto`:
12//! explicit `--inflate=buffer` picks the strategy, never a licence to exceed
13//! the budget. Two terms have to fit inside it — the whole compressed file
14//! (`inflate_buffered` must `fs::read` it regardless of the eventual
15//! decompressed size) and the decompressed output — so both the initial
16//! capacity and the retry ceiling are derived from the budget, and a
17//! compressed file bigger than the budget is refused before it is ever read
18//! into memory.
19
20use std::io::{self, Read, Seek, SeekFrom};
21use std::path::Path;
22
23use crate::cli::InflateMode;
24
25/// Below this, buffering buys nothing over streaming, so it is the floor for
26/// both the initial capacity and the effective ceiling — even under a
27/// stingy or zero `--inflate-budget`.
28const MIN_CAPACITY: u64 = 1 << 16; // 64 KiB
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Strategy {
32    Stream,
33    Buffer { capacity: usize, ceiling: usize },
34}
35
36/// Does this file begin with the gzip magic? The buffered path is gzip-only.
37fn is_gzip(path: &Path) -> bool {
38    use std::io::Read;
39    let Ok(mut f) = std::fs::File::open(path) else { return false };
40    let mut m = [0u8; 2];
41    f.read_exact(&mut m).is_ok() && m == [0x1f, 0x8b]
42}
43
44/// Last four bytes of a gzip stream: uncompressed size modulo 2^32.
45pub fn isize_hint(path: &Path) -> Option<u64> {
46    let mut f = std::fs::File::open(path).ok()?;
47    let len = f.metadata().ok()?.len();
48    if len < 18 {
49        return None; // shorter than the smallest possible gzip member
50    }
51    f.seek(SeekFrom::End(-4)).ok()?;
52    let mut b = [0u8; 4];
53    f.read_exact(&mut b).ok()?;
54    Some(u32::from_le_bytes(b) as u64)
55}
56
57/// `budget`, floored to `MIN_CAPACITY`. Applied uniformly to the compressed-
58/// size guard and to the derived capacity/ceiling, so a zero or tiny budget
59/// still leaves room for genuinely small archives to use the fast path
60/// instead of being refused outright.
61fn effective_ceiling(budget: u64) -> u64 {
62    budget.max(MIN_CAPACITY)
63}
64
65pub fn choose(mode: InflateMode, path: &Path, budget: u64) -> Strategy {
66    match mode {
67        InflateMode::Stream => Strategy::Stream,
68        // The whole-buffer path is libdeflate, which is gzip-only, and
69        // `isize_hint` reads gzip's 4-byte trailer. Any other compressed format
70        // streams — which is the safe direction: it costs a slower path, never
71        // a wrong answer.
72        InflateMode::Buffer if !is_gzip(path) => Strategy::Stream,
73        InflateMode::Buffer => buffer_within_budget(path, budget, isize_hint(path)),
74        // Streaming, always. `auto` used to buffer archives whose trailer
75        // fitted the budget, from a measurement that predates zlib-ng; with
76        // zlib-ng streaming, the buffered path never used less total CPU on
77        // any machine measured on 2026-10-23, and on archives inflating to
78        // 42 MB it used 8% (x86-64) to 59% (arm64) more, at 3.6x the memory.
79        // The mode stays a separate variant so the default can change again
80        // on new evidence without changing the CLI.
81        InflateMode::Auto => Strategy::Stream,
82    }
83}
84
85/// Build a `Buffer` strategy whose capacity and ceiling are both derived from
86/// `budget`, falling back to `Stream` when the compressed file itself would
87/// already blow the budget — `inflate_buffered` reads that file whole
88/// (`fs::read`) no matter how the decompressed side turns out, so it is the
89/// other unbounded term and must be checked before anything is read into
90/// memory.
91fn buffer_within_budget(path: &Path, budget: u64, hint: Option<u64>) -> Strategy {
92    let ceiling = effective_ceiling(budget);
93    let compressed = std::fs::metadata(path).map(|m| m.len()).unwrap_or(u64::MAX);
94    if compressed > ceiling {
95        return Strategy::Stream;
96    }
97    let capacity = hint.unwrap_or(1 << 20).min(budget).max(MIN_CAPACITY);
98    Strategy::Buffer { capacity: capacity as usize, ceiling: ceiling as usize }
99}
100
101/// Whole-buffer inflate. Doubles the output buffer and retries while the
102/// decompressor reports insufficient space, up to `ceiling` bytes — an
103/// absolute byte count derived from `--inflate-budget`, not a multiplier of
104/// `capacity`. A lying ISIZE trailer can make this retry a few times, but it
105/// can never make it allocate past the budget the operator set: past
106/// `ceiling` this returns `Err` instead of growing further, and the caller
107/// falls back to streaming.
108pub fn inflate_buffered(path: &Path, capacity: usize, ceiling: usize) -> io::Result<Vec<u8>> {
109    let raw = std::fs::read(path)?;
110    inflate_bytes(&raw, path, capacity, ceiling)
111}
112
113/// The retry loop of [`inflate_buffered`], separated from the read so that a
114/// caller which has already read the compressed bytes — `archive.rs` does,
115/// through a file handle it has run `resource::prepare` on — does not have to
116/// open and read the file a second time. `path` is used only for messages.
117pub fn inflate_bytes(
118    raw: &[u8],
119    path: &Path,
120    capacity: usize,
121    ceiling: usize,
122) -> io::Result<Vec<u8>> {
123    let mut cap = capacity.max(MIN_CAPACITY as usize);
124    let ceiling = ceiling.max(cap);
125
126    // The trailer is the last member's size mod 2^32, so the output is at
127    // least that long. Over the ceiling the attempt is certain to fail, and
128    // making it anyway costs a full decompression before the stream fallback
129    // decompresses everything again.
130    let trailer = raw.last_chunk::<4>().map_or(0, |b| u32::from_le_bytes(*b) as usize);
131    if trailer > ceiling {
132        return Err(io::Error::other(format!(
133            "the {ceiling}-byte inflate budget would be exceeded: \
134             the archive inflates to at least {trailer} bytes"
135        )));
136    }
137
138    loop {
139        let mut d = libdeflater::Decompressor::new();
140        let mut out = vec![0u8; cap];
141        match d.gzip_decompress(raw, &mut out) {
142            Ok(n) => {
143                out.truncate(n);
144                return Ok(out);
145            }
146            Err(libdeflater::DecompressionError::InsufficientSpace) if cap < ceiling => {
147                cap = cap.saturating_mul(4).min(ceiling);
148            }
149            Err(libdeflater::DecompressionError::InsufficientSpace) => {
150                // Not a bug: an undersized budget for this archive is an
151                // expected, silent-fallback case, not an error condition. The
152                // caller (`archive::open_decoded_with`) already frames this
153                // as a fallback, so this message states only the reason.
154                return Err(io::Error::other(format!(
155                    "the {ceiling}-byte inflate budget would be exceeded"
156                )));
157            }
158            Err(e) => {
159                return Err(io::Error::other(format!(
160                    "whole-buffer inflate failed for {}: {e:?}",
161                    path.display()
162                )));
163            }
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::io::Write;
172
173    fn gz(body: &[u8]) -> Vec<u8> {
174        let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
175        e.write_all(body).unwrap();
176        e.finish().unwrap()
177    }
178
179    /// Deliberately poorly-compressible bytes, so a gzip of `n` of these
180    /// stays close to `n` bytes on disk instead of collapsing via run-length
181    /// matches the way `vec![b'a'; n]` would.
182    fn incompressible(n: usize) -> Vec<u8> {
183        let mut s: u64 = 0x2545F4914F6CDD1D;
184        (0..n)
185            .map(|_| {
186                s = s.wrapping_mul(6364136223846793005).wrapping_add(1);
187                (s >> 33) as u8
188            })
189            .collect()
190    }
191
192    fn tmp(bytes: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
193        let d = tempfile::tempdir().unwrap();
194        let p = d.path().join("x.gz");
195        std::fs::write(&p, bytes).unwrap();
196        (d, p)
197    }
198
199    #[test]
200    fn reads_the_isize_trailer() {
201        let body = vec![b'a'; 5000];
202        let (_d, p) = tmp(&gz(&body));
203        assert_eq!(isize_hint(&p), Some(5000));
204    }
205
206    #[test]
207    fn isize_of_a_too_short_file_is_none() {
208        let (_d, p) = tmp(b"tiny");
209        assert_eq!(isize_hint(&p), None);
210    }
211
212    /// Measured 2026-10-23 on x86-64, arm64 and the neh-app container: with
213    /// zlib-ng streaming, the buffered path never used less total CPU, and on
214    /// archives inflating to 42 MB it used 8-59% more. So `auto` streams even
215    /// when the archive would fit the budget.
216    #[test]
217    fn auto_streams_even_when_the_hint_fits_the_budget() {
218        let (_d, p) = tmp(&gz(&vec![b'a'; 5000]));
219        assert!(matches!(
220            choose(crate::cli::InflateMode::Auto, &p, 64 * 1024 * 1024),
221            Strategy::Stream
222        ));
223    }
224
225    #[test]
226    fn explicit_modes_override_the_heuristic() {
227        let (_d, p) = tmp(&gz(&vec![b'a'; 5000]));
228        assert!(matches!(choose(crate::cli::InflateMode::Stream, &p, u64::MAX), Strategy::Stream));
229        assert!(matches!(choose(crate::cli::InflateMode::Buffer, &p, 0), Strategy::Buffer { .. }));
230    }
231
232    /// The trailer is a lower bound on the output, so a trailer over the
233    /// ceiling means the attempt cannot fit: refuse before decompressing
234    /// anything. The body here is corrupt, so only a refusal that never
235    /// touched it can name the budget rather than the bad data.
236    #[test]
237    fn a_trailer_over_the_ceiling_is_refused_without_decompressing() {
238        let mut bytes = gz(&incompressible(200_000));
239        bytes[12] ^= 0xff;
240        bytes[13] ^= 0xff;
241        let err = inflate_bytes(&bytes, Path::new("x.gz"), 1 << 16, 100_000).unwrap_err();
242        assert!(err.to_string().contains("budget"), "decompressed despite the trailer: {err}");
243    }
244
245    #[test]
246    fn buffered_inflate_grows_when_the_hint_was_too_small() {
247        // ISIZE lies (concatenated stream); inflate must still succeed. The
248        // body must exceed the 65_536-byte capacity floor in
249        // `inflate_buffered`, or the grow/retry loop never actually runs and
250        // this test would pass without exercising it.
251        let (_d, p) = tmp(&gz(&vec![b'z'; 200_000]));
252        let out = inflate_buffered(&p, 16, 1 << 20).unwrap();
253        assert_eq!(out.len(), 200_000);
254    }
255
256    // --- Fix round 1: --inflate-budget is a hard ceiling in every mode ---
257
258    #[test]
259    fn buffer_mode_capacity_and_ceiling_never_exceed_the_budget() {
260        let (_d, p) = tmp(&gz(&vec![b'a'; 500_000]));
261        let budget = 10_000u64;
262        match choose(InflateMode::Buffer, &p, budget) {
263            Strategy::Buffer { capacity, ceiling } => {
264                let bound = budget.max(1 << 16);
265                assert!(capacity as u64 <= bound, "capacity {capacity} exceeds budget-derived bound {bound}");
266                assert!(ceiling as u64 <= bound, "ceiling {ceiling} exceeds budget-derived bound {bound}");
267            }
268            Strategy::Stream => {} // also an acceptable outcome per the compressed-size guard
269        }
270    }
271
272    #[test]
273    fn a_compressed_file_larger_than_the_budget_selects_stream() {
274        // Explicit `buffer` mode never runs the `auto` heuristic's
275        // hint-vs-compressed "implausible" check, so this is the case that
276        // isolates the compressed-file-size guard itself: a large,
277        // incompressible body keeps the on-disk gzip well above the budget's
278        // effective ceiling even though the mode is forced.
279        let (_d, p) = tmp(&gz(&incompressible(200_000)));
280        assert!(matches!(choose(InflateMode::Buffer, &p, 1024), Strategy::Stream));
281    }
282
283    #[test]
284    fn inflate_budget_zero_still_means_never_buffer_in_auto() {
285        let (_d, p) = tmp(&gz(&vec![b'a'; 5000]));
286        assert!(matches!(choose(InflateMode::Auto, &p, 0), Strategy::Stream));
287    }
288}