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`.
77///
78/// The two trailing-index warnings say what is true when the walk starts. A
79/// container that reads and checks its index at the end of the stream may
80/// remove them from its report then (zip's forward reader does, ruling B2);
81/// the report's `warnings` are its own to edit.
82fn seed_report(rung: Rung, format: FormatId, caps: ContainerCaps) -> FidelityReport {
83    let mut report = FidelityReport::new(rung);
84    if rung.is_authoritative() {
85        return report;
86    }
87    if caps.trailing_index {
88        report.warn(Fidelity::TrailingIndexUnread { format });
89        report.warn(Fidelity::EntryCountUnknown);
90    }
91    report
92}
93
94/// Places `src` on the highest ladder rung reachable under `policy`.
95///
96/// Order: Exact → ForwardOnly → Spilled → Degraded. ForwardOnly is preferred
97/// over Spilled so that piping a zip into a grep does not touch the disk. A
98/// user who wants full fidelity instead opts out via `allow_forward_only: false`.
99pub fn resolve(
100    src: Box<dyn Source>,
101    format: FormatId,
102    caps: ContainerCaps,
103    policy: &StreamPolicy,
104) -> Result<Resolved> {
105    // Rung 1: already seekable.
106    if src.caps().seekable {
107        return Ok(Resolved {
108            source: src,
109            rung: Rung::Exact,
110            report: FidelityReport::exact(),
111        });
112    }
113
114    let (allow_forward_only, spill, allow_degraded) = match policy {
115        StreamPolicy::Exact => return Err(Error::NotSeekable { format }),
116        StreamPolicy::ForwardOnly => (true, &NO_SPILL, false),
117        StreamPolicy::Adaptive {
118            allow_forward_only,
119            spill,
120            allow_degraded,
121        } => (*allow_forward_only, spill, *allow_degraded),
122    };
123
124    // Rung 2: full-fidelity forward parse.
125    if caps.forward_parse && allow_forward_only && !caps.needs_seek {
126        return Ok(Resolved {
127            source: src,
128            rung: Rung::ForwardOnly,
129            report: seed_report(Rung::ForwardOnly, format, caps),
130        });
131    }
132
133    // Rung 3: spool so the container gets real random access.
134    if spill.is_enabled() {
135        let spilled = SpillSource::materialize(src, spill)?;
136        return Ok(Resolved {
137            source: Box::new(spilled),
138            rung: Rung::Spilled,
139            report: FidelityReport::new(Rung::Spilled),
140        });
141    }
142
143    // Rung 4: best-effort salvage.
144    if allow_degraded && caps.degraded_parse {
145        return Ok(Resolved {
146            source: src,
147            rung: Rung::Degraded,
148            report: seed_report(Rung::Degraded, format, caps),
149        });
150    }
151
152    Err(Error::NotSeekable { format })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::source::{FileSource, ReaderSource};
159    use crate::{Fidelity, FormatId, Rung};
160
161    const ZIP: FormatId = FormatId::new("zip");
162    const SQUASHFS: FormatId = FormatId::new("squashfs");
163
164    fn pipe(bytes: &[u8]) -> Box<dyn Source> {
165        Box::new(ReaderSource::new(std::io::Cursor::new(bytes.to_vec())))
166    }
167
168    fn file(bytes: &[u8]) -> Box<dyn Source> {
169        let mut f = tempfile::NamedTempFile::new().unwrap();
170        std::io::Write::write_all(&mut f, bytes).unwrap();
171        let (_, path) = f.keep().unwrap();
172        Box::new(FileSource::open(&path).unwrap())
173    }
174
175    /// zip: forward-parseable, trailing index, per-entry codecs.
176    fn zip_caps() -> ContainerCaps {
177        ContainerCaps {
178            read: true,
179            forward_parse: true,
180            trailing_index: true,
181            per_entry_codec: true,
182            ..Default::default()
183        }
184    }
185
186    /// squashfs: a filesystem image. Cannot be parsed forward at all.
187    fn squashfs_caps() -> ContainerCaps {
188        ContainerCaps {
189            read: true,
190            needs_seek: true,
191            ..Default::default()
192        }
193    }
194
195    #[test]
196    fn seekable_input_reaches_exact_and_reports_no_loss() {
197        let r = resolve(file(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
198        assert_eq!(r.rung, Rung::Exact);
199        assert!(r.report.is_lossless());
200    }
201
202    #[test]
203    fn pipe_prefers_forward_only_over_spilling_to_disk() {
204        // The motivating case: grep a zip on a pipe without touching the disk.
205        let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
206        assert_eq!(r.rung, Rung::ForwardOnly);
207        assert!(!r.report.is_lossless());
208    }
209
210    #[test]
211    fn forward_only_on_a_trailing_index_format_records_the_index_loss() {
212        let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
213        assert!(
214            r.report
215                .warnings
216                .contains(&Fidelity::TrailingIndexUnread { format: ZIP })
217        );
218        assert!(r.report.warnings.contains(&Fidelity::EntryCountUnknown));
219    }
220
221    #[test]
222    fn a_forward_parseable_format_without_a_trailing_index_loses_nothing_specific() {
223        // tar on a pipe: rung is ForwardOnly, but there is no index to lose.
224        let tar = ContainerCaps {
225            read: true,
226            forward_parse: true,
227            ..Default::default()
228        };
229        let r = resolve(
230            pipe(b"payload"),
231            FormatId::new("tar"),
232            tar,
233            &StreamPolicy::default(),
234        )
235        .unwrap();
236        assert_eq!(r.rung, Rung::ForwardOnly);
237        assert!(r.report.warnings.is_empty());
238    }
239
240    #[test]
241    fn needs_seek_format_on_a_pipe_spills_and_stays_authoritative() {
242        let r = resolve(
243            pipe(b"payload"),
244            SQUASHFS,
245            squashfs_caps(),
246            &StreamPolicy::default(),
247        )
248        .unwrap();
249        assert_eq!(r.rung, Rung::Spilled);
250        assert!(r.rung.is_authoritative());
251        assert!(r.report.is_lossless());
252        assert!(
253            r.source.caps().seekable,
254            "spilling must produce a seekable source"
255        );
256    }
257
258    #[test]
259    fn spilling_preserves_the_bytes() {
260        let mut r = resolve(
261            pipe(b"0123456789"),
262            SQUASHFS,
263            squashfs_caps(),
264            &StreamPolicy::default(),
265        )
266        .unwrap();
267        let mut out = Vec::new();
268        std::io::Read::read_to_end(&mut r.source, &mut out).unwrap();
269        assert_eq!(out, b"0123456789");
270    }
271
272    #[test]
273    fn exact_policy_refuses_a_pipe_and_names_the_format() {
274        let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &StreamPolicy::Exact).unwrap_err();
275        assert!(matches!(err, crate::Error::NotSeekable { format } if format == SQUASHFS));
276    }
277
278    #[test]
279    fn forward_only_policy_never_spills() {
280        let err = resolve(
281            pipe(b"x"),
282            SQUASHFS,
283            squashfs_caps(),
284            &StreamPolicy::ForwardOnly,
285        )
286        .unwrap_err();
287        assert!(matches!(err, crate::Error::NotSeekable { .. }));
288    }
289
290    #[test]
291    fn forward_only_policy_still_works_where_the_format_allows_it() {
292        let r = resolve(pipe(b"x"), ZIP, zip_caps(), &StreamPolicy::ForwardOnly).unwrap();
293        assert_eq!(r.rung, Rung::ForwardOnly);
294    }
295
296    #[test]
297    fn spill_off_falls_through_to_degraded_when_the_format_can_salvage() {
298        let sevenz = ContainerCaps {
299            read: true,
300            degraded_parse: true,
301            trailing_index: true,
302            solid: true,
303            needs_seek: true,
304            ..Default::default()
305        };
306        let policy = StreamPolicy::Adaptive {
307            allow_forward_only: true,
308            spill: SpillPolicy::Off,
309            allow_degraded: true,
310        };
311        let r = resolve(pipe(b"x"), FormatId::new("7z"), sevenz, &policy).unwrap();
312        assert_eq!(r.rung, Rung::Degraded);
313        assert!(!r.rung.is_authoritative());
314    }
315
316    #[test]
317    fn spill_off_and_no_salvage_path_is_an_error_not_a_lie() {
318        let policy = StreamPolicy::Adaptive {
319            allow_forward_only: true,
320            spill: SpillPolicy::Off,
321            allow_degraded: true,
322        };
323        let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &policy).unwrap_err();
324        assert!(matches!(err, crate::Error::NotSeekable { .. }));
325    }
326
327    #[test]
328    fn disallowing_forward_only_forces_a_spill_even_where_forward_would_work() {
329        // Opting out of approximation must actually raise fidelity.
330        let policy = StreamPolicy::Adaptive {
331            allow_forward_only: false,
332            spill: SpillPolicy::default(),
333            allow_degraded: true,
334        };
335        let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &policy).unwrap();
336        assert_eq!(r.rung, Rung::Spilled);
337        assert!(r.report.is_lossless());
338    }
339
340    /// Rung 3 drains its source into a spool, and until the decode-side
341    /// marker existed it did so through a bare `?` — so a codec reporting
342    /// malformed bytes to `SpillSource::materialize` became `Error::Io`, exit
343    /// 1, "stuffr failed".
344    ///
345    /// This is the site the release-blocking reproducer hit: a 21-byte zstd
346    /// frame whose decoded bytes open with ARJ's `60 EA` magic. ARJ declares
347    /// `needs_seek`, a decoder's output is never seekable, so the ladder
348    /// always takes this rung for that pair — `stuffr list` and `stuffr test`
349    /// exited 1 where `stuffr cat` on the identical bytes exited 5. Measured
350    /// from the backtrace, not inferred: `SpillSource::materialize` <-
351    /// `ladder::resolve` <- `entries::open_archive`.
352    ///
353    /// `Error::Corrupt`, exit 5 and not 6, per `Error::exit_code`'s own rule:
354    /// stuffr read the bytes and they contradict each other; no allocation was
355    /// ever in question.
356    #[test]
357    fn spilling_classifies_a_decode_side_failure_as_corruption() {
358        use crate::testing::decode_side_failing_source;
359
360        let err = resolve(
361            decode_side_failing_source(b"some good bytes", std::io::ErrorKind::InvalidData),
362            SQUASHFS,
363            squashfs_caps(),
364            &StreamPolicy::default(),
365        )
366        .expect_err("a source that fails part-way cannot spool");
367
368        assert!(
369            matches!(err, crate::Error::Corrupt(_)),
370            "a decoder's malformed-input report must not claim stuffr failed: {err:?}"
371        );
372        assert_eq!(err.exit_code(), 5);
373        assert!(
374            err.to_string()
375                .contains(crate::testing::FAILING_SOURCE_MESSAGE),
376            "the decoder's own wording must survive classification: {err}"
377        );
378    }
379
380    /// The hazard side, and the reason the fix is a marker rather than a
381    /// `map_err` at this call site: the IDENTICAL error kind, raised by a
382    /// source no decoder produced, must stay `Error::Io`. A spool drains
383    /// whatever the ladder was handed — a bare `.arj` arriving on a pipe is
384    /// the raw file — so classifying unconditionally here would report a disk
385    /// or pipe failure as a corrupt archive.
386    #[test]
387    fn spilling_leaves_a_raw_side_failure_as_an_io_error() {
388        use crate::testing::raw_failing_source;
389
390        let err = resolve(
391            raw_failing_source(b"some good bytes", std::io::ErrorKind::InvalidData),
392            SQUASHFS,
393            squashfs_caps(),
394            &StreamPolicy::default(),
395        )
396        .expect_err("a source that fails part-way cannot spool");
397
398        assert!(
399            matches!(err, crate::Error::Io(_)),
400            "only the marking separates this from the test above: {err:?}"
401        );
402        assert_eq!(err.exit_code(), 1);
403    }
404
405    /// A `--max-ratio` refusal below a container took the same route and the
406    /// same wrong turn. `RatioGuardedSource` raises `OutOfMemory` precisely so
407    /// the classification maps it to `ResourceLimit` (exit 6); unmarked, it
408    /// met this spool's bare `?` instead. Measured before the fix: `stuffr
409    /// list` on a 60-byte zstd frame expanding to 1 MiB of ARJ-magic-prefixed
410    /// zeros answered exit 1, against exit 6 from `stuffr cat`.
411    #[test]
412    fn spilling_classifies_a_decode_side_ratio_refusal_as_a_resource_limit() {
413        use crate::testing::decode_side_failing_source;
414
415        let err = resolve(
416            decode_side_failing_source(b"some good bytes", std::io::ErrorKind::OutOfMemory),
417            SQUASHFS,
418            squashfs_caps(),
419            &StreamPolicy::default(),
420        )
421        .expect_err("a source that fails part-way cannot spool");
422
423        assert!(
424            matches!(err, crate::Error::ResourceLimit(_)),
425            "a refused expansion is a limit, never corruption and never exit 1: {err:?}"
426        );
427        assert_eq!(err.exit_code(), 6);
428    }
429
430    #[test]
431    fn default_policy_enables_every_rung() {
432        match StreamPolicy::default() {
433            StreamPolicy::Adaptive {
434                allow_forward_only,
435                allow_degraded,
436                spill,
437            } => {
438                assert!(allow_forward_only && allow_degraded && spill.is_enabled());
439            }
440            other => panic!("unexpected default: {other:?}"),
441        }
442    }
443}