Skip to main content

stuffr_core/source/
limit.rs

1//! Decompression-bomb protection.
2//!
3//! Two pieces rather than one, because the codec sits between them: [`Counting`]
4//! wraps the source and tallies compressed bytes going in, and [`RatioGuard`]
5//! is called by the copy loop with decompressed bytes coming out. Once output
6//! outgrows input implausibly, the decode is refused.
7
8use std::io::{Read, Write};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use super::{SeekRead, Source, SourceCaps};
13use crate::error::{Error, Result};
14
15/// Output below which the ratio is not enforced.
16///
17/// At stream start the input tally reflects a partially-filled buffer while
18/// output can already be substantial, so a naive check trips on legitimate
19/// files. No plausible bomb has done damage under a megabyte.
20pub const RATIO_FLOOR: u64 = 1024 * 1024;
21
22/// Default expansion ratio past which a decode is refused.
23///
24/// An order of magnitude above what a single codec can achieve: deflate's
25/// structural ceiling is roughly 1032:1, because its maximum match is 258
26/// bytes, and ordinary all-zeros input reaches 1014-1027:1. A limit at 1000
27/// would therefore reject legitimate sparse files while being unable to catch
28/// a single-member bomb, since deflate cannot produce one. The catastrophic
29/// cases are nested and archive bombs reaching millions-to-one.
30pub const DEFAULT_MAX_RATIO: u64 = 10_000;
31
32/// A [`Source`] that tallies the bytes it has served.
33///
34/// Capabilities are forwarded unchanged, so wrapping does not push a read down
35/// a ladder rung. A caller that seeks makes the tally approximate; that is
36/// acceptable because the guard exists to catch pathological expansion, where
37/// approximation is irrelevant.
38pub struct Counting {
39    inner: Box<dyn Source>,
40    count: Arc<AtomicU64>,
41}
42
43impl Counting {
44    /// Returns the wrapper and a handle to its running tally.
45    pub fn new(inner: Box<dyn Source>) -> (Self, Arc<AtomicU64>) {
46        let count = Arc::new(AtomicU64::new(0));
47        (
48            Self {
49                inner,
50                count: Arc::clone(&count),
51            },
52            count,
53        )
54    }
55}
56
57impl Read for Counting {
58    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
59        let n = self.inner.read(buf)?;
60        self.count.fetch_add(n as u64, Ordering::Relaxed);
61        Ok(n)
62    }
63}
64
65impl Source for Counting {
66    fn caps(&self) -> SourceCaps {
67        self.inner.caps()
68    }
69
70    fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
71        self.inner.as_seek()
72    }
73}
74
75/// Counts bytes on their way out, so a caller can report `bytes_out` correctly
76/// even for a destination with no length — stdout, notably.
77///
78/// The write-side twin of [`Counting`]. Both live here so Phase 2's containers,
79/// which need to account for bytes in both directions per entry, find one
80/// mechanism rather than re-inventing this one.
81pub struct CountingWriter {
82    inner: Box<dyn Write + Send>,
83    count: Arc<AtomicU64>,
84}
85
86impl CountingWriter {
87    /// Returns the wrapper and a handle to its running tally.
88    pub fn new(inner: Box<dyn Write + Send>) -> (Self, Arc<AtomicU64>) {
89        let count = Arc::new(AtomicU64::new(0));
90        (
91            Self {
92                inner,
93                count: Arc::clone(&count),
94            },
95            count,
96        )
97    }
98}
99
100impl Write for CountingWriter {
101    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
102        let n = self.inner.write(buf)?;
103        self.count.fetch_add(n as u64, Ordering::Relaxed);
104        Ok(n)
105    }
106
107    fn flush(&mut self) -> std::io::Result<()> {
108        self.inner.flush()
109    }
110}
111
112/// Refuses a decode whose output outgrows its input implausibly.
113///
114/// Deliberately not a `Read` wrapper: `Read::read` can only return
115/// `std::io::Error`, so a reader-based guard would box a typed error inside an
116/// io error and force the caller to downcast it back. The copy loop calls
117/// [`Self::record`] instead, and `Error::ResourceLimit` reaches `exit_code()`
118/// as 6 without that round trip.
119pub struct RatioGuard {
120    input: Arc<AtomicU64>,
121    produced: u64,
122    max_ratio: u64,
123}
124
125impl RatioGuard {
126    /// Creates a new guard watching input tally from `input` and limiting output to `max_ratio`.
127    pub fn new(input: Arc<AtomicU64>, max_ratio: u64) -> Self {
128        Self {
129            input,
130            produced: 0,
131            max_ratio,
132        }
133    }
134
135    /// Records `n` bytes of output, failing if the expansion is implausible.
136    pub fn record(&mut self, n: usize) -> Result<()> {
137        self.produced = self.produced.saturating_add(n as u64);
138        if self.produced <= RATIO_FLOOR {
139            return Ok(());
140        }
141        // `.max(1)` guards the division: a source that has served nothing yet
142        // while output is already past the floor is itself the pathological case.
143        let consumed = self.input.load(Ordering::Relaxed).max(1);
144        let ratio = self.produced / consumed;
145        if ratio > self.max_ratio {
146            return Err(Error::ResourceLimit(format!(
147                "expansion ratio {ratio}:1 exceeds the {}:1 limit \
148                 ({consumed} bytes read, {} produced); raise --max-ratio if this is genuine",
149                self.max_ratio, self.produced
150            )));
151        }
152        Ok(())
153    }
154
155    /// Total output recorded so far.
156    pub fn produced(&self) -> u64 {
157        self.produced
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::source::ReaderSource;
165
166    fn src(bytes: Vec<u8>) -> Box<dyn Source> {
167        Box::new(ReaderSource::new(std::io::Cursor::new(bytes)))
168    }
169
170    #[test]
171    fn counting_tallies_what_it_serves_and_passes_bytes_through() {
172        let (mut c, count) = Counting::new(src(b"0123456789".to_vec()));
173        let mut out = Vec::new();
174        c.read_to_end(&mut out).unwrap();
175        assert_eq!(out, b"0123456789");
176        assert_eq!(count.load(Ordering::Relaxed), 10);
177    }
178
179    #[test]
180    fn counting_forwards_the_inner_capabilities() {
181        // Wrapping must not change which ladder rung the source reaches.
182        let (c, _) = Counting::new(src(b"x".to_vec()));
183        assert!(!c.caps().seekable);
184    }
185
186    #[test]
187    fn the_guard_ignores_everything_below_the_floor() {
188        // At stream start the input tally reflects a partly-filled buffer while
189        // output can already be substantial. Enforcing there fails legitimate
190        // files, and nothing under a megabyte has done damage.
191        let input = Arc::new(AtomicU64::new(1));
192        let mut g = RatioGuard::new(Arc::clone(&input), 10);
193        // 1 byte in, RATIO_FLOOR out — a 1_048_576:1 ratio, still not enforced.
194        assert!(g.record(RATIO_FLOOR as usize).is_ok());
195    }
196
197    #[test]
198    fn the_guard_refuses_a_pathological_expansion_past_the_floor() {
199        let input = Arc::new(AtomicU64::new(1));
200        let mut g = RatioGuard::new(Arc::clone(&input), 1000);
201        assert!(g.record(RATIO_FLOOR as usize).is_ok());
202        let err = g.record(1024).unwrap_err();
203        assert!(matches!(err, Error::ResourceLimit(_)));
204        assert_eq!(err.exit_code(), 6, "a bomb must exit 6, not 1");
205        let msg = err.to_string();
206        assert!(
207            msg.contains("1000"),
208            "the message must name the limit: {msg}"
209        );
210    }
211
212    #[test]
213    fn a_legitimate_ratio_passes_indefinitely() {
214        // 4:1 is ordinary text compression and must never trip.
215        let input = Arc::new(AtomicU64::new(0));
216        let mut g = RatioGuard::new(Arc::clone(&input), 1000);
217        for _ in 0..64 {
218            input.fetch_add(64 * 1024, Ordering::Relaxed);
219            g.record(256 * 1024).unwrap();
220        }
221    }
222
223    #[test]
224    fn a_zero_input_tally_does_not_divide_by_zero() {
225        let input = Arc::new(AtomicU64::new(0));
226        let mut g = RatioGuard::new(input, 1000);
227        // Output past the floor with nothing counted in: must error, not panic.
228        assert!(g.record(RATIO_FLOOR as usize + 1).is_err());
229    }
230
231    #[test]
232    fn counting_writer_tallies_every_byte_including_a_short_write() {
233        // A writer that accepts only part of each buffer, which write_all
234        // handles by looping. The tally must follow what was ACCEPTED, not what
235        // was offered, or bytes_out over-reports on any writer that short-writes.
236        struct Short;
237        impl std::io::Write for Short {
238            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
239                Ok(b.len().min(3))
240            }
241            fn flush(&mut self) -> std::io::Result<()> {
242                Ok(())
243            }
244        }
245
246        let (mut w, count) = CountingWriter::new(Box::new(Short));
247        w.write_all(b"0123456789").unwrap();
248        assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 10);
249    }
250}