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}