Skip to main content

stuffr_core/
error.rs

1use crate::format::FormatId;
2
3pub type Result<T> = std::result::Result<T, Error>;
4
5#[derive(Debug, thiserror::Error)]
6#[non_exhaustive]
7pub enum Error {
8    #[error("i/o error: {0}")]
9    Io(#[from] std::io::Error),
10
11    #[error("usage error: {0}")]
12    Usage(String),
13
14    #[error("format `{0}` is not available in this build")]
15    FormatNotEnabled(FormatId),
16
17    #[error("could not detect format; leading bytes were {seen}")]
18    UnknownFormat { seen: String },
19
20    #[error("format is ambiguous; candidates: {candidates}. Pass --format to choose.")]
21    AmbiguousFormat { candidates: String },
22
23    #[error("input is not seekable and `{format}` requires random access")]
24    NotSeekable { format: FormatId },
25
26    #[error("spill limit of {limit} bytes exceeded; raise --max-spill or use a seekable input")]
27    SpillLimitExceeded { limit: u64 },
28
29    #[error("resource limit exceeded: {0}")]
30    ResourceLimit(String),
31
32    #[error("archive is corrupt: {0}")]
33    Corrupt(String),
34
35    #[error("unsafe entry path `{path}` refused: {reason}")]
36    UnsafePath { path: String, reason: &'static str },
37
38    #[error("fidelity degraded under strict mode: {0} warning(s)")]
39    FidelityDegraded(usize),
40
41    #[error("entry `{0}` not found")]
42    EntryNotFound(String),
43
44    #[error("unsupported: {0}")]
45    Unsupported(String),
46
47    #[error("`{format}` can be {available} but not {requested} by this build")]
48    CapabilityUnavailable {
49        format: FormatId,
50        /// The direction that IS available, e.g. "read".
51        available: &'static str,
52        /// The direction that was asked for and is not, e.g. "written".
53        requested: &'static str,
54    },
55
56    /// Nesting exceeded [`crate::probe::MAX_CHAIN_DEPTH`]. Names the bound so
57    /// the message is actionable rather than merely a refusal.
58    #[error("archive nesting exceeds the {depth}-layer limit")]
59    ChainTooDeep { depth: usize },
60
61    /// The chain resolved to raw bytes with no container to open — e.g.
62    /// `list`/`test` pointed at a plain codec stream, or at a file whose
63    /// magic and extension both name none. Names what the input actually
64    /// resolved to (`chain.describe()`), so "unpack this .gz" is actionable
65    /// rather than a bare refusal.
66    #[error("{chain} is not an archive — it has no entries to list")]
67    NotAnArchive { chain: String },
68}
69
70impl Error {
71    /// "Entry number `index` does not exist; here is what does."
72    ///
73    /// One constructor rather than a `format!` at each raiser, because there
74    /// are two routes to an out-of-range index and they must report it
75    /// IDENTICALLY: a container's own [`crate::ArchiveRead::by_index`] finds
76    /// it immediately (zip, which has a central directory to check against),
77    /// while a caller counting a forward walk finds it only at the end of the
78    /// archive (tar, ar, cpio, and anything on a pipe). A script that could
79    /// tell those apart from the message would be reading an implementation
80    /// detail of the source it was handed.
81    ///
82    /// [`Self::EntryNotFound`] rather than [`Self::Usage`] because the variant
83    /// exists for exactly this — "you named an entry this archive does not
84    /// have" — and carries the same exit code, 2. The payload is shaped to
85    /// read inside that variant's own `entry \`{0}\` not found` wording.
86    ///
87    /// Indices are 0-based throughout, so the valid range is stated
88    /// explicitly rather than left to be derived from the count: `0-5` is
89    /// harder to get wrong than "6 entries" is.
90    pub fn entry_index_out_of_range(index: usize, entries: usize) -> Self {
91        Error::EntryNotFound(match entries {
92            0 => format!("#{index} (this archive has no entries)"),
93            n => format!(
94                "#{index} (valid indices are 0-{} in this {n}-entry archive)",
95                n - 1
96            ),
97        })
98    }
99
100    /// Maps to the process exit code, per the spec's table. Lives here rather
101    /// than in the CLI so it is unit-testable without a process spawn.
102    ///
103    /// # Exit 5 or exit 6 on an absurd header field: the rule the guards actually follow
104    ///
105    /// Five of this workspace's guards refuse a declared header field before
106    /// it can do damage, and they split across two codes. The table is
107    /// exhaustive of the five it names, NOT of every bounding check in the
108    /// tree — see the known exception below it:
109    ///
110    /// | guard | error | exit |
111    /// |---|---|---|
112    /// | `cpio.rs`'s `c_namesize` ceiling | `ResourceLimit` | 6 |
113    /// | `ar.rs`'s `refuse_if_over` (GNU name table, BSD identifier) | `ResourceLimit` | 6 |
114    /// | `ar.rs`'s `/N` index past the declared name table | `Corrupt` | 5 |
115    /// | `zip.rs`'s entry payload that never delivers its declared size | `Corrupt` | 5 |
116    /// | `zip.rs`'s index that reaches no entry over a file that opens with one | `Corrupt` | 5 |
117    ///
118    /// **The rule is a fact about what stuffr DID, not about what the input
119    /// declared:**
120    ///
121    /// * **Exit 6 — stuffr declined to ask the allocator.** The field was
122    ///   measured against a structural ceiling and refused before anything was
123    ///   sized from it. Nothing was read past the header.
124    /// * **Exit 5 — stuffr read the bytes and they contradict each other.** No
125    ///   allocation was ever in question; what is wrong is the file's
126    ///   agreement with itself.
127    ///
128    /// **It is NOT "could a bigger machine honour it".** Three of those sites
129    /// used to argue that locally, and their own reproducers falsify it:
130    /// cpio's is a **274-byte file declaring a 2.6 GiB name**, which no
131    /// machine anywhere can deliver, and it answers 6; ar's two guards split
132    /// across 5 and 6 on the **same 68-byte file**. Nothing about the input's
133    /// plausibility separates them, so a caller cannot predict the code from
134    /// the file — only from which guard stopped first. Each site links here
135    /// rather than re-deriving a local justification, because four local
136    /// derivations produced four different rules for one contract.
137    ///
138    /// **One live pair does NOT follow this rule, and saying so here is the
139    /// point of writing the rule down.** `cpio.rs`'s and `zip.rs`'s
140    /// `read_symlink_target` apply the SAME predicate against the SAME
141    /// constant — `declared > MAX_SYMLINK_TARGET_LEN`, 65,536 — and answer
142    /// differently: cpio `ResourceLimit`/6, zip `Corrupt`/5. By the rule above
143    /// both are 6: the field is measured against a structural ceiling and
144    /// refused before anything is sized from it. zip's own message even reads
145    /// "refusing to allocate", which is this rule's definition of exit 6.
146    ///
147    /// It is left inconsistent rather than fixed silently because changing it
148    /// is a user-visible exit-code change and **no test covers either site's
149    /// code today** — an untested behaviour change is not something to slip in
150    /// beside a documentation fix. Whoever aligns them should write that test
151    /// first. Two earlier attempts to state this contract were each falsified
152    /// by a live site; this is the third, and it names its own exception
153    /// rather than waiting to be caught by a fourth reader.
154    ///
155    /// What a caller can still read off the code: exit 6 says a LIMIT decided
156    /// the outcome, exit 5 says the FILE did and no budget changes that. The
157    /// container ceilings above are fixed rather than tunable (`OpenOpts`
158    /// carries no memory field, unlike `DecodeOpts::memory_limit`), so exit 6
159    /// there is not an invitation to retry with a bigger budget — it is still
160    /// the honest code, because a ceiling and not the bytes is what decided.
161    pub fn exit_code(&self) -> i32 {
162        match self {
163            Error::Usage(_) => 2,
164            Error::NotAnArchive { .. } => 2,
165            // The caller named an entry the archive does not contain — the
166            // same shape as naming a format this build does not have, and
167            // the same answer `NotAnArchive` above gives for "you pointed
168            // this verb at the wrong thing". Explicit, not the wildcard
169            // below: `stuffr unpack a.tar -C out nosuch.txt` extracting
170            // nothing must be distinguishable from an i/o failure.
171            Error::EntryNotFound(_) => 2,
172            // The same shape again, and the fourth wrong code this
173            // wildcard has produced. `stuffr list plain.txt.gz` (not an
174            // archive) exited 2; `stuffr list plain.txt` (no format at all)
175            // exited 1, as if stuffr had failed rather than the caller
176            // having pointed a verb at the wrong file. `AmbiguousFormat`'s
177            // own message literally says "Pass `--format` to choose" —
178            // actionable advice with an exit code that says "internal
179            // failure". Both are usage, both are 2.
180            Error::UnknownFormat { .. } | Error::AmbiguousFormat { .. } => 2,
181            Error::FormatNotEnabled(_) => 3,
182            Error::CapabilityUnavailable { .. } => 3,
183            // Exit 3 is "this build cannot do that", and every raiser of
184            // `Unsupported` in this workspace is exactly that — a capability
185            // or expressiveness limit, never an internal failure:
186            //
187            // * tar's, ar's and cpio's `by_index` refusal on a seekable
188            //   source ("this container carries no entry index, by design").
189            // * cpio's 4 GiB per-entry ceiling, which is its `u32` size
190            //   field and not a bug.
191            // * zip's unsupported compression method — an encrypted entry, a
192            //   pre-deflate legacy method, or the pure tier's zstd gap, whose
193            //   message names `--features c-backed`.
194            // * a `Chain` variant added upstream that this build does not
195            //   know how to open, decode or describe.
196            // * `create_symlink` on a non-unix host.
197            //
198            // Explicit, not the wildcard below, and it did fall through it
199            // until Phase 2's Task 11: every one of those was reported as a
200            // generic failure, indistinguishable from an internal error, so a
201            // user told "rebuild with `--features c-backed`" got the same
202            // exit code as a panic-adjacent bug. That is the THIRD wrong code
203            // this wildcard has produced in this phase, after
204            // `ChainTooDeep` and `EntryNotFound`.
205            Error::Unsupported(_) => 3,
206            // "You asked for random access and this source cannot do it" is
207            // the same answer as the two arms above — a capability limit,
208            // actionable, and never an internal failure. It is the *required*
209            // reply to `by_index` on a forward-only source (container
210            // conformance property 6, and SEVEN raisers: `tar.rs`, `ar.rs`,
211            // `cpio.rs` and `zip.rs` each raise it once for exactly this, and
212            // `ladder.rs` (twice) and `probe.rs` (once) raise it from below
213            // the container layer, when the ladder itself cannot supply
214            // seek), so a valid archive on a pipe produced it on every single
215            // read — and the wildcard below
216            // reported that by-design refusal as exit 1, "stuffr failed".
217            // `entries.rs:453` already treats it as interchangeable with
218            // `Unsupported` when deciding whether to fall back to a forward
219            // walk; the exit code now agrees with that. The FIFTH wrong code
220            // this wildcard has produced, after `ChainTooDeep`,
221            // `EntryNotFound`, `Unsupported` and the
222            // `UnknownFormat`/`AmbiguousFormat` pair. Found by Phase 3a's
223            // honesty oracle (`honesty.rs`), before the fuzzer had run once.
224            Error::NotSeekable { .. } => 3,
225            Error::FidelityDegraded(_) => 4,
226            Error::Corrupt(_) => 5,
227            // A nesting bound is a bound on WORK, the same family as the
228            // spill and ratio limits — never exit 5, so a refused nesting
229            // bomb stays distinguishable from a corrupt file. Explicit, not
230            // the wildcard below: falling through it would make a refused
231            // nesting bomb indistinguishable from an internal error.
232            Error::SpillLimitExceeded { .. }
233            | Error::ResourceLimit(_)
234            | Error::ChainTooDeep { .. } => 6,
235            Error::UnsafePath { .. } => 7,
236            // A wrong exit code landing here is not always this match's own
237            // fault: it can equally mean the wrong `Error` variant was
238            // constructed upstream, before this function ever ran. See
239            // `Error::from_decode_io` — Task 4b's fix lives there, not here.
240            _ => 1,
241        }
242    }
243
244    /// Classifies an io error that arose while decoding.
245    ///
246    /// Malformed input becomes [`Error::Corrupt`] (exit 5); everything else
247    /// stays [`Error::Io`] (exit 1). `InvalidData` is the convention every
248    /// codec's decoder is expected to normalise onto for malformed input; a
249    /// codec whose backend disagrees adapts at its own boundary rather than
250    /// this classification changing. gzip is one such codec: flate2's
251    /// pure-Rust backend actually raises `InvalidInput` and `UnexpectedEof`,
252    /// never `InvalidData`, so gzip's decoder wraps it in an adapter that
253    /// folds both onto `InvalidData` before this function ever sees them.
254    ///
255    /// `OutOfMemory` becomes [`Error::ResourceLimit`] (exit 6), not
256    /// `Corrupt`: a codec whose dictionary allocation would exceed
257    /// `DecodeOpts::memory_limit` is refusing to allocate, not reporting a
258    /// damaged file, and exit 5 would actively mislead the caller about
259    /// which of those happened. A codec that raises this kind is expected to
260    /// have NOT routed it through the same `InvalidData`-folding adapter it
261    /// uses for malformed input, the same way `InvalidData` above is a
262    /// convention the codec opts into deliberately.
263    ///
264    /// One rule here rather than a helper each codec calls: putting the
265    /// decision in nine places means the natural code — a bare `?` on an
266    /// `io::Error` — silently bypasses it, and a convention whose failure mode
267    /// is invisible is not a convention. Conformance property 9 enforces the
268    /// codec half, that malformed input surfaces as `InvalidData` at all.
269    ///
270    /// Lives in `stuffr-core` rather than in `ops` because Phase 2's containers
271    /// need the identical classification on their own decode paths.
272    ///
273    /// **Task 4b (Phase 3a's fuzzing harness) added a fourth call site:**
274    /// `probe.rs`'s `resolve_chain_deep_with` re-probes a DECODED stream
275    /// whenever a codec has no container above it and the path named
276    /// nothing further (stdin, or an extension that ran out) — the loop
277    /// documented on `resolve_chain_deep`'s own doc comment. That re-probe
278    /// used to read through a bare `?`, so a truncated zlib stream
279    /// (`\x78\xda\x0a`, three bytes) or an empty file named `.lz` reported
280    /// `Error::Io` — exit 1, "stuffr failed" — for input that is simply
281    /// corrupt, while `stuffr cat` on the IDENTICAL bytes (via
282    /// `ops::decompress`, whose decode read was already wired to this
283    /// function) correctly exited 5. This is deliberately NOT filed as a
284    /// sixth instance of `exit_code`'s `_ => 1` wildcard misfiling a KNOWN
285    /// variant (see that match's own comment for the five it has produced):
286    /// `Error::Io` reaching that wildcard is correct, by construction —
287    /// it has no dedicated arm because the wildcard IS its arm. The bug
288    /// here was upstream of `exit_code` entirely: the wrong variant got
289    /// constructed in the first place, at a read that passed through a
290    /// decoder without this classifier's protection.
291    pub fn from_decode_io(e: std::io::Error) -> Self {
292        match e.kind() {
293            std::io::ErrorKind::InvalidData => Error::Corrupt(e.to_string()),
294            std::io::ErrorKind::OutOfMemory => Error::ResourceLimit(e.to_string()),
295            _ => Error::Io(e),
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::format::FormatId;
304
305    #[test]
306    fn exit_codes_match_the_spec_table() {
307        assert_eq!(Error::Usage("bad flag".into()).exit_code(), 2);
308        assert_eq!(
309            Error::FormatNotEnabled(FormatId::new("zstd")).exit_code(),
310            3
311        );
312        assert_eq!(
313            Error::Unsupported("no zstd in this build".into()).exit_code(),
314            3
315        );
316        assert_eq!(
317            Error::NotSeekable {
318                format: FormatId::new("squashfs")
319            }
320            .exit_code(),
321            3
322        );
323        assert_eq!(Error::FidelityDegraded(2).exit_code(), 4);
324        assert_eq!(Error::Corrupt("bad crc".into()).exit_code(), 5);
325        assert_eq!(Error::EntryNotFound("nosuch.txt".into()).exit_code(), 2);
326        assert_eq!(Error::SpillLimitExceeded { limit: 42 }.exit_code(), 6);
327        assert_eq!(Error::ResourceLimit("oom".into()).exit_code(), 6);
328        assert_eq!(
329            Error::UnsafePath {
330                path: "../x".into(),
331                reason: "path traversal above the destination",
332            }
333            .exit_code(),
334            7
335        );
336    }
337
338    /// The refusal has to say what IS valid, not merely that the asked-for
339    /// index is not — and the zero-entry case must not underflow its way to
340    /// "valid indices are 0-18446744073709551615".
341    #[test]
342    fn an_out_of_range_index_names_the_valid_range_and_survives_an_empty_archive() {
343        let e = Error::entry_index_out_of_range(9, 6);
344        assert_eq!(e.exit_code(), 2);
345        let text = e.to_string();
346        assert!(text.contains("#9"), "{text}");
347        assert!(
348            text.contains("0-5"),
349            "the valid range must be stated, not left to be derived: {text}"
350        );
351
352        let empty = Error::entry_index_out_of_range(0, 0).to_string();
353        assert!(
354            empty.contains("no entries") && !empty.contains("0-1844"),
355            "an empty archive must not underflow into a nonsense range: {empty}"
356        );
357    }
358
359    #[test]
360    fn from_decode_io_keys_resource_limit_off_out_of_memory_not_corrupt() {
361        // A codec refusing to allocate a declared dictionary is not reporting
362        // a damaged file — exit 6, never exit 5. See `DecodeOpts::memory_limit`.
363        let io_err = std::io::Error::new(std::io::ErrorKind::OutOfMemory, "too big");
364        let classified = Error::from_decode_io(io_err);
365        assert!(matches!(classified, Error::ResourceLimit(_)));
366        assert_eq!(classified.exit_code(), 6);
367    }
368
369    #[test]
370    fn not_seekable_names_the_format_so_callers_can_act() {
371        let e = Error::NotSeekable {
372            format: FormatId::new("squashfs"),
373        };
374        assert!(e.to_string().contains("squashfs"));
375        // Exit 3, not 1. A source that cannot seek is a capability limit in
376        // exactly the sense `Unsupported` and `CapabilityUnavailable` are,
377        // and it is the mandated answer to `by_index` on a forward-only
378        // source — so exit 1 meant every valid archive read from a pipe
379        // reported "stuffr failed". See `exit_code`'s own arm for why this
380        // is the fifth variant the wildcard has misfiled.
381        assert_eq!(e.exit_code(), 3);
382        assert_ne!(
383            e.exit_code(),
384            1,
385            "a by-design refusal must stay distinguishable from an internal failure"
386        );
387    }
388
389    #[test]
390    fn unsupported_is_a_capability_limit_not_a_generic_failure() {
391        // The three exit-3 variants say the same thing at different
392        // granularities — "this build cannot do that" — so they must not
393        // disagree on the code. `Unsupported` fell through the wildcard to 1
394        // until Phase 2's Task 11, which made a container's honest
395        // "rebuild with `--features c-backed`" indistinguishable from an
396        // internal error. See `exit_code`'s own comment for the full list of
397        // raisers and why every one of them belongs here.
398        let unsupported = Error::Unsupported(
399            "zip entry uses compression method 93 (zstd); rebuild with `--features c-backed`"
400                .into(),
401        );
402        assert_eq!(unsupported.exit_code(), 3);
403        assert_eq!(
404            Error::FormatNotEnabled(FormatId::new("zstd")).exit_code(),
405            3
406        );
407        assert_eq!(
408            Error::CapabilityUnavailable {
409                format: FormatId::new("lzma"),
410                available: "read as a zip entry",
411                requested: "written as one",
412            }
413            .exit_code(),
414            3
415        );
416        assert_ne!(
417            unsupported.exit_code(),
418            1,
419            "exit 1 is an internal failure; a capability limit is actionable and must be \
420             distinguishable from one"
421        );
422    }
423
424    #[test]
425    fn chain_too_deep_is_a_resource_limit_not_a_generic_failure() {
426        // A refused nesting bomb must be distinguishable from an internal
427        // error — falling through the wildcard to 1 would erase that.
428        assert_eq!(Error::ChainTooDeep { depth: 4 }.exit_code(), 6);
429    }
430
431    #[test]
432    fn not_an_archive_names_the_chain_and_is_a_usage_error() {
433        let e = Error::NotAnArchive {
434            chain: "gzip".into(),
435        };
436        assert!(
437            e.to_string().contains("gzip"),
438            "must name what the input resolved to: {e}"
439        );
440        assert_eq!(e.exit_code(), 2);
441    }
442
443    #[test]
444    fn unknown_format_reports_what_was_actually_seen() {
445        // The error must name the bytes, otherwise debugging a misdetection
446        // means adding print statements to the library.
447        let e = Error::UnknownFormat {
448            seen: "1f 8b 08".into(),
449        };
450        assert!(e.to_string().contains("1f 8b 08"));
451    }
452
453    /// "You pointed the verb at the wrong file" is one answer, not three.
454    ///
455    /// `NotAnArchive` already exited 2. `UnknownFormat` and
456    /// `AmbiguousFormat` fell through the wildcard to 1 — an internal
457    /// failure — so `stuffr list plain.txt.gz` exited 2 and `stuffr list
458    /// plain.txt` exited 1 for the same class of mistake. Explicit arms,
459    /// not the wildcard: that wildcard has now produced a wrong code five
460    /// times, `NotSeekable` being the fifth (see its own arm in
461    /// `exit_code`).
462    #[test]
463    fn pointing_a_verb_at_the_wrong_file_is_always_exit_two() {
464        assert_eq!(
465            Error::UnknownFormat {
466                seen: "de ad be ef".into()
467            }
468            .exit_code(),
469            2,
470            "an undetectable format is the caller's mistake, not an internal failure"
471        );
472        let ambiguous = Error::AmbiguousFormat {
473            candidates: "lzma, lzip".into(),
474        };
475        assert_eq!(
476            ambiguous.exit_code(),
477            2,
478            "a message that says `Pass --format to choose` is actionable, so exit 2"
479        );
480        assert!(
481            ambiguous.to_string().contains("--format"),
482            "and it must keep saying so: {ambiguous}"
483        );
484        // The neighbour they must agree with.
485        assert_eq!(
486            Error::NotAnArchive {
487                chain: "gzip".into()
488            }
489            .exit_code(),
490            2
491        );
492        // And the codes they must NOT collide with: a build-capability
493        // limit is still 3, a damaged file still 5.
494        assert_eq!(Error::Unsupported("no".into()).exit_code(), 3);
495        assert_eq!(Error::Corrupt("bad".into()).exit_code(), 5);
496    }
497}