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::{DecodeSideSource, 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.
375/// Builds one codec layer's decoder and marks its output as decode-side.
376///
377/// The ONE place in this crate that turns a [`Source`] into a decoded one, so
378/// it is also the one place that has to remember the marking — see
379/// [`crate::source::decode_side`] for why the marking exists at all, and for
380/// the four downstream `?`s it retroactively makes correct.
381///
382/// Two things happen here, and they cover the two moments a codec can fail:
383///
384/// * **Construction.** A codec that validates its header eagerly can fail
385///   inside `decoder()` itself, reading the source that is about to become
386///   decode-side but is not yet wrapped. That error is folded through
387///   [`Error::from_decode_io`] here. No registered codec takes this branch
388///   today (`CLAUDE.md`'s Phase 3c backlog records the same gap against
389///   `conformance.rs:393`), which is exactly why it is worth closing before
390///   one does: an unreached branch that is wrong by default is how this class
391///   keeps recurring.
392/// * **Reading.** Every later `read` is marked by [`DecodeSideSource`].
393///
394/// Wrapping is idempotent, so a three-layer chain pays one tag allocation per
395/// error rather than three.
396fn build_decoder(
397    reg: &Registry,
398    codec: FormatId,
399    src: Box<dyn Source>,
400    opts: &DecodeOpts,
401) -> Result<Box<dyn Source>> {
402    let decoded = reg
403        .require_codec(codec)?
404        .decoder(src, opts)
405        .map_err(|e| match e {
406            Error::Io(io_err) => Error::from_decode_io(io_err),
407            other => other,
408        })?;
409    Ok(DecodeSideSource::wrap(decoded))
410}
411
412fn decode_through_chain(
413    reg: &Registry,
414    chain: &Chain,
415    src: Box<dyn Source>,
416    opts: &DecodeOpts,
417) -> Result<Box<dyn Source>> {
418    match chain {
419        Chain::Codec { codec, inner } => {
420            let decoded = build_decoder(reg, *codec, src, opts)?;
421            decode_through_chain(reg, inner, decoded, opts)
422        }
423        Chain::Container { .. } | Chain::Raw => Ok(src),
424    }
425}
426
427/// [`resolve_chain`], but when the outer layer is a codec whose inner the
428/// path left as [`Chain::Raw`] — whether because there was no path at all,
429/// or because the path's own extensions ran out before naming a container —
430/// keeps going: decodes that layer, peeks [`PROBE_LEN`] of the *decoded*
431/// bytes, and resolves again from the decoded bytes' own magic. Closes the
432/// Phase 0 limitation where a codec on stdin always bottomed out at Raw
433/// purely for lack of an extension.
434///
435/// The path still gets first say: `resolve_chain` runs once with it, and if
436/// that alone already resolves the full chain (a bare container, or a codec
437/// whose inner the extensions named directly — `.tar.gz`), peeking never
438/// happens. Peeking is what runs when the path is silent, or was not enough.
439///
440/// Bounded by [`MAX_CHAIN_DEPTH`] on **both** routes, because re-probing a
441/// *decoded* stream is exactly what makes unbounded nesting attackable: each
442/// layer is cheap to produce and expensive to expand. A path is a name, not
443/// a proof — `a.gz.gz.gz.gz.gz` is refused the same way piped bytes with the
444/// identical shape are, and exceeding the bound is a typed
445/// [`Error::ChainTooDeep`], never a panic and never a silent stop.
446///
447/// Returns the resolved [`Chain`] alongside a [`Source`] **already decoded
448/// through every codec layer named in that `Chain`** — positioned exactly
449/// where the chain's innermost entry begins, ready to hand straight to a
450/// container or to read as raw bytes. This holds on both routes: a path
451/// that resolves in one step (no peeking needed) still gets its codec
452/// layers decoded before the source comes back, so a caller never has to
453/// ask whether the source it received happens to be raw or already decoded.
454pub fn resolve_chain_deep(
455    reg: &Registry,
456    path: Option<&Path>,
457    src: Box<dyn Source>,
458) -> Result<(Chain, Box<dyn Source>)> {
459    resolve_chain_deep_with(reg, path, src, &DecodeOpts::default())
460}
461
462/// [`resolve_chain_deep`], but with the [`DecodeOpts`] every codec layer's
463/// decoder is built from spelled out by the caller instead of defaulted.
464///
465/// This exists because [`DecodeOpts::memory_limit`] is the ONLY guard that
466/// can see a pure codec's pre-output dictionary allocation — the one sized
467/// from a value the file declares in its own header, before a single decoded
468/// byte exists. `--max-ratio` counts decoded output bytes and so cannot see
469/// it at all. Resolving a chain builds a decoder per codec layer, so a
470/// resolver that defaults its opts leaves every entry-aware verb (`list`,
471/// `test`, `cat`, `unpack -C`) unbounded against a crafted `.tar.lz` whose
472/// header declares a 512 MiB dictionary, while the single-stream path
473/// refuses the same bytes at exit 6.
474///
475/// `resolve_chain_deep` remains as the "library default" spelling —
476/// unbounded, matching `DecodeOpts::default()` everywhere else — but no CLI
477/// path should use it: the CLI always has a resolved limit to pass.
478pub fn resolve_chain_deep_with(
479    reg: &Registry,
480    path: Option<&Path>,
481    src: Box<dyn Source>,
482    opts: &DecodeOpts,
483) -> Result<(Chain, Box<dyn Source>)> {
484    let (prefix, src) = probe(src)?;
485    let chain = resolve_chain(reg, path, &prefix)?;
486
487    // Either a bare container, or a codec whose inner the path already named
488    // (e.g. `.tar.gz`) — the path (if any) is done contributing. Decode
489    // through whatever codec layers `chain` names and stop; there is nothing
490    // left to peek.
491    if !matches!(&chain, Chain::Codec { inner, .. } if matches!(inner.as_ref(), Chain::Raw)) {
492        let decoded = decode_through_chain(reg, &chain, src, opts)?;
493        return Ok((chain, decoded));
494    }
495
496    // The chain bottoms out at Raw. From here, only the decoded bytes' own
497    // magic decides — the path, if there was one, already had its say above
498    // and found nothing further.
499    let Chain::Codec { codec, .. } = chain else {
500        unreachable!("guarded above: chain is Chain::Codec with a Raw inner")
501    };
502    let mut layers: Vec<FormatId> = vec![codec];
503    let mut current_codec = codec;
504    let mut src = src;
505    let mut depth = 1usize;
506
507    loop {
508        if depth >= MAX_CHAIN_DEPTH {
509            return Err(Error::ChainTooDeep {
510                depth: MAX_CHAIN_DEPTH,
511            });
512        }
513
514        let decoded = build_decoder(reg, current_codec, src, opts)?;
515        // This `probe` reads through a DECODER — unlike the one at the top
516        // of this function, and unlike `resolve_chain_deep_with`'s own
517        // `probe(src)` two calls up, both of which read the RAW,
518        // undecoded source. An `io::Error` here means the bytes this codec
519        // just produced are malformed, not that the raw source or the
520        // environment failed, so it is reclassified via
521        // `Error::from_decode_io` exactly like every other decoder-side
522        // read in this crate (`ops.rs`, `entries.rs`, `conformance.rs`).
523        //
524        // Left as a bare `?` (as it was until Task 4b), a truncated `.zz`
525        // or an empty `.lz` — a codec stream with no container above it, so
526        // resolution bottoms out in this very loop re-probing the decoded
527        // bytes — surfaced as `Error::Io`: exit 1, "stuffr failed", for
528        // input that is simply corrupt. `ops::decompress` decodes the
529        // identical bytes through a call already wired to
530        // `Error::from_decode_io`, so `cat` and `list` disagreed on the
531        // exit code for the same input; see `error.rs`'s doc comment on
532        // `from_decode_io` for the convention this leans on, and
533        // `Error::exit_code`'s comment for this wildcard's history.
534        //
535        // Do NOT widen this to `probe(src)` above (raw source) or to
536        // `probe`/`PeekSource::fill` themselves — either would reclassify a
537        // genuine i/o failure on the raw source (a real disk error, a
538        // permission failure, a broken pipe) as `Corrupt`, which is a new
539        // lie in the opposite direction.
540        //
541        // As of the decode-side marker (see `crate::source::decode_side`),
542        // `build_decoder` has already tagged this source, so `probe`'s own
543        // internal `?` would classify it anyway and this `map_err` sees an
544        // `Error::Corrupt` and passes it through. Kept rather than deleted:
545        // it is the site Task 4b's regression tests name, and a belt that
546        // does not depend on the marker being applied one call up.
547        let (next_prefix, next_src) = probe(decoded).map_err(|e| match e {
548            Error::Io(io_err) => Error::from_decode_io(io_err),
549            other => other,
550        })?;
551        depth += 1;
552
553        let next_chain = match resolve_chain(reg, None, &next_prefix) {
554            Ok(chain) => chain,
555            Err(Error::UnknownFormat { .. }) => {
556                return Ok((wrap_layers(&layers, Chain::Raw), next_src));
557            }
558            Err(e) => return Err(e),
559        };
560
561        let Chain::Codec { codec: nested, .. } = next_chain else {
562            return Ok((wrap_layers(&layers, next_chain), next_src));
563        };
564
565        layers.push(nested);
566        current_codec = nested;
567        src = next_src;
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::archive::{Codec, DecodeOpts, EncodeOpts, Sink};
575    use crate::format::{CodecCaps, FormatId, FormatMeta, MagicRule};
576    use crate::registry::Registry;
577    use crate::source::{ReaderSource, Source, StreamOnly};
578    use crate::testing::{
579        MOCK_CODEC, MOCK_CONTAINER, MockCodec, MockContainer, SharedBuf, mock_archive_bytes,
580    };
581    use std::io;
582    use std::io::{Read, Write};
583    use std::path::Path;
584    use std::sync::Arc;
585
586    const GZIP: FormatId = FormatId::new("gzip");
587    const TAR: FormatId = FormatId::new("tar");
588
589    const GZIP_MAGIC: &[MagicRule] = &[MagicRule {
590        offset: 0,
591        bytes: &[0x1f, 0x8b],
592        format: GZIP,
593    }];
594    const TAR_MAGIC: &[MagicRule] = &[MagicRule {
595        offset: 257,
596        bytes: b"ustar",
597        format: TAR,
598    }];
599
600    fn registry() -> Registry {
601        let mut r = Registry::new();
602        r.register_codec(
603            Arc::new(MockCodec),
604            FormatMeta::codec(GZIP, &["gz", "tgz"], GZIP_MAGIC),
605        );
606        r.register_container(
607            Arc::new(MockContainer),
608            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
609        );
610        r
611    }
612
613    /// `registry()` above registers the mock codec under BOTH `gz` and `tgz`,
614    /// and the mock container under `tar` — exactly the shape the compound
615    /// alias needs, so no new fixture is required here.
616    #[test]
617    fn an_output_path_resolves_to_the_chain_its_name_promises() {
618        let reg = registry();
619        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
620        assert_eq!(c("x.tar"), "tar");
621        assert_eq!(c("x.gz"), "gzip");
622        assert_eq!(c("x.tar.gz"), "tar over gzip");
623        assert_eq!(
624            c("x.tgz"),
625            "tar over gzip",
626            "the compound alias must resolve too"
627        );
628        assert_eq!(c("x.TAR.GZ"), "tar over gzip", "extensions are caseless");
629        assert_eq!(c("x.txt"), "raw");
630        assert_eq!(c("x"), "raw");
631    }
632
633    /// Finding 4 of the Phase 2c final review. `bzip2` registers `bz2` and
634    /// nothing else, so stripping `.tbz`'s `t` looked up `bz`, found no
635    /// format, and resolved the whole name to [`Chain::Raw`] — which on the
636    /// write side is not a refusal but a silent fall back to the default
637    /// codec, so `-o f.tbz` wrote a gzip with no tar inside it at exit 0.
638    ///
639    /// The registry is deliberately the one place NOT fixed: `.bz` is bzip1.
640    #[test]
641    fn tbz_means_tar_over_bzip2_without_making_plain_bz_writable() {
642        const BZIP2: FormatId = FormatId::new("bzip2");
643        const BZIP2_MAGIC: &[MagicRule] = &[MagicRule {
644            offset: 0,
645            bytes: b"BZh",
646            format: BZIP2,
647        }];
648        let mut reg = Registry::new();
649        // `bz2` ONLY — the real bzip2 codec's whole extension list.
650        reg.register_codec(
651            Arc::new(MockCodec),
652            FormatMeta::codec(BZIP2, &["bz2"], BZIP2_MAGIC),
653        );
654        reg.register_container(
655            Arc::new(MockContainer),
656            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
657        );
658
659        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
660        assert_eq!(
661            c("f.tbz"),
662            "tar over bzip2",
663            "GNU tar and libarchive both read .tbz as tar.bz2"
664        );
665        assert_eq!(c("f.TBZ"), "tar over bzip2", "and extensions are caseless");
666        assert_eq!(
667            c("f.tbz2"),
668            "tar over bzip2",
669            "the spelling that always worked must keep working"
670        );
671        assert_eq!(
672            c("f.tar.bz2"),
673            "tar over bzip2",
674            "and so must the long form"
675        );
676
677        // The half that says WHERE the fix lives. `bz` is bzip1, a different
678        // and obsolete format: registering it on bzip2 would have fixed
679        // `.tbz` and simultaneously made `-o f.bz` write a bzip2 stream under
680        // a name that promises bzip1.
681        assert_eq!(
682            c("f.bz"),
683            "raw",
684            "`bz` must resolve to nothing outside the t-prefixed compound form"
685        );
686
687        // The READ side resolves the same name through the same function —
688        // that sharing is the point, and a second write-side table is what
689        // would let `.tbz` mean one thing to `pack` and another to `list`.
690        let chain = resolve_chain(&reg, Some(Path::new("f.tbz")), b"BZh\0").unwrap();
691        assert_eq!(
692            chain.describe(),
693            "tar over bzip2",
694            "reading a .tbz must agree with writing one"
695        );
696    }
697
698    /// The pair a writer actually consumes: the container to create, and the
699    /// codec to create FIRST because the container writes into it.
700    #[test]
701    fn the_outermost_codec_is_the_layer_a_writer_creates_first() {
702        let reg = registry();
703        let pair = |p: &str| {
704            let c = chain_for_new_path(&reg, Path::new(p));
705            (c.container(), c.outermost_codec())
706        };
707        assert_eq!(pair("x.tar"), (Some(TAR), None));
708        assert_eq!(pair("x.gz"), (None, Some(GZIP)));
709        assert_eq!(pair("x.tar.gz"), (Some(TAR), Some(GZIP)));
710        assert_eq!(pair("x.tgz"), (Some(TAR), Some(GZIP)));
711        assert_eq!(pair("x.txt"), (None, None));
712    }
713
714    #[test]
715    fn probe_does_not_consume_the_stream() {
716        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(
717            b"HEADERbody".to_vec(),
718        )));
719        let (prefix, mut rest) = probe(src).unwrap();
720        assert_eq!(&prefix[..6], b"HEADER");
721
722        let mut all = Vec::new();
723        rest.read_to_end(&mut all).unwrap();
724        assert_eq!(all, b"HEADERbody", "the consumer must still see byte zero");
725    }
726
727    #[test]
728    fn probe_reads_at_most_the_window() {
729        let big = vec![b'x'; PROBE_LEN * 3];
730        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(big)));
731        let (prefix, _) = probe(src).unwrap();
732        assert_eq!(prefix.len(), PROBE_LEN);
733    }
734
735    #[test]
736    fn magic_identifies_a_bare_codec_stream() {
737        let r = registry();
738        let chain = resolve_chain(&r, Some(Path::new("blob.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
739        assert_eq!(
740            chain,
741            Chain::Codec {
742                codec: GZIP,
743                inner: Box::new(Chain::Raw)
744            }
745        );
746        assert_eq!(chain.describe(), "gzip");
747    }
748
749    #[test]
750    fn a_double_extension_nests_the_container_inside_the_codec() {
751        let r = registry();
752        let chain = resolve_chain(&r, Some(Path::new("out.tar.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
753        assert_eq!(
754            chain,
755            Chain::Codec {
756                codec: GZIP,
757                inner: Box::new(Chain::Container { container: TAR })
758            }
759        );
760        assert_eq!(chain.describe(), "tar over gzip");
761    }
762
763    #[test]
764    fn a_codec_on_stdin_resolves_to_raw_inner() {
765        // No path means no extension hint. Phase 2 refines this by peeking
766        // after the first decoded block; until then Raw is the honest answer.
767        let r = registry();
768        let chain = resolve_chain(&r, None, &[0x1f, 0x8b, 0x08]).unwrap();
769        assert_eq!(
770            chain,
771            Chain::Codec {
772                codec: GZIP,
773                inner: Box::new(Chain::Raw)
774            }
775        );
776    }
777
778    #[test]
779    fn container_magic_resolves_directly() {
780        let r = registry();
781        let mut prefix = vec![0u8; 512];
782        prefix[257..262].copy_from_slice(b"ustar");
783        let chain = resolve_chain(&r, Some(Path::new("x.tar")), &prefix).unwrap();
784        assert_eq!(chain, Chain::Container { container: TAR });
785        assert_eq!(chain.describe(), "tar");
786    }
787
788    #[test]
789    fn extension_is_the_fallback_when_magic_says_nothing() {
790        let r = registry();
791        let chain = resolve_chain(&r, Some(Path::new("mystery.tar")), b"no magic here").unwrap();
792        assert_eq!(chain, Chain::Container { container: TAR });
793    }
794
795    /// `Chain::container` must see THROUGH codec layers, not only name a
796    /// bare container. `ops::inspect` uses it to decide whether it may report
797    /// a fidelity conclusion, and `backup.tar.gz` has a container it has not
798    /// opened just as surely as `backup.tar` does — a non-recursive version
799    /// would quietly re-introduce the false negative for every `.tar.gz`.
800    #[test]
801    fn chain_container_sees_through_codec_layers() {
802        let tar = FormatId::new("tar");
803        assert_eq!(Chain::Container { container: tar }.container(), Some(tar));
804        assert_eq!(
805            Chain::Codec {
806                codec: GZIP,
807                inner: Box::new(Chain::Container { container: tar }),
808            }
809            .container(),
810            Some(tar),
811            "tar over gzip still has a container nobody has opened"
812        );
813        assert_eq!(
814            Chain::Codec {
815                codec: GZIP,
816                inner: Box::new(Chain::Raw),
817            }
818            .container(),
819            None,
820            "a bare codec stream has no container, so an empty warning list is a real finding"
821        );
822        assert_eq!(Chain::Raw.container(), None);
823    }
824
825    #[test]
826    fn tgz_single_extension_still_nests_tar_inside_gzip() {
827        let r = registry();
828        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
829        assert_eq!(
830            chain,
831            Chain::Codec {
832                codec: GZIP,
833                inner: Box::new(Chain::Container { container: TAR })
834            }
835        );
836    }
837
838    #[test]
839    fn a_compound_extension_resolves_without_a_registered_compound_alias() {
840        // `.tgz` means "t" + gzip's own extension. Resolving it must not depend
841        // on gzip having separately registered "tgz" itself: a codec that
842        // forgets that alias would otherwise silently yield Raw instead of
843        // tar-over-<codec>, with no error raised.
844        let mut r = Registry::new();
845        r.register_codec(
846            Arc::new(MockCodec),
847            FormatMeta::codec(GZIP, &["gz"], GZIP_MAGIC), // deliberately NO "tgz" alias
848        );
849        r.register_container(
850            Arc::new(MockContainer),
851            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
852        );
853
854        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
855        assert_eq!(
856            chain,
857            Chain::Codec {
858                codec: GZIP,
859                inner: Box::new(Chain::Container { container: TAR })
860            }
861        );
862    }
863
864    #[test]
865    fn probe_does_not_consume_a_seekable_stream_either() {
866        // Non-consumption has two implementations — rewind for a seekable
867        // source, replay for a pipe. Only the pipe path was covered.
868        let mut f = tempfile::NamedTempFile::new().unwrap();
869        std::io::Write::write_all(&mut f, b"HEADERbody").unwrap();
870        let (_, path) = f.keep().unwrap();
871        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
872
873        let (prefix, mut rest) = probe(src).unwrap();
874        assert_eq!(&prefix[..6], b"HEADER");
875
876        let mut all = Vec::new();
877        rest.read_to_end(&mut all).unwrap();
878        assert_eq!(
879            all, b"HEADERbody",
880            "a seekable source must be rewound to byte zero"
881        );
882    }
883
884    #[test]
885    fn probe_bounds_the_window_on_a_seekable_stream_too() {
886        let big = vec![b'x'; PROBE_LEN * 3];
887        let mut f = tempfile::NamedTempFile::new().unwrap();
888        std::io::Write::write_all(&mut f, &big).unwrap();
889        let (_, path) = f.keep().unwrap();
890        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
891
892        let (prefix, _) = probe(src).unwrap();
893        assert_eq!(prefix.len(), PROBE_LEN);
894    }
895
896    #[test]
897    fn unknown_input_names_the_bytes_it_actually_saw() {
898        // Debugging a misdetection must not require adding prints to the library.
899        let r = registry();
900        let err = resolve_chain(&r, None, &[0xde, 0xad, 0xbe, 0xef]).unwrap_err();
901        let msg = err.to_string();
902        assert!(msg.contains("de ad be ef"), "message was: {msg}");
903        assert!(matches!(err, crate::Error::UnknownFormat { .. }));
904    }
905
906    #[test]
907    fn unknown_empty_input_still_produces_a_usable_message() {
908        let r = registry();
909        let err = resolve_chain(&r, None, b"").unwrap_err();
910        assert!(err.to_string().contains("empty"));
911    }
912
913    #[test]
914    fn equally_ranked_candidates_with_no_extension_are_an_error_not_a_guess() {
915        // A piped stream has no path to disambiguate with. Guessing silently is
916        // how a zip becomes an apk.
917        const A: &[MagicRule] = &[MagicRule {
918            offset: 0,
919            bytes: b"PK",
920            format: FormatId::new("alpha"),
921        }];
922        const B: &[MagicRule] = &[MagicRule {
923            offset: 0,
924            bytes: b"PK",
925            format: FormatId::new("beta"),
926        }];
927        let mut r = Registry::new();
928        r.register_container(
929            Arc::new(MockContainer),
930            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
931        );
932        r.register_container(
933            Arc::new(MockContainer),
934            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
935        );
936
937        let err = resolve_chain(&r, None, b"PKrest").unwrap_err();
938        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
939        let msg = err.to_string();
940        assert!(
941            msg.contains("alpha") && msg.contains("beta"),
942            "must name the candidates: {msg}"
943        );
944        // Exit 2, not 1. The message says "Pass `--format` to choose" —
945        // actionable advice, so this is the caller pointing stuffr at
946        // something it cannot resolve alone, the same class as
947        // `UnknownFormat` and `NotAnArchive`. It fell through
948        // `exit_code`'s wildcard to 1 (an internal failure) until the
949        // Phase 2 final review; see `error.rs`'s
950        // `pointing_a_verb_at_the_wrong_file_is_always_exit_two`.
951        assert_eq!(err.exit_code(), 2);
952    }
953
954    #[test]
955    fn a_higher_priority_candidate_resolves_without_an_extension() {
956        const A: &[MagicRule] = &[MagicRule {
957            offset: 0,
958            bytes: b"PK",
959            format: FormatId::new("alpha"),
960        }];
961        const B: &[MagicRule] = &[MagicRule {
962            offset: 0,
963            bytes: b"PK",
964            format: FormatId::new("beta"),
965        }];
966        let mut r = Registry::new();
967        r.register_container(
968            Arc::new(MockContainer),
969            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A).with_priority(-5),
970        );
971        r.register_container(
972            Arc::new(MockContainer),
973            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
974        );
975
976        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
977        assert_eq!(
978            chain,
979            Chain::Container {
980                container: FormatId::new("beta")
981            }
982        );
983    }
984
985    #[test]
986    fn an_extension_still_disambiguates_equal_candidates() {
987        const A: &[MagicRule] = &[MagicRule {
988            offset: 0,
989            bytes: b"PK",
990            format: FormatId::new("alpha"),
991        }];
992        const B: &[MagicRule] = &[MagicRule {
993            offset: 0,
994            bytes: b"PK",
995            format: FormatId::new("beta"),
996        }];
997        let mut r = Registry::new();
998        r.register_container(
999            Arc::new(MockContainer),
1000            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
1001        );
1002        r.register_container(
1003            Arc::new(MockContainer),
1004            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
1005        );
1006
1007        let chain = resolve_chain(&r, Some(Path::new("x.beta")), b"PKrest").unwrap();
1008        assert_eq!(
1009            chain,
1010            Chain::Container {
1011                container: FormatId::new("beta")
1012            }
1013        );
1014    }
1015
1016    #[test]
1017    fn an_extension_outside_the_tied_set_does_not_break_the_tie() {
1018        // A tie between two formats sharing a magic, with a path whose
1019        // extension names a THIRD format that does not share it. The extension
1020        // must not select a format whose magic never matched, and the error
1021        // must name the genuinely tied candidates rather than the irrelevant
1022        // one.
1023        const A: &[MagicRule] = &[MagicRule {
1024            offset: 0,
1025            bytes: b"PK",
1026            format: FormatId::new("alpha"),
1027        }];
1028        const B: &[MagicRule] = &[MagicRule {
1029            offset: 0,
1030            bytes: b"PK",
1031            format: FormatId::new("beta"),
1032        }];
1033        const C: &[MagicRule] = &[MagicRule {
1034            offset: 0,
1035            bytes: b"GZ",
1036            format: FormatId::new("gamma"),
1037        }];
1038        let mut r = Registry::new();
1039        r.register_container(
1040            Arc::new(MockContainer),
1041            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
1042        );
1043        r.register_container(
1044            Arc::new(MockContainer),
1045            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
1046        );
1047        r.register_container(
1048            Arc::new(MockContainer),
1049            FormatMeta::container(FormatId::new("gamma"), &["gamma"], C),
1050        );
1051
1052        let err = resolve_chain(&r, Some(Path::new("x.gamma")), b"PKrest").unwrap_err();
1053        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
1054        let msg = err.to_string();
1055        assert!(
1056            msg.contains("alpha") && msg.contains("beta"),
1057            "must name the tied candidates: {msg}"
1058        );
1059        assert!(
1060            !msg.contains("gamma"),
1061            "must not name the irrelevant extension: {msg}"
1062        );
1063    }
1064
1065    #[test]
1066    fn an_extension_reaches_a_lower_priority_format_that_shares_the_magic() {
1067        // The spec's worked example: zip outranks apk on a bare stream, but a
1068        // path saying `.apk` must still reach apk. Ranking must not override an
1069        // explicit filename.
1070        const ZIP_MAGIC: &[MagicRule] = &[MagicRule {
1071            offset: 0,
1072            bytes: b"PK",
1073            format: FormatId::new("zip"),
1074        }];
1075        const APK_MAGIC: &[MagicRule] = &[MagicRule {
1076            offset: 0,
1077            bytes: b"PK",
1078            format: FormatId::new("apk"),
1079        }];
1080        let mut r = Registry::new();
1081        r.register_container(
1082            Arc::new(MockContainer),
1083            FormatMeta::container(FormatId::new("zip"), &["zip"], ZIP_MAGIC),
1084        );
1085        r.register_container(
1086            Arc::new(MockContainer),
1087            FormatMeta::container(FormatId::new("apk"), &["apk"], APK_MAGIC).with_priority(-5),
1088        );
1089
1090        // With no path, the higher-priority zip wins on the bare magic hit.
1091        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
1092        assert_eq!(
1093            chain,
1094            Chain::Container {
1095                container: FormatId::new("zip")
1096            }
1097        );
1098
1099        // Same bytes, but the path names apk — the lower-ranked candidate must
1100        // still be reached; ranking cannot pre-empt an explicit filename.
1101        let chain = resolve_chain(&r, Some(Path::new("archive.apk")), b"PKrest").unwrap();
1102        assert_eq!(
1103            chain,
1104            Chain::Container {
1105                container: FormatId::new("apk")
1106            }
1107        );
1108    }
1109
1110    /// A `Source` that lies: it claims `seekable: true` but `as_seek()`
1111    /// always returns `None`. Nothing in this crate can implement `Source`
1112    /// this way today, but the trait is public and a third-party codec could
1113    /// — the point of this test is that `probe` must survive that instead of
1114    /// panicking.
1115    struct LiesAboutSeekability(std::io::Cursor<Vec<u8>>);
1116
1117    impl Read for LiesAboutSeekability {
1118        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1119            std::io::Read::read(&mut self.0, buf)
1120        }
1121    }
1122
1123    impl Source for LiesAboutSeekability {
1124        fn caps(&self) -> crate::source::SourceCaps {
1125            crate::source::SourceCaps {
1126                seekable: true,
1127                len: Some(self.0.get_ref().len() as u64),
1128            }
1129        }
1130
1131        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1132            None
1133        }
1134    }
1135
1136    #[test]
1137    fn a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking() {
1138        let src: Box<dyn Source> = Box::new(LiesAboutSeekability(std::io::Cursor::new(
1139            b"payload".to_vec(),
1140        )));
1141        // Not `.unwrap_err()`: the `Ok` side is `(Vec<u8>, Box<dyn Source>)`,
1142        // and `Source` is not `Debug`, so `Result::unwrap_err`'s `T: Debug`
1143        // bound can't be satisfied here.
1144        match probe(src) {
1145            Err(err) => assert!(
1146                matches!(err, crate::Error::NotSeekable { .. }),
1147                "must be NotSeekable, not a panic: {err:?}"
1148            ),
1149            Ok(_) => panic!("expected NotSeekable, got Ok"),
1150        }
1151    }
1152
1153    // --- Peek-based inner resolution (Task 4) -----------------------------
1154    //
1155    // `testing::MockCodec`'s bitwise-NOT transform is its own inverse:
1156    // encoding through it an EVEN number of times reproduces the original
1157    // bytes exactly, which would make a nesting-depth test silently
1158    // degenerate (the "6-deep" stream would collapse back to the bare
1159    // container and never exercise the bound at all). `PrefixedMockCodec`
1160    // instead prepends a fixed magic and passes the payload through
1161    // unchanged, so decoding always strips exactly one layer no matter how
1162    // many are stacked.
1163    const NESTED_MAGIC: &[u8] = b"MOCK";
1164
1165    struct PrefixedMockCodec;
1166
1167    impl Codec for PrefixedMockCodec {
1168        fn id(&self) -> FormatId {
1169            MOCK_CODEC
1170        }
1171
1172        fn caps(&self) -> CodecCaps {
1173            CodecCaps {
1174                encode: true,
1175                decode: true,
1176                ..Default::default()
1177            }
1178        }
1179
1180        fn decoder(&self, mut src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1181            let mut discard = vec![0u8; NESTED_MAGIC.len()];
1182            src.read_exact(&mut discard)?;
1183            Ok(Box::new(StreamOnly::new(src)))
1184        }
1185
1186        fn encoder(
1187            &self,
1188            mut dst: Box<dyn Write + Send>,
1189            _o: &EncodeOpts,
1190        ) -> Result<Box<dyn Sink>> {
1191            dst.write_all(NESTED_MAGIC)?;
1192            Ok(Box::new(PassthroughSink(dst)))
1193        }
1194    }
1195
1196    struct PassthroughSink(Box<dyn Write + Send>);
1197
1198    impl Write for PassthroughSink {
1199        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1200            self.0.write(buf)
1201        }
1202        fn flush(&mut self) -> std::io::Result<()> {
1203            self.0.flush()
1204        }
1205    }
1206
1207    impl Sink for PassthroughSink {
1208        fn finish(mut self: Box<Self>) -> Result<()> {
1209            self.0.flush()?;
1210            Ok(())
1211        }
1212    }
1213
1214    fn mock_registry_with_codec_and_container() -> Registry {
1215        const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
1216            offset: 0,
1217            bytes: NESTED_MAGIC,
1218            format: MOCK_CODEC,
1219        }];
1220        const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
1221            offset: 0,
1222            bytes: b"ME",
1223            format: MOCK_CONTAINER,
1224        }];
1225        let mut r = Registry::new();
1226        r.register_codec(
1227            Arc::new(PrefixedMockCodec),
1228            FormatMeta::codec(MOCK_CODEC, &["mock"], CODEC_MAGIC),
1229        );
1230        r.register_container(
1231            Arc::new(MockContainer),
1232            FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC),
1233        );
1234        r
1235    }
1236
1237    /// Wraps `payload` in one `PrefixedMockCodec` layer, via the codec's own
1238    /// `encoder` rather than hand-building the bytes, so the fixture is
1239    /// provably decodable by the exact codec `resolve_chain_deep` will call.
1240    fn mock_codec_encode(payload: &[u8]) -> Vec<u8> {
1241        let sink = SharedBuf::new();
1242        let mut s = PrefixedMockCodec
1243            .encoder(Box::new(sink.clone()), &EncodeOpts::default())
1244            .unwrap();
1245        s.write_all(payload).unwrap();
1246        s.finish().unwrap();
1247        sink.contents()
1248    }
1249
1250    #[test]
1251    fn a_container_inside_a_codec_resolves_from_the_stream_with_no_path() {
1252        let reg = mock_registry_with_codec_and_container();
1253        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1254        let outer = mock_codec_encode(&inner);
1255
1256        let (chain, _src) = resolve_chain_deep(
1257            &reg,
1258            None,
1259            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1260        )
1261        .expect("resolve");
1262
1263        // Before this task the inner layer was Chain::Raw, because no path meant
1264        // no extension to consult. The prefix of the DECODED stream now decides.
1265        assert_eq!(
1266            chain,
1267            Chain::Codec {
1268                codec: MOCK_CODEC,
1269                inner: Box::new(Chain::Container {
1270                    container: MOCK_CONTAINER
1271                }),
1272            },
1273            "a container nested inside a codec must be found by peeking the decoded stream"
1274        );
1275    }
1276
1277    #[test]
1278    fn nesting_beyond_the_depth_bound_is_a_typed_error_naming_the_depth() {
1279        let reg = mock_registry_with_codec_and_container();
1280        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1281        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1282            bytes = mock_codec_encode(&bytes);
1283        }
1284
1285        // Not `.expect_err(..)`: the `Ok` side is `(Chain, Box<dyn Source>)`,
1286        // and `Source` is not `Debug`, so `Result::expect_err`'s `T: Debug`
1287        // bound can't be satisfied here — same reason as
1288        // `a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking`
1289        // above.
1290        match resolve_chain_deep(
1291            &reg,
1292            None,
1293            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1294        ) {
1295            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1296            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1297            Ok(_) => panic!("nesting past the bound must be refused"),
1298        }
1299    }
1300
1301    #[test]
1302    fn nesting_beyond_the_depth_bound_is_refused_via_the_path_route_too() {
1303        // A path is a name, not a proof: `a.gz.gz.gz.gz.gz` must be refused
1304        // exactly like the identical bytes arriving with no path at all.
1305        // `mock_registry_with_codec_and_container` only ever exposes a
1306        // single extension per format ("mock"), so a path naming it gives
1307        // `inner_from_path` nothing to find a container with — the chain
1308        // still bottoms out at Raw, and peeking (and the bound) must engage
1309        // all the same.
1310        let reg = mock_registry_with_codec_and_container();
1311        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1312        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1313            bytes = mock_codec_encode(&bytes);
1314        }
1315
1316        match resolve_chain_deep(
1317            &reg,
1318            Some(Path::new("nested.mock")),
1319            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1320        ) {
1321            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1322            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1323            Ok(_) => panic!("nesting past the bound must be refused, path or no path"),
1324        }
1325    }
1326
1327    // --- Task 4b: a decoder-side io::Error must reclassify; a raw-source
1328    // one must not -------------------------------------------------------
1329
1330    /// A `Source` whose every read raises `InvalidData` — used two ways
1331    /// below: wrapped as the return of a codec's own `decoder()` (so the
1332    /// failure looks exactly like a corrupt payload discovered only once
1333    /// something reads the decoded bytes), and handed directly to
1334    /// `resolve_chain_deep` as the RAW top-level source (so the identical
1335    /// error kind arises before any codec is even involved). The two tests
1336    /// using it must disagree on the resulting `Error` variant — that
1337    /// disagreement is the whole point of Task 4b's fix.
1338    struct AlwaysInvalidData;
1339
1340    impl Read for AlwaysInvalidData {
1341        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1342            Err(std::io::Error::new(
1343                std::io::ErrorKind::InvalidData,
1344                "decoded bytes are malformed",
1345            ))
1346        }
1347    }
1348
1349    impl Source for AlwaysInvalidData {
1350        fn caps(&self) -> crate::source::SourceCaps {
1351            crate::source::SourceCaps {
1352                seekable: false,
1353                len: None,
1354            }
1355        }
1356
1357        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1358            None
1359        }
1360    }
1361
1362    /// A codec whose `decoder()` construction always succeeds but whose FIRST
1363    /// read fails with `InvalidData`, standing in for a corrupt payload behind
1364    /// an intact-looking header.
1365    ///
1366    /// `zlib.rs` is the lazy shape this models. Do NOT read it as "every real
1367    /// codec constructs lazily" — `xz_pure.rs` and `lzip.rs` both read at
1368    /// construction (`fill_buf()`), and reach the same place only because they
1369    /// end in `.unwrap_or(&[])`, which swallows the failure and defers it to
1370    /// the re-probe read this module classifies. An earlier version of this
1371    /// comment named `lzip.rs` as an example of the convention it contradicts.
1372    struct FailingDecodeCodec;
1373
1374    impl Codec for FailingDecodeCodec {
1375        fn id(&self) -> FormatId {
1376            FormatId::new("always-fails-to-decode")
1377        }
1378
1379        fn caps(&self) -> CodecCaps {
1380            CodecCaps {
1381                encode: false,
1382                decode: true,
1383                ..Default::default()
1384            }
1385        }
1386
1387        fn decoder(&self, _src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1388            Ok(Box::new(AlwaysInvalidData))
1389        }
1390
1391        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
1392            unimplemented!("this test never encodes")
1393        }
1394    }
1395
1396    /// The fix itself: a codec stream with no container above it (so
1397    /// resolution bottoms out at `Chain::Raw` and re-probes the decoded
1398    /// bytes, per `resolve_chain_deep_with`'s own doc comment) whose decoded
1399    /// bytes turn out to be malformed must report `Error::Corrupt` — exit 5
1400    /// — not `Error::Io`. Before Task 4b's fix this was `Err(Error::Io(_))`,
1401    /// the identical bug `list_reports_a_truncated_codec_stream_as_corrupt_
1402    /// not_as_an_io_failure` and `list_reports_an_empty_lzip_stream_as_
1403    /// corrupt_not_as_an_io_failure` pin at the CLI level with the real
1404    /// zlib and lzip codecs.
1405    #[test]
1406    fn a_decoder_side_io_error_while_re_probing_becomes_corrupt() {
1407        let mut reg = Registry::new();
1408        const FAILING: FormatId = FormatId::new("always-fails-to-decode");
1409        const FAILING_MAGIC: &[MagicRule] = &[MagicRule {
1410            offset: 0,
1411            bytes: &[0xab, 0xcd],
1412            format: FAILING,
1413        }];
1414        reg.register_codec(
1415            Arc::new(FailingDecodeCodec),
1416            FormatMeta::codec(FAILING, &["fail"], FAILING_MAGIC),
1417        );
1418
1419        // Real, readable bytes for the RAW top-level probe to sniff magic
1420        // from — only the DECODED stream (`AlwaysInvalidData`) ever fails.
1421        let bytes = vec![0xab, 0xcd, 0x00, 0x00];
1422        match resolve_chain_deep(
1423            &reg,
1424            None,
1425            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1426        ) {
1427            Err(Error::Corrupt(msg)) => assert!(
1428                msg.contains("malformed"),
1429                "must carry the decoder's own message via Error::from_decode_io: {msg}"
1430            ),
1431            Err(other) => panic!("expected Corrupt (a decoder-side read failed), got {other:?}"),
1432            Ok(_) => panic!("a decoder that always fails to read cannot resolve successfully"),
1433        }
1434    }
1435
1436    /// A codec that validates its header inside `decoder()` and REFUSES
1437    /// there — before a single decoded byte exists, so before
1438    /// [`crate::source::DecodeSideSource`] can mark anything — must still
1439    /// report `Error::Corrupt`.
1440    ///
1441    /// No registered codec takes this branch today; `.Z` parses its header
1442    /// lazily and every other decoder here is a `Read` adapter built without
1443    /// touching the source. It is pinned anyway, and `build_decoder` folds it
1444    /// anyway, because "unreached and wrong by default" is precisely the
1445    /// shape this class keeps recurring in: Task 4b closed the re-probe call,
1446    /// the ladder's spool was still open, and the enumeration that found the
1447    /// spool found this too. `CLAUDE.md`'s Phase 3c backlog records the same
1448    /// gap from the other side, against `conformance.rs:393`.
1449    #[test]
1450    fn a_decoder_that_refuses_at_construction_is_corrupt_not_an_io_failure() {
1451        struct RefusesToBuild;
1452
1453        impl Codec for RefusesToBuild {
1454            fn id(&self) -> FormatId {
1455                FormatId::new("refuses-to-build")
1456            }
1457
1458            fn caps(&self) -> CodecCaps {
1459                CodecCaps {
1460                    encode: false,
1461                    decode: true,
1462                    ..Default::default()
1463                }
1464            }
1465
1466            fn decoder(&self, _src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1467                Err(Error::Io(io::Error::new(
1468                    io::ErrorKind::InvalidData,
1469                    "the header is malformed and I will not build a decoder for it",
1470                )))
1471            }
1472
1473            fn encoder(
1474                &self,
1475                _dst: Box<dyn Write + Send>,
1476                _o: &EncodeOpts,
1477            ) -> Result<Box<dyn Sink>> {
1478                unimplemented!("this test never encodes")
1479            }
1480        }
1481
1482        const REFUSES: FormatId = FormatId::new("refuses-to-build");
1483        const REFUSES_MAGIC: &[MagicRule] = &[MagicRule {
1484            offset: 0,
1485            bytes: &[0xab, 0xcd],
1486            format: REFUSES,
1487        }];
1488
1489        let mut reg = Registry::new();
1490        reg.register_codec(
1491            Arc::new(RefusesToBuild),
1492            FormatMeta::codec(REFUSES, &["refuse"], REFUSES_MAGIC),
1493        );
1494
1495        // Both routes through `resolve_chain_deep_with` build a decoder — the
1496        // path route via `decode_through_chain`, the pathless one via the
1497        // re-probe loop — so both are checked, and both go through
1498        // `build_decoder`.
1499        for path in [None, Some(Path::new("thing.refuse"))] {
1500            let bytes = vec![0xab, 0xcd, 0x00, 0x00];
1501            match resolve_chain_deep(
1502                &reg,
1503                path,
1504                Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1505            ) {
1506                Err(Error::Corrupt(msg)) => assert!(
1507                    msg.contains("malformed"),
1508                    "must carry the codec's own message: {msg}"
1509                ),
1510                Err(other) => panic!("expected Corrupt for path {path:?}, got {other:?}"),
1511                Ok(_) => panic!("a codec that refuses to build cannot resolve successfully"),
1512            }
1513        }
1514    }
1515
1516    /// The hazard side: the SAME `InvalidData` error kind, but raised by the
1517    /// RAW source before any codec is even chosen, must stay `Error::Io` —
1518    /// exit 1 — never `Error::Corrupt`. This is what a fix that reclassified
1519    /// too broadly (inside `probe`/`PeekSource::fill` themselves, or at
1520    /// `resolve_chain_deep_with`'s own top-level `probe(src)` call) would
1521    /// get wrong: it has nothing to do with the error's `ErrorKind`, only
1522    /// with WHICH read produced it, so this guard cannot be satisfied by
1523    /// checking the kind alone the way the CLI-level directory test does.
1524    #[test]
1525    fn a_raw_source_io_error_of_the_identical_kind_stays_io_not_corrupt() {
1526        let reg = mock_registry_with_codec_and_container();
1527        match resolve_chain_deep(&reg, None, Box::new(AlwaysInvalidData)) {
1528            Err(Error::Io(io_err)) => assert_eq!(
1529                io_err.kind(),
1530                std::io::ErrorKind::InvalidData,
1531                "sanity: this must be the same error kind the test above reclassifies, so the \
1532                 two tests differ only in WHERE the read happened"
1533            ),
1534            Err(other) => panic!(
1535                "a raw-source failure must stay Io, not become {other:?} — reclassifying it \
1536                 would be a lie in the opposite direction from Task 4b's bug"
1537            ),
1538            Ok(_) => panic!("a raw source that always errors cannot resolve successfully"),
1539        }
1540    }
1541
1542    #[test]
1543    fn the_returned_source_is_decoded_through_every_codec_layer_on_both_routes() {
1544        // The defect this pins: the path route used to return the PRISTINE,
1545        // undecoded source (resolve_chain alone tells you the shape, nothing
1546        // decodes anything), while the no-path route returned the source
1547        // already peeled past every codec layer. A caller of
1548        // `resolve_chain_deep` cannot be expected to inspect `path.is_some()`
1549        // itself to know which kind of source it was just handed — both
1550        // routes must leave the source in the identical state: decoded
1551        // through every codec layer named in the returned `Chain`, ready to
1552        // hand straight to the container.
1553        //
1554        // "thing.mar.mock" mirrors `.tar.gz`: `inner_from_path` finds the
1555        // container ("mar") one extension in from the codec ("mock"), so
1556        // this path resolves the FULL chain in one `resolve_chain` call,
1557        // with no peeking at all — the route this test must prove decodes
1558        // its codec layer exactly as the peeling route does.
1559        let reg = mock_registry_with_codec_and_container();
1560        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1561        let outer = mock_codec_encode(&inner);
1562
1563        let (chain_with_path, mut src_with_path) = resolve_chain_deep(
1564            &reg,
1565            Some(Path::new("thing.mar.mock")),
1566            Box::new(ReaderSource::new(io::Cursor::new(outer.clone()))),
1567        )
1568        .expect("resolve with path");
1569        let (chain_no_path, mut src_no_path) = resolve_chain_deep(
1570            &reg,
1571            None,
1572            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1573        )
1574        .expect("resolve without path");
1575
1576        let expected_chain = Chain::Codec {
1577            codec: MOCK_CODEC,
1578            inner: Box::new(Chain::Container {
1579                container: MOCK_CONTAINER,
1580            }),
1581        };
1582        assert_eq!(chain_with_path, expected_chain);
1583        assert_eq!(chain_no_path, expected_chain);
1584
1585        let mut bytes_with_path = Vec::new();
1586        src_with_path.read_to_end(&mut bytes_with_path).unwrap();
1587        let mut bytes_no_path = Vec::new();
1588        src_no_path.read_to_end(&mut bytes_no_path).unwrap();
1589
1590        assert_eq!(
1591            bytes_with_path, inner,
1592            "the path route must decode through the codec layer, not return raw bytes"
1593        );
1594        assert_eq!(
1595            bytes_with_path, bytes_no_path,
1596            "both routes must leave the source in the identical, already-decoded state"
1597        );
1598    }
1599}