Skip to main content

stuffr_core/source/
decode_side.rs

1//! Marking a [`Source`] as sitting BELOW a codec decoder.
2//!
3//! # The problem this exists for
4//!
5//! `stuffr` answers two different questions with the same Rust type. A read
6//! that fails because the bytes are malformed is the ARCHIVE's fault —
7//! [`Error::Corrupt`], exit 5. A read that fails because the disk, the pipe or
8//! the permission bits gave out is STUFFR's environment failing —
9//! [`Error::Io`], exit 1, which this project reserves for "stuffr itself
10//! failed". Both arrive as an [`io::Error`], and [`Error::from_decode_io`]
11//! exists to make the first call correctly.
12//!
13//! What decides which one a given error is, is **not the error's kind** — it is
14//! WHICH READ produced it. And until this module existed, the only thing that
15//! recorded that was whether the human who wrote the `?` happened to remember.
16//! Every `io::Error` on every read path funnels through one conversion — `impl
17//! From<io::Error> for Error` — and that conversion could not see the
18//! difference, so a bare `?` below a decoder was **wrong by default**.
19//!
20//! It was wrong in four places at once, found by enumerating the funnel rather
21//! than by following a reproducer:
22//!
23//! * `spill.rs`'s two `src.read(..)?` calls — the ladder's rung 3, reached by
24//!   every `needs_seek` container (`arj`, `zoo`) over a codec.
25//! * `lha.rs`'s `PeekSource::fill(source, 1)?` in `Lha::open`.
26//! * `cpio.rs`'s `fill_header_prefix()?` per-header read-ahead.
27//! * `zip.rs`'s `read_fixed`/`skip`, whose `map_err(Error::from)` is the
28//!   funnel spelled out longhand.
29//!
30//! Three of those carry doc comments asserting the raw-source reading ("a
31//! genuine source failure keeps its own kind and exit code") — correct for a
32//! raw source, false for a decoded one, and nothing in the type system
33//! disagreed.
34//!
35//! # The mechanism
36//!
37//! [`DecodeSideSource`] wraps a codec's decoder output and tags every
38//! `io::Error` it yields with a private marker payload, preserving the error's
39//! [`io::ErrorKind`] and its `Display` text exactly. `From<io::Error> for
40//! Error` then routes a tagged error through [`Error::from_decode_io`] and
41//! leaves an untagged one as [`Error::Io`]. A bare `?` becomes correct on
42//! BOTH sides of the line, and stays correct for code nobody has written yet.
43//!
44//! # Why this cannot lie in the opposite direction
45//!
46//! [`Error::from_decode_io`] folds exactly two kinds — `InvalidData` to
47//! `Corrupt` and `OutOfMemory` to `ResourceLimit`. A real disk error, a
48//! permission failure or a broken pipe propagating up THROUGH a decoder keeps
49//! its own kind, so it is still [`Error::Io`] and still exit 1. The marker
50//! cannot turn an environment failure into "the archive is corrupt"; it can
51//! only stop a malformed-input report from claiming stuffr failed.
52//!
53//! The raw source is never marked. `resolve_chain_deep_with`'s own top-level
54//! `probe(src)` call, `Input::open`, and `SpillSource`'s writes to its temp
55//! file all read or write something that is nobody's decoder output, and all
56//! three still answer [`Error::Io`]. That is pinned from both ends by
57//! `probe.rs`'s `a_raw_source_io_error_of_the_identical_kind_stays_io_not_
58//! corrupt` and by `cli.rs`'s `list_reports_a_directory_as_an_io_failure_not_
59//! as_corrupt`.
60//!
61//! [`Error`]: crate::Error
62//! [`Error::Corrupt`]: crate::Error::Corrupt
63//! [`Error::Io`]: crate::Error::Io
64//! [`Error::from_decode_io`]: crate::Error::from_decode_io
65
66use std::fmt;
67use std::io::{self, Read};
68
69use super::{SeekRead, Source, SourceCaps};
70
71/// The payload that marks an [`io::Error`] as having been raised below a
72/// decoder.
73///
74/// Private on purpose: the only way to set it is [`mark_decode_side`] and the
75/// only way to read it is [`is_decode_side`], so no caller outside this module
76/// can forge or strip the distinction.
77///
78/// `Display` forwards to the wrapped error verbatim, which is what keeps every
79/// user-facing message byte-identical to what it was before the marker
80/// existed — `Error::Corrupt(e.to_string())` renders the decoder's own
81/// complaint, not a wrapper's paraphrase of it.
82#[derive(Debug)]
83struct DecodeSideTag(io::Error);
84
85impl fmt::Display for DecodeSideTag {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        fmt::Display::fmt(&self.0, f)
88    }
89}
90
91impl std::error::Error for DecodeSideTag {
92    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
93        Some(&self.0)
94    }
95}
96
97/// Tags `e` as having been raised by a read below a codec decoder.
98///
99/// Preserves [`io::Error::kind`] and the `Display` text. **Idempotent** — a
100/// stream decoded through three codec layers is wrapped three times, and only
101/// the innermost wrap allocates; the outer two hand the already-tagged error
102/// straight back.
103pub fn mark_decode_side(e: io::Error) -> io::Error {
104    if is_decode_side(&e) {
105        return e;
106    }
107    let kind = e.kind();
108    io::Error::new(kind, DecodeSideTag(e))
109}
110
111/// Whether `e` came from a read below a codec decoder.
112///
113/// Consulted by `impl From<io::Error> for Error`, which is the single place
114/// every `?` on a read path passes through.
115pub fn is_decode_side(e: &io::Error) -> bool {
116    e.get_ref().is_some_and(|inner| inner.is::<DecodeSideTag>())
117}
118
119/// A [`Source`] whose read failures are marked as decode-side.
120///
121/// Wrapped around a codec's decoder output by `probe.rs` — at both of the two
122/// places a decoder is built, so the marking is a property of "a decoder ran",
123/// not of which resolution route was taken.
124///
125/// `as_seek` FORWARDS rather than wrapping, and that is a deliberate gap with
126/// a measured floor under it: a codec's decoder returns [`StreamOnly`], whose
127/// `as_seek` is `None`, so no seek reaches the forwarded path from any
128/// registered codec today. Wrapping it would need a second adapter around a
129/// borrowed `&mut dyn SeekRead`, which is not expressible without boxing per
130/// call. A codec that one day exposes a real seek table (seekable zstd, an xz
131/// block index — the case [`Source::as_seek`] exists for) must revisit this.
132///
133/// [`StreamOnly`]: crate::source::StreamOnly
134pub struct DecodeSideSource {
135    inner: Box<dyn Source>,
136}
137
138impl DecodeSideSource {
139    /// Wraps `inner`, which must be a codec decoder's output.
140    ///
141    /// Returns a `Box<dyn Source>` rather than `Self` because every caller
142    /// immediately needs the trait object, and handing back the concrete type
143    /// would invite a caller to keep it and lose the marking by passing the
144    /// inner source on instead.
145    pub fn wrap(inner: Box<dyn Source>) -> Box<dyn Source> {
146        Box::new(Self { inner })
147    }
148}
149
150impl Read for DecodeSideSource {
151    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
152        self.inner.read(buf).map_err(mark_decode_side)
153    }
154}
155
156impl Source for DecodeSideSource {
157    fn caps(&self) -> SourceCaps {
158        self.inner.caps()
159    }
160
161    fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
162        self.inner.as_seek()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::error::Error;
170    use crate::source::ReaderSource;
171
172    /// A source that yields `good` bytes and then fails with `kind` forever.
173    struct FailsAfter {
174        good: Vec<u8>,
175        pos: usize,
176        kind: io::ErrorKind,
177    }
178
179    impl Read for FailsAfter {
180        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
181            if self.pos < self.good.len() {
182                let n = (self.good.len() - self.pos).min(buf.len());
183                buf[..n].copy_from_slice(&self.good[self.pos..self.pos + n]);
184                self.pos += n;
185                return Ok(n);
186            }
187            Err(io::Error::new(self.kind, "the decoded bytes are malformed"))
188        }
189    }
190
191    impl Source for FailsAfter {
192        fn caps(&self) -> SourceCaps {
193            SourceCaps {
194                seekable: false,
195                len: None,
196            }
197        }
198
199        fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
200            None
201        }
202    }
203
204    fn failing(kind: io::ErrorKind) -> Box<dyn Source> {
205        Box::new(FailsAfter {
206            good: b"prefix".to_vec(),
207            pos: 0,
208            kind,
209        })
210    }
211
212    #[test]
213    fn a_tagged_error_keeps_its_kind_and_message() {
214        let raw = io::Error::new(io::ErrorKind::InvalidData, "block header is malformed");
215        let expected = raw.to_string();
216        let tagged = mark_decode_side(raw);
217
218        assert_eq!(tagged.kind(), io::ErrorKind::InvalidData);
219        assert_eq!(
220            tagged.to_string(),
221            expected,
222            "the marker must be invisible in every user-facing message"
223        );
224        assert!(is_decode_side(&tagged));
225    }
226
227    /// A stream decoded through N codec layers is wrapped N times. Stacking
228    /// tags would work, but each wrap allocates, and a caller comparing
229    /// `kind()` through two layers of `Custom` would be reading a different
230    /// error than the one the decoder raised.
231    #[test]
232    fn marking_is_idempotent() {
233        let once = mark_decode_side(io::Error::new(io::ErrorKind::InvalidData, "boom"));
234        let twice = mark_decode_side(once);
235        let thrice = mark_decode_side(twice);
236
237        assert!(is_decode_side(&thrice));
238        assert_eq!(thrice.kind(), io::ErrorKind::InvalidData);
239        assert_eq!(thrice.to_string(), "boom");
240        // One unwrap reaches the ORIGINAL error, not a second tag.
241        let inner = thrice.get_ref().expect("tagged errors carry a payload");
242        let tag = inner
243            .downcast_ref::<DecodeSideTag>()
244            .expect("the payload is the tag");
245        assert!(
246            !is_decode_side(&tag.0),
247            "a second wrap must not have stacked a tag inside the first"
248        );
249    }
250
251    /// The hazard half. An identical error that never passed through a
252    /// decoder carries no tag, so `?` still produces `Error::Io` — exit 1 —
253    /// which is what a real disk or permission failure deserves.
254    #[test]
255    fn an_untagged_error_still_becomes_error_io() {
256        let raw = io::Error::new(io::ErrorKind::InvalidData, "raw source said no");
257        assert!(!is_decode_side(&raw));
258        assert!(
259            matches!(Error::from(raw), Error::Io(_)),
260            "an unmarked InvalidData must stay Io; the marker, not the kind, is what decides"
261        );
262    }
263
264    /// The funnel itself, exercised the way every real call site exercises it:
265    /// a bare `?` on a read, with nothing at the call site that knows about
266    /// classification. This is the property the whole module exists to buy.
267    #[test]
268    fn a_tagged_invalid_data_becomes_corrupt_through_a_bare_question_mark() {
269        fn drain(src: &mut dyn Source) -> crate::error::Result<usize> {
270            let mut sink = Vec::new();
271            // A BARE `?`. No `from_decode_io`, no `map_err`, nothing the
272            // author of this function had to remember.
273            let n = src.read_to_end(&mut sink)?;
274            Ok(n)
275        }
276
277        let mut wrapped = DecodeSideSource::wrap(failing(io::ErrorKind::InvalidData));
278        match drain(wrapped.as_mut()) {
279            Err(Error::Corrupt(msg)) => {
280                assert!(msg.contains("malformed"), "decoder's own wording: {msg}");
281                assert_eq!(Error::Corrupt(msg).exit_code(), 5);
282            }
283            other => panic!("expected Corrupt from a marked decode-side read, got {other:?}"),
284        }
285    }
286
287    /// Finding B's half of the same funnel: `RatioGuardedSource` raises
288    /// `OutOfMemory` specifically so `from_decode_io` maps it to
289    /// `ResourceLimit` (exit 6). Below a container that conversion never ran,
290    /// and a refused expansion bomb reported "stuffr failed".
291    #[test]
292    fn a_tagged_out_of_memory_becomes_resource_limit_through_a_bare_question_mark() {
293        fn drain(src: &mut dyn Source) -> crate::error::Result<usize> {
294            let mut sink = Vec::new();
295            let n = src.read_to_end(&mut sink)?;
296            Ok(n)
297        }
298
299        let mut wrapped = DecodeSideSource::wrap(failing(io::ErrorKind::OutOfMemory));
300        match drain(wrapped.as_mut()) {
301            Err(e @ Error::ResourceLimit(_)) => assert_eq!(e.exit_code(), 6),
302            other => panic!("expected ResourceLimit, got {other:?}"),
303        }
304    }
305
306    /// A kind `from_decode_io` does NOT fold must survive the marker intact.
307    /// This is the guarantee that makes the marker safe to apply broadly: it
308    /// narrows nothing and widens nothing beyond the two kinds the
309    /// classification already owned.
310    #[test]
311    fn a_marked_environment_failure_is_still_an_io_failure() {
312        let mut wrapped = DecodeSideSource::wrap(failing(io::ErrorKind::PermissionDenied));
313        let mut sink = Vec::new();
314        let err = Error::from(wrapped.read_to_end(&mut sink).unwrap_err());
315        assert!(
316            matches!(err, Error::Io(_)),
317            "a permission failure under a decoder is still stuffr's environment failing: {err:?}"
318        );
319        assert_eq!(err.exit_code(), 1);
320    }
321
322    #[test]
323    fn wrapping_passes_bytes_and_caps_through_unchanged() {
324        let mut wrapped = DecodeSideSource::wrap(Box::new(ReaderSource::new(io::Cursor::new(
325            b"payload".to_vec(),
326        ))));
327        assert_eq!(wrapped.caps(), SourceCaps::default());
328        assert!(wrapped.as_seek().is_none());
329
330        let mut out = Vec::new();
331        wrapped.read_to_end(&mut out).unwrap();
332        assert_eq!(out, b"payload");
333    }
334}