Skip to main content

stuffr_core/
ladder.rs

1//! The adaptive stream ladder.
2//!
3//! A container never opens a file. It declares what it needs via
4//! [`ContainerCaps`] and this module supplies the highest reachable rung,
5//! together with the fidelity cost of getting there.
6
7use crate::error::{Error, Result};
8use crate::fidelity::{Fidelity, FidelityReport, Rung};
9use crate::format::{ContainerCaps, FormatId};
10use crate::source::{Source, SpillPolicy, SpillSource};
11
12#[derive(Clone, PartialEq, Eq, Debug)]
13pub enum StreamPolicy {
14    /// Require an authoritative read. Errors rather than approximating.
15    Exact,
16    /// The full ladder. Each rung can be individually disabled.
17    Adaptive {
18        allow_forward_only: bool,
19        spill: SpillPolicy,
20        allow_degraded: bool,
21    },
22    /// Never seek and never spool. Constant memory, single pass.
23    ForwardOnly,
24}
25
26impl Default for StreamPolicy {
27    fn default() -> Self {
28        StreamPolicy::Adaptive {
29            allow_forward_only: true,
30            spill: SpillPolicy::default(),
31            allow_degraded: true,
32        }
33    }
34}
35
36/// A source prepared for a specific container, with the cost of preparing it.
37///
38/// `#[non_exhaustive]`: this struct is constructed only inside `stuffr-core`
39/// (see the constructors in this module) and destructured everywhere else —
40/// every `Container::open` impl will do `let Resolved { source, rung, report
41/// } = resolved;`. A struct in that shape — destructured downstream, never
42/// constructed — takes the attribute for free: there is no
43/// `..Default::default()` literal to break, only a future field to add
44/// without breaking every format crate's destructure at once. See the
45/// `#[non_exhaustive]` section of CONTRIBUTING.md.
46#[non_exhaustive]
47pub struct Resolved {
48    pub source: Box<dyn Source>,
49    pub rung: Rung,
50    pub report: FidelityReport,
51}
52
53// Manual impl: `Source` carries no `Debug` bound, so `Box<dyn Source>` cannot
54// derive it. Needed for `Result<Resolved, _>::unwrap_err()` in the tests below.
55impl std::fmt::Debug for Resolved {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("Resolved")
58            .field("source", &"<dyn Source>")
59            .field("rung", &self.rung)
60            .field("report", &self.report)
61            .finish()
62    }
63}
64
65/// Referenced by `resolve` for the `ForwardOnly` policy. A `static` rather than
66/// an inline `&SpillPolicy::Off`: `SpillPolicy` owns a `PathBuf`, so it has a
67/// `Drop` impl, rvalue static promotion does not apply, and the temporary would
68/// not outlive the borrow.
69static NO_SPILL: SpillPolicy = SpillPolicy::Off;
70
71/// Seeds the report with the losses implied by reading `caps` at `rung`.
72///
73/// Only capability-derived warnings belong here. Per-entry findings are added
74/// by the container as it parses, by calling [`FidelityReport::warn`] directly
75/// on the report it inherited from [`Resolved`] — not by building a second
76/// report and folding it in with `merge`.
77fn seed_report(rung: Rung, format: FormatId, caps: ContainerCaps) -> FidelityReport {
78    let mut report = FidelityReport::new(rung);
79    if rung.is_authoritative() {
80        return report;
81    }
82    if caps.trailing_index {
83        report.warn(Fidelity::TrailingIndexUnread { format });
84        report.warn(Fidelity::EntryCountUnknown);
85    }
86    report
87}
88
89/// Places `src` on the highest ladder rung reachable under `policy`.
90///
91/// Order: Exact → ForwardOnly → Spilled → Degraded. ForwardOnly is preferred
92/// over Spilled so that piping a zip into a grep does not touch the disk. A
93/// user who wants full fidelity instead opts out via `allow_forward_only: false`.
94pub fn resolve(
95    src: Box<dyn Source>,
96    format: FormatId,
97    caps: ContainerCaps,
98    policy: &StreamPolicy,
99) -> Result<Resolved> {
100    // Rung 1: already seekable.
101    if src.caps().seekable {
102        return Ok(Resolved {
103            source: src,
104            rung: Rung::Exact,
105            report: FidelityReport::exact(),
106        });
107    }
108
109    let (allow_forward_only, spill, allow_degraded) = match policy {
110        StreamPolicy::Exact => return Err(Error::NotSeekable { format }),
111        StreamPolicy::ForwardOnly => (true, &NO_SPILL, false),
112        StreamPolicy::Adaptive {
113            allow_forward_only,
114            spill,
115            allow_degraded,
116        } => (*allow_forward_only, spill, *allow_degraded),
117    };
118
119    // Rung 2: full-fidelity forward parse.
120    if caps.forward_parse && allow_forward_only && !caps.needs_seek {
121        return Ok(Resolved {
122            source: src,
123            rung: Rung::ForwardOnly,
124            report: seed_report(Rung::ForwardOnly, format, caps),
125        });
126    }
127
128    // Rung 3: spool so the container gets real random access.
129    if spill.is_enabled() {
130        let spilled = SpillSource::materialize(src, spill)?;
131        return Ok(Resolved {
132            source: Box::new(spilled),
133            rung: Rung::Spilled,
134            report: FidelityReport::new(Rung::Spilled),
135        });
136    }
137
138    // Rung 4: best-effort salvage.
139    if allow_degraded && caps.degraded_parse {
140        return Ok(Resolved {
141            source: src,
142            rung: Rung::Degraded,
143            report: seed_report(Rung::Degraded, format, caps),
144        });
145    }
146
147    Err(Error::NotSeekable { format })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::source::{FileSource, ReaderSource};
154    use crate::{Fidelity, FormatId, Rung};
155
156    const ZIP: FormatId = FormatId::new("zip");
157    const SQUASHFS: FormatId = FormatId::new("squashfs");
158
159    fn pipe(bytes: &[u8]) -> Box<dyn Source> {
160        Box::new(ReaderSource::new(std::io::Cursor::new(bytes.to_vec())))
161    }
162
163    fn file(bytes: &[u8]) -> Box<dyn Source> {
164        let mut f = tempfile::NamedTempFile::new().unwrap();
165        std::io::Write::write_all(&mut f, bytes).unwrap();
166        let (_, path) = f.keep().unwrap();
167        Box::new(FileSource::open(&path).unwrap())
168    }
169
170    /// zip: forward-parseable, trailing index, per-entry codecs.
171    fn zip_caps() -> ContainerCaps {
172        ContainerCaps {
173            read: true,
174            forward_parse: true,
175            trailing_index: true,
176            per_entry_codec: true,
177            ..Default::default()
178        }
179    }
180
181    /// squashfs: a filesystem image. Cannot be parsed forward at all.
182    fn squashfs_caps() -> ContainerCaps {
183        ContainerCaps {
184            read: true,
185            needs_seek: true,
186            ..Default::default()
187        }
188    }
189
190    #[test]
191    fn seekable_input_reaches_exact_and_reports_no_loss() {
192        let r = resolve(file(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
193        assert_eq!(r.rung, Rung::Exact);
194        assert!(r.report.is_lossless());
195    }
196
197    #[test]
198    fn pipe_prefers_forward_only_over_spilling_to_disk() {
199        // The motivating case: grep a zip on a pipe without touching the disk.
200        let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
201        assert_eq!(r.rung, Rung::ForwardOnly);
202        assert!(!r.report.is_lossless());
203    }
204
205    #[test]
206    fn forward_only_on_a_trailing_index_format_records_the_index_loss() {
207        let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
208        assert!(
209            r.report
210                .warnings
211                .contains(&Fidelity::TrailingIndexUnread { format: ZIP })
212        );
213        assert!(r.report.warnings.contains(&Fidelity::EntryCountUnknown));
214    }
215
216    #[test]
217    fn a_forward_parseable_format_without_a_trailing_index_loses_nothing_specific() {
218        // tar on a pipe: rung is ForwardOnly, but there is no index to lose.
219        let tar = ContainerCaps {
220            read: true,
221            forward_parse: true,
222            ..Default::default()
223        };
224        let r = resolve(
225            pipe(b"payload"),
226            FormatId::new("tar"),
227            tar,
228            &StreamPolicy::default(),
229        )
230        .unwrap();
231        assert_eq!(r.rung, Rung::ForwardOnly);
232        assert!(r.report.warnings.is_empty());
233    }
234
235    #[test]
236    fn needs_seek_format_on_a_pipe_spills_and_stays_authoritative() {
237        let r = resolve(
238            pipe(b"payload"),
239            SQUASHFS,
240            squashfs_caps(),
241            &StreamPolicy::default(),
242        )
243        .unwrap();
244        assert_eq!(r.rung, Rung::Spilled);
245        assert!(r.rung.is_authoritative());
246        assert!(r.report.is_lossless());
247        assert!(
248            r.source.caps().seekable,
249            "spilling must produce a seekable source"
250        );
251    }
252
253    #[test]
254    fn spilling_preserves_the_bytes() {
255        let mut r = resolve(
256            pipe(b"0123456789"),
257            SQUASHFS,
258            squashfs_caps(),
259            &StreamPolicy::default(),
260        )
261        .unwrap();
262        let mut out = Vec::new();
263        std::io::Read::read_to_end(&mut r.source, &mut out).unwrap();
264        assert_eq!(out, b"0123456789");
265    }
266
267    #[test]
268    fn exact_policy_refuses_a_pipe_and_names_the_format() {
269        let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &StreamPolicy::Exact).unwrap_err();
270        assert!(matches!(err, crate::Error::NotSeekable { format } if format == SQUASHFS));
271    }
272
273    #[test]
274    fn forward_only_policy_never_spills() {
275        let err = resolve(
276            pipe(b"x"),
277            SQUASHFS,
278            squashfs_caps(),
279            &StreamPolicy::ForwardOnly,
280        )
281        .unwrap_err();
282        assert!(matches!(err, crate::Error::NotSeekable { .. }));
283    }
284
285    #[test]
286    fn forward_only_policy_still_works_where_the_format_allows_it() {
287        let r = resolve(pipe(b"x"), ZIP, zip_caps(), &StreamPolicy::ForwardOnly).unwrap();
288        assert_eq!(r.rung, Rung::ForwardOnly);
289    }
290
291    #[test]
292    fn spill_off_falls_through_to_degraded_when_the_format_can_salvage() {
293        let sevenz = ContainerCaps {
294            read: true,
295            degraded_parse: true,
296            trailing_index: true,
297            solid: true,
298            needs_seek: true,
299            ..Default::default()
300        };
301        let policy = StreamPolicy::Adaptive {
302            allow_forward_only: true,
303            spill: SpillPolicy::Off,
304            allow_degraded: true,
305        };
306        let r = resolve(pipe(b"x"), FormatId::new("7z"), sevenz, &policy).unwrap();
307        assert_eq!(r.rung, Rung::Degraded);
308        assert!(!r.rung.is_authoritative());
309    }
310
311    #[test]
312    fn spill_off_and_no_salvage_path_is_an_error_not_a_lie() {
313        let policy = StreamPolicy::Adaptive {
314            allow_forward_only: true,
315            spill: SpillPolicy::Off,
316            allow_degraded: true,
317        };
318        let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &policy).unwrap_err();
319        assert!(matches!(err, crate::Error::NotSeekable { .. }));
320    }
321
322    #[test]
323    fn disallowing_forward_only_forces_a_spill_even_where_forward_would_work() {
324        // Opting out of approximation must actually raise fidelity.
325        let policy = StreamPolicy::Adaptive {
326            allow_forward_only: false,
327            spill: SpillPolicy::default(),
328            allow_degraded: true,
329        };
330        let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &policy).unwrap();
331        assert_eq!(r.rung, Rung::Spilled);
332        assert!(r.report.is_lossless());
333    }
334
335    #[test]
336    fn default_policy_enables_every_rung() {
337        match StreamPolicy::default() {
338            StreamPolicy::Adaptive {
339                allow_forward_only,
340                allow_degraded,
341                spill,
342            } => {
343                assert!(allow_forward_only && allow_degraded && spill.is_enabled());
344            }
345            other => panic!("unexpected default: {other:?}"),
346        }
347    }
348}