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    pub fn exit_code(&self) -> i32 {
103        match self {
104            Error::Usage(_) => 2,
105            Error::NotAnArchive { .. } => 2,
106            // The caller named an entry the archive does not contain — the
107            // same shape as naming a format this build does not have, and
108            // the same answer `NotAnArchive` above gives for "you pointed
109            // this verb at the wrong thing". Explicit, not the wildcard
110            // below: `stuffr unpack a.tar -C out nosuch.txt` extracting
111            // nothing must be distinguishable from an i/o failure.
112            Error::EntryNotFound(_) => 2,
113            // The same shape again, and the fourth wrong code this
114            // wildcard has produced. `stuffr list plain.txt.gz` (not an
115            // archive) exited 2; `stuffr list plain.txt` (no format at all)
116            // exited 1, as if stuffr had failed rather than the caller
117            // having pointed a verb at the wrong file. `AmbiguousFormat`'s
118            // own message literally says "Pass `--format` to choose" —
119            // actionable advice with an exit code that says "internal
120            // failure". Both are usage, both are 2.
121            Error::UnknownFormat { .. } | Error::AmbiguousFormat { .. } => 2,
122            Error::FormatNotEnabled(_) => 3,
123            Error::CapabilityUnavailable { .. } => 3,
124            // Exit 3 is "this build cannot do that", and every raiser of
125            // `Unsupported` in this workspace is exactly that — a capability
126            // or expressiveness limit, never an internal failure:
127            //
128            // * tar's, ar's and cpio's `by_index` refusal on a seekable
129            //   source ("this container carries no entry index, by design").
130            // * cpio's 4 GiB per-entry ceiling, which is its `u32` size
131            //   field and not a bug.
132            // * zip's unsupported compression method — an encrypted entry, a
133            //   pre-deflate legacy method, or the pure tier's zstd gap, whose
134            //   message names `--features c-backed`.
135            // * a `Chain` variant added upstream that this build does not
136            //   know how to open, decode or describe.
137            // * `create_symlink` on a non-unix host.
138            //
139            // Explicit, not the wildcard below, and it did fall through it
140            // until Phase 2's Task 11: every one of those was reported as a
141            // generic failure, indistinguishable from an internal error, so a
142            // user told "rebuild with `--features c-backed`" got the same
143            // exit code as a panic-adjacent bug. That is the THIRD wrong code
144            // this wildcard has produced in this phase, after
145            // `ChainTooDeep` and `EntryNotFound`.
146            Error::Unsupported(_) => 3,
147            Error::FidelityDegraded(_) => 4,
148            Error::Corrupt(_) => 5,
149            // A nesting bound is a bound on WORK, the same family as the
150            // spill and ratio limits — never exit 5, so a refused nesting
151            // bomb stays distinguishable from a corrupt file. Explicit, not
152            // the wildcard below: falling through it would make a refused
153            // nesting bomb indistinguishable from an internal error.
154            Error::SpillLimitExceeded { .. }
155            | Error::ResourceLimit(_)
156            | Error::ChainTooDeep { .. } => 6,
157            Error::UnsafePath { .. } => 7,
158            _ => 1,
159        }
160    }
161
162    /// Classifies an io error that arose while decoding.
163    ///
164    /// Malformed input becomes [`Error::Corrupt`] (exit 5); everything else
165    /// stays [`Error::Io`] (exit 1). `InvalidData` is the convention every
166    /// codec's decoder is expected to normalise onto for malformed input; a
167    /// codec whose backend disagrees adapts at its own boundary rather than
168    /// this classification changing. gzip is one such codec: flate2's
169    /// pure-Rust backend actually raises `InvalidInput` and `UnexpectedEof`,
170    /// never `InvalidData`, so gzip's decoder wraps it in an adapter that
171    /// folds both onto `InvalidData` before this function ever sees them.
172    ///
173    /// `OutOfMemory` becomes [`Error::ResourceLimit`] (exit 6), not
174    /// `Corrupt`: a codec whose dictionary allocation would exceed
175    /// `DecodeOpts::memory_limit` is refusing to allocate, not reporting a
176    /// damaged file, and exit 5 would actively mislead the caller about
177    /// which of those happened. A codec that raises this kind is expected to
178    /// have NOT routed it through the same `InvalidData`-folding adapter it
179    /// uses for malformed input, the same way `InvalidData` above is a
180    /// convention the codec opts into deliberately.
181    ///
182    /// One rule here rather than a helper each codec calls: putting the
183    /// decision in nine places means the natural code — a bare `?` on an
184    /// `io::Error` — silently bypasses it, and a convention whose failure mode
185    /// is invisible is not a convention. Conformance property 9 enforces the
186    /// codec half, that malformed input surfaces as `InvalidData` at all.
187    ///
188    /// Lives in `stuffr-core` rather than in `ops` because Phase 2's containers
189    /// need the identical classification on their own decode paths.
190    pub fn from_decode_io(e: std::io::Error) -> Self {
191        match e.kind() {
192            std::io::ErrorKind::InvalidData => Error::Corrupt(e.to_string()),
193            std::io::ErrorKind::OutOfMemory => Error::ResourceLimit(e.to_string()),
194            _ => Error::Io(e),
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::format::FormatId;
203
204    #[test]
205    fn exit_codes_match_the_spec_table() {
206        assert_eq!(Error::Usage("bad flag".into()).exit_code(), 2);
207        assert_eq!(
208            Error::FormatNotEnabled(FormatId::new("zstd")).exit_code(),
209            3
210        );
211        assert_eq!(
212            Error::Unsupported("no zstd in this build".into()).exit_code(),
213            3
214        );
215        assert_eq!(Error::FidelityDegraded(2).exit_code(), 4);
216        assert_eq!(Error::Corrupt("bad crc".into()).exit_code(), 5);
217        assert_eq!(Error::EntryNotFound("nosuch.txt".into()).exit_code(), 2);
218        assert_eq!(Error::SpillLimitExceeded { limit: 42 }.exit_code(), 6);
219        assert_eq!(Error::ResourceLimit("oom".into()).exit_code(), 6);
220        assert_eq!(
221            Error::UnsafePath {
222                path: "../x".into(),
223                reason: "path traversal above the destination",
224            }
225            .exit_code(),
226            7
227        );
228    }
229
230    /// The refusal has to say what IS valid, not merely that the asked-for
231    /// index is not — and the zero-entry case must not underflow its way to
232    /// "valid indices are 0-18446744073709551615".
233    #[test]
234    fn an_out_of_range_index_names_the_valid_range_and_survives_an_empty_archive() {
235        let e = Error::entry_index_out_of_range(9, 6);
236        assert_eq!(e.exit_code(), 2);
237        let text = e.to_string();
238        assert!(text.contains("#9"), "{text}");
239        assert!(
240            text.contains("0-5"),
241            "the valid range must be stated, not left to be derived: {text}"
242        );
243
244        let empty = Error::entry_index_out_of_range(0, 0).to_string();
245        assert!(
246            empty.contains("no entries") && !empty.contains("0-1844"),
247            "an empty archive must not underflow into a nonsense range: {empty}"
248        );
249    }
250
251    #[test]
252    fn from_decode_io_keys_resource_limit_off_out_of_memory_not_corrupt() {
253        // A codec refusing to allocate a declared dictionary is not reporting
254        // a damaged file — exit 6, never exit 5. See `DecodeOpts::memory_limit`.
255        let io_err = std::io::Error::new(std::io::ErrorKind::OutOfMemory, "too big");
256        let classified = Error::from_decode_io(io_err);
257        assert!(matches!(classified, Error::ResourceLimit(_)));
258        assert_eq!(classified.exit_code(), 6);
259    }
260
261    #[test]
262    fn not_seekable_names_the_format_so_callers_can_act() {
263        let e = Error::NotSeekable {
264            format: FormatId::new("squashfs"),
265        };
266        assert!(e.to_string().contains("squashfs"));
267        assert_eq!(e.exit_code(), 1);
268    }
269
270    #[test]
271    fn unsupported_is_a_capability_limit_not_a_generic_failure() {
272        // The three exit-3 variants say the same thing at different
273        // granularities — "this build cannot do that" — so they must not
274        // disagree on the code. `Unsupported` fell through the wildcard to 1
275        // until Phase 2's Task 11, which made a container's honest
276        // "rebuild with `--features c-backed`" indistinguishable from an
277        // internal error. See `exit_code`'s own comment for the full list of
278        // raisers and why every one of them belongs here.
279        let unsupported = Error::Unsupported(
280            "zip entry uses compression method 93 (zstd); rebuild with `--features c-backed`"
281                .into(),
282        );
283        assert_eq!(unsupported.exit_code(), 3);
284        assert_eq!(
285            Error::FormatNotEnabled(FormatId::new("zstd")).exit_code(),
286            3
287        );
288        assert_eq!(
289            Error::CapabilityUnavailable {
290                format: FormatId::new("lzma"),
291                available: "read as a zip entry",
292                requested: "written as one",
293            }
294            .exit_code(),
295            3
296        );
297        assert_ne!(
298            unsupported.exit_code(),
299            1,
300            "exit 1 is an internal failure; a capability limit is actionable and must be \
301             distinguishable from one"
302        );
303    }
304
305    #[test]
306    fn chain_too_deep_is_a_resource_limit_not_a_generic_failure() {
307        // A refused nesting bomb must be distinguishable from an internal
308        // error — falling through the wildcard to 1 would erase that.
309        assert_eq!(Error::ChainTooDeep { depth: 4 }.exit_code(), 6);
310    }
311
312    #[test]
313    fn not_an_archive_names_the_chain_and_is_a_usage_error() {
314        let e = Error::NotAnArchive {
315            chain: "gzip".into(),
316        };
317        assert!(
318            e.to_string().contains("gzip"),
319            "must name what the input resolved to: {e}"
320        );
321        assert_eq!(e.exit_code(), 2);
322    }
323
324    #[test]
325    fn unknown_format_reports_what_was_actually_seen() {
326        // The error must name the bytes, otherwise debugging a misdetection
327        // means adding print statements to the library.
328        let e = Error::UnknownFormat {
329            seen: "1f 8b 08".into(),
330        };
331        assert!(e.to_string().contains("1f 8b 08"));
332    }
333
334    /// "You pointed the verb at the wrong file" is one answer, not three.
335    ///
336    /// `NotAnArchive` already exited 2. `UnknownFormat` and
337    /// `AmbiguousFormat` fell through the wildcard to 1 — an internal
338    /// failure — so `stuffr list plain.txt.gz` exited 2 and `stuffr list
339    /// plain.txt` exited 1 for the same class of mistake. Explicit arms,
340    /// not the wildcard: that wildcard has now produced a wrong code four
341    /// times in this phase.
342    #[test]
343    fn pointing_a_verb_at_the_wrong_file_is_always_exit_two() {
344        assert_eq!(
345            Error::UnknownFormat {
346                seen: "de ad be ef".into()
347            }
348            .exit_code(),
349            2,
350            "an undetectable format is the caller's mistake, not an internal failure"
351        );
352        let ambiguous = Error::AmbiguousFormat {
353            candidates: "lzma, lzip".into(),
354        };
355        assert_eq!(
356            ambiguous.exit_code(),
357            2,
358            "a message that says `Pass --format to choose` is actionable, so exit 2"
359        );
360        assert!(
361            ambiguous.to_string().contains("--format"),
362            "and it must keep saying so: {ambiguous}"
363        );
364        // The neighbour they must agree with.
365        assert_eq!(
366            Error::NotAnArchive {
367                chain: "gzip".into()
368            }
369            .exit_code(),
370            2
371        );
372        // And the codes they must NOT collide with: a build-capability
373        // limit is still 3, a damaged file still 5.
374        assert_eq!(Error::Unsupported("no".into()).exit_code(), 3);
375        assert_eq!(Error::Corrupt("bad".into()).exit_code(), 5);
376    }
377}