Skip to main content

stuffr_core/
probe.rs

1//! Format detection and pipeline resolution.
2//!
3//! Streams have no filename, so extensions are a hint and never the decision.
4//!
5//! [`resolve_chain`] is the single-layer resolution: outer format only, with
6//! the inner layer decided from the path's remaining extensions when one is
7//! given ([`inner_from_path`]) and [`Chain::Raw`] otherwise. [`resolve_chain_deep`]
8//! closes that gap: whenever the outer layer is a codec and the inner layer
9//! is still [`Chain::Raw`] — no path at all (stdin), or a path whose
10//! extensions ran out — it decodes that layer, peeks [`PROBE_LEN`] of the
11//! *decoded* bytes, and resolves again — recursively, bounded by
12//! [`MAX_CHAIN_DEPTH`] on both routes, so that neither a piped stream nor a
13//! misleadingly-named file can turn re-probing a decoded stream into an
14//! unbounded decompression-nesting attack. Either way the source it returns
15//! is already decoded through every codec layer named in the resolved
16//! `Chain`, so a caller never has to guess which route produced it.
17
18use std::path::Path;
19
20use crate::archive::DecodeOpts;
21use crate::error::{Error, Result};
22use crate::format::{FormatId, FormatKind};
23use crate::registry::Registry;
24use crate::source::{PeekSource, Source};
25
26/// The detection window, per the spec.
27pub const PROBE_LEN: usize = 4096;
28
29/// Maximum layers when re-probing a decoded stream.
30///
31/// Four, chosen against real shapes: `.tar.gz` is depth 2 and `.tar.gz.gz` at
32/// depth 3 is already pathological, so this leaves headroom without admitting
33/// a decompression-nesting bomb. `Chain` is recursive, so the bound lives at
34/// the resolution site rather than in the type.
35pub const MAX_CHAIN_DEPTH: usize = 4;
36
37/// Reads a bounded prefix without consuming it.
38///
39/// A seekable source is rewound; a pipe is wrapped in a [`PeekSource`] that
40/// replays the prefix. Either way the caller still sees byte zero.
41pub fn probe(mut src: Box<dyn Source>) -> Result<(Vec<u8>, Box<dyn Source>)> {
42    if src.caps().seekable {
43        let mut prefix = vec![0u8; PROBE_LEN];
44        // `caps().seekable` is the `Source`'s own claim; `as_seek()` is a
45        // second, independent method on the same trait, and `Source` is
46        // public — a third-party codec (Phase 2+) can implement one without
47        // honouring the other. Trusting the claim with `.expect(..)` turned a
48        // buggy-but-foreign `Source` impl into a panic reachable through this
49        // crate's own public `probe`; surfacing it as a typed error instead
50        // lets a caller match on it same as any other seek failure. There is
51        // no format to name yet at this stage of detection, so the sentinel
52        // id below is a placeholder, not a registered format.
53        let Some(seek) = src.as_seek() else {
54            return Err(Error::NotSeekable {
55                format: FormatId::new("<probe>"),
56            });
57        };
58        let mut filled = 0;
59        while filled < PROBE_LEN {
60            match std::io::Read::read(seek, &mut prefix[filled..])? {
61                0 => break,
62                n => filled += n,
63            }
64        }
65        prefix.truncate(filled);
66        std::io::Seek::seek(seek, std::io::SeekFrom::Start(0))?;
67        return Ok((prefix, src));
68    }
69
70    let peek = PeekSource::fill(src, PROBE_LEN)?;
71    let prefix = peek.prefix().to_vec();
72    Ok((prefix, Box::new(peek)))
73}
74
75/// A resolved decode pipeline.
76#[non_exhaustive]
77#[derive(Clone, PartialEq, Eq, Debug)]
78pub enum Chain {
79    /// A codec wrapping something else. Decode order: this codec, then `inner`.
80    Codec { codec: FormatId, inner: Box<Chain> },
81    /// A container of entries.
82    Container { container: FormatId },
83    /// Opaque bytes. The terminal case for a bare compressed file.
84    Raw,
85}
86
87impl Chain {
88    /// The container this chain resolves to, if any, at ANY depth.
89    ///
90    /// `Some(tar)` for `tar over gzip` as well as for a bare `tar`: however
91    /// many codec layers sit above it, the entries live in a container, and a
92    /// caller that has not OPENED that container cannot speak for its
93    /// fidelity. That is the whole use — `ops::inspect` identifies a stream
94    /// WITHOUT decoding it, so it never opens the container and must not
95    /// report a fidelity conclusion it did not reach. See
96    /// `ops::Inspection::fidelity_evaluated`.
97    ///
98    /// The `_` arm covers [`Chain::Raw`] and anything added later: `Chain` is
99    /// `#[non_exhaustive]`, and "no container" is the right answer for a
100    /// shape this method does not recognise, since the caller's only use of
101    /// `Some` is to withhold a claim.
102    pub fn container(&self) -> Option<FormatId> {
103        match self {
104            Chain::Container { container } => Some(*container),
105            Chain::Codec { inner, .. } => inner.container(),
106            _ => None,
107        }
108    }
109
110    /// The outermost codec, if any — the layer a writer must create FIRST,
111    /// because the container writes into it.
112    ///
113    /// Deliberately only the outermost, and deliberately NOT recursive the
114    /// way [`Chain::container`] is: a write-side chain is at most one codec
115    /// deep. `MAX_CHAIN_DEPTH` bounds the READ side, where deeper nesting is
116    /// a hostile-input concern rather than a shape stuffr emits, and a
117    /// recursive answer here would let a caller silently write only the
118    /// innermost of several codecs.
119    pub fn outermost_codec(&self) -> Option<FormatId> {
120        match self {
121            Chain::Codec { codec, .. } => Some(*codec),
122            _ => None,
123        }
124    }
125
126    /// Human-readable form, innermost first: `"tar over gzip"`.
127    pub fn describe(&self) -> String {
128        match self {
129            Chain::Raw => "raw".to_string(),
130            Chain::Container { container } => container.to_string(),
131            Chain::Codec { codec, inner } => match inner.as_ref() {
132                Chain::Raw => codec.to_string(),
133                other => format!("{} over {}", other.describe(), codec),
134            },
135        }
136    }
137}
138
139fn hex_prefix(bytes: &[u8]) -> String {
140    if bytes.is_empty() {
141        return "empty input".to_string();
142    }
143    bytes
144        .iter()
145        .take(8)
146        .map(|b| format!("{b:02x}"))
147        .collect::<Vec<_>>()
148        .join(" ")
149}
150
151/// Lowercase extension components of a path, outermost last.
152/// `"out.tar.gz"` → `["tar", "gz"]`.
153fn extensions(path: &Path) -> Vec<String> {
154    let name = path
155        .file_name()
156        .and_then(|s| s.to_str())
157        .unwrap_or_default();
158    name.split('.')
159        .skip(1)
160        .map(|s| s.to_ascii_lowercase())
161        .collect()
162}
163
164/// Resolves the inner layer beneath an outer codec, using the path's remaining
165/// extensions. `.tar.gz` → tar; `.tgz` → tar; `.gz` or no path → Raw.
166fn inner_from_path(reg: &Registry, path: Option<&Path>, outer: FormatId) -> Chain {
167    let Some(path) = path else {
168        return Chain::Raw;
169    };
170    let exts = extensions(path);
171
172    // `.tgz` and friends: one extension meaning "tar inside <codec>", spelled
173    // as "t" + the codec's own extension. Resolved through the SAME
174    // [`compound_alias`] the write side uses, never a second copy of the strip
175    // rule — the copy is how `.tbz` came to mean tar-over-bzip2 when reading
176    // and a bare gzip when writing, which is the exact defect class
177    // `chain_for_new_path` shares this function to prevent.
178    if let Some(last) = exts.last()
179        && compound_alias(reg, last) == Some(outer)
180        && let Some(id) = reg.by_extension("tar")
181        && reg.container(id).is_some()
182    {
183        return Chain::Container { container: id };
184    }
185
186    // Otherwise look one extension inwards.
187    if exts.len() >= 2
188        && let Some(id) = reg.by_extension(&exts[exts.len() - 2])
189        && reg.container(id).is_some()
190    {
191        return Chain::Container { container: id };
192    }
193    Chain::Raw
194}
195
196/// The codec a `.tgz`-style compound extension names — "t" + the codec's own
197/// extension, meaning "tar inside <codec>".
198///
199/// Needed only on the write side. The read side takes the outer codec from
200/// the magic bytes and never has to parse `tgz` at all; a file being created
201/// has no magic, so the name is the only evidence there is. No codec
202/// registers its own compound alias and none should — `tar::meta` spells out
203/// why registering `tgz` on tar would make a `.tgz` resolve to a bare tar —
204/// so the suffix is stripped here instead, and `inner_from_path` calls this
205/// same function when deciding what is inside.
206///
207/// # `.tlz` means tar-over-**lzip** here, and that is a decision
208///
209/// The strip rule sends `.tlz` to `lz`, which lzip registers, so stuffr reads
210/// and writes `.tlz` as `tar.lz` — lzip. **GNU tar and libarchive both read
211/// `.tlz` as `tar.lzma`** (LZMA1, the `.lzma` alone-format), so an archive
212/// stuffr writes as `.tlz` will not open in GNU tar, and one GNU tar wrote
213/// will not open here.
214///
215/// It is left as it is, deliberately and for now: `lzip` is the format
216/// actually worth writing of the two (it carries a CRC and a recognised
217/// magic, where `.lzma` has neither), the spelling is genuinely contested
218/// rather than settled — `.tlz` predates lzip and both readings are in the
219/// wild — and changing it would silently reinterpret existing files. What is
220/// NOT acceptable is that the answer was an accident of which extension
221/// happened to be registered; it is written down here so the next change to
222/// it is a decision rather than a side effect. If it is ever revisited, the
223/// honest options are to refuse `.tlz` outright as ambiguous, or to add it to
224/// [`COMPOUND_SPELLINGS`] pointing at `lzma`.
225fn compound_alias(reg: &Registry, ext: &str) -> Option<FormatId> {
226    let stripped = ext.strip_prefix(['t', 'T'])?;
227    if stripped.is_empty() {
228        return None;
229    }
230    let stripped = stripped.to_ascii_lowercase();
231    let spelling = COMPOUND_SPELLINGS
232        .iter()
233        .find(|(alias, _)| *alias == stripped)
234        .map_or(stripped.as_str(), |(_, real)| *real);
235    let id = reg.by_extension(spelling)?;
236    // A container never sits inside itself: `.tar` is resolved by the direct
237    // lookup above, and anything reached through the alias must be a codec.
238    reg.container(id).is_none().then_some(id)
239}
240
241/// The compound spellings whose remainder after the `t` is NOT the codec's
242/// own registered extension, and what it really means.
243///
244/// Without this table the strip rule simply fails to find a codec and the
245/// whole name resolves to [`Chain::Raw`] — which on the write side is not a
246/// refusal but a silent fallback to `default_format()`, so **`stuffr pack
247/// notes.txt -o f.tbz` wrote a gzip stream, with no tar inside it, at exit
248/// 0.** A name promising one thing and bytes that are another, reported as
249/// success, is the defect class the write-side chain resolution exists to
250/// close, and `.tbz` was its last surviving member.
251///
252/// The table lives here rather than in the registry ON PURPOSE. Registering
253/// `bz` as an extension of bzip2 would fix `.tbz` and simultaneously make a
254/// plain `.bz` writable — and `.bz` is **bzip1**, a different, obsolete
255/// format this build cannot produce. The alias is only ever consulted in the
256/// `t`-prefixed compound position, so `stuffr pack notes.txt -o f.bz` still
257/// finds no format for `bz` and behaves exactly as it did.
258///
259/// GNU tar and libarchive both read `.tbz` as `tar.bz2`; `.tbz2` needs no
260/// entry, because stripping its `t` already yields bzip2's registered `bz2`.
261const COMPOUND_SPELLINGS: &[(&str, &str)] = &[("bz", "bz2")];
262
263/// The chain an OUTPUT path names, from its extensions alone.
264///
265/// The write-side twin of [`resolve_chain`], which cannot serve here: that one
266/// sniffs magic bytes, and a file about to be created has none. Shares
267/// `inner_from_path` with the read side deliberately — a second, write-side
268/// extension table would drift, and `.tar.gz` resolving one way for reading
269/// and another for writing is the whole class of bug this exists to prevent.
270///
271/// Never fails: an unknown or absent extension is [`Chain::Raw`], which every
272/// caller already has to handle.
273pub fn chain_for_new_path(reg: &Registry, path: &Path) -> Chain {
274    let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
275        return Chain::Raw;
276    };
277    let Some(outer) = reg.by_extension(ext).or_else(|| compound_alias(reg, ext)) else {
278        return Chain::Raw;
279    };
280    // `x.tar` — a container with nothing above it.
281    if reg.container(outer).is_some() {
282        return Chain::Container { container: outer };
283    }
284    // `outer` is a codec. `x.tar.gz` and `x.tgz` both name a container inside
285    // it; `x.gz` names nothing, and resolves to a bare codec over Raw.
286    let inner = inner_from_path(reg, Some(path), outer);
287    Chain::Codec {
288        codec: outer,
289        inner: Box::new(inner),
290    }
291}
292
293/// Turns a path and/or a probed prefix into a concrete pipeline.
294///
295/// The extension is consulted first, against the full magic-hit set; priority
296/// decides only when the path is silent or names a format whose magic did not
297/// match. Failure names the bytes actually seen so a misdetection is
298/// debuggable without library prints.
299pub fn resolve_chain(reg: &Registry, path: Option<&Path>, prefix: &[u8]) -> Result<Chain> {
300    let mut candidates = reg.match_magic(prefix);
301
302    if candidates.len() > 1 {
303        // The extension is consulted FIRST, and against every magic hit — a
304        // path saying `.apk` must reach apk even though zip outranks it. That
305        // is the spec's own worked example, and ranking cannot be allowed to
306        // pre-empt an explicit filename. Priority decides only when the path
307        // is silent, or names a format whose magic never matched.
308        let by_ext =
309            path.and_then(|p| extensions(p).iter().rev().find_map(|e| reg.by_extension(e)));
310        match by_ext.filter(|id| candidates.contains(id)) {
311            Some(id) => candidates = vec![id],
312            None => {
313                let top = reg.priority_of(candidates[0]);
314                let tied: Vec<FormatId> = candidates
315                    .iter()
316                    .copied()
317                    .filter(|id| reg.priority_of(*id) == top)
318                    .collect();
319                if tied.len() == 1 {
320                    candidates = tied;
321                } else {
322                    let names: Vec<&str> = tied.iter().map(|id| id.as_str()).collect();
323                    return Err(Error::AmbiguousFormat {
324                        candidates: names.join(", "),
325                    });
326                }
327            }
328        }
329    }
330
331    let outer = candidates.first().copied().or_else(|| {
332        path.and_then(|p| extensions(p).iter().rev().find_map(|e| reg.by_extension(e)))
333    });
334
335    let Some(outer) = outer else {
336        return Err(Error::UnknownFormat {
337            seen: hex_prefix(prefix),
338        });
339    };
340
341    let kind = if reg.container(outer).is_some() {
342        FormatKind::Container
343    } else if reg.codec(outer).is_some() {
344        FormatKind::Codec
345    } else {
346        return Err(Error::FormatNotEnabled(outer));
347    };
348
349    Ok(match kind {
350        FormatKind::Container => Chain::Container { container: outer },
351        FormatKind::Codec => Chain::Codec {
352            codec: outer,
353            inner: Box::new(inner_from_path(reg, path, outer)),
354        },
355    })
356}
357
358/// Builds `innermost` back up under each codec in `layers`, outermost last so
359/// the final fold produces the correct nesting order.
360fn wrap_layers(layers: &[FormatId], innermost: Chain) -> Chain {
361    layers
362        .iter()
363        .rev()
364        .fold(innermost, |inner, codec| Chain::Codec {
365            codec: *codec,
366            inner: Box::new(inner),
367        })
368}
369
370/// Decodes `src` through every [`Chain::Codec`] layer named in `chain`,
371/// outermost first, so the returned source is positioned exactly where
372/// `chain`'s innermost entry (a [`Chain::Container`] or [`Chain::Raw`])
373/// begins. A chain with no codec layers at all (a bare container) hands
374/// `src` back untouched.
375fn decode_through_chain(
376    reg: &Registry,
377    chain: &Chain,
378    src: Box<dyn Source>,
379    opts: &DecodeOpts,
380) -> Result<Box<dyn Source>> {
381    match chain {
382        Chain::Codec { codec, inner } => {
383            let decoded = reg.require_codec(*codec)?.decoder(src, opts)?;
384            decode_through_chain(reg, inner, decoded, opts)
385        }
386        Chain::Container { .. } | Chain::Raw => Ok(src),
387    }
388}
389
390/// [`resolve_chain`], but when the outer layer is a codec whose inner the
391/// path left as [`Chain::Raw`] — whether because there was no path at all,
392/// or because the path's own extensions ran out before naming a container —
393/// keeps going: decodes that layer, peeks [`PROBE_LEN`] of the *decoded*
394/// bytes, and resolves again from the decoded bytes' own magic. Closes the
395/// Phase 0 limitation where a codec on stdin always bottomed out at Raw
396/// purely for lack of an extension.
397///
398/// The path still gets first say: `resolve_chain` runs once with it, and if
399/// that alone already resolves the full chain (a bare container, or a codec
400/// whose inner the extensions named directly — `.tar.gz`), peeking never
401/// happens. Peeking is what runs when the path is silent, or was not enough.
402///
403/// Bounded by [`MAX_CHAIN_DEPTH`] on **both** routes, because re-probing a
404/// *decoded* stream is exactly what makes unbounded nesting attackable: each
405/// layer is cheap to produce and expensive to expand. A path is a name, not
406/// a proof — `a.gz.gz.gz.gz.gz` is refused the same way piped bytes with the
407/// identical shape are, and exceeding the bound is a typed
408/// [`Error::ChainTooDeep`], never a panic and never a silent stop.
409///
410/// Returns the resolved [`Chain`] alongside a [`Source`] **already decoded
411/// through every codec layer named in that `Chain`** — positioned exactly
412/// where the chain's innermost entry begins, ready to hand straight to a
413/// container or to read as raw bytes. This holds on both routes: a path
414/// that resolves in one step (no peeking needed) still gets its codec
415/// layers decoded before the source comes back, so a caller never has to
416/// ask whether the source it received happens to be raw or already decoded.
417pub fn resolve_chain_deep(
418    reg: &Registry,
419    path: Option<&Path>,
420    src: Box<dyn Source>,
421) -> Result<(Chain, Box<dyn Source>)> {
422    resolve_chain_deep_with(reg, path, src, &DecodeOpts::default())
423}
424
425/// [`resolve_chain_deep`], but with the [`DecodeOpts`] every codec layer's
426/// decoder is built from spelled out by the caller instead of defaulted.
427///
428/// This exists because [`DecodeOpts::memory_limit`] is the ONLY guard that
429/// can see a pure codec's pre-output dictionary allocation — the one sized
430/// from a value the file declares in its own header, before a single decoded
431/// byte exists. `--max-ratio` counts decoded output bytes and so cannot see
432/// it at all. Resolving a chain builds a decoder per codec layer, so a
433/// resolver that defaults its opts leaves every entry-aware verb (`list`,
434/// `test`, `cat`, `unpack -C`) unbounded against a crafted `.tar.lz` whose
435/// header declares a 512 MiB dictionary, while the single-stream path
436/// refuses the same bytes at exit 6.
437///
438/// `resolve_chain_deep` remains as the "library default" spelling —
439/// unbounded, matching `DecodeOpts::default()` everywhere else — but no CLI
440/// path should use it: the CLI always has a resolved limit to pass.
441pub fn resolve_chain_deep_with(
442    reg: &Registry,
443    path: Option<&Path>,
444    src: Box<dyn Source>,
445    opts: &DecodeOpts,
446) -> Result<(Chain, Box<dyn Source>)> {
447    let (prefix, src) = probe(src)?;
448    let chain = resolve_chain(reg, path, &prefix)?;
449
450    // Either a bare container, or a codec whose inner the path already named
451    // (e.g. `.tar.gz`) — the path (if any) is done contributing. Decode
452    // through whatever codec layers `chain` names and stop; there is nothing
453    // left to peek.
454    if !matches!(&chain, Chain::Codec { inner, .. } if matches!(inner.as_ref(), Chain::Raw)) {
455        let decoded = decode_through_chain(reg, &chain, src, opts)?;
456        return Ok((chain, decoded));
457    }
458
459    // The chain bottoms out at Raw. From here, only the decoded bytes' own
460    // magic decides — the path, if there was one, already had its say above
461    // and found nothing further.
462    let Chain::Codec { codec, .. } = chain else {
463        unreachable!("guarded above: chain is Chain::Codec with a Raw inner")
464    };
465    let mut layers: Vec<FormatId> = vec![codec];
466    let mut current_codec = codec;
467    let mut src = src;
468    let mut depth = 1usize;
469
470    loop {
471        if depth >= MAX_CHAIN_DEPTH {
472            return Err(Error::ChainTooDeep {
473                depth: MAX_CHAIN_DEPTH,
474            });
475        }
476
477        let decoded = reg.require_codec(current_codec)?.decoder(src, opts)?;
478        // This `probe` reads through a DECODER — unlike the one at the top
479        // of this function, and unlike `resolve_chain_deep_with`'s own
480        // `probe(src)` two calls up, both of which read the RAW,
481        // undecoded source. An `io::Error` here means the bytes this codec
482        // just produced are malformed, not that the raw source or the
483        // environment failed, so it is reclassified via
484        // `Error::from_decode_io` exactly like every other decoder-side
485        // read in this crate (`ops.rs`, `entries.rs`, `conformance.rs`).
486        //
487        // Left as a bare `?` (as it was until Task 4b), a truncated `.zz`
488        // or an empty `.lz` — a codec stream with no container above it, so
489        // resolution bottoms out in this very loop re-probing the decoded
490        // bytes — surfaced as `Error::Io`: exit 1, "stuffr failed", for
491        // input that is simply corrupt. `ops::decompress` decodes the
492        // identical bytes through a call already wired to
493        // `Error::from_decode_io`, so `cat` and `list` disagreed on the
494        // exit code for the same input; see `error.rs`'s doc comment on
495        // `from_decode_io` for the convention this leans on, and
496        // `Error::exit_code`'s comment for this wildcard's history.
497        //
498        // Do NOT widen this to `probe(src)` above (raw source) or to
499        // `probe`/`PeekSource::fill` themselves — either would reclassify a
500        // genuine i/o failure on the raw source (a real disk error, a
501        // permission failure, a broken pipe) as `Corrupt`, which is a new
502        // lie in the opposite direction.
503        let (next_prefix, next_src) = probe(decoded).map_err(|e| match e {
504            Error::Io(io_err) => Error::from_decode_io(io_err),
505            other => other,
506        })?;
507        depth += 1;
508
509        let next_chain = match resolve_chain(reg, None, &next_prefix) {
510            Ok(chain) => chain,
511            Err(Error::UnknownFormat { .. }) => {
512                return Ok((wrap_layers(&layers, Chain::Raw), next_src));
513            }
514            Err(e) => return Err(e),
515        };
516
517        let Chain::Codec { codec: nested, .. } = next_chain else {
518            return Ok((wrap_layers(&layers, next_chain), next_src));
519        };
520
521        layers.push(nested);
522        current_codec = nested;
523        src = next_src;
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::archive::{Codec, DecodeOpts, EncodeOpts, Sink};
531    use crate::format::{CodecCaps, FormatId, FormatMeta, MagicRule};
532    use crate::registry::Registry;
533    use crate::source::{ReaderSource, Source, StreamOnly};
534    use crate::testing::{
535        MOCK_CODEC, MOCK_CONTAINER, MockCodec, MockContainer, SharedBuf, mock_archive_bytes,
536    };
537    use std::io;
538    use std::io::{Read, Write};
539    use std::path::Path;
540    use std::sync::Arc;
541
542    const GZIP: FormatId = FormatId::new("gzip");
543    const TAR: FormatId = FormatId::new("tar");
544
545    const GZIP_MAGIC: &[MagicRule] = &[MagicRule {
546        offset: 0,
547        bytes: &[0x1f, 0x8b],
548        format: GZIP,
549    }];
550    const TAR_MAGIC: &[MagicRule] = &[MagicRule {
551        offset: 257,
552        bytes: b"ustar",
553        format: TAR,
554    }];
555
556    fn registry() -> Registry {
557        let mut r = Registry::new();
558        r.register_codec(
559            Arc::new(MockCodec),
560            FormatMeta::codec(GZIP, &["gz", "tgz"], GZIP_MAGIC),
561        );
562        r.register_container(
563            Arc::new(MockContainer),
564            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
565        );
566        r
567    }
568
569    /// `registry()` above registers the mock codec under BOTH `gz` and `tgz`,
570    /// and the mock container under `tar` — exactly the shape the compound
571    /// alias needs, so no new fixture is required here.
572    #[test]
573    fn an_output_path_resolves_to_the_chain_its_name_promises() {
574        let reg = registry();
575        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
576        assert_eq!(c("x.tar"), "tar");
577        assert_eq!(c("x.gz"), "gzip");
578        assert_eq!(c("x.tar.gz"), "tar over gzip");
579        assert_eq!(
580            c("x.tgz"),
581            "tar over gzip",
582            "the compound alias must resolve too"
583        );
584        assert_eq!(c("x.TAR.GZ"), "tar over gzip", "extensions are caseless");
585        assert_eq!(c("x.txt"), "raw");
586        assert_eq!(c("x"), "raw");
587    }
588
589    /// Finding 4 of the Phase 2c final review. `bzip2` registers `bz2` and
590    /// nothing else, so stripping `.tbz`'s `t` looked up `bz`, found no
591    /// format, and resolved the whole name to [`Chain::Raw`] — which on the
592    /// write side is not a refusal but a silent fall back to the default
593    /// codec, so `-o f.tbz` wrote a gzip with no tar inside it at exit 0.
594    ///
595    /// The registry is deliberately the one place NOT fixed: `.bz` is bzip1.
596    #[test]
597    fn tbz_means_tar_over_bzip2_without_making_plain_bz_writable() {
598        const BZIP2: FormatId = FormatId::new("bzip2");
599        const BZIP2_MAGIC: &[MagicRule] = &[MagicRule {
600            offset: 0,
601            bytes: b"BZh",
602            format: BZIP2,
603        }];
604        let mut reg = Registry::new();
605        // `bz2` ONLY — the real bzip2 codec's whole extension list.
606        reg.register_codec(
607            Arc::new(MockCodec),
608            FormatMeta::codec(BZIP2, &["bz2"], BZIP2_MAGIC),
609        );
610        reg.register_container(
611            Arc::new(MockContainer),
612            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
613        );
614
615        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
616        assert_eq!(
617            c("f.tbz"),
618            "tar over bzip2",
619            "GNU tar and libarchive both read .tbz as tar.bz2"
620        );
621        assert_eq!(c("f.TBZ"), "tar over bzip2", "and extensions are caseless");
622        assert_eq!(
623            c("f.tbz2"),
624            "tar over bzip2",
625            "the spelling that always worked must keep working"
626        );
627        assert_eq!(
628            c("f.tar.bz2"),
629            "tar over bzip2",
630            "and so must the long form"
631        );
632
633        // The half that says WHERE the fix lives. `bz` is bzip1, a different
634        // and obsolete format: registering it on bzip2 would have fixed
635        // `.tbz` and simultaneously made `-o f.bz` write a bzip2 stream under
636        // a name that promises bzip1.
637        assert_eq!(
638            c("f.bz"),
639            "raw",
640            "`bz` must resolve to nothing outside the t-prefixed compound form"
641        );
642
643        // The READ side resolves the same name through the same function —
644        // that sharing is the point, and a second write-side table is what
645        // would let `.tbz` mean one thing to `pack` and another to `list`.
646        let chain = resolve_chain(&reg, Some(Path::new("f.tbz")), b"BZh\0").unwrap();
647        assert_eq!(
648            chain.describe(),
649            "tar over bzip2",
650            "reading a .tbz must agree with writing one"
651        );
652    }
653
654    /// The pair a writer actually consumes: the container to create, and the
655    /// codec to create FIRST because the container writes into it.
656    #[test]
657    fn the_outermost_codec_is_the_layer_a_writer_creates_first() {
658        let reg = registry();
659        let pair = |p: &str| {
660            let c = chain_for_new_path(&reg, Path::new(p));
661            (c.container(), c.outermost_codec())
662        };
663        assert_eq!(pair("x.tar"), (Some(TAR), None));
664        assert_eq!(pair("x.gz"), (None, Some(GZIP)));
665        assert_eq!(pair("x.tar.gz"), (Some(TAR), Some(GZIP)));
666        assert_eq!(pair("x.tgz"), (Some(TAR), Some(GZIP)));
667        assert_eq!(pair("x.txt"), (None, None));
668    }
669
670    #[test]
671    fn probe_does_not_consume_the_stream() {
672        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(
673            b"HEADERbody".to_vec(),
674        )));
675        let (prefix, mut rest) = probe(src).unwrap();
676        assert_eq!(&prefix[..6], b"HEADER");
677
678        let mut all = Vec::new();
679        rest.read_to_end(&mut all).unwrap();
680        assert_eq!(all, b"HEADERbody", "the consumer must still see byte zero");
681    }
682
683    #[test]
684    fn probe_reads_at_most_the_window() {
685        let big = vec![b'x'; PROBE_LEN * 3];
686        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(big)));
687        let (prefix, _) = probe(src).unwrap();
688        assert_eq!(prefix.len(), PROBE_LEN);
689    }
690
691    #[test]
692    fn magic_identifies_a_bare_codec_stream() {
693        let r = registry();
694        let chain = resolve_chain(&r, Some(Path::new("blob.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
695        assert_eq!(
696            chain,
697            Chain::Codec {
698                codec: GZIP,
699                inner: Box::new(Chain::Raw)
700            }
701        );
702        assert_eq!(chain.describe(), "gzip");
703    }
704
705    #[test]
706    fn a_double_extension_nests_the_container_inside_the_codec() {
707        let r = registry();
708        let chain = resolve_chain(&r, Some(Path::new("out.tar.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
709        assert_eq!(
710            chain,
711            Chain::Codec {
712                codec: GZIP,
713                inner: Box::new(Chain::Container { container: TAR })
714            }
715        );
716        assert_eq!(chain.describe(), "tar over gzip");
717    }
718
719    #[test]
720    fn a_codec_on_stdin_resolves_to_raw_inner() {
721        // No path means no extension hint. Phase 2 refines this by peeking
722        // after the first decoded block; until then Raw is the honest answer.
723        let r = registry();
724        let chain = resolve_chain(&r, None, &[0x1f, 0x8b, 0x08]).unwrap();
725        assert_eq!(
726            chain,
727            Chain::Codec {
728                codec: GZIP,
729                inner: Box::new(Chain::Raw)
730            }
731        );
732    }
733
734    #[test]
735    fn container_magic_resolves_directly() {
736        let r = registry();
737        let mut prefix = vec![0u8; 512];
738        prefix[257..262].copy_from_slice(b"ustar");
739        let chain = resolve_chain(&r, Some(Path::new("x.tar")), &prefix).unwrap();
740        assert_eq!(chain, Chain::Container { container: TAR });
741        assert_eq!(chain.describe(), "tar");
742    }
743
744    #[test]
745    fn extension_is_the_fallback_when_magic_says_nothing() {
746        let r = registry();
747        let chain = resolve_chain(&r, Some(Path::new("mystery.tar")), b"no magic here").unwrap();
748        assert_eq!(chain, Chain::Container { container: TAR });
749    }
750
751    /// `Chain::container` must see THROUGH codec layers, not only name a
752    /// bare container. `ops::inspect` uses it to decide whether it may report
753    /// a fidelity conclusion, and `backup.tar.gz` has a container it has not
754    /// opened just as surely as `backup.tar` does — a non-recursive version
755    /// would quietly re-introduce the false negative for every `.tar.gz`.
756    #[test]
757    fn chain_container_sees_through_codec_layers() {
758        let tar = FormatId::new("tar");
759        assert_eq!(Chain::Container { container: tar }.container(), Some(tar));
760        assert_eq!(
761            Chain::Codec {
762                codec: GZIP,
763                inner: Box::new(Chain::Container { container: tar }),
764            }
765            .container(),
766            Some(tar),
767            "tar over gzip still has a container nobody has opened"
768        );
769        assert_eq!(
770            Chain::Codec {
771                codec: GZIP,
772                inner: Box::new(Chain::Raw),
773            }
774            .container(),
775            None,
776            "a bare codec stream has no container, so an empty warning list is a real finding"
777        );
778        assert_eq!(Chain::Raw.container(), None);
779    }
780
781    #[test]
782    fn tgz_single_extension_still_nests_tar_inside_gzip() {
783        let r = registry();
784        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
785        assert_eq!(
786            chain,
787            Chain::Codec {
788                codec: GZIP,
789                inner: Box::new(Chain::Container { container: TAR })
790            }
791        );
792    }
793
794    #[test]
795    fn a_compound_extension_resolves_without_a_registered_compound_alias() {
796        // `.tgz` means "t" + gzip's own extension. Resolving it must not depend
797        // on gzip having separately registered "tgz" itself: a codec that
798        // forgets that alias would otherwise silently yield Raw instead of
799        // tar-over-<codec>, with no error raised.
800        let mut r = Registry::new();
801        r.register_codec(
802            Arc::new(MockCodec),
803            FormatMeta::codec(GZIP, &["gz"], GZIP_MAGIC), // deliberately NO "tgz" alias
804        );
805        r.register_container(
806            Arc::new(MockContainer),
807            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
808        );
809
810        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
811        assert_eq!(
812            chain,
813            Chain::Codec {
814                codec: GZIP,
815                inner: Box::new(Chain::Container { container: TAR })
816            }
817        );
818    }
819
820    #[test]
821    fn probe_does_not_consume_a_seekable_stream_either() {
822        // Non-consumption has two implementations — rewind for a seekable
823        // source, replay for a pipe. Only the pipe path was covered.
824        let mut f = tempfile::NamedTempFile::new().unwrap();
825        std::io::Write::write_all(&mut f, b"HEADERbody").unwrap();
826        let (_, path) = f.keep().unwrap();
827        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
828
829        let (prefix, mut rest) = probe(src).unwrap();
830        assert_eq!(&prefix[..6], b"HEADER");
831
832        let mut all = Vec::new();
833        rest.read_to_end(&mut all).unwrap();
834        assert_eq!(
835            all, b"HEADERbody",
836            "a seekable source must be rewound to byte zero"
837        );
838    }
839
840    #[test]
841    fn probe_bounds_the_window_on_a_seekable_stream_too() {
842        let big = vec![b'x'; PROBE_LEN * 3];
843        let mut f = tempfile::NamedTempFile::new().unwrap();
844        std::io::Write::write_all(&mut f, &big).unwrap();
845        let (_, path) = f.keep().unwrap();
846        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
847
848        let (prefix, _) = probe(src).unwrap();
849        assert_eq!(prefix.len(), PROBE_LEN);
850    }
851
852    #[test]
853    fn unknown_input_names_the_bytes_it_actually_saw() {
854        // Debugging a misdetection must not require adding prints to the library.
855        let r = registry();
856        let err = resolve_chain(&r, None, &[0xde, 0xad, 0xbe, 0xef]).unwrap_err();
857        let msg = err.to_string();
858        assert!(msg.contains("de ad be ef"), "message was: {msg}");
859        assert!(matches!(err, crate::Error::UnknownFormat { .. }));
860    }
861
862    #[test]
863    fn unknown_empty_input_still_produces_a_usable_message() {
864        let r = registry();
865        let err = resolve_chain(&r, None, b"").unwrap_err();
866        assert!(err.to_string().contains("empty"));
867    }
868
869    #[test]
870    fn equally_ranked_candidates_with_no_extension_are_an_error_not_a_guess() {
871        // A piped stream has no path to disambiguate with. Guessing silently is
872        // how a zip becomes an apk.
873        const A: &[MagicRule] = &[MagicRule {
874            offset: 0,
875            bytes: b"PK",
876            format: FormatId::new("alpha"),
877        }];
878        const B: &[MagicRule] = &[MagicRule {
879            offset: 0,
880            bytes: b"PK",
881            format: FormatId::new("beta"),
882        }];
883        let mut r = Registry::new();
884        r.register_container(
885            Arc::new(MockContainer),
886            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
887        );
888        r.register_container(
889            Arc::new(MockContainer),
890            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
891        );
892
893        let err = resolve_chain(&r, None, b"PKrest").unwrap_err();
894        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
895        let msg = err.to_string();
896        assert!(
897            msg.contains("alpha") && msg.contains("beta"),
898            "must name the candidates: {msg}"
899        );
900        // Exit 2, not 1. The message says "Pass `--format` to choose" —
901        // actionable advice, so this is the caller pointing stuffr at
902        // something it cannot resolve alone, the same class as
903        // `UnknownFormat` and `NotAnArchive`. It fell through
904        // `exit_code`'s wildcard to 1 (an internal failure) until the
905        // Phase 2 final review; see `error.rs`'s
906        // `pointing_a_verb_at_the_wrong_file_is_always_exit_two`.
907        assert_eq!(err.exit_code(), 2);
908    }
909
910    #[test]
911    fn a_higher_priority_candidate_resolves_without_an_extension() {
912        const A: &[MagicRule] = &[MagicRule {
913            offset: 0,
914            bytes: b"PK",
915            format: FormatId::new("alpha"),
916        }];
917        const B: &[MagicRule] = &[MagicRule {
918            offset: 0,
919            bytes: b"PK",
920            format: FormatId::new("beta"),
921        }];
922        let mut r = Registry::new();
923        r.register_container(
924            Arc::new(MockContainer),
925            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A).with_priority(-5),
926        );
927        r.register_container(
928            Arc::new(MockContainer),
929            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
930        );
931
932        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
933        assert_eq!(
934            chain,
935            Chain::Container {
936                container: FormatId::new("beta")
937            }
938        );
939    }
940
941    #[test]
942    fn an_extension_still_disambiguates_equal_candidates() {
943        const A: &[MagicRule] = &[MagicRule {
944            offset: 0,
945            bytes: b"PK",
946            format: FormatId::new("alpha"),
947        }];
948        const B: &[MagicRule] = &[MagicRule {
949            offset: 0,
950            bytes: b"PK",
951            format: FormatId::new("beta"),
952        }];
953        let mut r = Registry::new();
954        r.register_container(
955            Arc::new(MockContainer),
956            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
957        );
958        r.register_container(
959            Arc::new(MockContainer),
960            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
961        );
962
963        let chain = resolve_chain(&r, Some(Path::new("x.beta")), b"PKrest").unwrap();
964        assert_eq!(
965            chain,
966            Chain::Container {
967                container: FormatId::new("beta")
968            }
969        );
970    }
971
972    #[test]
973    fn an_extension_outside_the_tied_set_does_not_break_the_tie() {
974        // A tie between two formats sharing a magic, with a path whose
975        // extension names a THIRD format that does not share it. The extension
976        // must not select a format whose magic never matched, and the error
977        // must name the genuinely tied candidates rather than the irrelevant
978        // one.
979        const A: &[MagicRule] = &[MagicRule {
980            offset: 0,
981            bytes: b"PK",
982            format: FormatId::new("alpha"),
983        }];
984        const B: &[MagicRule] = &[MagicRule {
985            offset: 0,
986            bytes: b"PK",
987            format: FormatId::new("beta"),
988        }];
989        const C: &[MagicRule] = &[MagicRule {
990            offset: 0,
991            bytes: b"GZ",
992            format: FormatId::new("gamma"),
993        }];
994        let mut r = Registry::new();
995        r.register_container(
996            Arc::new(MockContainer),
997            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
998        );
999        r.register_container(
1000            Arc::new(MockContainer),
1001            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
1002        );
1003        r.register_container(
1004            Arc::new(MockContainer),
1005            FormatMeta::container(FormatId::new("gamma"), &["gamma"], C),
1006        );
1007
1008        let err = resolve_chain(&r, Some(Path::new("x.gamma")), b"PKrest").unwrap_err();
1009        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
1010        let msg = err.to_string();
1011        assert!(
1012            msg.contains("alpha") && msg.contains("beta"),
1013            "must name the tied candidates: {msg}"
1014        );
1015        assert!(
1016            !msg.contains("gamma"),
1017            "must not name the irrelevant extension: {msg}"
1018        );
1019    }
1020
1021    #[test]
1022    fn an_extension_reaches_a_lower_priority_format_that_shares_the_magic() {
1023        // The spec's worked example: zip outranks apk on a bare stream, but a
1024        // path saying `.apk` must still reach apk. Ranking must not override an
1025        // explicit filename.
1026        const ZIP_MAGIC: &[MagicRule] = &[MagicRule {
1027            offset: 0,
1028            bytes: b"PK",
1029            format: FormatId::new("zip"),
1030        }];
1031        const APK_MAGIC: &[MagicRule] = &[MagicRule {
1032            offset: 0,
1033            bytes: b"PK",
1034            format: FormatId::new("apk"),
1035        }];
1036        let mut r = Registry::new();
1037        r.register_container(
1038            Arc::new(MockContainer),
1039            FormatMeta::container(FormatId::new("zip"), &["zip"], ZIP_MAGIC),
1040        );
1041        r.register_container(
1042            Arc::new(MockContainer),
1043            FormatMeta::container(FormatId::new("apk"), &["apk"], APK_MAGIC).with_priority(-5),
1044        );
1045
1046        // With no path, the higher-priority zip wins on the bare magic hit.
1047        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
1048        assert_eq!(
1049            chain,
1050            Chain::Container {
1051                container: FormatId::new("zip")
1052            }
1053        );
1054
1055        // Same bytes, but the path names apk — the lower-ranked candidate must
1056        // still be reached; ranking cannot pre-empt an explicit filename.
1057        let chain = resolve_chain(&r, Some(Path::new("archive.apk")), b"PKrest").unwrap();
1058        assert_eq!(
1059            chain,
1060            Chain::Container {
1061                container: FormatId::new("apk")
1062            }
1063        );
1064    }
1065
1066    /// A `Source` that lies: it claims `seekable: true` but `as_seek()`
1067    /// always returns `None`. Nothing in this crate can implement `Source`
1068    /// this way today, but the trait is public and a third-party codec could
1069    /// — the point of this test is that `probe` must survive that instead of
1070    /// panicking.
1071    struct LiesAboutSeekability(std::io::Cursor<Vec<u8>>);
1072
1073    impl Read for LiesAboutSeekability {
1074        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1075            std::io::Read::read(&mut self.0, buf)
1076        }
1077    }
1078
1079    impl Source for LiesAboutSeekability {
1080        fn caps(&self) -> crate::source::SourceCaps {
1081            crate::source::SourceCaps {
1082                seekable: true,
1083                len: Some(self.0.get_ref().len() as u64),
1084            }
1085        }
1086
1087        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1088            None
1089        }
1090    }
1091
1092    #[test]
1093    fn a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking() {
1094        let src: Box<dyn Source> = Box::new(LiesAboutSeekability(std::io::Cursor::new(
1095            b"payload".to_vec(),
1096        )));
1097        // Not `.unwrap_err()`: the `Ok` side is `(Vec<u8>, Box<dyn Source>)`,
1098        // and `Source` is not `Debug`, so `Result::unwrap_err`'s `T: Debug`
1099        // bound can't be satisfied here.
1100        match probe(src) {
1101            Err(err) => assert!(
1102                matches!(err, crate::Error::NotSeekable { .. }),
1103                "must be NotSeekable, not a panic: {err:?}"
1104            ),
1105            Ok(_) => panic!("expected NotSeekable, got Ok"),
1106        }
1107    }
1108
1109    // --- Peek-based inner resolution (Task 4) -----------------------------
1110    //
1111    // `testing::MockCodec`'s bitwise-NOT transform is its own inverse:
1112    // encoding through it an EVEN number of times reproduces the original
1113    // bytes exactly, which would make a nesting-depth test silently
1114    // degenerate (the "6-deep" stream would collapse back to the bare
1115    // container and never exercise the bound at all). `PrefixedMockCodec`
1116    // instead prepends a fixed magic and passes the payload through
1117    // unchanged, so decoding always strips exactly one layer no matter how
1118    // many are stacked.
1119    const NESTED_MAGIC: &[u8] = b"MOCK";
1120
1121    struct PrefixedMockCodec;
1122
1123    impl Codec for PrefixedMockCodec {
1124        fn id(&self) -> FormatId {
1125            MOCK_CODEC
1126        }
1127
1128        fn caps(&self) -> CodecCaps {
1129            CodecCaps {
1130                encode: true,
1131                decode: true,
1132                ..Default::default()
1133            }
1134        }
1135
1136        fn decoder(&self, mut src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1137            let mut discard = vec![0u8; NESTED_MAGIC.len()];
1138            src.read_exact(&mut discard)?;
1139            Ok(Box::new(StreamOnly::new(src)))
1140        }
1141
1142        fn encoder(
1143            &self,
1144            mut dst: Box<dyn Write + Send>,
1145            _o: &EncodeOpts,
1146        ) -> Result<Box<dyn Sink>> {
1147            dst.write_all(NESTED_MAGIC)?;
1148            Ok(Box::new(PassthroughSink(dst)))
1149        }
1150    }
1151
1152    struct PassthroughSink(Box<dyn Write + Send>);
1153
1154    impl Write for PassthroughSink {
1155        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1156            self.0.write(buf)
1157        }
1158        fn flush(&mut self) -> std::io::Result<()> {
1159            self.0.flush()
1160        }
1161    }
1162
1163    impl Sink for PassthroughSink {
1164        fn finish(mut self: Box<Self>) -> Result<()> {
1165            self.0.flush()?;
1166            Ok(())
1167        }
1168    }
1169
1170    fn mock_registry_with_codec_and_container() -> Registry {
1171        const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
1172            offset: 0,
1173            bytes: NESTED_MAGIC,
1174            format: MOCK_CODEC,
1175        }];
1176        const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
1177            offset: 0,
1178            bytes: b"ME",
1179            format: MOCK_CONTAINER,
1180        }];
1181        let mut r = Registry::new();
1182        r.register_codec(
1183            Arc::new(PrefixedMockCodec),
1184            FormatMeta::codec(MOCK_CODEC, &["mock"], CODEC_MAGIC),
1185        );
1186        r.register_container(
1187            Arc::new(MockContainer),
1188            FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC),
1189        );
1190        r
1191    }
1192
1193    /// Wraps `payload` in one `PrefixedMockCodec` layer, via the codec's own
1194    /// `encoder` rather than hand-building the bytes, so the fixture is
1195    /// provably decodable by the exact codec `resolve_chain_deep` will call.
1196    fn mock_codec_encode(payload: &[u8]) -> Vec<u8> {
1197        let sink = SharedBuf::new();
1198        let mut s = PrefixedMockCodec
1199            .encoder(Box::new(sink.clone()), &EncodeOpts::default())
1200            .unwrap();
1201        s.write_all(payload).unwrap();
1202        s.finish().unwrap();
1203        sink.contents()
1204    }
1205
1206    #[test]
1207    fn a_container_inside_a_codec_resolves_from_the_stream_with_no_path() {
1208        let reg = mock_registry_with_codec_and_container();
1209        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1210        let outer = mock_codec_encode(&inner);
1211
1212        let (chain, _src) = resolve_chain_deep(
1213            &reg,
1214            None,
1215            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1216        )
1217        .expect("resolve");
1218
1219        // Before this task the inner layer was Chain::Raw, because no path meant
1220        // no extension to consult. The prefix of the DECODED stream now decides.
1221        assert_eq!(
1222            chain,
1223            Chain::Codec {
1224                codec: MOCK_CODEC,
1225                inner: Box::new(Chain::Container {
1226                    container: MOCK_CONTAINER
1227                }),
1228            },
1229            "a container nested inside a codec must be found by peeking the decoded stream"
1230        );
1231    }
1232
1233    #[test]
1234    fn nesting_beyond_the_depth_bound_is_a_typed_error_naming_the_depth() {
1235        let reg = mock_registry_with_codec_and_container();
1236        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1237        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1238            bytes = mock_codec_encode(&bytes);
1239        }
1240
1241        // Not `.expect_err(..)`: the `Ok` side is `(Chain, Box<dyn Source>)`,
1242        // and `Source` is not `Debug`, so `Result::expect_err`'s `T: Debug`
1243        // bound can't be satisfied here — same reason as
1244        // `a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking`
1245        // above.
1246        match resolve_chain_deep(
1247            &reg,
1248            None,
1249            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1250        ) {
1251            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1252            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1253            Ok(_) => panic!("nesting past the bound must be refused"),
1254        }
1255    }
1256
1257    #[test]
1258    fn nesting_beyond_the_depth_bound_is_refused_via_the_path_route_too() {
1259        // A path is a name, not a proof: `a.gz.gz.gz.gz.gz` must be refused
1260        // exactly like the identical bytes arriving with no path at all.
1261        // `mock_registry_with_codec_and_container` only ever exposes a
1262        // single extension per format ("mock"), so a path naming it gives
1263        // `inner_from_path` nothing to find a container with — the chain
1264        // still bottoms out at Raw, and peeking (and the bound) must engage
1265        // all the same.
1266        let reg = mock_registry_with_codec_and_container();
1267        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1268        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1269            bytes = mock_codec_encode(&bytes);
1270        }
1271
1272        match resolve_chain_deep(
1273            &reg,
1274            Some(Path::new("nested.mock")),
1275            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1276        ) {
1277            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1278            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1279            Ok(_) => panic!("nesting past the bound must be refused, path or no path"),
1280        }
1281    }
1282
1283    // --- Task 4b: a decoder-side io::Error must reclassify; a raw-source
1284    // one must not -------------------------------------------------------
1285
1286    /// A `Source` whose every read raises `InvalidData` — used two ways
1287    /// below: wrapped as the return of a codec's own `decoder()` (so the
1288    /// failure looks exactly like a corrupt payload discovered only once
1289    /// something reads the decoded bytes), and handed directly to
1290    /// `resolve_chain_deep` as the RAW top-level source (so the identical
1291    /// error kind arises before any codec is even involved). The two tests
1292    /// using it must disagree on the resulting `Error` variant — that
1293    /// disagreement is the whole point of Task 4b's fix.
1294    struct AlwaysInvalidData;
1295
1296    impl Read for AlwaysInvalidData {
1297        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1298            Err(std::io::Error::new(
1299                std::io::ErrorKind::InvalidData,
1300                "decoded bytes are malformed",
1301            ))
1302        }
1303    }
1304
1305    impl Source for AlwaysInvalidData {
1306        fn caps(&self) -> crate::source::SourceCaps {
1307            crate::source::SourceCaps {
1308                seekable: false,
1309                len: None,
1310            }
1311        }
1312
1313        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1314            None
1315        }
1316    }
1317
1318    /// A codec whose `decoder()` construction always succeeds but whose FIRST
1319    /// read fails with `InvalidData`, standing in for a corrupt payload behind
1320    /// an intact-looking header.
1321    ///
1322    /// `zlib.rs` is the lazy shape this models. Do NOT read it as "every real
1323    /// codec constructs lazily" — `xz_pure.rs` and `lzip.rs` both read at
1324    /// construction (`fill_buf()`), and reach the same place only because they
1325    /// end in `.unwrap_or(&[])`, which swallows the failure and defers it to
1326    /// the re-probe read this module classifies. An earlier version of this
1327    /// comment named `lzip.rs` as an example of the convention it contradicts.
1328    struct FailingDecodeCodec;
1329
1330    impl Codec for FailingDecodeCodec {
1331        fn id(&self) -> FormatId {
1332            FormatId::new("always-fails-to-decode")
1333        }
1334
1335        fn caps(&self) -> CodecCaps {
1336            CodecCaps {
1337                encode: false,
1338                decode: true,
1339                ..Default::default()
1340            }
1341        }
1342
1343        fn decoder(&self, _src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1344            Ok(Box::new(AlwaysInvalidData))
1345        }
1346
1347        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
1348            unimplemented!("this test never encodes")
1349        }
1350    }
1351
1352    /// The fix itself: a codec stream with no container above it (so
1353    /// resolution bottoms out at `Chain::Raw` and re-probes the decoded
1354    /// bytes, per `resolve_chain_deep_with`'s own doc comment) whose decoded
1355    /// bytes turn out to be malformed must report `Error::Corrupt` — exit 5
1356    /// — not `Error::Io`. Before Task 4b's fix this was `Err(Error::Io(_))`,
1357    /// the identical bug `list_reports_a_truncated_codec_stream_as_corrupt_
1358    /// not_as_an_io_failure` and `list_reports_an_empty_lzip_stream_as_
1359    /// corrupt_not_as_an_io_failure` pin at the CLI level with the real
1360    /// zlib and lzip codecs.
1361    #[test]
1362    fn a_decoder_side_io_error_while_re_probing_becomes_corrupt() {
1363        let mut reg = Registry::new();
1364        const FAILING: FormatId = FormatId::new("always-fails-to-decode");
1365        const FAILING_MAGIC: &[MagicRule] = &[MagicRule {
1366            offset: 0,
1367            bytes: &[0xab, 0xcd],
1368            format: FAILING,
1369        }];
1370        reg.register_codec(
1371            Arc::new(FailingDecodeCodec),
1372            FormatMeta::codec(FAILING, &["fail"], FAILING_MAGIC),
1373        );
1374
1375        // Real, readable bytes for the RAW top-level probe to sniff magic
1376        // from — only the DECODED stream (`AlwaysInvalidData`) ever fails.
1377        let bytes = vec![0xab, 0xcd, 0x00, 0x00];
1378        match resolve_chain_deep(
1379            &reg,
1380            None,
1381            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1382        ) {
1383            Err(Error::Corrupt(msg)) => assert!(
1384                msg.contains("malformed"),
1385                "must carry the decoder's own message via Error::from_decode_io: {msg}"
1386            ),
1387            Err(other) => panic!("expected Corrupt (a decoder-side read failed), got {other:?}"),
1388            Ok(_) => panic!("a decoder that always fails to read cannot resolve successfully"),
1389        }
1390    }
1391
1392    /// The hazard side: the SAME `InvalidData` error kind, but raised by the
1393    /// RAW source before any codec is even chosen, must stay `Error::Io` —
1394    /// exit 1 — never `Error::Corrupt`. This is what a fix that reclassified
1395    /// too broadly (inside `probe`/`PeekSource::fill` themselves, or at
1396    /// `resolve_chain_deep_with`'s own top-level `probe(src)` call) would
1397    /// get wrong: it has nothing to do with the error's `ErrorKind`, only
1398    /// with WHICH read produced it, so this guard cannot be satisfied by
1399    /// checking the kind alone the way the CLI-level directory test does.
1400    #[test]
1401    fn a_raw_source_io_error_of_the_identical_kind_stays_io_not_corrupt() {
1402        let reg = mock_registry_with_codec_and_container();
1403        match resolve_chain_deep(&reg, None, Box::new(AlwaysInvalidData)) {
1404            Err(Error::Io(io_err)) => assert_eq!(
1405                io_err.kind(),
1406                std::io::ErrorKind::InvalidData,
1407                "sanity: this must be the same error kind the test above reclassifies, so the \
1408                 two tests differ only in WHERE the read happened"
1409            ),
1410            Err(other) => panic!(
1411                "a raw-source failure must stay Io, not become {other:?} — reclassifying it \
1412                 would be a lie in the opposite direction from Task 4b's bug"
1413            ),
1414            Ok(_) => panic!("a raw source that always errors cannot resolve successfully"),
1415        }
1416    }
1417
1418    #[test]
1419    fn the_returned_source_is_decoded_through_every_codec_layer_on_both_routes() {
1420        // The defect this pins: the path route used to return the PRISTINE,
1421        // undecoded source (resolve_chain alone tells you the shape, nothing
1422        // decodes anything), while the no-path route returned the source
1423        // already peeled past every codec layer. A caller of
1424        // `resolve_chain_deep` cannot be expected to inspect `path.is_some()`
1425        // itself to know which kind of source it was just handed — both
1426        // routes must leave the source in the identical state: decoded
1427        // through every codec layer named in the returned `Chain`, ready to
1428        // hand straight to the container.
1429        //
1430        // "thing.mar.mock" mirrors `.tar.gz`: `inner_from_path` finds the
1431        // container ("mar") one extension in from the codec ("mock"), so
1432        // this path resolves the FULL chain in one `resolve_chain` call,
1433        // with no peeking at all — the route this test must prove decodes
1434        // its codec layer exactly as the peeling route does.
1435        let reg = mock_registry_with_codec_and_container();
1436        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1437        let outer = mock_codec_encode(&inner);
1438
1439        let (chain_with_path, mut src_with_path) = resolve_chain_deep(
1440            &reg,
1441            Some(Path::new("thing.mar.mock")),
1442            Box::new(ReaderSource::new(io::Cursor::new(outer.clone()))),
1443        )
1444        .expect("resolve with path");
1445        let (chain_no_path, mut src_no_path) = resolve_chain_deep(
1446            &reg,
1447            None,
1448            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1449        )
1450        .expect("resolve without path");
1451
1452        let expected_chain = Chain::Codec {
1453            codec: MOCK_CODEC,
1454            inner: Box::new(Chain::Container {
1455                container: MOCK_CONTAINER,
1456            }),
1457        };
1458        assert_eq!(chain_with_path, expected_chain);
1459        assert_eq!(chain_no_path, expected_chain);
1460
1461        let mut bytes_with_path = Vec::new();
1462        src_with_path.read_to_end(&mut bytes_with_path).unwrap();
1463        let mut bytes_no_path = Vec::new();
1464        src_no_path.read_to_end(&mut bytes_no_path).unwrap();
1465
1466        assert_eq!(
1467            bytes_with_path, inner,
1468            "the path route must decode through the codec layer, not return raw bytes"
1469        );
1470        assert_eq!(
1471            bytes_with_path, bytes_no_path,
1472            "both routes must leave the source in the identical, already-decoded state"
1473        );
1474    }
1475}