Skip to main content

stuffr_core/
error.rs

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