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        let (next_prefix, next_src) = probe(decoded)?;
479        depth += 1;
480
481        let next_chain = match resolve_chain(reg, None, &next_prefix) {
482            Ok(chain) => chain,
483            Err(Error::UnknownFormat { .. }) => {
484                return Ok((wrap_layers(&layers, Chain::Raw), next_src));
485            }
486            Err(e) => return Err(e),
487        };
488
489        let Chain::Codec { codec: nested, .. } = next_chain else {
490            return Ok((wrap_layers(&layers, next_chain), next_src));
491        };
492
493        layers.push(nested);
494        current_codec = nested;
495        src = next_src;
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::archive::{Codec, DecodeOpts, EncodeOpts, Sink};
503    use crate::format::{CodecCaps, FormatId, FormatMeta, MagicRule};
504    use crate::registry::Registry;
505    use crate::source::{ReaderSource, Source, StreamOnly};
506    use crate::testing::{
507        MOCK_CODEC, MOCK_CONTAINER, MockCodec, MockContainer, SharedBuf, mock_archive_bytes,
508    };
509    use std::io;
510    use std::io::{Read, Write};
511    use std::path::Path;
512    use std::sync::Arc;
513
514    const GZIP: FormatId = FormatId::new("gzip");
515    const TAR: FormatId = FormatId::new("tar");
516
517    const GZIP_MAGIC: &[MagicRule] = &[MagicRule {
518        offset: 0,
519        bytes: &[0x1f, 0x8b],
520        format: GZIP,
521    }];
522    const TAR_MAGIC: &[MagicRule] = &[MagicRule {
523        offset: 257,
524        bytes: b"ustar",
525        format: TAR,
526    }];
527
528    fn registry() -> Registry {
529        let mut r = Registry::new();
530        r.register_codec(
531            Arc::new(MockCodec),
532            FormatMeta::codec(GZIP, &["gz", "tgz"], GZIP_MAGIC),
533        );
534        r.register_container(
535            Arc::new(MockContainer),
536            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
537        );
538        r
539    }
540
541    /// `registry()` above registers the mock codec under BOTH `gz` and `tgz`,
542    /// and the mock container under `tar` — exactly the shape the compound
543    /// alias needs, so no new fixture is required here.
544    #[test]
545    fn an_output_path_resolves_to_the_chain_its_name_promises() {
546        let reg = registry();
547        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
548        assert_eq!(c("x.tar"), "tar");
549        assert_eq!(c("x.gz"), "gzip");
550        assert_eq!(c("x.tar.gz"), "tar over gzip");
551        assert_eq!(
552            c("x.tgz"),
553            "tar over gzip",
554            "the compound alias must resolve too"
555        );
556        assert_eq!(c("x.TAR.GZ"), "tar over gzip", "extensions are caseless");
557        assert_eq!(c("x.txt"), "raw");
558        assert_eq!(c("x"), "raw");
559    }
560
561    /// Finding 4 of the Phase 2c final review. `bzip2` registers `bz2` and
562    /// nothing else, so stripping `.tbz`'s `t` looked up `bz`, found no
563    /// format, and resolved the whole name to [`Chain::Raw`] — which on the
564    /// write side is not a refusal but a silent fall back to the default
565    /// codec, so `-o f.tbz` wrote a gzip with no tar inside it at exit 0.
566    ///
567    /// The registry is deliberately the one place NOT fixed: `.bz` is bzip1.
568    #[test]
569    fn tbz_means_tar_over_bzip2_without_making_plain_bz_writable() {
570        const BZIP2: FormatId = FormatId::new("bzip2");
571        const BZIP2_MAGIC: &[MagicRule] = &[MagicRule {
572            offset: 0,
573            bytes: b"BZh",
574            format: BZIP2,
575        }];
576        let mut reg = Registry::new();
577        // `bz2` ONLY — the real bzip2 codec's whole extension list.
578        reg.register_codec(
579            Arc::new(MockCodec),
580            FormatMeta::codec(BZIP2, &["bz2"], BZIP2_MAGIC),
581        );
582        reg.register_container(
583            Arc::new(MockContainer),
584            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
585        );
586
587        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
588        assert_eq!(
589            c("f.tbz"),
590            "tar over bzip2",
591            "GNU tar and libarchive both read .tbz as tar.bz2"
592        );
593        assert_eq!(c("f.TBZ"), "tar over bzip2", "and extensions are caseless");
594        assert_eq!(
595            c("f.tbz2"),
596            "tar over bzip2",
597            "the spelling that always worked must keep working"
598        );
599        assert_eq!(
600            c("f.tar.bz2"),
601            "tar over bzip2",
602            "and so must the long form"
603        );
604
605        // The half that says WHERE the fix lives. `bz` is bzip1, a different
606        // and obsolete format: registering it on bzip2 would have fixed
607        // `.tbz` and simultaneously made `-o f.bz` write a bzip2 stream under
608        // a name that promises bzip1.
609        assert_eq!(
610            c("f.bz"),
611            "raw",
612            "`bz` must resolve to nothing outside the t-prefixed compound form"
613        );
614
615        // The READ side resolves the same name through the same function —
616        // that sharing is the point, and a second write-side table is what
617        // would let `.tbz` mean one thing to `pack` and another to `list`.
618        let chain = resolve_chain(&reg, Some(Path::new("f.tbz")), b"BZh\0").unwrap();
619        assert_eq!(
620            chain.describe(),
621            "tar over bzip2",
622            "reading a .tbz must agree with writing one"
623        );
624    }
625
626    /// The pair a writer actually consumes: the container to create, and the
627    /// codec to create FIRST because the container writes into it.
628    #[test]
629    fn the_outermost_codec_is_the_layer_a_writer_creates_first() {
630        let reg = registry();
631        let pair = |p: &str| {
632            let c = chain_for_new_path(&reg, Path::new(p));
633            (c.container(), c.outermost_codec())
634        };
635        assert_eq!(pair("x.tar"), (Some(TAR), None));
636        assert_eq!(pair("x.gz"), (None, Some(GZIP)));
637        assert_eq!(pair("x.tar.gz"), (Some(TAR), Some(GZIP)));
638        assert_eq!(pair("x.tgz"), (Some(TAR), Some(GZIP)));
639        assert_eq!(pair("x.txt"), (None, None));
640    }
641
642    #[test]
643    fn probe_does_not_consume_the_stream() {
644        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(
645            b"HEADERbody".to_vec(),
646        )));
647        let (prefix, mut rest) = probe(src).unwrap();
648        assert_eq!(&prefix[..6], b"HEADER");
649
650        let mut all = Vec::new();
651        rest.read_to_end(&mut all).unwrap();
652        assert_eq!(all, b"HEADERbody", "the consumer must still see byte zero");
653    }
654
655    #[test]
656    fn probe_reads_at_most_the_window() {
657        let big = vec![b'x'; PROBE_LEN * 3];
658        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(big)));
659        let (prefix, _) = probe(src).unwrap();
660        assert_eq!(prefix.len(), PROBE_LEN);
661    }
662
663    #[test]
664    fn magic_identifies_a_bare_codec_stream() {
665        let r = registry();
666        let chain = resolve_chain(&r, Some(Path::new("blob.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
667        assert_eq!(
668            chain,
669            Chain::Codec {
670                codec: GZIP,
671                inner: Box::new(Chain::Raw)
672            }
673        );
674        assert_eq!(chain.describe(), "gzip");
675    }
676
677    #[test]
678    fn a_double_extension_nests_the_container_inside_the_codec() {
679        let r = registry();
680        let chain = resolve_chain(&r, Some(Path::new("out.tar.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
681        assert_eq!(
682            chain,
683            Chain::Codec {
684                codec: GZIP,
685                inner: Box::new(Chain::Container { container: TAR })
686            }
687        );
688        assert_eq!(chain.describe(), "tar over gzip");
689    }
690
691    #[test]
692    fn a_codec_on_stdin_resolves_to_raw_inner() {
693        // No path means no extension hint. Phase 2 refines this by peeking
694        // after the first decoded block; until then Raw is the honest answer.
695        let r = registry();
696        let chain = resolve_chain(&r, None, &[0x1f, 0x8b, 0x08]).unwrap();
697        assert_eq!(
698            chain,
699            Chain::Codec {
700                codec: GZIP,
701                inner: Box::new(Chain::Raw)
702            }
703        );
704    }
705
706    #[test]
707    fn container_magic_resolves_directly() {
708        let r = registry();
709        let mut prefix = vec![0u8; 512];
710        prefix[257..262].copy_from_slice(b"ustar");
711        let chain = resolve_chain(&r, Some(Path::new("x.tar")), &prefix).unwrap();
712        assert_eq!(chain, Chain::Container { container: TAR });
713        assert_eq!(chain.describe(), "tar");
714    }
715
716    #[test]
717    fn extension_is_the_fallback_when_magic_says_nothing() {
718        let r = registry();
719        let chain = resolve_chain(&r, Some(Path::new("mystery.tar")), b"no magic here").unwrap();
720        assert_eq!(chain, Chain::Container { container: TAR });
721    }
722
723    /// `Chain::container` must see THROUGH codec layers, not only name a
724    /// bare container. `ops::inspect` uses it to decide whether it may report
725    /// a fidelity conclusion, and `backup.tar.gz` has a container it has not
726    /// opened just as surely as `backup.tar` does — a non-recursive version
727    /// would quietly re-introduce the false negative for every `.tar.gz`.
728    #[test]
729    fn chain_container_sees_through_codec_layers() {
730        let tar = FormatId::new("tar");
731        assert_eq!(Chain::Container { container: tar }.container(), Some(tar));
732        assert_eq!(
733            Chain::Codec {
734                codec: GZIP,
735                inner: Box::new(Chain::Container { container: tar }),
736            }
737            .container(),
738            Some(tar),
739            "tar over gzip still has a container nobody has opened"
740        );
741        assert_eq!(
742            Chain::Codec {
743                codec: GZIP,
744                inner: Box::new(Chain::Raw),
745            }
746            .container(),
747            None,
748            "a bare codec stream has no container, so an empty warning list is a real finding"
749        );
750        assert_eq!(Chain::Raw.container(), None);
751    }
752
753    #[test]
754    fn tgz_single_extension_still_nests_tar_inside_gzip() {
755        let r = registry();
756        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
757        assert_eq!(
758            chain,
759            Chain::Codec {
760                codec: GZIP,
761                inner: Box::new(Chain::Container { container: TAR })
762            }
763        );
764    }
765
766    #[test]
767    fn a_compound_extension_resolves_without_a_registered_compound_alias() {
768        // `.tgz` means "t" + gzip's own extension. Resolving it must not depend
769        // on gzip having separately registered "tgz" itself: a codec that
770        // forgets that alias would otherwise silently yield Raw instead of
771        // tar-over-<codec>, with no error raised.
772        let mut r = Registry::new();
773        r.register_codec(
774            Arc::new(MockCodec),
775            FormatMeta::codec(GZIP, &["gz"], GZIP_MAGIC), // deliberately NO "tgz" alias
776        );
777        r.register_container(
778            Arc::new(MockContainer),
779            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
780        );
781
782        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
783        assert_eq!(
784            chain,
785            Chain::Codec {
786                codec: GZIP,
787                inner: Box::new(Chain::Container { container: TAR })
788            }
789        );
790    }
791
792    #[test]
793    fn probe_does_not_consume_a_seekable_stream_either() {
794        // Non-consumption has two implementations — rewind for a seekable
795        // source, replay for a pipe. Only the pipe path was covered.
796        let mut f = tempfile::NamedTempFile::new().unwrap();
797        std::io::Write::write_all(&mut f, b"HEADERbody").unwrap();
798        let (_, path) = f.keep().unwrap();
799        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
800
801        let (prefix, mut rest) = probe(src).unwrap();
802        assert_eq!(&prefix[..6], b"HEADER");
803
804        let mut all = Vec::new();
805        rest.read_to_end(&mut all).unwrap();
806        assert_eq!(
807            all, b"HEADERbody",
808            "a seekable source must be rewound to byte zero"
809        );
810    }
811
812    #[test]
813    fn probe_bounds_the_window_on_a_seekable_stream_too() {
814        let big = vec![b'x'; PROBE_LEN * 3];
815        let mut f = tempfile::NamedTempFile::new().unwrap();
816        std::io::Write::write_all(&mut f, &big).unwrap();
817        let (_, path) = f.keep().unwrap();
818        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
819
820        let (prefix, _) = probe(src).unwrap();
821        assert_eq!(prefix.len(), PROBE_LEN);
822    }
823
824    #[test]
825    fn unknown_input_names_the_bytes_it_actually_saw() {
826        // Debugging a misdetection must not require adding prints to the library.
827        let r = registry();
828        let err = resolve_chain(&r, None, &[0xde, 0xad, 0xbe, 0xef]).unwrap_err();
829        let msg = err.to_string();
830        assert!(msg.contains("de ad be ef"), "message was: {msg}");
831        assert!(matches!(err, crate::Error::UnknownFormat { .. }));
832    }
833
834    #[test]
835    fn unknown_empty_input_still_produces_a_usable_message() {
836        let r = registry();
837        let err = resolve_chain(&r, None, b"").unwrap_err();
838        assert!(err.to_string().contains("empty"));
839    }
840
841    #[test]
842    fn equally_ranked_candidates_with_no_extension_are_an_error_not_a_guess() {
843        // A piped stream has no path to disambiguate with. Guessing silently is
844        // how a zip becomes an apk.
845        const A: &[MagicRule] = &[MagicRule {
846            offset: 0,
847            bytes: b"PK",
848            format: FormatId::new("alpha"),
849        }];
850        const B: &[MagicRule] = &[MagicRule {
851            offset: 0,
852            bytes: b"PK",
853            format: FormatId::new("beta"),
854        }];
855        let mut r = Registry::new();
856        r.register_container(
857            Arc::new(MockContainer),
858            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
859        );
860        r.register_container(
861            Arc::new(MockContainer),
862            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
863        );
864
865        let err = resolve_chain(&r, None, b"PKrest").unwrap_err();
866        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
867        let msg = err.to_string();
868        assert!(
869            msg.contains("alpha") && msg.contains("beta"),
870            "must name the candidates: {msg}"
871        );
872        // Exit 2, not 1. The message says "Pass `--format` to choose" —
873        // actionable advice, so this is the caller pointing stuffr at
874        // something it cannot resolve alone, the same class as
875        // `UnknownFormat` and `NotAnArchive`. It fell through
876        // `exit_code`'s wildcard to 1 (an internal failure) until the
877        // Phase 2 final review; see `error.rs`'s
878        // `pointing_a_verb_at_the_wrong_file_is_always_exit_two`.
879        assert_eq!(err.exit_code(), 2);
880    }
881
882    #[test]
883    fn a_higher_priority_candidate_resolves_without_an_extension() {
884        const A: &[MagicRule] = &[MagicRule {
885            offset: 0,
886            bytes: b"PK",
887            format: FormatId::new("alpha"),
888        }];
889        const B: &[MagicRule] = &[MagicRule {
890            offset: 0,
891            bytes: b"PK",
892            format: FormatId::new("beta"),
893        }];
894        let mut r = Registry::new();
895        r.register_container(
896            Arc::new(MockContainer),
897            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A).with_priority(-5),
898        );
899        r.register_container(
900            Arc::new(MockContainer),
901            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
902        );
903
904        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
905        assert_eq!(
906            chain,
907            Chain::Container {
908                container: FormatId::new("beta")
909            }
910        );
911    }
912
913    #[test]
914    fn an_extension_still_disambiguates_equal_candidates() {
915        const A: &[MagicRule] = &[MagicRule {
916            offset: 0,
917            bytes: b"PK",
918            format: FormatId::new("alpha"),
919        }];
920        const B: &[MagicRule] = &[MagicRule {
921            offset: 0,
922            bytes: b"PK",
923            format: FormatId::new("beta"),
924        }];
925        let mut r = Registry::new();
926        r.register_container(
927            Arc::new(MockContainer),
928            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
929        );
930        r.register_container(
931            Arc::new(MockContainer),
932            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
933        );
934
935        let chain = resolve_chain(&r, Some(Path::new("x.beta")), b"PKrest").unwrap();
936        assert_eq!(
937            chain,
938            Chain::Container {
939                container: FormatId::new("beta")
940            }
941        );
942    }
943
944    #[test]
945    fn an_extension_outside_the_tied_set_does_not_break_the_tie() {
946        // A tie between two formats sharing a magic, with a path whose
947        // extension names a THIRD format that does not share it. The extension
948        // must not select a format whose magic never matched, and the error
949        // must name the genuinely tied candidates rather than the irrelevant
950        // one.
951        const A: &[MagicRule] = &[MagicRule {
952            offset: 0,
953            bytes: b"PK",
954            format: FormatId::new("alpha"),
955        }];
956        const B: &[MagicRule] = &[MagicRule {
957            offset: 0,
958            bytes: b"PK",
959            format: FormatId::new("beta"),
960        }];
961        const C: &[MagicRule] = &[MagicRule {
962            offset: 0,
963            bytes: b"GZ",
964            format: FormatId::new("gamma"),
965        }];
966        let mut r = Registry::new();
967        r.register_container(
968            Arc::new(MockContainer),
969            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
970        );
971        r.register_container(
972            Arc::new(MockContainer),
973            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
974        );
975        r.register_container(
976            Arc::new(MockContainer),
977            FormatMeta::container(FormatId::new("gamma"), &["gamma"], C),
978        );
979
980        let err = resolve_chain(&r, Some(Path::new("x.gamma")), b"PKrest").unwrap_err();
981        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
982        let msg = err.to_string();
983        assert!(
984            msg.contains("alpha") && msg.contains("beta"),
985            "must name the tied candidates: {msg}"
986        );
987        assert!(
988            !msg.contains("gamma"),
989            "must not name the irrelevant extension: {msg}"
990        );
991    }
992
993    #[test]
994    fn an_extension_reaches_a_lower_priority_format_that_shares_the_magic() {
995        // The spec's worked example: zip outranks apk on a bare stream, but a
996        // path saying `.apk` must still reach apk. Ranking must not override an
997        // explicit filename.
998        const ZIP_MAGIC: &[MagicRule] = &[MagicRule {
999            offset: 0,
1000            bytes: b"PK",
1001            format: FormatId::new("zip"),
1002        }];
1003        const APK_MAGIC: &[MagicRule] = &[MagicRule {
1004            offset: 0,
1005            bytes: b"PK",
1006            format: FormatId::new("apk"),
1007        }];
1008        let mut r = Registry::new();
1009        r.register_container(
1010            Arc::new(MockContainer),
1011            FormatMeta::container(FormatId::new("zip"), &["zip"], ZIP_MAGIC),
1012        );
1013        r.register_container(
1014            Arc::new(MockContainer),
1015            FormatMeta::container(FormatId::new("apk"), &["apk"], APK_MAGIC).with_priority(-5),
1016        );
1017
1018        // With no path, the higher-priority zip wins on the bare magic hit.
1019        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
1020        assert_eq!(
1021            chain,
1022            Chain::Container {
1023                container: FormatId::new("zip")
1024            }
1025        );
1026
1027        // Same bytes, but the path names apk — the lower-ranked candidate must
1028        // still be reached; ranking cannot pre-empt an explicit filename.
1029        let chain = resolve_chain(&r, Some(Path::new("archive.apk")), b"PKrest").unwrap();
1030        assert_eq!(
1031            chain,
1032            Chain::Container {
1033                container: FormatId::new("apk")
1034            }
1035        );
1036    }
1037
1038    /// A `Source` that lies: it claims `seekable: true` but `as_seek()`
1039    /// always returns `None`. Nothing in this crate can implement `Source`
1040    /// this way today, but the trait is public and a third-party codec could
1041    /// — the point of this test is that `probe` must survive that instead of
1042    /// panicking.
1043    struct LiesAboutSeekability(std::io::Cursor<Vec<u8>>);
1044
1045    impl Read for LiesAboutSeekability {
1046        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1047            std::io::Read::read(&mut self.0, buf)
1048        }
1049    }
1050
1051    impl Source for LiesAboutSeekability {
1052        fn caps(&self) -> crate::source::SourceCaps {
1053            crate::source::SourceCaps {
1054                seekable: true,
1055                len: Some(self.0.get_ref().len() as u64),
1056            }
1057        }
1058
1059        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1060            None
1061        }
1062    }
1063
1064    #[test]
1065    fn a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking() {
1066        let src: Box<dyn Source> = Box::new(LiesAboutSeekability(std::io::Cursor::new(
1067            b"payload".to_vec(),
1068        )));
1069        // Not `.unwrap_err()`: the `Ok` side is `(Vec<u8>, Box<dyn Source>)`,
1070        // and `Source` is not `Debug`, so `Result::unwrap_err`'s `T: Debug`
1071        // bound can't be satisfied here.
1072        match probe(src) {
1073            Err(err) => assert!(
1074                matches!(err, crate::Error::NotSeekable { .. }),
1075                "must be NotSeekable, not a panic: {err:?}"
1076            ),
1077            Ok(_) => panic!("expected NotSeekable, got Ok"),
1078        }
1079    }
1080
1081    // --- Peek-based inner resolution (Task 4) -----------------------------
1082    //
1083    // `testing::MockCodec`'s bitwise-NOT transform is its own inverse:
1084    // encoding through it an EVEN number of times reproduces the original
1085    // bytes exactly, which would make a nesting-depth test silently
1086    // degenerate (the "6-deep" stream would collapse back to the bare
1087    // container and never exercise the bound at all). `PrefixedMockCodec`
1088    // instead prepends a fixed magic and passes the payload through
1089    // unchanged, so decoding always strips exactly one layer no matter how
1090    // many are stacked.
1091    const NESTED_MAGIC: &[u8] = b"MOCK";
1092
1093    struct PrefixedMockCodec;
1094
1095    impl Codec for PrefixedMockCodec {
1096        fn id(&self) -> FormatId {
1097            MOCK_CODEC
1098        }
1099
1100        fn caps(&self) -> CodecCaps {
1101            CodecCaps {
1102                encode: true,
1103                decode: true,
1104                ..Default::default()
1105            }
1106        }
1107
1108        fn decoder(&self, mut src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1109            let mut discard = vec![0u8; NESTED_MAGIC.len()];
1110            src.read_exact(&mut discard)?;
1111            Ok(Box::new(StreamOnly::new(src)))
1112        }
1113
1114        fn encoder(
1115            &self,
1116            mut dst: Box<dyn Write + Send>,
1117            _o: &EncodeOpts,
1118        ) -> Result<Box<dyn Sink>> {
1119            dst.write_all(NESTED_MAGIC)?;
1120            Ok(Box::new(PassthroughSink(dst)))
1121        }
1122    }
1123
1124    struct PassthroughSink(Box<dyn Write + Send>);
1125
1126    impl Write for PassthroughSink {
1127        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1128            self.0.write(buf)
1129        }
1130        fn flush(&mut self) -> std::io::Result<()> {
1131            self.0.flush()
1132        }
1133    }
1134
1135    impl Sink for PassthroughSink {
1136        fn finish(mut self: Box<Self>) -> Result<()> {
1137            self.0.flush()?;
1138            Ok(())
1139        }
1140    }
1141
1142    fn mock_registry_with_codec_and_container() -> Registry {
1143        const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
1144            offset: 0,
1145            bytes: NESTED_MAGIC,
1146            format: MOCK_CODEC,
1147        }];
1148        const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
1149            offset: 0,
1150            bytes: b"ME",
1151            format: MOCK_CONTAINER,
1152        }];
1153        let mut r = Registry::new();
1154        r.register_codec(
1155            Arc::new(PrefixedMockCodec),
1156            FormatMeta::codec(MOCK_CODEC, &["mock"], CODEC_MAGIC),
1157        );
1158        r.register_container(
1159            Arc::new(MockContainer),
1160            FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC),
1161        );
1162        r
1163    }
1164
1165    /// Wraps `payload` in one `PrefixedMockCodec` layer, via the codec's own
1166    /// `encoder` rather than hand-building the bytes, so the fixture is
1167    /// provably decodable by the exact codec `resolve_chain_deep` will call.
1168    fn mock_codec_encode(payload: &[u8]) -> Vec<u8> {
1169        let sink = SharedBuf::new();
1170        let mut s = PrefixedMockCodec
1171            .encoder(Box::new(sink.clone()), &EncodeOpts::default())
1172            .unwrap();
1173        s.write_all(payload).unwrap();
1174        s.finish().unwrap();
1175        sink.contents()
1176    }
1177
1178    #[test]
1179    fn a_container_inside_a_codec_resolves_from_the_stream_with_no_path() {
1180        let reg = mock_registry_with_codec_and_container();
1181        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1182        let outer = mock_codec_encode(&inner);
1183
1184        let (chain, _src) = resolve_chain_deep(
1185            &reg,
1186            None,
1187            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1188        )
1189        .expect("resolve");
1190
1191        // Before this task the inner layer was Chain::Raw, because no path meant
1192        // no extension to consult. The prefix of the DECODED stream now decides.
1193        assert_eq!(
1194            chain,
1195            Chain::Codec {
1196                codec: MOCK_CODEC,
1197                inner: Box::new(Chain::Container {
1198                    container: MOCK_CONTAINER
1199                }),
1200            },
1201            "a container nested inside a codec must be found by peeking the decoded stream"
1202        );
1203    }
1204
1205    #[test]
1206    fn nesting_beyond_the_depth_bound_is_a_typed_error_naming_the_depth() {
1207        let reg = mock_registry_with_codec_and_container();
1208        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1209        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1210            bytes = mock_codec_encode(&bytes);
1211        }
1212
1213        // Not `.expect_err(..)`: the `Ok` side is `(Chain, Box<dyn Source>)`,
1214        // and `Source` is not `Debug`, so `Result::expect_err`'s `T: Debug`
1215        // bound can't be satisfied here — same reason as
1216        // `a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking`
1217        // above.
1218        match resolve_chain_deep(
1219            &reg,
1220            None,
1221            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1222        ) {
1223            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1224            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1225            Ok(_) => panic!("nesting past the bound must be refused"),
1226        }
1227    }
1228
1229    #[test]
1230    fn nesting_beyond_the_depth_bound_is_refused_via_the_path_route_too() {
1231        // A path is a name, not a proof: `a.gz.gz.gz.gz.gz` must be refused
1232        // exactly like the identical bytes arriving with no path at all.
1233        // `mock_registry_with_codec_and_container` only ever exposes a
1234        // single extension per format ("mock"), so a path naming it gives
1235        // `inner_from_path` nothing to find a container with — the chain
1236        // still bottoms out at Raw, and peeking (and the bound) must engage
1237        // all the same.
1238        let reg = mock_registry_with_codec_and_container();
1239        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1240        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1241            bytes = mock_codec_encode(&bytes);
1242        }
1243
1244        match resolve_chain_deep(
1245            &reg,
1246            Some(Path::new("nested.mock")),
1247            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1248        ) {
1249            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1250            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1251            Ok(_) => panic!("nesting past the bound must be refused, path or no path"),
1252        }
1253    }
1254
1255    #[test]
1256    fn the_returned_source_is_decoded_through_every_codec_layer_on_both_routes() {
1257        // The defect this pins: the path route used to return the PRISTINE,
1258        // undecoded source (resolve_chain alone tells you the shape, nothing
1259        // decodes anything), while the no-path route returned the source
1260        // already peeled past every codec layer. A caller of
1261        // `resolve_chain_deep` cannot be expected to inspect `path.is_some()`
1262        // itself to know which kind of source it was just handed — both
1263        // routes must leave the source in the identical state: decoded
1264        // through every codec layer named in the returned `Chain`, ready to
1265        // hand straight to the container.
1266        //
1267        // "thing.mar.mock" mirrors `.tar.gz`: `inner_from_path` finds the
1268        // container ("mar") one extension in from the codec ("mock"), so
1269        // this path resolves the FULL chain in one `resolve_chain` call,
1270        // with no peeking at all — the route this test must prove decodes
1271        // its codec layer exactly as the peeling route does.
1272        let reg = mock_registry_with_codec_and_container();
1273        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1274        let outer = mock_codec_encode(&inner);
1275
1276        let (chain_with_path, mut src_with_path) = resolve_chain_deep(
1277            &reg,
1278            Some(Path::new("thing.mar.mock")),
1279            Box::new(ReaderSource::new(io::Cursor::new(outer.clone()))),
1280        )
1281        .expect("resolve with path");
1282        let (chain_no_path, mut src_no_path) = resolve_chain_deep(
1283            &reg,
1284            None,
1285            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1286        )
1287        .expect("resolve without path");
1288
1289        let expected_chain = Chain::Codec {
1290            codec: MOCK_CODEC,
1291            inner: Box::new(Chain::Container {
1292                container: MOCK_CONTAINER,
1293            }),
1294        };
1295        assert_eq!(chain_with_path, expected_chain);
1296        assert_eq!(chain_no_path, expected_chain);
1297
1298        let mut bytes_with_path = Vec::new();
1299        src_with_path.read_to_end(&mut bytes_with_path).unwrap();
1300        let mut bytes_no_path = Vec::new();
1301        src_no_path.read_to_end(&mut bytes_no_path).unwrap();
1302
1303        assert_eq!(
1304            bytes_with_path, inner,
1305            "the path route must decode through the codec layer, not return raw bytes"
1306        );
1307        assert_eq!(
1308            bytes_with_path, bytes_no_path,
1309            "both routes must leave the source in the identical, already-decoded state"
1310        );
1311    }
1312}