Skip to main content

trg/
inflate.rs

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