Skip to main content

stuffr_core/source/
spill.rs

1//! Rung 3 of the ladder: spool a non-seekable input so it becomes seekable.
2//!
3//! Spilling costs disk and latency, never accuracy — which is why
4//! [`crate::Rung::Spilled`] counts as authoritative.
5
6use std::io::{Cursor, Read, Seek, Write};
7use std::path::PathBuf;
8
9use super::{GuardedSeek, SeekRead, Source, SourceCaps};
10use crate::error::{Error, Result};
11
12pub const DEFAULT_MEM_CAP: u64 = 64 * 1024 * 1024;
13pub const DEFAULT_MAX_SPILL: u64 = 2 * 1024 * 1024 * 1024;
14
15#[derive(Clone, PartialEq, Eq, Debug)]
16pub enum SpillPolicy {
17    /// Never spool. Collapses the ladder from four rungs to three.
18    Off,
19    /// Spool in memory only, failing past `cap`.
20    Memory { cap: u64 },
21    /// Spool in memory up to `mem_cap`, then escalate to a temp file, failing
22    /// past `max`.
23    Temp {
24        dir: Option<PathBuf>,
25        mem_cap: u64,
26        max: u64,
27    },
28}
29
30impl Default for SpillPolicy {
31    fn default() -> Self {
32        SpillPolicy::Temp {
33            dir: None,
34            mem_cap: DEFAULT_MEM_CAP,
35            max: DEFAULT_MAX_SPILL,
36        }
37    }
38}
39
40impl SpillPolicy {
41    pub fn is_enabled(&self) -> bool {
42        !matches!(self, SpillPolicy::Off)
43    }
44
45    fn hard_limit(&self) -> u64 {
46        match self {
47            SpillPolicy::Off => 0,
48            SpillPolicy::Memory { cap } => *cap,
49            SpillPolicy::Temp { max, .. } => *max,
50        }
51    }
52}
53
54#[derive(Debug)]
55enum Backing {
56    Mem(GuardedSeek<Cursor<Vec<u8>>>),
57    File(GuardedSeek<std::fs::File>),
58}
59
60/// A seekable source produced by draining a forward-only one.
61#[derive(Debug)]
62pub struct SpillSource {
63    backing: Backing,
64    len: u64,
65    on_disk: bool,
66}
67
68impl SpillSource {
69    /// Drains `src` into a seekable backing store, eagerly.
70    ///
71    /// Eager rather than lazy: a container that asked for seek is about to read
72    /// the trailing index, so it will need the whole stream regardless, and the
73    /// eager path has no partially-materialized states to get wrong.
74    pub fn materialize(mut src: Box<dyn Source>, policy: &SpillPolicy) -> Result<Self> {
75        Self::materialize_from(&mut *src, policy)
76    }
77
78    /// [`Self::materialize`] from any reader, borrowed: the one owner of the
79    /// spill loop and its limits, for a caller whose bytes are not a
80    /// [`Source`] — an archive entry's payload borrows its archive, so it can
81    /// be neither boxed as `'static` nor sent. `stuffr convert` buffers an
82    /// entry of unknown size through this, under the same policy.
83    pub fn materialize_from<R: Read + ?Sized>(src: &mut R, policy: &SpillPolicy) -> Result<Self> {
84        let limit = policy.hard_limit();
85        if !policy.is_enabled() {
86            return Err(Error::SpillLimitExceeded { limit: 0 });
87        }
88
89        let mem_cap = match policy {
90            SpillPolicy::Off => 0,
91            SpillPolicy::Memory { cap } => *cap,
92            SpillPolicy::Temp { mem_cap, .. } => *mem_cap,
93        };
94
95        let mut mem: Vec<u8> = Vec::new();
96        let mut buf = vec![0u8; 64 * 1024];
97        let mut total: u64 = 0;
98
99        // Phase 1: fill memory up to mem_cap.
100        loop {
101            let n = src.read(&mut buf)?;
102            if n == 0 {
103                return Ok(Self {
104                    len: total,
105                    backing: Backing::Mem(GuardedSeek::new(Cursor::new(mem))),
106                    on_disk: false,
107                });
108            }
109            total += n as u64;
110            if total > limit {
111                return Err(Error::SpillLimitExceeded { limit });
112            }
113            if total > mem_cap {
114                mem.extend_from_slice(&buf[..n]);
115                break;
116            }
117            mem.extend_from_slice(&buf[..n]);
118        }
119
120        // Phase 2: escalate to a temp file, carrying the memory buffer over.
121        let SpillPolicy::Temp { dir, .. } = policy else {
122            // Defensive, unreachable while `mem_cap == hard_limit()` holds for
123            // `Memory`: the `total > limit` check above always fires first, so
124            // the Phase-1 loop never breaks into this arm for that policy.
125            return Err(Error::SpillLimitExceeded { limit });
126        };
127
128        let mut file = match dir {
129            Some(d) => tempfile::tempfile_in(d)?,
130            None => tempfile::tempfile()?,
131        };
132        file.write_all(&mem)?;
133        drop(mem);
134
135        loop {
136            let n = src.read(&mut buf)?;
137            if n == 0 {
138                break;
139            }
140            total += n as u64;
141            if total > limit {
142                return Err(Error::SpillLimitExceeded { limit });
143            }
144            file.write_all(&buf[..n])?;
145        }
146
147        file.flush()?;
148        file.seek(std::io::SeekFrom::Start(0))?;
149        Ok(Self {
150            backing: Backing::File(GuardedSeek::new(file)),
151            len: total,
152            on_disk: true,
153        })
154    }
155
156    /// Whether the spool escalated past the memory cap. Surfaced by `stuffr info`.
157    pub fn spilled_to_disk(&self) -> bool {
158        self.on_disk
159    }
160}
161
162impl Read for SpillSource {
163    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
164        match &mut self.backing {
165            Backing::Mem(c) => c.read(buf),
166            Backing::File(f) => f.read(buf),
167        }
168    }
169}
170
171impl Source for SpillSource {
172    fn caps(&self) -> SourceCaps {
173        SourceCaps {
174            seekable: true,
175            len: Some(self.len),
176        }
177    }
178
179    fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
180        match &mut self.backing {
181            Backing::Mem(c) => Some(c),
182            Backing::File(f) => Some(f),
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use crate::source::ReaderSource;
190    use std::io::{Read, SeekFrom};
191
192    use super::*;
193
194    fn pipe(bytes: Vec<u8>) -> Box<dyn crate::source::Source> {
195        Box::new(ReaderSource::new(std::io::Cursor::new(bytes)))
196    }
197
198    #[test]
199    fn small_input_stays_in_memory_and_becomes_seekable() {
200        let mut s = SpillSource::materialize(
201            pipe(b"0123456789".to_vec()),
202            &SpillPolicy::Temp {
203                dir: None,
204                mem_cap: 1024,
205                max: 1 << 20,
206            },
207        )
208        .unwrap();
209
210        assert!(!s.spilled_to_disk());
211        assert!(s.caps().seekable);
212        assert_eq!(s.caps().len, Some(10));
213
214        let seek = s.as_seek().unwrap();
215        seek.seek(SeekFrom::Start(7)).unwrap();
216        let mut buf = [0u8; 3];
217        seek.read_exact(&mut buf).unwrap();
218        assert_eq!(&buf, b"789");
219    }
220
221    #[test]
222    fn input_over_the_memory_cap_escalates_to_a_temp_file() {
223        let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
224        let mut s = SpillSource::materialize(
225            pipe(data.clone()),
226            &SpillPolicy::Temp {
227                dir: None,
228                mem_cap: 64,
229                max: 1 << 20,
230            },
231        )
232        .unwrap();
233
234        assert!(s.spilled_to_disk());
235        assert_eq!(s.caps().len, Some(4096));
236
237        // Escalation must not lose or reorder the bytes already buffered.
238        let mut out = Vec::new();
239        s.read_to_end(&mut out).unwrap();
240        assert_eq!(out, data);
241    }
242
243    #[test]
244    fn memory_only_policy_refuses_to_exceed_its_cap() {
245        let err = SpillSource::materialize(pipe(vec![7u8; 500]), &SpillPolicy::Memory { cap: 100 })
246            .unwrap_err();
247
248        // Never a silent truncation: a stream too big to spool must say so.
249        assert!(matches!(
250            err,
251            crate::Error::SpillLimitExceeded { limit: 100 }
252        ));
253        assert_eq!(err.exit_code(), 6);
254    }
255
256    #[test]
257    fn max_spill_is_enforced_for_the_temp_policy_too() {
258        let err = SpillSource::materialize(
259            pipe(vec![7u8; 5000]),
260            &SpillPolicy::Temp {
261                dir: None,
262                mem_cap: 16,
263                max: 1000,
264            },
265        )
266        .unwrap_err();
267        assert!(matches!(
268            err,
269            crate::Error::SpillLimitExceeded { limit: 1000 }
270        ));
271    }
272
273    #[test]
274    fn off_policy_is_not_enabled_and_cannot_materialize() {
275        assert!(!SpillPolicy::Off.is_enabled());
276        assert!(SpillPolicy::default().is_enabled());
277        let err = SpillSource::materialize(pipe(b"x".to_vec()), &SpillPolicy::Off).unwrap_err();
278        assert!(matches!(err, crate::Error::SpillLimitExceeded { limit: 0 }));
279    }
280
281    #[test]
282    fn defaults_match_the_spec() {
283        match SpillPolicy::default() {
284            SpillPolicy::Temp { mem_cap, max, dir } => {
285                assert_eq!(mem_cap, 64 * 1024 * 1024);
286                assert_eq!(max, 2 * 1024 * 1024 * 1024);
287                assert!(dir.is_none());
288            }
289            other => panic!("unexpected default: {other:?}"),
290        }
291    }
292
293    #[test]
294    fn empty_input_materializes_to_an_empty_seekable_source() {
295        let mut s = SpillSource::materialize(pipe(Vec::new()), &SpillPolicy::default()).unwrap();
296        assert_eq!(s.caps().len, Some(0));
297        let mut out = Vec::new();
298        s.read_to_end(&mut out).unwrap();
299        assert!(out.is_empty());
300    }
301
302    #[test]
303    fn escalation_preserves_order_across_many_sub_cap_chunks() {
304        // A Cursor hands over the whole input in one read, so the multi-chunk
305        // accumulate-then-escalate path never runs. Force 100-byte reads so the
306        // memory buffer genuinely grows across several iterations before the
307        // temp file takes over.
308        let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
309        let src: Box<dyn Source> = Box::new(crate::source::ReaderSource::new(
310            crate::testing::ChunkedReader::new(data.clone(), 100),
311        ));
312        let mut s = SpillSource::materialize(
313            src,
314            &SpillPolicy::Temp {
315                dir: None,
316                mem_cap: 1000,
317                max: 1 << 20,
318            },
319        )
320        .unwrap();
321
322        assert!(s.spilled_to_disk());
323        let mut out = Vec::new();
324        s.read_to_end(&mut out).unwrap();
325        assert_eq!(out, data, "bytes must survive escalation in order");
326    }
327}