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/// How many bytes [`read_tail`] reads from the END of a seekable input: the
38/// size of the one trailer [`foreign_from_tail`] knows, UDIF's `koly` block.
39pub const TAIL_LEN: usize = 512;
40
41/// A format stuffr recognises in order to refuse it, rather than to open it.
42///
43/// Exists because some files' first bytes carry a magic stuffr DOES open: an
44/// Apple disk image (UDZO) begins with a zlib stream, so without this check
45/// `stuffr ls` listed it as `zlib` and every other verb took it for a zlib
46/// stream. Recognising the container from its own trailer is cheaper and
47/// truer than teaching zlib's check to guess.
48#[non_exhaustive]
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum Foreign {
51    /// An Apple disk image (UDIF: `.dmg`), identified by its `koly` trailer.
52    AppleDiskImage,
53}
54
55impl std::fmt::Display for Foreign {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Foreign::AppleDiskImage => f.write_str("an Apple disk image (UDIF)"),
59        }
60    }
61}
62
63/// Recognises a foreign format from the last [`TAIL_LEN`] bytes of a file.
64///
65/// UDIF's trailer is the last 512 bytes: `koly`, then a big-endian u32
66/// version (4), then a big-endian u32 header size (512). All three are
67/// required — the four-letter signature alone is 32 bits of evidence, and
68/// the version and size fields turn it into 96 that no ordinary file's tail
69/// matches by accident. Anything but exactly [`TAIL_LEN`] bytes is `None`:
70/// a shorter tail is not where the trailer lives.
71pub fn foreign_from_tail(tail: &[u8]) -> Option<Foreign> {
72    (tail.len() == TAIL_LEN
73        && tail[0..4] == *b"koly"
74        && tail[4..8] == [0, 0, 0, 4]
75        && tail[8..12] == [0, 0, 2, 0])
76    .then_some(Foreign::AppleDiskImage)
77}
78
79/// Reads the last [`TAIL_LEN`] bytes of a seekable source and rewinds it to
80/// byte zero.
81///
82/// `None` — having read nothing — for a source that cannot seek (a pipe has
83/// no end to look at without consuming it, and buffering a whole pipe to
84/// learn its last 512 bytes is not a probe), and for one shorter than
85/// [`TAIL_LEN`], which cannot hold the trailer. Also `None` if the source
86/// delivers fewer bytes than its own length promised (a file shrinking under
87/// the read): no tail is better evidence than a torn one.
88///
89/// The position afterwards is byte zero in every case that seeked at all,
90/// which is where [`probe`] expects its source to start.
91pub fn read_tail(src: &mut dyn Source) -> Result<Option<Vec<u8>>> {
92    if !src.caps().seekable {
93        return Ok(None);
94    }
95    // A source that claims to seek and has no seek view is `probe`'s to
96    // refuse (`NotSeekable`); this function only declines to look.
97    let Some(seek) = src.as_seek() else {
98        return Ok(None);
99    };
100    use std::io::SeekFrom;
101    let len = seek.seek(SeekFrom::End(0))?;
102    if len < TAIL_LEN as u64 {
103        seek.seek(SeekFrom::Start(0))?;
104        return Ok(None);
105    }
106    seek.seek(SeekFrom::Start(len - TAIL_LEN as u64))?;
107    let mut tail = vec![0u8; TAIL_LEN];
108    let mut filled = 0;
109    while filled < TAIL_LEN {
110        match seek.read(&mut tail[filled..])? {
111            0 => break,
112            n => filled += n,
113        }
114    }
115    seek.seek(SeekFrom::Start(0))?;
116    Ok((filled == TAIL_LEN).then_some(tail))
117}
118
119/// Reads a bounded prefix without consuming it.
120///
121/// A seekable source is rewound; a pipe is wrapped in a [`PeekSource`] that
122/// replays the prefix. Either way the caller still sees byte zero.
123///
124/// A seekable source has its last [`TAIL_LEN`] bytes read first
125/// ([`read_tail`]), and a foreign trailer ([`foreign_from_tail`]) is
126/// [`Error::ForeignFormat`] — exit 3 — before any magic is consulted. Every
127/// verb that identifies its input goes through here, so an Apple disk image
128/// is refused by name everywhere rather than decoded as the zlib stream it
129/// begins with. A pipe's tail is never read: that would consume it. A caller
130/// that must not refuse (`--format`, `salvage`) must not call `probe`.
131///
132/// Reading the tail first also means a seekable source that cannot really
133/// seek (a FIFO opened by path) fails before a byte of it is consumed, where
134/// it used to fail on the rewind after the prefix — the same `Error::Io`.
135pub fn probe(mut src: Box<dyn Source>) -> Result<(Vec<u8>, Box<dyn Source>)> {
136    if src.caps().seekable {
137        if let Some(foreign) = read_tail(src.as_mut())?
138            .as_deref()
139            .and_then(foreign_from_tail)
140        {
141            return Err(Error::ForeignFormat(foreign));
142        }
143        let mut prefix = vec![0u8; PROBE_LEN];
144        // `caps().seekable` is the `Source`'s own claim; `as_seek()` is a
145        // second, independent method on the same trait, and `Source` is
146        // public — a third-party codec (Phase 2+) can implement one without
147        // honouring the other. Trusting the claim with `.expect(..)` turned a
148        // buggy-but-foreign `Source` impl into a panic reachable through this
149        // crate's own public `probe`; surfacing it as a typed error instead
150        // lets a caller match on it same as any other seek failure. There is
151        // no format to name yet at this stage of detection, so the sentinel
152        // id below is a placeholder, not a registered format.
153        let Some(seek) = src.as_seek() else {
154            return Err(Error::NotSeekable {
155                format: FormatId::new("<probe>"),
156            });
157        };
158        let mut filled = 0;
159        while filled < PROBE_LEN {
160            match std::io::Read::read(seek, &mut prefix[filled..])? {
161                0 => break,
162                n => filled += n,
163            }
164        }
165        prefix.truncate(filled);
166        std::io::Seek::seek(seek, std::io::SeekFrom::Start(0))?;
167        return Ok((prefix, src));
168    }
169
170    let peek = PeekSource::fill(src, PROBE_LEN)?;
171    let prefix = peek.prefix().to_vec();
172    Ok((prefix, Box::new(peek)))
173}
174
175/// A resolved decode pipeline.
176#[non_exhaustive]
177#[derive(Clone, PartialEq, Eq, Debug)]
178pub enum Chain {
179    /// A codec wrapping something else. Decode order: this codec, then `inner`.
180    Codec { codec: FormatId, inner: Box<Chain> },
181    /// A container of entries.
182    Container { container: FormatId },
183    /// Opaque bytes. The terminal case for a bare compressed file.
184    Raw,
185}
186
187impl Chain {
188    /// The container this chain resolves to, if any, at ANY depth.
189    ///
190    /// `Some(tar)` for `tar over gzip` as well as for a bare `tar`: however
191    /// many codec layers sit above it, the entries live in a container, and a
192    /// caller that has not OPENED that container cannot speak for its
193    /// fidelity. That is the whole use — `ops::inspect` identifies a stream
194    /// WITHOUT decoding it, so it never opens the container and must not
195    /// report a fidelity conclusion it did not reach. See
196    /// `ops::Inspection::fidelity_evaluated`.
197    ///
198    /// The `_` arm covers [`Chain::Raw`] and anything added later: `Chain` is
199    /// `#[non_exhaustive]`, and "no container" is the right answer for a
200    /// shape this method does not recognise, since the caller's only use of
201    /// `Some` is to withhold a claim.
202    pub fn container(&self) -> Option<FormatId> {
203        match self {
204            Chain::Container { container } => Some(*container),
205            Chain::Codec { inner, .. } => inner.container(),
206            _ => None,
207        }
208    }
209
210    /// The outermost codec, if any — the layer a writer must create FIRST,
211    /// because the container writes into it.
212    ///
213    /// Deliberately only the outermost, and deliberately NOT recursive the
214    /// way [`Chain::container`] is: a write-side chain is at most one codec
215    /// deep. `MAX_CHAIN_DEPTH` bounds the READ side, where deeper nesting is
216    /// a hostile-input concern rather than a shape stuffr emits, and a
217    /// recursive answer here would let a caller silently write only the
218    /// innermost of several codecs.
219    pub fn outermost_codec(&self) -> Option<FormatId> {
220        match self {
221            Chain::Codec { codec, .. } => Some(*codec),
222            _ => None,
223        }
224    }
225
226    /// Human-readable form, innermost first: `"tar over gzip"`.
227    pub fn describe(&self) -> String {
228        match self {
229            Chain::Raw => "raw".to_string(),
230            Chain::Container { container } => container.to_string(),
231            Chain::Codec { codec, inner } => match inner.as_ref() {
232                Chain::Raw => codec.to_string(),
233                other => format!("{} over {}", other.describe(), codec),
234            },
235        }
236    }
237}
238
239fn hex_prefix(bytes: &[u8]) -> String {
240    if bytes.is_empty() {
241        return "empty input".to_string();
242    }
243    bytes
244        .iter()
245        .take(8)
246        .map(|b| format!("{b:02x}"))
247        .collect::<Vec<_>>()
248        .join(" ")
249}
250
251/// Lowercase extension components of a path, outermost last.
252/// `"out.tar.gz"` → `["tar", "gz"]`.
253fn extensions(path: &Path) -> Vec<String> {
254    let name = path
255        .file_name()
256        .and_then(|s| s.to_str())
257        .unwrap_or_default();
258    name.split('.')
259        .skip(1)
260        .map(|s| s.to_ascii_lowercase())
261        .collect()
262}
263
264/// Resolves the inner layer beneath an outer codec, using the path's remaining
265/// extensions. `.tar.gz` → tar; `.tgz` → tar; `.gz` or no path → Raw.
266fn inner_from_path(reg: &Registry, path: Option<&Path>, outer: FormatId) -> Chain {
267    let Some(path) = path else {
268        return Chain::Raw;
269    };
270    let exts = extensions(path);
271
272    // `.tgz` and friends: one extension meaning "tar inside <codec>", spelled
273    // as "t" + the codec's own extension. Resolved through the SAME
274    // [`compound_alias`] the write side uses, never a second copy of the strip
275    // rule — the copy is how `.tbz` came to mean tar-over-bzip2 when reading
276    // and a bare gzip when writing, which is the exact defect class
277    // `chain_for_new_path` shares this function to prevent.
278    if let Some(last) = exts.last()
279        && compound_alias(reg, last) == Some(outer)
280        && let Some(id) = reg.by_extension("tar")
281        && reg.container(id).is_some()
282    {
283        return Chain::Container { container: id };
284    }
285
286    // Otherwise look one extension inwards.
287    if exts.len() >= 2
288        && let Some(id) = reg.by_extension(&exts[exts.len() - 2])
289        && reg.container(id).is_some()
290    {
291        return Chain::Container { container: id };
292    }
293    Chain::Raw
294}
295
296/// The codec a `.tgz`-style compound extension names — "t" + the codec's own
297/// extension, meaning "tar inside <codec>".
298///
299/// Needed only on the write side. The read side takes the outer codec from
300/// the magic bytes and never has to parse `tgz` at all; a file being created
301/// has no magic, so the name is the only evidence there is. No codec
302/// registers its own compound alias and none should — `tar::meta` spells out
303/// why registering `tgz` on tar would make a `.tgz` resolve to a bare tar —
304/// so the suffix is stripped here instead, and `inner_from_path` calls this
305/// same function when deciding what is inside.
306///
307/// # `.tlz` means tar-over-**lzip** here, and that is a decision
308///
309/// The strip rule sends `.tlz` to `lz`, which lzip registers, so stuffr reads
310/// and writes `.tlz` as `tar.lz` — lzip. **GNU tar and libarchive both read
311/// `.tlz` as `tar.lzma`** (LZMA1, the `.lzma` alone-format), so an archive
312/// stuffr writes as `.tlz` will not open in GNU tar, and one GNU tar wrote
313/// will not open here.
314///
315/// It is left as it is, deliberately and for now: `lzip` is the format
316/// actually worth writing of the two (it carries a CRC and a recognised
317/// magic, where `.lzma` has neither), the spelling is genuinely contested
318/// rather than settled — `.tlz` predates lzip and both readings are in the
319/// wild — and changing it would silently reinterpret existing files. What is
320/// NOT acceptable is that the answer was an accident of which extension
321/// happened to be registered; it is written down here so the next change to
322/// it is a decision rather than a side effect. If it is ever revisited, the
323/// honest options are to refuse `.tlz` outright as ambiguous, or to add it to
324/// [`COMPOUND_SPELLINGS`] pointing at `lzma`.
325fn compound_alias(reg: &Registry, ext: &str) -> Option<FormatId> {
326    let stripped = ext.strip_prefix(['t', 'T'])?;
327    if stripped.is_empty() {
328        return None;
329    }
330    let stripped = stripped.to_ascii_lowercase();
331    let spelling = COMPOUND_SPELLINGS
332        .iter()
333        .find(|(alias, _)| *alias == stripped)
334        .map_or(stripped.as_str(), |(_, real)| *real);
335    let id = reg.by_extension(spelling)?;
336    // A container never sits inside itself: `.tar` is resolved by the direct
337    // lookup above, and anything reached through the alias must be a codec.
338    reg.container(id).is_none().then_some(id)
339}
340
341/// The compound spellings whose remainder after the `t` is NOT the codec's
342/// own registered extension, and what it really means.
343///
344/// Without this table the strip rule simply fails to find a codec and the
345/// whole name resolves to [`Chain::Raw`] — which on the write side is not a
346/// refusal but a silent fallback to `default_format()`, so **`stuffr pack
347/// notes.txt -o f.tbz` wrote a gzip stream, with no tar inside it, at exit
348/// 0.** A name promising one thing and bytes that are another, reported as
349/// success, is the defect class the write-side chain resolution exists to
350/// close, and `.tbz` was its last surviving member.
351///
352/// The table lives here rather than in the registry ON PURPOSE. Registering
353/// `bz` as an extension of bzip2 would fix `.tbz` and simultaneously make a
354/// plain `.bz` writable — and `.bz` is **bzip1**, a different, obsolete
355/// format this build cannot produce. The alias is only ever consulted in the
356/// `t`-prefixed compound position, so `stuffr pack notes.txt -o f.bz` still
357/// finds no format for `bz` and behaves exactly as it did.
358///
359/// GNU tar and libarchive both read `.tbz` as `tar.bz2`; `.tbz2` needs no
360/// entry, because stripping its `t` already yields bzip2's registered `bz2`.
361const COMPOUND_SPELLINGS: &[(&str, &str)] = &[("bz", "bz2")];
362
363/// The chain an OUTPUT path names, from its extensions alone.
364///
365/// The write-side twin of [`resolve_chain`], which cannot serve here: that one
366/// sniffs magic bytes, and a file about to be created has none. Shares
367/// `inner_from_path` with the read side deliberately — a second, write-side
368/// extension table would drift, and `.tar.gz` resolving one way for reading
369/// and another for writing is the whole class of bug this exists to prevent.
370///
371/// Never fails: an unknown or absent extension is [`Chain::Raw`], which every
372/// caller already has to handle.
373pub fn chain_for_new_path(reg: &Registry, path: &Path) -> Chain {
374    let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
375        return Chain::Raw;
376    };
377    let Some(outer) = reg.by_extension(ext).or_else(|| compound_alias(reg, ext)) else {
378        return Chain::Raw;
379    };
380    // `x.tar` — a container with nothing above it.
381    if reg.container(outer).is_some() {
382        return Chain::Container { container: outer };
383    }
384    // `outer` is a codec. `x.tar.gz` and `x.tgz` both name a container inside
385    // it; `x.gz` names nothing, and resolves to a bare codec over Raw.
386    let inner = inner_from_path(reg, Some(path), outer);
387    Chain::Codec {
388        codec: outer,
389        inner: Box::new(inner),
390    }
391}
392
393/// Turns a path and/or a probed prefix into a concrete pipeline.
394///
395/// The extension is consulted first, against the full magic-hit set; priority
396/// decides only when the path is silent or names a format whose magic did not
397/// match. Failure names the bytes actually seen so a misdetection is
398/// debuggable without library prints.
399///
400/// The lenient resolution every verb given a file uses: a magic hit its
401/// format does not confirm still resolves (see [`resolve_chain_checked`]),
402/// so a damaged archive the user names reaches its reader and is reported
403/// corrupt (exit 5) rather than unknown.
404pub fn resolve_chain(reg: &Registry, path: Option<&Path>, prefix: &[u8]) -> Result<Chain> {
405    resolve_chain_checked(reg, path, prefix).map(|(chain, _)| chain)
406}
407
408/// How the outer layer of a resolved [`Chain`] was reached.
409#[non_exhaustive]
410#[derive(Clone, Copy, PartialEq, Eq, Debug)]
411pub enum Evidence {
412    /// A magic hit (or, for a magicless format, the extension) that the
413    /// format's own [`crate::format::ConfirmFn`] accepted — or that has no
414    /// such function, which counts as accepting.
415    Confirmed,
416    /// Only magic hits (or an extension) that the format's function refused.
417    /// Still resolved, because the lenient verbs must reach a damaged file's
418    /// reader; [`identify_strict`] refuses it.
419    Unconfirmed,
420    /// No magic matched, and the extension named a format that has no
421    /// confirmation function: a name, and nothing else (brotli, raw deflate).
422    ByName,
423}
424
425/// [`resolve_chain`], also saying how the outer layer was reached.
426///
427/// Candidates are ranked by confirmation before anything else: the magic
428/// hits whose format confirms the prefix (or registered no function) are
429/// selected among exactly as `resolve_chain` always selected — the extension
430/// first, then priority, [`Error::AmbiguousFormat`] on a tie — and the
431/// answer is [`Evidence::Confirmed`]. Only when no hit confirms are all the
432/// hits selected among the same way, [`Evidence::Unconfirmed`]. So a
433/// refused hit can no longer tie with, or outrank, a confirmed one: two
434/// formats sharing a magic are separated by their bytes, not by an error.
435///
436/// With no magic hit at all the extension decides, and its format's function
437/// (if any) grades it: `Confirmed`, `Unconfirmed`, or [`Evidence::ByName`]
438/// when there is none. Every error is the one `resolve_chain` raised before
439/// confirmation existed.
440///
441/// `prefix` is a probe prefix: shorter than [`PROBE_LEN`] means it is the
442/// whole stream, which every confirmation is told (see
443/// [`crate::format::ConfirmFn`]).
444pub fn resolve_chain_checked(
445    reg: &Registry,
446    path: Option<&Path>,
447    prefix: &[u8],
448) -> Result<(Chain, Evidence)> {
449    resolve_checked(reg, path, prefix, prefix.len() < PROBE_LEN)
450}
451
452/// [`resolve_chain_checked`] with the "these bytes are the whole stream"
453/// flag given rather than read off the length: a decoded layer that stopped
454/// on an error is short without being whole.
455fn resolve_checked(
456    reg: &Registry,
457    path: Option<&Path>,
458    prefix: &[u8],
459    whole: bool,
460) -> Result<(Chain, Evidence)> {
461    let hits = reg.match_magic(prefix);
462    // `match_magic`'s order (priority descending) survives the filter, which
463    // the tie-break below relies on.
464    let confirmed: Vec<FormatId> = hits
465        .iter()
466        .copied()
467        .filter(|id| reg.confirm(*id, prefix, whole) != Some(false))
468        .collect();
469    let (mut candidates, mut evidence) = if confirmed.is_empty() {
470        (hits, Evidence::Unconfirmed)
471    } else {
472        (confirmed, Evidence::Confirmed)
473    };
474
475    if candidates.len() > 1 {
476        // The extension is consulted FIRST, and against every magic hit — a
477        // path saying `.apk` must reach apk even though zip outranks it. That
478        // is the spec's own worked example, and ranking cannot be allowed to
479        // pre-empt an explicit filename. Priority decides only when the path
480        // is silent, or names a format whose magic never matched.
481        let by_ext =
482            path.and_then(|p| extensions(p).iter().rev().find_map(|e| reg.by_extension(e)));
483        match by_ext.filter(|id| candidates.contains(id)) {
484            Some(id) => candidates = vec![id],
485            None => {
486                let top = reg.priority_of(candidates[0]);
487                let tied: Vec<FormatId> = candidates
488                    .iter()
489                    .copied()
490                    .filter(|id| reg.priority_of(*id) == top)
491                    .collect();
492                if tied.len() == 1 {
493                    candidates = tied;
494                } else {
495                    let names: Vec<&str> = tied.iter().map(|id| id.as_str()).collect();
496                    return Err(Error::AmbiguousFormat {
497                        candidates: names.join(", "),
498                    });
499                }
500            }
501        }
502    }
503
504    let outer = match candidates.first() {
505        Some(id) => Some(*id),
506        None => {
507            let by_name =
508                path.and_then(|p| extensions(p).iter().rev().find_map(|e| reg.by_extension(e)));
509            if let Some(id) = by_name {
510                evidence = match reg.confirm(id, prefix, whole) {
511                    Some(true) => Evidence::Confirmed,
512                    Some(false) => Evidence::Unconfirmed,
513                    None => Evidence::ByName,
514                };
515            }
516            by_name
517        }
518    };
519
520    let Some(outer) = outer else {
521        return Err(Error::UnknownFormat {
522            seen: hex_prefix(prefix),
523        });
524    };
525
526    let kind = if reg.container(outer).is_some() {
527        FormatKind::Container
528    } else if reg.codec(outer).is_some() {
529        FormatKind::Codec
530    } else {
531        return Err(Error::FormatNotEnabled(outer));
532    };
533
534    let chain = match kind {
535        FormatKind::Container => Chain::Container { container: outer },
536        FormatKind::Codec => Chain::Codec {
537            codec: outer,
538            inner: Box::new(inner_from_path(reg, path, outer)),
539        },
540    };
541    Ok((chain, evidence))
542}
543
544/// Builds `innermost` back up under each codec in `layers`, outermost last so
545/// the final fold produces the correct nesting order.
546fn wrap_layers(layers: &[FormatId], innermost: Chain) -> Chain {
547    layers
548        .iter()
549        .rev()
550        .fold(innermost, |inner, codec| Chain::Codec {
551            codec: *codec,
552            inner: Box::new(inner),
553        })
554}
555
556/// Decodes `src` through every [`Chain::Codec`] layer named in `chain`,
557/// outermost first, so the returned source is positioned exactly where
558/// `chain`'s innermost entry (a [`Chain::Container`] or [`Chain::Raw`])
559/// begins. A chain with no codec layers at all (a bare container) hands
560/// `src` back untouched.
561/// Builds one codec layer's decoder and marks its output as decode-side.
562///
563/// The ONE place in this crate that turns a [`Source`] into a decoded one, so
564/// it is also the one place that has to remember the marking — see
565/// [`crate::source::decode_side`] for why the marking exists at all, and for
566/// the four downstream `?`s it retroactively makes correct.
567///
568/// Two things happen here, and they cover the two moments a codec can fail:
569///
570/// * **Construction.** A codec that validates its header eagerly can fail
571///   inside `decoder()` itself, reading the source that is about to become
572///   decode-side but is not yet wrapped. That error is folded through
573///   [`Error::from_decode_io`] here. No registered codec takes this branch
574///   today (`CLAUDE.md`'s Phase 3c backlog records the same gap against
575///   `conformance.rs:393`), which is exactly why it is worth closing before
576///   one does: an unreached branch that is wrong by default is how this class
577///   keeps recurring.
578/// * **Reading.** Every later `read` is marked by [`DecodeSideSource`].
579///
580/// Wrapping is idempotent, so a three-layer chain pays one tag allocation per
581/// error rather than three.
582fn build_decoder(
583    reg: &Registry,
584    codec: FormatId,
585    src: Box<dyn Source>,
586    opts: &DecodeOpts,
587) -> Result<Box<dyn Source>> {
588    let decoded = reg
589        .require_codec(codec)?
590        .decoder(src, opts)
591        .map_err(|e| match e {
592            Error::Io(io_err) => Error::from_decode_io(io_err),
593            other => other,
594        })?;
595    Ok(DecodeSideSource::wrap(decoded))
596}
597
598fn decode_through_chain(
599    reg: &Registry,
600    chain: &Chain,
601    src: Box<dyn Source>,
602    opts: &DecodeOpts,
603) -> Result<Box<dyn Source>> {
604    match chain {
605        Chain::Codec { codec, inner } => {
606            let decoded = build_decoder(reg, *codec, src, opts)?;
607            decode_through_chain(reg, inner, decoded, opts)
608        }
609        Chain::Container { .. } | Chain::Raw => Ok(src),
610    }
611}
612
613/// [`resolve_chain`], but when the outer layer is a codec whose inner the
614/// path left as [`Chain::Raw`] — whether because there was no path at all,
615/// or because the path's own extensions ran out before naming a container —
616/// keeps going: decodes that layer, peeks [`PROBE_LEN`] of the *decoded*
617/// bytes, and resolves again from the decoded bytes' own magic. Closes the
618/// Phase 0 limitation where a codec on stdin always bottomed out at Raw
619/// purely for lack of an extension.
620///
621/// The path still gets first say: `resolve_chain` runs once with it, and if
622/// that alone already resolves the full chain (a bare container, or a codec
623/// whose inner the extensions named directly — `.tar.gz`), peeking never
624/// happens. Peeking is what runs when the path is silent, or was not enough.
625///
626/// Bounded by [`MAX_CHAIN_DEPTH`] on **both** routes, because re-probing a
627/// *decoded* stream is exactly what makes unbounded nesting attackable: each
628/// layer is cheap to produce and expensive to expand. A path is a name, not
629/// a proof — `a.gz.gz.gz.gz.gz` is refused the same way piped bytes with the
630/// identical shape are, and exceeding the bound is a typed
631/// [`Error::ChainTooDeep`], never a panic and never a silent stop.
632///
633/// Returns the resolved [`Chain`] alongside a [`Source`] **already decoded
634/// through every codec layer named in that `Chain`** — positioned exactly
635/// where the chain's innermost entry begins, ready to hand straight to a
636/// container or to read as raw bytes. This holds on both routes: a path
637/// that resolves in one step (no peeking needed) still gets its codec
638/// layers decoded before the source comes back, so a caller never has to
639/// ask whether the source it received happens to be raw or already decoded.
640pub fn resolve_chain_deep(
641    reg: &Registry,
642    path: Option<&Path>,
643    src: Box<dyn Source>,
644) -> Result<(Chain, Box<dyn Source>)> {
645    resolve_chain_deep_with(reg, path, src, &DecodeOpts::default())
646}
647
648/// [`resolve_chain_deep`], but with the [`DecodeOpts`] every codec layer's
649/// decoder is built from spelled out by the caller instead of defaulted.
650///
651/// This exists because [`DecodeOpts::memory_limit`] is the ONLY guard that
652/// can see a pure codec's pre-output dictionary allocation — the one sized
653/// from a value the file declares in its own header, before a single decoded
654/// byte exists. `--max-ratio` counts decoded output bytes and so cannot see
655/// it at all. Resolving a chain builds a decoder per codec layer, so a
656/// resolver that defaults its opts leaves every entry-aware verb (`list`,
657/// `test`, `cat`, `unpack -C`) unbounded against a crafted `.tar.lz` whose
658/// header declares a 512 MiB dictionary, while the single-stream path
659/// refuses the same bytes at exit 6.
660///
661/// `resolve_chain_deep` remains as the "library default" spelling —
662/// unbounded, matching `DecodeOpts::default()` everywhere else — but no CLI
663/// path should use it: the CLI always has a resolved limit to pass.
664pub fn resolve_chain_deep_with(
665    reg: &Registry,
666    path: Option<&Path>,
667    src: Box<dyn Source>,
668    opts: &DecodeOpts,
669) -> Result<(Chain, Box<dyn Source>)> {
670    let (prefix, src) = probe(src)?;
671    let chain = resolve_chain(reg, path, &prefix)?;
672
673    // Either a bare container, or a codec whose inner the path already named
674    // (e.g. `.tar.gz`) — the path (if any) is done contributing. Decode
675    // through whatever codec layers `chain` names and stop; there is nothing
676    // left to peek.
677    if !matches!(&chain, Chain::Codec { inner, .. } if matches!(inner.as_ref(), Chain::Raw)) {
678        let decoded = decode_through_chain(reg, &chain, src, opts)?;
679        return Ok((chain, decoded));
680    }
681
682    // The chain bottoms out at Raw. From here, only the decoded bytes' own
683    // magic decides — the path, if there was one, already had its say above
684    // and found nothing further.
685    let Chain::Codec { codec, .. } = chain else {
686        unreachable!("guarded above: chain is Chain::Codec with a Raw inner")
687    };
688    let mut layers: Vec<FormatId> = vec![codec];
689    let mut current_codec = codec;
690    let mut src = src;
691    let mut depth = 1usize;
692
693    loop {
694        if depth >= MAX_CHAIN_DEPTH {
695            return Err(Error::ChainTooDeep {
696                depth: MAX_CHAIN_DEPTH,
697            });
698        }
699
700        let decoded = build_decoder(reg, current_codec, src, opts)?;
701        // This `probe` reads through a DECODER — unlike the one at the top
702        // of this function, and unlike `resolve_chain_deep_with`'s own
703        // `probe(src)` two calls up, both of which read the RAW,
704        // undecoded source. An `io::Error` here means the bytes this codec
705        // just produced are malformed, not that the raw source or the
706        // environment failed, so it is reclassified via
707        // `Error::from_decode_io` exactly like every other decoder-side
708        // read in this crate (`ops.rs`, `entries.rs`, `conformance.rs`).
709        //
710        // Left as a bare `?` (as it was until Task 4b), a truncated `.zz`
711        // or an empty `.lz` — a codec stream with no container above it, so
712        // resolution bottoms out in this very loop re-probing the decoded
713        // bytes — surfaced as `Error::Io`: exit 1, "stuffr failed", for
714        // input that is simply corrupt. `ops::decompress` decodes the
715        // identical bytes through a call already wired to
716        // `Error::from_decode_io`, so `cat` and `list` disagreed on the
717        // exit code for the same input; see `error.rs`'s doc comment on
718        // `from_decode_io` for the convention this leans on, and
719        // `Error::exit_code`'s comment for this wildcard's history.
720        //
721        // Do NOT widen this to `probe(src)` above (raw source) or to
722        // `probe`/`PeekSource::fill` themselves — either would reclassify a
723        // genuine i/o failure on the raw source (a real disk error, a
724        // permission failure, a broken pipe) as `Corrupt`, which is a new
725        // lie in the opposite direction.
726        //
727        // As of the decode-side marker (see `crate::source::decode_side`),
728        // `build_decoder` has already tagged this source, so `probe`'s own
729        // internal `?` would classify it anyway and this `map_err` sees an
730        // `Error::Corrupt` and passes it through. Kept rather than deleted:
731        // it is the site Task 4b's regression tests name, and a belt that
732        // does not depend on the marker being applied one call up.
733        let (next_prefix, next_src) = probe(decoded).map_err(|e| match e {
734            Error::Io(io_err) => Error::from_decode_io(io_err),
735            other => other,
736        })?;
737        depth += 1;
738
739        let next_chain = match resolve_chain(reg, None, &next_prefix) {
740            Ok(chain) => chain,
741            Err(Error::UnknownFormat { .. }) => {
742                return Ok((wrap_layers(&layers, Chain::Raw), next_src));
743            }
744            Err(e) => return Err(e),
745        };
746
747        let Chain::Codec { codec: nested, .. } = next_chain else {
748            return Ok((wrap_layers(&layers, next_chain), next_src));
749        };
750
751        layers.push(nested);
752        current_codec = nested;
753        src = next_src;
754    }
755}
756
757/// Identifies an input for a listing that must not claim what it cannot
758/// show: `Some(chain)` only when every layer is confirmed, `None` for "not
759/// something stuffr opens".
760///
761/// The strict twin of [`resolve_chain`], for `stuffr ls`, which reads a
762/// folder of arbitrary files and has no user naming any of them: a lenient
763/// answer there is a false positive in a list, where for a verb given a file
764/// it is the route to an honest exit 5. Reads nothing — the caller supplies
765/// the probe prefix and, for a seekable file, [`read_tail`]'s tail.
766///
767/// * A foreign tail ([`foreign_from_tail`]) is `None` before anything else.
768/// * The outer layer comes from [`resolve_chain_checked`]; an error or
769///   [`Evidence::Unconfirmed`] is `None`. [`Evidence::ByName`] is accepted:
770///   a magicless format has nothing more to show.
771/// * A container is the answer.
772/// * A codec is decoded from the prefix itself — never from the file — up to
773///   [`PROBE_LEN`] output bytes, keeping what arrives before any error (a
774///   prefix is a truncated stream, so an error at its end is expected). What
775///   came out is resolved with no path; a confirmed codec or container
776///   becomes the inner layer (a codec recursively), anything else is
777///   [`Chain::Raw`]. The decoded bytes are the whole inner stream when they
778///   stop short of [`PROBE_LEN`] without an error, and their confirmation is
779///   told so.
780/// * Decoded bytes that match no magic at all fall back to the container
781///   the path names for the outermost codec (`inner_from_path`, as
782///   [`resolve_chain`] answers), but only when that container's own check
783///   accepts the bytes (or it has none): a v7 tar carries no `ustar` magic
784///   and `v7.tar.gz` is still tar over gzip, while a text file named
785///   `y.tar.gz` fails tar's checksum and is a gzip, whatever its name says.
786///   Decoded bytes whose magic matched but did not confirm get no such
787///   fallback.
788/// * A codec that decodes **nothing** depends on whether the prefix was the
789///   whole file. A cut-off read (`prefix.len() == PROBE_LEN`) can simply be
790///   shorter than the codec's first block — measured on a real 168 KB `.bz2`
791///   and on `.zst`, and true of lz4 and framed snappy whenever the first
792///   block is larger than the window — so the codec, already confirmed by
793///   its magic and check, is kept, over the inner layer its path names
794///   (exactly what [`resolve_chain`] answers: `big.tar.bz2` stays tar over
795///   bzip2). The whole file decoding nothing before an error is `None`; the
796///   whole file decoding nothing and ending cleanly is an empty stream, a
797///   codec over `Raw`. A decoder refused at construction is `None` either
798///   way.
799///
800/// Nested layers follow the same rule, with "cut off" meaning the bytes fed
801/// in were themselves incomplete — the layer above filled the window or
802/// stopped on an error. Their fallback is `Raw`, not the path: a name speaks
803/// for the outermost layers, and which of its extensions belongs to a layer
804/// this deep is a guess.
805///
806/// The nesting is bounded below [`MAX_CHAIN_DEPTH`] codec layers — the most
807/// [`resolve_chain_deep_with`] will open — and a deeper stack stops at
808/// `Raw` rather than failing, because the outer layers did decode.
809pub fn identify_strict(
810    reg: &Registry,
811    path: Option<&Path>,
812    prefix: &[u8],
813    tail: Option<&[u8]>,
814    opts: &DecodeOpts,
815) -> Option<Chain> {
816    if tail.and_then(foreign_from_tail).is_some() {
817        return None;
818    }
819    let (chain, evidence) = resolve_chain_checked(reg, path, prefix).ok()?;
820    if evidence == Evidence::Unconfirmed {
821        return None;
822    }
823    match chain {
824        Chain::Container { .. } => Some(chain),
825        Chain::Codec { codec, inner } => {
826            let layer = StrictInput {
827                bytes: prefix,
828                cut: prefix.len() == PROBE_LEN,
829                unseen_inner: *inner,
830            };
831            strict_codec_layer(reg, codec, layer, opts, 1)
832        }
833        // `resolve_chain_checked` answers a container or a codec; anything
834        // added to `Chain` later is not something this function can vouch for.
835        _ => None,
836    }
837}
838
839/// The outermost layer of `chain` whose format's header check refuses the
840/// bytes that layer begins with, or `None` when every layer it can see
841/// checks out.
842///
843/// For `stuffr info`, which names a lenient chain (the one every verb given
844/// the file would read) and must still say which of its layers did not check
845/// out: a `.tar.gz` whose tar header fails its checksum resolves as tar over
846/// gzip, and this answers `tar`. Reads nothing — `prefix` is the probe
847/// prefix, and each codec layer is decoded from it as [`identify_strict`]
848/// decodes it, up to [`PROBE_LEN`] output bytes.
849///
850/// A layer it cannot see says nothing: a codec whose decoder refuses at
851/// construction, or whose prefix decodes to no bytes at all (a cut-off read
852/// shorter than its first block), ends the walk with `None`. A format with no
853/// check function counts as checking out, as it does everywhere else.
854pub fn first_unconfirmed_layer(
855    reg: &Registry,
856    chain: &Chain,
857    prefix: &[u8],
858    opts: &DecodeOpts,
859) -> Option<FormatId> {
860    let mut layer = chain;
861    let mut owned: Vec<u8>;
862    let mut bytes = prefix;
863    // A probe prefix short of the window is the whole stream; a decoded
864    // layer is whole when it ended cleanly inside the window.
865    let mut whole = prefix.len() < PROBE_LEN;
866    for _ in 0..MAX_CHAIN_DEPTH {
867        match layer {
868            Chain::Container { container } => {
869                return (reg.confirm(*container, bytes, whole) == Some(false))
870                    .then_some(*container);
871            }
872            Chain::Codec { codec, inner } => {
873                if reg.confirm(*codec, bytes, whole) == Some(false) {
874                    return Some(*codec);
875                }
876                if matches!(**inner, Chain::Raw) {
877                    return None;
878                }
879                let decoded = decode_prefix(reg, *codec, bytes, opts)?;
880                if decoded.bytes.is_empty() {
881                    return None;
882                }
883                whole = decoded.is_whole();
884                owned = decoded.bytes;
885                bytes = &owned;
886                layer = inner;
887            }
888            _ => return None,
889        }
890    }
891    None
892}
893
894/// What one codec layer of [`identify_strict`] is given to decode.
895struct StrictInput<'a> {
896    bytes: &'a [u8],
897    /// `bytes` stop short of the stream's real end.
898    cut: bool,
899    /// The path's answer for the outermost layer, `Raw` below it: claimed
900    /// when `cut` and nothing decodes, and the fallback (when its check
901    /// accepts) for decoded bytes that match no magic.
902    unseen_inner: Chain,
903}
904
905/// One codec layer of [`identify_strict`]: decodes `input.bytes` through
906/// `codec` and resolves what comes out. `depth` counts the codec layers down
907/// to and including this one.
908fn strict_codec_layer(
909    reg: &Registry,
910    codec: FormatId,
911    input: StrictInput<'_>,
912    opts: &DecodeOpts,
913    depth: usize,
914) -> Option<Chain> {
915    let decoded = decode_prefix(reg, codec, input.bytes, opts)?;
916    let whole = decoded.is_whole();
917    let inner = if decoded.bytes.is_empty() {
918        match (input.cut, decoded.failed) {
919            // Too little input for the codec's first block: the window, not
920            // the file, ran out.
921            (true, _) => input.unseen_inner,
922            // The whole stream, and it decodes to nothing but an error.
923            (false, true) => return None,
924            // The whole stream, cleanly empty.
925            (false, false) => Chain::Raw,
926        }
927    } else {
928        match resolve_checked(reg, None, &decoded.bytes, whole) {
929            Ok((inner @ Chain::Container { .. }, Evidence::Confirmed)) => inner,
930            Ok((Chain::Codec { codec: nested, .. }, Evidence::Confirmed))
931                if depth + 1 < MAX_CHAIN_DEPTH =>
932            {
933                let nested_input = StrictInput {
934                    bytes: &decoded.bytes,
935                    cut: !whole,
936                    unseen_inner: Chain::Raw,
937                };
938                // A nested codec whose own bytes decode to nothing is not a
939                // reason to disown the layer above, which did decode: that
940                // layer is then a codec over opaque bytes.
941                strict_codec_layer(reg, nested, nested_input, opts, depth + 1).unwrap_or(Chain::Raw)
942            }
943            // No magic at all in what came out: the name may still speak for
944            // a magicless inner layer (a v7 tar has no `ustar`), but only
945            // where that layer's own check accepts the bytes.
946            _ if reg.match_magic(&decoded.bytes).is_empty() => match input.unseen_inner {
947                Chain::Container { container }
948                    if reg.confirm(container, &decoded.bytes, whole) != Some(false) =>
949                {
950                    Chain::Container { container }
951                }
952                _ => Chain::Raw,
953            },
954            _ => Chain::Raw,
955        }
956    };
957    Some(Chain::Codec {
958        codec,
959        inner: Box::new(inner),
960    })
961}
962
963/// What [`decode_prefix`] got out of a decoder.
964struct DecodedPrefix {
965    /// Up to [`PROBE_LEN`] decoded bytes.
966    bytes: Vec<u8>,
967    /// The read stopped on an error rather than at the window or at EOF.
968    failed: bool,
969}
970
971impl DecodedPrefix {
972    /// The decoded bytes are the layer's entire stream: it ended cleanly
973    /// before filling the window.
974    fn is_whole(&self) -> bool {
975        !self.failed && self.bytes.len() < PROBE_LEN
976    }
977}
978
979/// How many `Interrupted` reads in a row [`decode_prefix`] retries before
980/// taking the decoder at its word that it cannot make progress. A real
981/// `EINTR` clears on the next call; a decoder that answers `Interrupted`
982/// forever would otherwise hang a folder listing on one file.
983const MAX_INTERRUPTED_RETRIES: usize = 64;
984
985/// Up to [`PROBE_LEN`] bytes of `codec`'s decoding of `bytes`, keeping what
986/// arrives before any read error. `None` only when the decoder refuses at
987/// construction.
988fn decode_prefix(
989    reg: &Registry,
990    codec: FormatId,
991    bytes: &[u8],
992    opts: &DecodeOpts,
993) -> Option<DecodedPrefix> {
994    // Copied because a decoder takes a `Box<dyn Source>`, which is `'static`:
995    // it cannot borrow the caller's prefix. At most `PROBE_LEN` bytes.
996    let src = Box::new(crate::source::ReaderSource::new(std::io::Cursor::new(
997        bytes.to_vec(),
998    )));
999    let mut decoder = build_decoder(reg, codec, src, opts).ok()?;
1000    let mut out = vec![0u8; PROBE_LEN];
1001    let mut filled = 0;
1002    let mut failed = false;
1003    let mut interrupted = 0;
1004    while filled < PROBE_LEN {
1005        match std::io::Read::read(&mut decoder, &mut out[filled..]) {
1006            Ok(0) => break,
1007            Ok(n) => {
1008                filled += n;
1009                interrupted = 0;
1010            }
1011            Err(e)
1012                if e.kind() == std::io::ErrorKind::Interrupted
1013                    && interrupted < MAX_INTERRUPTED_RETRIES =>
1014            {
1015                interrupted += 1;
1016            }
1017            Err(_) => {
1018                failed = true;
1019                break;
1020            }
1021        }
1022    }
1023    out.truncate(filled);
1024    Some(DecodedPrefix { bytes: out, failed })
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030    use crate::archive::{Codec, DecodeOpts, EncodeOpts, Sink};
1031    use crate::format::{CodecCaps, FormatId, FormatMeta, MagicRule};
1032    use crate::registry::Registry;
1033    use crate::source::{ReaderSource, Source, StreamOnly};
1034    use crate::testing::{
1035        MOCK_CODEC, MOCK_CONTAINER, MockCodec, MockContainer, SharedBuf, mock_archive_bytes,
1036    };
1037    use std::io;
1038    use std::io::{Read, Write};
1039    use std::path::Path;
1040    use std::sync::Arc;
1041
1042    const GZIP: FormatId = FormatId::new("gzip");
1043    const TAR: FormatId = FormatId::new("tar");
1044
1045    const GZIP_MAGIC: &[MagicRule] = &[MagicRule {
1046        offset: 0,
1047        bytes: &[0x1f, 0x8b],
1048        format: GZIP,
1049    }];
1050    const TAR_MAGIC: &[MagicRule] = &[MagicRule {
1051        offset: 257,
1052        bytes: b"ustar",
1053        format: TAR,
1054    }];
1055
1056    fn registry() -> Registry {
1057        let mut r = Registry::new();
1058        r.register_codec(
1059            Arc::new(MockCodec),
1060            FormatMeta::codec(GZIP, &["gz", "tgz"], GZIP_MAGIC),
1061        );
1062        r.register_container(
1063            Arc::new(MockContainer),
1064            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
1065        );
1066        r
1067    }
1068
1069    /// `registry()` above registers the mock codec under BOTH `gz` and `tgz`,
1070    /// and the mock container under `tar` — exactly the shape the compound
1071    /// alias needs, so no new fixture is required here.
1072    #[test]
1073    fn an_output_path_resolves_to_the_chain_its_name_promises() {
1074        let reg = registry();
1075        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
1076        assert_eq!(c("x.tar"), "tar");
1077        assert_eq!(c("x.gz"), "gzip");
1078        assert_eq!(c("x.tar.gz"), "tar over gzip");
1079        assert_eq!(
1080            c("x.tgz"),
1081            "tar over gzip",
1082            "the compound alias must resolve too"
1083        );
1084        assert_eq!(c("x.TAR.GZ"), "tar over gzip", "extensions are caseless");
1085        assert_eq!(c("x.txt"), "raw");
1086        assert_eq!(c("x"), "raw");
1087    }
1088
1089    /// Finding 4 of the Phase 2c final review. `bzip2` registers `bz2` and
1090    /// nothing else, so stripping `.tbz`'s `t` looked up `bz`, found no
1091    /// format, and resolved the whole name to [`Chain::Raw`] — which on the
1092    /// write side is not a refusal but a silent fall back to the default
1093    /// codec, so `-o f.tbz` wrote a gzip with no tar inside it at exit 0.
1094    ///
1095    /// The registry is deliberately the one place NOT fixed: `.bz` is bzip1.
1096    #[test]
1097    fn tbz_means_tar_over_bzip2_without_making_plain_bz_writable() {
1098        const BZIP2: FormatId = FormatId::new("bzip2");
1099        const BZIP2_MAGIC: &[MagicRule] = &[MagicRule {
1100            offset: 0,
1101            bytes: b"BZh",
1102            format: BZIP2,
1103        }];
1104        let mut reg = Registry::new();
1105        // `bz2` ONLY — the real bzip2 codec's whole extension list.
1106        reg.register_codec(
1107            Arc::new(MockCodec),
1108            FormatMeta::codec(BZIP2, &["bz2"], BZIP2_MAGIC),
1109        );
1110        reg.register_container(
1111            Arc::new(MockContainer),
1112            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
1113        );
1114
1115        let c = |p: &str| chain_for_new_path(&reg, Path::new(p)).describe();
1116        assert_eq!(
1117            c("f.tbz"),
1118            "tar over bzip2",
1119            "GNU tar and libarchive both read .tbz as tar.bz2"
1120        );
1121        assert_eq!(c("f.TBZ"), "tar over bzip2", "and extensions are caseless");
1122        assert_eq!(
1123            c("f.tbz2"),
1124            "tar over bzip2",
1125            "the spelling that always worked must keep working"
1126        );
1127        assert_eq!(
1128            c("f.tar.bz2"),
1129            "tar over bzip2",
1130            "and so must the long form"
1131        );
1132
1133        // The half that says WHERE the fix lives. `bz` is bzip1, a different
1134        // and obsolete format: registering it on bzip2 would have fixed
1135        // `.tbz` and simultaneously made `-o f.bz` write a bzip2 stream under
1136        // a name that promises bzip1.
1137        assert_eq!(
1138            c("f.bz"),
1139            "raw",
1140            "`bz` must resolve to nothing outside the t-prefixed compound form"
1141        );
1142
1143        // The READ side resolves the same name through the same function —
1144        // that sharing is the point, and a second write-side table is what
1145        // would let `.tbz` mean one thing to `pack` and another to `list`.
1146        let chain = resolve_chain(&reg, Some(Path::new("f.tbz")), b"BZh\0").unwrap();
1147        assert_eq!(
1148            chain.describe(),
1149            "tar over bzip2",
1150            "reading a .tbz must agree with writing one"
1151        );
1152    }
1153
1154    /// The pair a writer actually consumes: the container to create, and the
1155    /// codec to create FIRST because the container writes into it.
1156    #[test]
1157    fn the_outermost_codec_is_the_layer_a_writer_creates_first() {
1158        let reg = registry();
1159        let pair = |p: &str| {
1160            let c = chain_for_new_path(&reg, Path::new(p));
1161            (c.container(), c.outermost_codec())
1162        };
1163        assert_eq!(pair("x.tar"), (Some(TAR), None));
1164        assert_eq!(pair("x.gz"), (None, Some(GZIP)));
1165        assert_eq!(pair("x.tar.gz"), (Some(TAR), Some(GZIP)));
1166        assert_eq!(pair("x.tgz"), (Some(TAR), Some(GZIP)));
1167        assert_eq!(pair("x.txt"), (None, None));
1168    }
1169
1170    #[test]
1171    fn probe_does_not_consume_the_stream() {
1172        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(
1173            b"HEADERbody".to_vec(),
1174        )));
1175        let (prefix, mut rest) = probe(src).unwrap();
1176        assert_eq!(&prefix[..6], b"HEADER");
1177
1178        let mut all = Vec::new();
1179        rest.read_to_end(&mut all).unwrap();
1180        assert_eq!(all, b"HEADERbody", "the consumer must still see byte zero");
1181    }
1182
1183    #[test]
1184    fn probe_reads_at_most_the_window() {
1185        let big = vec![b'x'; PROBE_LEN * 3];
1186        let src: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(big)));
1187        let (prefix, _) = probe(src).unwrap();
1188        assert_eq!(prefix.len(), PROBE_LEN);
1189    }
1190
1191    #[test]
1192    fn magic_identifies_a_bare_codec_stream() {
1193        let r = registry();
1194        let chain = resolve_chain(&r, Some(Path::new("blob.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
1195        assert_eq!(
1196            chain,
1197            Chain::Codec {
1198                codec: GZIP,
1199                inner: Box::new(Chain::Raw)
1200            }
1201        );
1202        assert_eq!(chain.describe(), "gzip");
1203    }
1204
1205    #[test]
1206    fn a_double_extension_nests_the_container_inside_the_codec() {
1207        let r = registry();
1208        let chain = resolve_chain(&r, Some(Path::new("out.tar.gz")), &[0x1f, 0x8b, 0x08]).unwrap();
1209        assert_eq!(
1210            chain,
1211            Chain::Codec {
1212                codec: GZIP,
1213                inner: Box::new(Chain::Container { container: TAR })
1214            }
1215        );
1216        assert_eq!(chain.describe(), "tar over gzip");
1217    }
1218
1219    #[test]
1220    fn a_codec_on_stdin_resolves_to_raw_inner() {
1221        // No path means no extension hint. Phase 2 refines this by peeking
1222        // after the first decoded block; until then Raw is the honest answer.
1223        let r = registry();
1224        let chain = resolve_chain(&r, None, &[0x1f, 0x8b, 0x08]).unwrap();
1225        assert_eq!(
1226            chain,
1227            Chain::Codec {
1228                codec: GZIP,
1229                inner: Box::new(Chain::Raw)
1230            }
1231        );
1232    }
1233
1234    #[test]
1235    fn container_magic_resolves_directly() {
1236        let r = registry();
1237        let mut prefix = vec![0u8; 512];
1238        prefix[257..262].copy_from_slice(b"ustar");
1239        let chain = resolve_chain(&r, Some(Path::new("x.tar")), &prefix).unwrap();
1240        assert_eq!(chain, Chain::Container { container: TAR });
1241        assert_eq!(chain.describe(), "tar");
1242    }
1243
1244    #[test]
1245    fn extension_is_the_fallback_when_magic_says_nothing() {
1246        let r = registry();
1247        let chain = resolve_chain(&r, Some(Path::new("mystery.tar")), b"no magic here").unwrap();
1248        assert_eq!(chain, Chain::Container { container: TAR });
1249    }
1250
1251    /// `Chain::container` must see THROUGH codec layers, not only name a
1252    /// bare container. `ops::inspect` uses it to decide whether it may report
1253    /// a fidelity conclusion, and `backup.tar.gz` has a container it has not
1254    /// opened just as surely as `backup.tar` does — a non-recursive version
1255    /// would quietly re-introduce the false negative for every `.tar.gz`.
1256    #[test]
1257    fn chain_container_sees_through_codec_layers() {
1258        let tar = FormatId::new("tar");
1259        assert_eq!(Chain::Container { container: tar }.container(), Some(tar));
1260        assert_eq!(
1261            Chain::Codec {
1262                codec: GZIP,
1263                inner: Box::new(Chain::Container { container: tar }),
1264            }
1265            .container(),
1266            Some(tar),
1267            "tar over gzip still has a container nobody has opened"
1268        );
1269        assert_eq!(
1270            Chain::Codec {
1271                codec: GZIP,
1272                inner: Box::new(Chain::Raw),
1273            }
1274            .container(),
1275            None,
1276            "a bare codec stream has no container, so an empty warning list is a real finding"
1277        );
1278        assert_eq!(Chain::Raw.container(), None);
1279    }
1280
1281    #[test]
1282    fn tgz_single_extension_still_nests_tar_inside_gzip() {
1283        let r = registry();
1284        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
1285        assert_eq!(
1286            chain,
1287            Chain::Codec {
1288                codec: GZIP,
1289                inner: Box::new(Chain::Container { container: TAR })
1290            }
1291        );
1292    }
1293
1294    #[test]
1295    fn a_compound_extension_resolves_without_a_registered_compound_alias() {
1296        // `.tgz` means "t" + gzip's own extension. Resolving it must not depend
1297        // on gzip having separately registered "tgz" itself: a codec that
1298        // forgets that alias would otherwise silently yield Raw instead of
1299        // tar-over-<codec>, with no error raised.
1300        let mut r = Registry::new();
1301        r.register_codec(
1302            Arc::new(MockCodec),
1303            FormatMeta::codec(GZIP, &["gz"], GZIP_MAGIC), // deliberately NO "tgz" alias
1304        );
1305        r.register_container(
1306            Arc::new(MockContainer),
1307            FormatMeta::container(TAR, &["tar"], TAR_MAGIC),
1308        );
1309
1310        let chain = resolve_chain(&r, Some(Path::new("a.tgz")), &[0x1f, 0x8b]).unwrap();
1311        assert_eq!(
1312            chain,
1313            Chain::Codec {
1314                codec: GZIP,
1315                inner: Box::new(Chain::Container { container: TAR })
1316            }
1317        );
1318    }
1319
1320    #[test]
1321    fn probe_does_not_consume_a_seekable_stream_either() {
1322        // Non-consumption has two implementations — rewind for a seekable
1323        // source, replay for a pipe. Only the pipe path was covered.
1324        let mut f = tempfile::NamedTempFile::new().unwrap();
1325        std::io::Write::write_all(&mut f, b"HEADERbody").unwrap();
1326        let (_, path) = f.keep().unwrap();
1327        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
1328
1329        let (prefix, mut rest) = probe(src).unwrap();
1330        assert_eq!(&prefix[..6], b"HEADER");
1331
1332        let mut all = Vec::new();
1333        rest.read_to_end(&mut all).unwrap();
1334        assert_eq!(
1335            all, b"HEADERbody",
1336            "a seekable source must be rewound to byte zero"
1337        );
1338    }
1339
1340    #[test]
1341    fn probe_bounds_the_window_on_a_seekable_stream_too() {
1342        let big = vec![b'x'; PROBE_LEN * 3];
1343        let mut f = tempfile::NamedTempFile::new().unwrap();
1344        std::io::Write::write_all(&mut f, &big).unwrap();
1345        let (_, path) = f.keep().unwrap();
1346        let src: Box<dyn Source> = Box::new(crate::source::FileSource::open(&path).unwrap());
1347
1348        let (prefix, _) = probe(src).unwrap();
1349        assert_eq!(prefix.len(), PROBE_LEN);
1350    }
1351
1352    #[test]
1353    fn unknown_input_names_the_bytes_it_actually_saw() {
1354        // Debugging a misdetection must not require adding prints to the library.
1355        let r = registry();
1356        let err = resolve_chain(&r, None, &[0xde, 0xad, 0xbe, 0xef]).unwrap_err();
1357        let msg = err.to_string();
1358        assert!(msg.contains("de ad be ef"), "message was: {msg}");
1359        assert!(matches!(err, crate::Error::UnknownFormat { .. }));
1360    }
1361
1362    #[test]
1363    fn unknown_empty_input_still_produces_a_usable_message() {
1364        let r = registry();
1365        let err = resolve_chain(&r, None, b"").unwrap_err();
1366        assert!(err.to_string().contains("empty"));
1367    }
1368
1369    #[test]
1370    fn equally_ranked_candidates_with_no_extension_are_an_error_not_a_guess() {
1371        // A piped stream has no path to disambiguate with. Guessing silently is
1372        // how a zip becomes an apk.
1373        const A: &[MagicRule] = &[MagicRule {
1374            offset: 0,
1375            bytes: b"PK",
1376            format: FormatId::new("alpha"),
1377        }];
1378        const B: &[MagicRule] = &[MagicRule {
1379            offset: 0,
1380            bytes: b"PK",
1381            format: FormatId::new("beta"),
1382        }];
1383        let mut r = Registry::new();
1384        r.register_container(
1385            Arc::new(MockContainer),
1386            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
1387        );
1388        r.register_container(
1389            Arc::new(MockContainer),
1390            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
1391        );
1392
1393        let err = resolve_chain(&r, None, b"PKrest").unwrap_err();
1394        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
1395        let msg = err.to_string();
1396        assert!(
1397            msg.contains("alpha") && msg.contains("beta"),
1398            "must name the candidates: {msg}"
1399        );
1400        // Exit 2, not 1. The message says "Pass `--format` to choose" —
1401        // actionable advice, so this is the caller pointing stuffr at
1402        // something it cannot resolve alone, the same class as
1403        // `UnknownFormat` and `NotAnArchive`. It fell through
1404        // `exit_code`'s wildcard to 1 (an internal failure) until the
1405        // Phase 2 final review; see `error.rs`'s
1406        // `pointing_a_verb_at_the_wrong_file_is_always_exit_two`.
1407        assert_eq!(err.exit_code(), 2);
1408    }
1409
1410    #[test]
1411    fn a_higher_priority_candidate_resolves_without_an_extension() {
1412        const A: &[MagicRule] = &[MagicRule {
1413            offset: 0,
1414            bytes: b"PK",
1415            format: FormatId::new("alpha"),
1416        }];
1417        const B: &[MagicRule] = &[MagicRule {
1418            offset: 0,
1419            bytes: b"PK",
1420            format: FormatId::new("beta"),
1421        }];
1422        let mut r = Registry::new();
1423        r.register_container(
1424            Arc::new(MockContainer),
1425            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A).with_priority(-5),
1426        );
1427        r.register_container(
1428            Arc::new(MockContainer),
1429            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
1430        );
1431
1432        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
1433        assert_eq!(
1434            chain,
1435            Chain::Container {
1436                container: FormatId::new("beta")
1437            }
1438        );
1439    }
1440
1441    #[test]
1442    fn an_extension_still_disambiguates_equal_candidates() {
1443        const A: &[MagicRule] = &[MagicRule {
1444            offset: 0,
1445            bytes: b"PK",
1446            format: FormatId::new("alpha"),
1447        }];
1448        const B: &[MagicRule] = &[MagicRule {
1449            offset: 0,
1450            bytes: b"PK",
1451            format: FormatId::new("beta"),
1452        }];
1453        let mut r = Registry::new();
1454        r.register_container(
1455            Arc::new(MockContainer),
1456            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
1457        );
1458        r.register_container(
1459            Arc::new(MockContainer),
1460            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
1461        );
1462
1463        let chain = resolve_chain(&r, Some(Path::new("x.beta")), b"PKrest").unwrap();
1464        assert_eq!(
1465            chain,
1466            Chain::Container {
1467                container: FormatId::new("beta")
1468            }
1469        );
1470    }
1471
1472    #[test]
1473    fn an_extension_outside_the_tied_set_does_not_break_the_tie() {
1474        // A tie between two formats sharing a magic, with a path whose
1475        // extension names a THIRD format that does not share it. The extension
1476        // must not select a format whose magic never matched, and the error
1477        // must name the genuinely tied candidates rather than the irrelevant
1478        // one.
1479        const A: &[MagicRule] = &[MagicRule {
1480            offset: 0,
1481            bytes: b"PK",
1482            format: FormatId::new("alpha"),
1483        }];
1484        const B: &[MagicRule] = &[MagicRule {
1485            offset: 0,
1486            bytes: b"PK",
1487            format: FormatId::new("beta"),
1488        }];
1489        const C: &[MagicRule] = &[MagicRule {
1490            offset: 0,
1491            bytes: b"GZ",
1492            format: FormatId::new("gamma"),
1493        }];
1494        let mut r = Registry::new();
1495        r.register_container(
1496            Arc::new(MockContainer),
1497            FormatMeta::container(FormatId::new("alpha"), &["alpha"], A),
1498        );
1499        r.register_container(
1500            Arc::new(MockContainer),
1501            FormatMeta::container(FormatId::new("beta"), &["beta"], B),
1502        );
1503        r.register_container(
1504            Arc::new(MockContainer),
1505            FormatMeta::container(FormatId::new("gamma"), &["gamma"], C),
1506        );
1507
1508        let err = resolve_chain(&r, Some(Path::new("x.gamma")), b"PKrest").unwrap_err();
1509        assert!(matches!(err, crate::Error::AmbiguousFormat { .. }));
1510        let msg = err.to_string();
1511        assert!(
1512            msg.contains("alpha") && msg.contains("beta"),
1513            "must name the tied candidates: {msg}"
1514        );
1515        assert!(
1516            !msg.contains("gamma"),
1517            "must not name the irrelevant extension: {msg}"
1518        );
1519    }
1520
1521    #[test]
1522    fn an_extension_reaches_a_lower_priority_format_that_shares_the_magic() {
1523        // The spec's worked example: zip outranks apk on a bare stream, but a
1524        // path saying `.apk` must still reach apk. Ranking must not override an
1525        // explicit filename.
1526        const ZIP_MAGIC: &[MagicRule] = &[MagicRule {
1527            offset: 0,
1528            bytes: b"PK",
1529            format: FormatId::new("zip"),
1530        }];
1531        const APK_MAGIC: &[MagicRule] = &[MagicRule {
1532            offset: 0,
1533            bytes: b"PK",
1534            format: FormatId::new("apk"),
1535        }];
1536        let mut r = Registry::new();
1537        r.register_container(
1538            Arc::new(MockContainer),
1539            FormatMeta::container(FormatId::new("zip"), &["zip"], ZIP_MAGIC),
1540        );
1541        r.register_container(
1542            Arc::new(MockContainer),
1543            FormatMeta::container(FormatId::new("apk"), &["apk"], APK_MAGIC).with_priority(-5),
1544        );
1545
1546        // With no path, the higher-priority zip wins on the bare magic hit.
1547        let chain = resolve_chain(&r, None, b"PKrest").unwrap();
1548        assert_eq!(
1549            chain,
1550            Chain::Container {
1551                container: FormatId::new("zip")
1552            }
1553        );
1554
1555        // Same bytes, but the path names apk — the lower-ranked candidate must
1556        // still be reached; ranking cannot pre-empt an explicit filename.
1557        let chain = resolve_chain(&r, Some(Path::new("archive.apk")), b"PKrest").unwrap();
1558        assert_eq!(
1559            chain,
1560            Chain::Container {
1561                container: FormatId::new("apk")
1562            }
1563        );
1564    }
1565
1566    /// A `Source` that lies: it claims `seekable: true` but `as_seek()`
1567    /// always returns `None`. Nothing in this crate can implement `Source`
1568    /// this way today, but the trait is public and a third-party codec could
1569    /// — the point of this test is that `probe` must survive that instead of
1570    /// panicking.
1571    struct LiesAboutSeekability(std::io::Cursor<Vec<u8>>);
1572
1573    impl Read for LiesAboutSeekability {
1574        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1575            std::io::Read::read(&mut self.0, buf)
1576        }
1577    }
1578
1579    impl Source for LiesAboutSeekability {
1580        fn caps(&self) -> crate::source::SourceCaps {
1581            crate::source::SourceCaps {
1582                seekable: true,
1583                len: Some(self.0.get_ref().len() as u64),
1584            }
1585        }
1586
1587        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1588            None
1589        }
1590    }
1591
1592    #[test]
1593    fn a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking() {
1594        let src: Box<dyn Source> = Box::new(LiesAboutSeekability(std::io::Cursor::new(
1595            b"payload".to_vec(),
1596        )));
1597        // Not `.unwrap_err()`: the `Ok` side is `(Vec<u8>, Box<dyn Source>)`,
1598        // and `Source` is not `Debug`, so `Result::unwrap_err`'s `T: Debug`
1599        // bound can't be satisfied here.
1600        match probe(src) {
1601            Err(err) => assert!(
1602                matches!(err, crate::Error::NotSeekable { .. }),
1603                "must be NotSeekable, not a panic: {err:?}"
1604            ),
1605            Ok(_) => panic!("expected NotSeekable, got Ok"),
1606        }
1607    }
1608
1609    // --- Peek-based inner resolution (Task 4) -----------------------------
1610    //
1611    // `testing::MockCodec`'s bitwise-NOT transform is its own inverse:
1612    // encoding through it an EVEN number of times reproduces the original
1613    // bytes exactly, which would make a nesting-depth test silently
1614    // degenerate (the "6-deep" stream would collapse back to the bare
1615    // container and never exercise the bound at all). `PrefixedMockCodec`
1616    // instead prepends a fixed magic and passes the payload through
1617    // unchanged, so decoding always strips exactly one layer no matter how
1618    // many are stacked.
1619    const NESTED_MAGIC: &[u8] = b"MOCK";
1620
1621    struct PrefixedMockCodec;
1622
1623    impl Codec for PrefixedMockCodec {
1624        fn id(&self) -> FormatId {
1625            MOCK_CODEC
1626        }
1627
1628        fn caps(&self) -> CodecCaps {
1629            CodecCaps {
1630                encode: true,
1631                decode: true,
1632                ..Default::default()
1633            }
1634        }
1635
1636        fn decoder(&self, mut src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1637            let mut discard = vec![0u8; NESTED_MAGIC.len()];
1638            src.read_exact(&mut discard)?;
1639            Ok(Box::new(StreamOnly::new(src)))
1640        }
1641
1642        fn encoder(
1643            &self,
1644            mut dst: Box<dyn Write + Send>,
1645            _o: &EncodeOpts,
1646        ) -> Result<Box<dyn Sink>> {
1647            dst.write_all(NESTED_MAGIC)?;
1648            Ok(Box::new(PassthroughSink(dst)))
1649        }
1650    }
1651
1652    struct PassthroughSink(Box<dyn Write + Send>);
1653
1654    impl Write for PassthroughSink {
1655        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1656            self.0.write(buf)
1657        }
1658        fn flush(&mut self) -> std::io::Result<()> {
1659            self.0.flush()
1660        }
1661    }
1662
1663    impl Sink for PassthroughSink {
1664        fn finish(mut self: Box<Self>) -> Result<()> {
1665            self.0.flush()?;
1666            Ok(())
1667        }
1668    }
1669
1670    fn mock_registry_with_codec_and_container() -> Registry {
1671        const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
1672            offset: 0,
1673            bytes: NESTED_MAGIC,
1674            format: MOCK_CODEC,
1675        }];
1676        const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
1677            offset: 0,
1678            bytes: b"ME",
1679            format: MOCK_CONTAINER,
1680        }];
1681        let mut r = Registry::new();
1682        r.register_codec(
1683            Arc::new(PrefixedMockCodec),
1684            FormatMeta::codec(MOCK_CODEC, &["mock"], CODEC_MAGIC),
1685        );
1686        r.register_container(
1687            Arc::new(MockContainer),
1688            FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC),
1689        );
1690        r
1691    }
1692
1693    /// Wraps `payload` in one `PrefixedMockCodec` layer, via the codec's own
1694    /// `encoder` rather than hand-building the bytes, so the fixture is
1695    /// provably decodable by the exact codec `resolve_chain_deep` will call.
1696    fn mock_codec_encode(payload: &[u8]) -> Vec<u8> {
1697        let sink = SharedBuf::new();
1698        let mut s = PrefixedMockCodec
1699            .encoder(Box::new(sink.clone()), &EncodeOpts::default())
1700            .unwrap();
1701        s.write_all(payload).unwrap();
1702        s.finish().unwrap();
1703        sink.contents()
1704    }
1705
1706    #[test]
1707    fn a_container_inside_a_codec_resolves_from_the_stream_with_no_path() {
1708        let reg = mock_registry_with_codec_and_container();
1709        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1710        let outer = mock_codec_encode(&inner);
1711
1712        let (chain, _src) = resolve_chain_deep(
1713            &reg,
1714            None,
1715            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1716        )
1717        .expect("resolve");
1718
1719        // Before this task the inner layer was Chain::Raw, because no path meant
1720        // no extension to consult. The prefix of the DECODED stream now decides.
1721        assert_eq!(
1722            chain,
1723            Chain::Codec {
1724                codec: MOCK_CODEC,
1725                inner: Box::new(Chain::Container {
1726                    container: MOCK_CONTAINER
1727                }),
1728            },
1729            "a container nested inside a codec must be found by peeking the decoded stream"
1730        );
1731    }
1732
1733    #[test]
1734    fn nesting_beyond_the_depth_bound_is_a_typed_error_naming_the_depth() {
1735        let reg = mock_registry_with_codec_and_container();
1736        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1737        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1738            bytes = mock_codec_encode(&bytes);
1739        }
1740
1741        // Not `.expect_err(..)`: the `Ok` side is `(Chain, Box<dyn Source>)`,
1742        // and `Source` is not `Debug`, so `Result::expect_err`'s `T: Debug`
1743        // bound can't be satisfied here — same reason as
1744        // `a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking`
1745        // above.
1746        match resolve_chain_deep(
1747            &reg,
1748            None,
1749            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1750        ) {
1751            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1752            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1753            Ok(_) => panic!("nesting past the bound must be refused"),
1754        }
1755    }
1756
1757    #[test]
1758    fn nesting_beyond_the_depth_bound_is_refused_via_the_path_route_too() {
1759        // A path is a name, not a proof: `a.gz.gz.gz.gz.gz` must be refused
1760        // exactly like the identical bytes arriving with no path at all.
1761        // `mock_registry_with_codec_and_container` only ever exposes a
1762        // single extension per format ("mock"), so a path naming it gives
1763        // `inner_from_path` nothing to find a container with — the chain
1764        // still bottoms out at Raw, and peeking (and the bound) must engage
1765        // all the same.
1766        let reg = mock_registry_with_codec_and_container();
1767        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1768        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1769            bytes = mock_codec_encode(&bytes);
1770        }
1771
1772        match resolve_chain_deep(
1773            &reg,
1774            Some(Path::new("nested.mock")),
1775            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1776        ) {
1777            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1778            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1779            Ok(_) => panic!("nesting past the bound must be refused, path or no path"),
1780        }
1781    }
1782
1783    // --- Task 4b: a decoder-side io::Error must reclassify; a raw-source
1784    // one must not -------------------------------------------------------
1785
1786    /// A `Source` whose every read raises `InvalidData` — used two ways
1787    /// below: wrapped as the return of a codec's own `decoder()` (so the
1788    /// failure looks exactly like a corrupt payload discovered only once
1789    /// something reads the decoded bytes), and handed directly to
1790    /// `resolve_chain_deep` as the RAW top-level source (so the identical
1791    /// error kind arises before any codec is even involved). The two tests
1792    /// using it must disagree on the resulting `Error` variant — that
1793    /// disagreement is the whole point of Task 4b's fix.
1794    struct AlwaysInvalidData;
1795
1796    impl Read for AlwaysInvalidData {
1797        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1798            Err(std::io::Error::new(
1799                std::io::ErrorKind::InvalidData,
1800                "decoded bytes are malformed",
1801            ))
1802        }
1803    }
1804
1805    impl Source for AlwaysInvalidData {
1806        fn caps(&self) -> crate::source::SourceCaps {
1807            crate::source::SourceCaps {
1808                seekable: false,
1809                len: None,
1810            }
1811        }
1812
1813        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1814            None
1815        }
1816    }
1817
1818    /// A codec whose `decoder()` construction always succeeds but whose FIRST
1819    /// read fails with `InvalidData`, standing in for a corrupt payload behind
1820    /// an intact-looking header.
1821    ///
1822    /// `zlib.rs` is the lazy shape this models. Do NOT read it as "every real
1823    /// codec constructs lazily" — `xz_pure.rs` and `lzip.rs` both read at
1824    /// construction (`fill_buf()`), and reach the same place only because they
1825    /// end in `.unwrap_or(&[])`, which swallows the failure and defers it to
1826    /// the re-probe read this module classifies. An earlier version of this
1827    /// comment named `lzip.rs` as an example of the convention it contradicts.
1828    struct FailingDecodeCodec;
1829
1830    impl Codec for FailingDecodeCodec {
1831        fn id(&self) -> FormatId {
1832            FormatId::new("always-fails-to-decode")
1833        }
1834
1835        fn caps(&self) -> CodecCaps {
1836            CodecCaps {
1837                encode: false,
1838                decode: true,
1839                ..Default::default()
1840            }
1841        }
1842
1843        fn decoder(&self, _src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1844            Ok(Box::new(AlwaysInvalidData))
1845        }
1846
1847        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
1848            unimplemented!("this test never encodes")
1849        }
1850    }
1851
1852    /// The fix itself: a codec stream with no container above it (so
1853    /// resolution bottoms out at `Chain::Raw` and re-probes the decoded
1854    /// bytes, per `resolve_chain_deep_with`'s own doc comment) whose decoded
1855    /// bytes turn out to be malformed must report `Error::Corrupt` — exit 5
1856    /// — not `Error::Io`. Before Task 4b's fix this was `Err(Error::Io(_))`,
1857    /// the identical bug `list_reports_a_truncated_codec_stream_as_corrupt_
1858    /// not_as_an_io_failure` and `list_reports_an_empty_lzip_stream_as_
1859    /// corrupt_not_as_an_io_failure` pin at the CLI level with the real
1860    /// zlib and lzip codecs.
1861    #[test]
1862    fn a_decoder_side_io_error_while_re_probing_becomes_corrupt() {
1863        let mut reg = Registry::new();
1864        const FAILING: FormatId = FormatId::new("always-fails-to-decode");
1865        const FAILING_MAGIC: &[MagicRule] = &[MagicRule {
1866            offset: 0,
1867            bytes: &[0xab, 0xcd],
1868            format: FAILING,
1869        }];
1870        reg.register_codec(
1871            Arc::new(FailingDecodeCodec),
1872            FormatMeta::codec(FAILING, &["fail"], FAILING_MAGIC),
1873        );
1874
1875        // Real, readable bytes for the RAW top-level probe to sniff magic
1876        // from — only the DECODED stream (`AlwaysInvalidData`) ever fails.
1877        let bytes = vec![0xab, 0xcd, 0x00, 0x00];
1878        match resolve_chain_deep(
1879            &reg,
1880            None,
1881            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1882        ) {
1883            Err(Error::Corrupt(msg)) => assert!(
1884                msg.contains("malformed"),
1885                "must carry the decoder's own message via Error::from_decode_io: {msg}"
1886            ),
1887            Err(other) => panic!("expected Corrupt (a decoder-side read failed), got {other:?}"),
1888            Ok(_) => panic!("a decoder that always fails to read cannot resolve successfully"),
1889        }
1890    }
1891
1892    /// A codec that validates its header inside `decoder()` and REFUSES
1893    /// there — before a single decoded byte exists, so before
1894    /// [`crate::source::DecodeSideSource`] can mark anything — must still
1895    /// report `Error::Corrupt`.
1896    ///
1897    /// No registered codec takes this branch today; `.Z` parses its header
1898    /// lazily and every other decoder here is a `Read` adapter built without
1899    /// touching the source. It is pinned anyway, and `build_decoder` folds it
1900    /// anyway, because "unreached and wrong by default" is precisely the
1901    /// shape this class keeps recurring in: Task 4b closed the re-probe call,
1902    /// the ladder's spool was still open, and the enumeration that found the
1903    /// spool found this too. `CLAUDE.md`'s Phase 3c backlog records the same
1904    /// gap from the other side, against `conformance.rs:393`.
1905    #[test]
1906    fn a_decoder_that_refuses_at_construction_is_corrupt_not_an_io_failure() {
1907        struct RefusesToBuild;
1908
1909        impl Codec for RefusesToBuild {
1910            fn id(&self) -> FormatId {
1911                FormatId::new("refuses-to-build")
1912            }
1913
1914            fn caps(&self) -> CodecCaps {
1915                CodecCaps {
1916                    encode: false,
1917                    decode: true,
1918                    ..Default::default()
1919                }
1920            }
1921
1922            fn decoder(&self, _src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
1923                Err(Error::Io(io::Error::new(
1924                    io::ErrorKind::InvalidData,
1925                    "the header is malformed and I will not build a decoder for it",
1926                )))
1927            }
1928
1929            fn encoder(
1930                &self,
1931                _dst: Box<dyn Write + Send>,
1932                _o: &EncodeOpts,
1933            ) -> Result<Box<dyn Sink>> {
1934                unimplemented!("this test never encodes")
1935            }
1936        }
1937
1938        const REFUSES: FormatId = FormatId::new("refuses-to-build");
1939        const REFUSES_MAGIC: &[MagicRule] = &[MagicRule {
1940            offset: 0,
1941            bytes: &[0xab, 0xcd],
1942            format: REFUSES,
1943        }];
1944
1945        let mut reg = Registry::new();
1946        reg.register_codec(
1947            Arc::new(RefusesToBuild),
1948            FormatMeta::codec(REFUSES, &["refuse"], REFUSES_MAGIC),
1949        );
1950
1951        // Both routes through `resolve_chain_deep_with` build a decoder — the
1952        // path route via `decode_through_chain`, the pathless one via the
1953        // re-probe loop — so both are checked, and both go through
1954        // `build_decoder`.
1955        for path in [None, Some(Path::new("thing.refuse"))] {
1956            let bytes = vec![0xab, 0xcd, 0x00, 0x00];
1957            match resolve_chain_deep(
1958                &reg,
1959                path,
1960                Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1961            ) {
1962                Err(Error::Corrupt(msg)) => assert!(
1963                    msg.contains("malformed"),
1964                    "must carry the codec's own message: {msg}"
1965                ),
1966                Err(other) => panic!("expected Corrupt for path {path:?}, got {other:?}"),
1967                Ok(_) => panic!("a codec that refuses to build cannot resolve successfully"),
1968            }
1969        }
1970    }
1971
1972    /// The hazard side: the SAME `InvalidData` error kind, but raised by the
1973    /// RAW source before any codec is even chosen, must stay `Error::Io` —
1974    /// exit 1 — never `Error::Corrupt`. This is what a fix that reclassified
1975    /// too broadly (inside `probe`/`PeekSource::fill` themselves, or at
1976    /// `resolve_chain_deep_with`'s own top-level `probe(src)` call) would
1977    /// get wrong: it has nothing to do with the error's `ErrorKind`, only
1978    /// with WHICH read produced it, so this guard cannot be satisfied by
1979    /// checking the kind alone the way the CLI-level directory test does.
1980    #[test]
1981    fn a_raw_source_io_error_of_the_identical_kind_stays_io_not_corrupt() {
1982        let reg = mock_registry_with_codec_and_container();
1983        match resolve_chain_deep(&reg, None, Box::new(AlwaysInvalidData)) {
1984            Err(Error::Io(io_err)) => assert_eq!(
1985                io_err.kind(),
1986                std::io::ErrorKind::InvalidData,
1987                "sanity: this must be the same error kind the test above reclassifies, so the \
1988                 two tests differ only in WHERE the read happened"
1989            ),
1990            Err(other) => panic!(
1991                "a raw-source failure must stay Io, not become {other:?} — reclassifying it \
1992                 would be a lie in the opposite direction from Task 4b's bug"
1993            ),
1994            Ok(_) => panic!("a raw source that always errors cannot resolve successfully"),
1995        }
1996    }
1997
1998    #[test]
1999    fn the_returned_source_is_decoded_through_every_codec_layer_on_both_routes() {
2000        // The defect this pins: the path route used to return the PRISTINE,
2001        // undecoded source (resolve_chain alone tells you the shape, nothing
2002        // decodes anything), while the no-path route returned the source
2003        // already peeled past every codec layer. A caller of
2004        // `resolve_chain_deep` cannot be expected to inspect `path.is_some()`
2005        // itself to know which kind of source it was just handed — both
2006        // routes must leave the source in the identical state: decoded
2007        // through every codec layer named in the returned `Chain`, ready to
2008        // hand straight to the container.
2009        //
2010        // "thing.mar.mock" mirrors `.tar.gz`: `inner_from_path` finds the
2011        // container ("mar") one extension in from the codec ("mock"), so
2012        // this path resolves the FULL chain in one `resolve_chain` call,
2013        // with no peeking at all — the route this test must prove decodes
2014        // its codec layer exactly as the peeling route does.
2015        let reg = mock_registry_with_codec_and_container();
2016        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
2017        let outer = mock_codec_encode(&inner);
2018
2019        let (chain_with_path, mut src_with_path) = resolve_chain_deep(
2020            &reg,
2021            Some(Path::new("thing.mar.mock")),
2022            Box::new(ReaderSource::new(io::Cursor::new(outer.clone()))),
2023        )
2024        .expect("resolve with path");
2025        let (chain_no_path, mut src_no_path) = resolve_chain_deep(
2026            &reg,
2027            None,
2028            Box::new(ReaderSource::new(io::Cursor::new(outer))),
2029        )
2030        .expect("resolve without path");
2031
2032        let expected_chain = Chain::Codec {
2033            codec: MOCK_CODEC,
2034            inner: Box::new(Chain::Container {
2035                container: MOCK_CONTAINER,
2036            }),
2037        };
2038        assert_eq!(chain_with_path, expected_chain);
2039        assert_eq!(chain_no_path, expected_chain);
2040
2041        let mut bytes_with_path = Vec::new();
2042        src_with_path.read_to_end(&mut bytes_with_path).unwrap();
2043        let mut bytes_no_path = Vec::new();
2044        src_no_path.read_to_end(&mut bytes_no_path).unwrap();
2045
2046        assert_eq!(
2047            bytes_with_path, inner,
2048            "the path route must decode through the codec layer, not return raw bytes"
2049        );
2050        assert_eq!(
2051            bytes_with_path, bytes_no_path,
2052            "both routes must leave the source in the identical, already-decoded state"
2053        );
2054    }
2055}
2056
2057#[cfg(test)]
2058mod truthful_identification_tests {
2059    //! The confirmation hook, [`Evidence`], [`identify_strict`] and the
2060    //! foreign tail. Mock formats only: core has no real format to confirm.
2061    use super::*;
2062    use crate::archive::{Codec, DecodeOpts, EncodeOpts, Sink};
2063    use crate::format::{CodecCaps, ConfirmFn, FormatId, FormatMeta, MagicRule};
2064    use crate::registry::Registry;
2065    use crate::source::{ReaderSource, SeekRead, Source, SourceCaps, StreamOnly};
2066    use crate::testing::{
2067        MOCK_CODEC, MOCK_CONTAINER, MockCodec, MockContainer, mock_archive_bytes,
2068    };
2069    use std::io::{self, Read, Write};
2070    use std::path::Path;
2071    use std::sync::Arc;
2072
2073    fn yes(_: &[u8], _: bool) -> bool {
2074        true
2075    }
2076
2077    fn no(_: &[u8], _: bool) -> bool {
2078        false
2079    }
2080
2081    /// The UDIF trailer, as the spec states it: `koly`, big-endian version 4,
2082    /// big-endian header size 512, zeros for the rest.
2083    fn koly(version: u32, size: u32) -> Vec<u8> {
2084        let mut t = vec![0u8; TAIL_LEN];
2085        t[0..4].copy_from_slice(b"koly");
2086        t[4..8].copy_from_slice(&version.to_be_bytes());
2087        t[8..12].copy_from_slice(&size.to_be_bytes());
2088        t
2089    }
2090
2091    /// An in-memory seekable source: `FileSource` without the file.
2092    struct SeekCursor(io::Cursor<Vec<u8>>);
2093
2094    impl Read for SeekCursor {
2095        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2096            self.0.read(buf)
2097        }
2098    }
2099
2100    impl Source for SeekCursor {
2101        fn caps(&self) -> SourceCaps {
2102            SourceCaps {
2103                seekable: true,
2104                len: Some(self.0.get_ref().len() as u64),
2105            }
2106        }
2107
2108        fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
2109            Some(&mut self.0)
2110        }
2111    }
2112
2113    fn zlib_ish_image() -> Vec<u8> {
2114        let mut bytes = vec![0x78, 0x9c];
2115        bytes.extend(std::iter::repeat_n(0xa5, 1000));
2116        bytes.extend(koly(4, 512));
2117        bytes
2118    }
2119
2120    #[test]
2121    fn foreign_tail_needs_signature_version_and_size() {
2122        assert_eq!(
2123            foreign_from_tail(&koly(4, 512)),
2124            Some(Foreign::AppleDiskImage)
2125        );
2126        assert_eq!(foreign_from_tail(&koly(3, 512)), None, "version 3");
2127        assert_eq!(foreign_from_tail(&koly(4, 256)), None, "header size 256");
2128        assert_eq!(foreign_from_tail(&koly(4, 512)[..511]), None, "511 bytes");
2129        let mut kolx = koly(4, 512);
2130        kolx[3] = b'x';
2131        assert_eq!(foreign_from_tail(&kolx), None, "kolx");
2132        assert_eq!(
2133            Foreign::AppleDiskImage.to_string(),
2134            "an Apple disk image (UDIF)"
2135        );
2136    }
2137
2138    #[test]
2139    fn probe_refuses_a_seekable_source_with_a_koly_tail() {
2140        let src: Box<dyn Source> = Box::new(SeekCursor(io::Cursor::new(zlib_ish_image())));
2141        match probe(src) {
2142            Err(e) => {
2143                assert!(
2144                    matches!(e, Error::ForeignFormat(Foreign::AppleDiskImage)),
2145                    "{e:?}"
2146                );
2147                assert_eq!(e.exit_code(), 3);
2148                assert_eq!(
2149                    e.to_string(),
2150                    "this is an Apple disk image (UDIF), which stuffr cannot open"
2151                );
2152            }
2153            Ok(_) => panic!("a koly tail must be refused"),
2154        }
2155    }
2156
2157    #[test]
2158    fn probe_does_not_read_the_tail_of_a_pipe() {
2159        let src: Box<dyn Source> = Box::new(ReaderSource::new(io::Cursor::new(zlib_ish_image())));
2160        let Ok((prefix, _)) = probe(src) else {
2161            panic!("a pipe has no tail to read, so it must probe as before");
2162        };
2163        assert_eq!(&prefix[..2], &[0x78, 0x9c]);
2164    }
2165
2166    #[test]
2167    fn read_tail_leaves_a_seekable_source_at_zero() {
2168        let mut bytes = b"HEAD".to_vec();
2169        bytes.extend(vec![7u8; 600]);
2170        let mut src = SeekCursor(io::Cursor::new(bytes.clone()));
2171        let tail = read_tail(&mut src)
2172            .unwrap()
2173            .expect("600+ bytes have a tail");
2174        assert_eq!(tail.len(), TAIL_LEN);
2175        assert_eq!(tail, bytes[bytes.len() - TAIL_LEN..]);
2176        let mut all = Vec::new();
2177        src.read_to_end(&mut all).unwrap();
2178        assert_eq!(all, bytes, "the position must be back at byte zero");
2179
2180        // Shorter than a tail: nothing read, still at zero.
2181        let mut short = SeekCursor(io::Cursor::new(b"tiny".to_vec()));
2182        assert_eq!(read_tail(&mut short).unwrap(), None);
2183        let mut all = Vec::new();
2184        short.read_to_end(&mut all).unwrap();
2185        assert_eq!(all, b"tiny");
2186
2187        // A pipe: nothing read.
2188        let mut pipe = ReaderSource::new(io::Cursor::new(bytes));
2189        assert_eq!(read_tail(&mut pipe).unwrap(), None);
2190    }
2191
2192    const ALPHA: FormatId = FormatId::new("alpha");
2193    const BETA: FormatId = FormatId::new("beta");
2194    const SHARED_MAGIC: &[MagicRule] = &[
2195        MagicRule {
2196            offset: 0,
2197            bytes: b"SH",
2198            format: ALPHA,
2199        },
2200        MagicRule {
2201            offset: 0,
2202            bytes: b"SH",
2203            format: BETA,
2204        },
2205    ];
2206
2207    #[test]
2208    fn confirmed_candidate_beats_an_unconfirmed_one() {
2209        // Equal priority and no path: without confirmation this is an
2210        // `AmbiguousFormat`. Each way round, so the answer is not an accident
2211        // of id order.
2212        for (alpha_ok, winner) in [(true, ALPHA), (false, BETA)] {
2213            let mut r = Registry::new();
2214            let (a, b): (ConfirmFn, ConfirmFn) = if alpha_ok { (yes, no) } else { (no, yes) };
2215            r.register_codec(
2216                Arc::new(MockCodec),
2217                FormatMeta::codec(ALPHA, &[], SHARED_MAGIC).with_confirm(a),
2218            );
2219            r.register_codec(
2220                Arc::new(MockCodec),
2221                FormatMeta::codec(BETA, &[], SHARED_MAGIC).with_confirm(b),
2222            );
2223            let (chain, ev) = resolve_chain_checked(&r, None, b"SH..").expect("no ambiguity");
2224            assert_eq!(chain.outermost_codec(), Some(winner));
2225            assert_eq!(ev, Evidence::Confirmed);
2226        }
2227    }
2228
2229    #[test]
2230    fn a_format_with_no_confirm_function_counts_as_confirmed() {
2231        let mut r = Registry::new();
2232        r.register_codec(
2233            Arc::new(MockCodec),
2234            FormatMeta::codec(ALPHA, &[], SHARED_MAGIC).with_confirm(no),
2235        );
2236        r.register_codec(
2237            Arc::new(MockCodec),
2238            FormatMeta::codec(BETA, &[], SHARED_MAGIC),
2239        );
2240        let (chain, ev) = resolve_chain_checked(&r, None, b"SH..").unwrap();
2241        assert_eq!(chain.outermost_codec(), Some(BETA));
2242        assert_eq!(ev, Evidence::Confirmed);
2243    }
2244
2245    #[test]
2246    fn unconfirmed_magic_still_resolves_leniently() {
2247        let mut r = Registry::new();
2248        r.register_codec(
2249            Arc::new(MockCodec),
2250            FormatMeta::codec(ALPHA, &["al"], &SHARED_MAGIC[..1]).with_confirm(no),
2251        );
2252        let chain = resolve_chain(&r, None, b"SH..").expect("lenient resolution keeps it");
2253        assert_eq!(chain.outermost_codec(), Some(ALPHA));
2254        let (_, ev) = resolve_chain_checked(&r, None, b"SH..").unwrap();
2255        assert_eq!(ev, Evidence::Unconfirmed);
2256    }
2257
2258    #[test]
2259    fn extension_without_confirm_is_by_name() {
2260        let mut r = Registry::new();
2261        r.register_codec(Arc::new(MockCodec), FormatMeta::codec(ALPHA, &["al"], &[]));
2262        let (chain, ev) = resolve_chain_checked(&r, Some(Path::new("x.al")), b"whatever").unwrap();
2263        assert_eq!(chain.outermost_codec(), Some(ALPHA));
2264        assert_eq!(ev, Evidence::ByName);
2265    }
2266
2267    #[test]
2268    fn extension_with_failing_confirm_is_unconfirmed() {
2269        let mut r = Registry::new();
2270        r.register_codec(
2271            Arc::new(MockCodec),
2272            FormatMeta::codec(ALPHA, &["al"], &[]).with_confirm(no),
2273        );
2274        let (_, ev) = resolve_chain_checked(&r, Some(Path::new("x.al")), b"whatever").unwrap();
2275        assert_eq!(ev, Evidence::Unconfirmed);
2276
2277        let mut r = Registry::new();
2278        r.register_codec(
2279            Arc::new(MockCodec),
2280            FormatMeta::codec(ALPHA, &["al"], &[]).with_confirm(yes),
2281        );
2282        let (_, ev) = resolve_chain_checked(&r, Some(Path::new("x.al")), b"whatever").unwrap();
2283        assert_eq!(ev, Evidence::Confirmed);
2284    }
2285
2286    #[test]
2287    fn errors_are_unchanged() {
2288        let r = Registry::new();
2289        assert!(matches!(
2290            resolve_chain_checked(&r, None, b"??"),
2291            Err(Error::UnknownFormat { .. })
2292        ));
2293        let mut r = Registry::new();
2294        r.register_codec(
2295            Arc::new(MockCodec),
2296            FormatMeta::codec(ALPHA, &[], SHARED_MAGIC).with_confirm(no),
2297        );
2298        r.register_codec(
2299            Arc::new(MockCodec),
2300            FormatMeta::codec(BETA, &[], SHARED_MAGIC).with_confirm(no),
2301        );
2302        assert!(matches!(
2303            resolve_chain_checked(&r, None, b"SH"),
2304            Err(Error::AmbiguousFormat { .. })
2305        ));
2306    }
2307
2308    // --- identify_strict -------------------------------------------------
2309
2310    const LAYER_MAGIC: &[u8] = b"MOCK";
2311
2312    /// Strips `LAYER_MAGIC` and passes the rest through — one layer peeled
2313    /// per decode, however many are stacked (`MockCodec`'s XOR would undo
2314    /// itself every second layer).
2315    struct LayerCodec;
2316
2317    impl Codec for LayerCodec {
2318        fn id(&self) -> FormatId {
2319            MOCK_CODEC
2320        }
2321
2322        fn caps(&self) -> CodecCaps {
2323            CodecCaps::round_trip()
2324        }
2325
2326        fn decoder(&self, mut src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
2327            let mut discard = [0u8; 4];
2328            src.read_exact(&mut discard)?;
2329            Ok(Box::new(StreamOnly::new(src)))
2330        }
2331
2332        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
2333            unreachable!("identification never encodes")
2334        }
2335    }
2336
2337    /// The mock container's record header is `ME` + a little-endian u16 name
2338    /// length; a name that runs past the prefix is not a mock archive.
2339    fn mock_archive_confirms(p: &[u8], _whole: bool) -> bool {
2340        p.len() >= 4 && {
2341            let n = u16::from_le_bytes([p[2], p[3]]) as usize;
2342            n > 0 && 4 + n <= p.len()
2343        }
2344    }
2345
2346    fn strict_registry() -> Registry {
2347        const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
2348            offset: 0,
2349            bytes: LAYER_MAGIC,
2350            format: MOCK_CODEC,
2351        }];
2352        const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
2353            offset: 0,
2354            bytes: b"ME",
2355            format: MOCK_CONTAINER,
2356        }];
2357        let mut r = Registry::new();
2358        r.register_codec(
2359            Arc::new(LayerCodec),
2360            FormatMeta::codec(MOCK_CODEC, &["mock"], CODEC_MAGIC)
2361                .with_confirm(|p, _| p.starts_with(LAYER_MAGIC)),
2362        );
2363        r.register_container(
2364            Arc::new(MockContainer),
2365            FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC)
2366                .with_confirm(mock_archive_confirms),
2367        );
2368        r
2369    }
2370
2371    fn layered(layers: usize, inner: &[u8]) -> Vec<u8> {
2372        let mut out = LAYER_MAGIC.repeat(layers);
2373        out.extend_from_slice(inner);
2374        out
2375    }
2376
2377    fn strict(
2378        r: &Registry,
2379        path: Option<&str>,
2380        prefix: &[u8],
2381        tail: Option<&[u8]>,
2382    ) -> Option<Chain> {
2383        identify_strict(r, path.map(Path::new), prefix, tail, &DecodeOpts::default())
2384    }
2385
2386    #[test]
2387    fn identify_strict_rejects_unconfirmed_and_foreign() {
2388        let r = strict_registry();
2389        assert_eq!(
2390            strict(&r, None, b"ME\xff\xffabc", None),
2391            None,
2392            "a magic hit whose confirmation fails is not openable"
2393        );
2394        let archive = mock_archive_bytes(&[("a.txt", b"alpha")]);
2395        assert_eq!(
2396            strict(&r, None, &archive, None),
2397            Some(Chain::Container {
2398                container: MOCK_CONTAINER
2399            })
2400        );
2401        assert_eq!(
2402            strict(&r, None, &archive, Some(&koly(4, 512))),
2403            None,
2404            "a koly tail outranks a confirmed prefix"
2405        );
2406        assert_eq!(strict(&r, None, b"nothing", None), None, "unknown");
2407    }
2408
2409    #[test]
2410    fn identify_strict_decodes_the_inner_layer_from_the_prefix() {
2411        let r = strict_registry();
2412        let archive = mock_archive_bytes(&[("a.txt", b"alpha")]);
2413        assert_eq!(
2414            strict(&r, None, &layered(1, &archive), None),
2415            Some(Chain::Codec {
2416                codec: MOCK_CODEC,
2417                inner: Box::new(Chain::Container {
2418                    container: MOCK_CONTAINER
2419                }),
2420            })
2421        );
2422        assert_eq!(
2423            strict(&r, Some("x.mar.mock"), &layered(1, b"ME\xff\xffabc"), None),
2424            Some(Chain::Codec {
2425                codec: MOCK_CODEC,
2426                inner: Box::new(Chain::Raw),
2427            }),
2428            "an inner layer failing confirmation is Raw, whatever the path says"
2429        );
2430    }
2431
2432    #[test]
2433    fn identify_strict_bounds_the_nesting() {
2434        let r = strict_registry();
2435        let archive = mock_archive_bytes(&[("a.txt", b"alpha")]);
2436        let depth = |c: &Chain| {
2437            let mut n = 0;
2438            let mut c = c;
2439            while let Chain::Codec { inner, .. } = c {
2440                n += 1;
2441                c = inner;
2442            }
2443            n
2444        };
2445        let three = strict(&r, None, &layered(3, &archive), None).unwrap();
2446        assert_eq!(depth(&three), 3);
2447        assert_eq!(three.container(), Some(MOCK_CONTAINER));
2448        let many = strict(&r, None, &layered(10, &archive), None).unwrap();
2449        assert!(depth(&many) < MAX_CHAIN_DEPTH, "{many:?}");
2450        assert_eq!(many.container(), None, "stopped with Raw at the bound");
2451    }
2452
2453    /// A codec whose decoder yields `yields` and then either errors or ends.
2454    struct Scripted {
2455        yields: &'static [u8],
2456        then_error: bool,
2457    }
2458
2459    struct ScriptedSource {
2460        rest: &'static [u8],
2461        then_error: bool,
2462    }
2463
2464    impl Read for ScriptedSource {
2465        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2466            if self.rest.is_empty() {
2467                return if self.then_error {
2468                    Err(io::Error::new(io::ErrorKind::InvalidData, "bad stream"))
2469                } else {
2470                    Ok(0)
2471                };
2472            }
2473            let n = buf.len().min(self.rest.len());
2474            buf[..n].copy_from_slice(&self.rest[..n]);
2475            self.rest = &self.rest[n..];
2476            Ok(n)
2477        }
2478    }
2479
2480    impl Source for ScriptedSource {
2481        fn caps(&self) -> SourceCaps {
2482            SourceCaps::default()
2483        }
2484
2485        fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
2486            None
2487        }
2488    }
2489
2490    impl Codec for Scripted {
2491        fn id(&self) -> FormatId {
2492            MOCK_CODEC
2493        }
2494
2495        fn caps(&self) -> CodecCaps {
2496            CodecCaps::decode_only()
2497        }
2498
2499        fn decoder(&self, _src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
2500            Ok(Box::new(ScriptedSource {
2501                rest: self.yields,
2502                then_error: self.then_error,
2503            }))
2504        }
2505
2506        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
2507            unreachable!("identification never encodes")
2508        }
2509    }
2510
2511    fn scripted_registry(yields: &'static [u8], then_error: bool) -> Registry {
2512        let mut r = strict_registry();
2513        r.register_codec(
2514            Arc::new(Scripted { yields, then_error }),
2515            FormatMeta::codec(
2516                MOCK_CODEC,
2517                &["mock"],
2518                &[MagicRule {
2519                    offset: 0,
2520                    bytes: LAYER_MAGIC,
2521                    format: MOCK_CODEC,
2522                }],
2523            )
2524            .with_confirm(yes),
2525        );
2526        r
2527    }
2528
2529    #[test]
2530    fn identify_strict_drops_a_codec_that_decodes_nothing() {
2531        let raw = Some(Chain::Codec {
2532            codec: MOCK_CODEC,
2533            inner: Box::new(Chain::Raw),
2534        });
2535        assert_eq!(
2536            strict(&scripted_registry(b"", true), None, b"MOCKjunk", None),
2537            None,
2538            "an error before the first byte: nothing here decodes"
2539        );
2540        assert_eq!(
2541            strict(&scripted_registry(b"", false), None, b"MOCKjunk", None),
2542            raw,
2543            "a clean, empty stream is still a stream"
2544        );
2545        assert_eq!(
2546            strict(
2547                &scripted_registry(b"plain text", true),
2548                None,
2549                b"MOCKjunk",
2550                None
2551            ),
2552            raw,
2553            "what arrived before the error is kept"
2554        );
2555    }
2556
2557    /// A codec whose first block is larger than the probe window: its
2558    /// decoder drains the source and, short of `NEEDS` bytes, yields nothing
2559    /// and errors — bzip2's and zstd's behaviour on a 4096-byte prefix of a
2560    /// real file.
2561    struct NeedsMore;
2562
2563    const NEEDS: usize = PROBE_LEN * 2;
2564    const SLOW: FormatId = FormatId::new("slow");
2565    const TAR: FormatId = FormatId::new("tar");
2566
2567    impl Codec for NeedsMore {
2568        fn id(&self) -> FormatId {
2569            SLOW
2570        }
2571
2572        fn caps(&self) -> CodecCaps {
2573            CodecCaps::decode_only()
2574        }
2575
2576        fn decoder(&self, mut src: Box<dyn Source>, _o: &DecodeOpts) -> Result<Box<dyn Source>> {
2577            let mut all = Vec::new();
2578            src.read_to_end(&mut all)?;
2579            Ok(Box::new(ScriptedSource {
2580                rest: if all.len() >= NEEDS { b"payload" } else { b"" },
2581                then_error: all.len() < NEEDS,
2582            }))
2583        }
2584
2585        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
2586            unreachable!("identification never encodes")
2587        }
2588    }
2589
2590    fn needs_more_registry() -> Registry {
2591        let mut r = strict_registry();
2592        r.register_codec(
2593            Arc::new(NeedsMore),
2594            FormatMeta::codec(
2595                SLOW,
2596                &["slow"],
2597                &[MagicRule {
2598                    offset: 0,
2599                    bytes: b"SLOW",
2600                    format: SLOW,
2601                }],
2602            )
2603            .with_confirm(yes),
2604        );
2605        r.register_container(
2606            Arc::new(MockContainer),
2607            FormatMeta::container(TAR, &["tar"], &[]),
2608        );
2609        r
2610    }
2611
2612    fn slow_prefix(len: usize) -> Vec<u8> {
2613        let mut p = b"SLOW".to_vec();
2614        p.resize(len, 0x5a);
2615        p
2616    }
2617
2618    #[test]
2619    fn a_cut_off_prefix_that_decodes_nothing_keeps_the_codec_over_the_paths_inner() {
2620        let r = needs_more_registry();
2621        assert_eq!(
2622            strict(&r, Some("big.tar.slow"), &slow_prefix(PROBE_LEN), None),
2623            Some(Chain::Codec {
2624                codec: SLOW,
2625                inner: Box::new(Chain::Container { container: TAR }),
2626            }),
2627            "the window ran out, not the file: as 0.21.0 listed it"
2628        );
2629        assert_eq!(
2630            strict(&r, Some("big.slow"), &slow_prefix(PROBE_LEN), None),
2631            Some(Chain::Codec {
2632                codec: SLOW,
2633                inner: Box::new(Chain::Raw),
2634            }),
2635        );
2636    }
2637
2638    #[test]
2639    fn a_whole_file_that_decodes_nothing_before_an_error_is_not_openable() {
2640        let r = needs_more_registry();
2641        assert_eq!(
2642            strict(&r, Some("small.tar.slow"), &slow_prefix(100), None),
2643            None,
2644            "the prefix is the whole file, and it decodes to nothing but an error"
2645        );
2646    }
2647
2648    #[test]
2649    fn an_extension_naming_a_refused_hit_falls_to_the_confirmed_ones() {
2650        const ZIP: FormatId = FormatId::new("zip");
2651        const JAR: FormatId = FormatId::new("jar");
2652        const APK: FormatId = FormatId::new("apk");
2653        const PK: &[MagicRule] = &[
2654            MagicRule {
2655                offset: 0,
2656                bytes: b"PK",
2657                format: ZIP,
2658            },
2659            MagicRule {
2660                offset: 0,
2661                bytes: b"PK",
2662                format: JAR,
2663            },
2664            MagicRule {
2665                offset: 0,
2666                bytes: b"PK",
2667                format: APK,
2668            },
2669        ];
2670        let mut r = Registry::new();
2671        r.register_container(
2672            Arc::new(MockContainer),
2673            FormatMeta::container(ZIP, &["zip"], PK)
2674                .with_priority(1)
2675                .with_confirm(yes),
2676        );
2677        r.register_container(
2678            Arc::new(MockContainer),
2679            FormatMeta::container(JAR, &["jar"], PK).with_confirm(yes),
2680        );
2681        r.register_container(
2682            Arc::new(MockContainer),
2683            FormatMeta::container(APK, &["apk"], PK).with_confirm(no),
2684        );
2685        let (chain, ev) = resolve_chain_checked(&r, Some(Path::new("x.apk")), b"PK..").unwrap();
2686        assert_eq!(chain, Chain::Container { container: ZIP });
2687        assert_eq!(ev, Evidence::Confirmed);
2688    }
2689    // --- whole streams ----------------------------------------------------
2690
2691    /// Refuses exactly what is whole and shorter than eight bytes.
2692    fn eight_when_whole(p: &[u8], whole: bool) -> bool {
2693        !whole || p.len() >= 8
2694    }
2695
2696    #[test]
2697    fn a_probe_prefix_short_of_the_window_is_confirmed_as_the_whole_stream() {
2698        let mut r = Registry::new();
2699        r.register_codec(
2700            Arc::new(MockCodec),
2701            FormatMeta::codec(ALPHA, &[], &SHARED_MAGIC[..1]).with_confirm(eight_when_whole),
2702        );
2703        let ev = |prefix: &[u8]| resolve_chain_checked(&r, None, prefix).unwrap().1;
2704        assert_eq!(ev(b"SH"), Evidence::Unconfirmed, "a two-byte file is whole");
2705        assert_eq!(ev(b"SH345678"), Evidence::Confirmed);
2706        // A full window is never whole, however little of it the check reads.
2707        let mut window = vec![0u8; PROBE_LEN];
2708        window[..2].copy_from_slice(b"SH");
2709        assert_eq!(ev(&window), Evidence::Confirmed);
2710    }
2711
2712    const PLAIN: FormatId = FormatId::new("plain");
2713    const WHOLE: FormatId = FormatId::new("wholecheck");
2714
2715    /// `scripted_registry` plus a magicless container (`.pln`, confirmed by
2716    /// a leading `PL`) and one whose magic `WH` is refused only when whole.
2717    fn layered_registry(yields: &'static [u8], then_error: bool) -> Registry {
2718        let mut r = scripted_registry(yields, then_error);
2719        r.register_container(
2720            Arc::new(MockContainer),
2721            FormatMeta::container(PLAIN, &["pln"], &[]).with_confirm(|p, _| p.starts_with(b"PL")),
2722        );
2723        r.register_container(
2724            Arc::new(MockContainer),
2725            FormatMeta::container(
2726                WHOLE,
2727                &["wh"],
2728                &[MagicRule {
2729                    offset: 0,
2730                    bytes: b"WH",
2731                    format: WHOLE,
2732                }],
2733            )
2734            .with_confirm(eight_when_whole),
2735        );
2736        r
2737    }
2738
2739    fn over(inner: Chain) -> Option<Chain> {
2740        Some(Chain::Codec {
2741            codec: MOCK_CODEC,
2742            inner: Box::new(inner),
2743        })
2744    }
2745
2746    #[test]
2747    fn a_decoded_layer_is_whole_only_when_it_ended_cleanly_inside_the_window() {
2748        // Ended cleanly at two bytes: the whole inner stream, refused.
2749        let r = layered_registry(b"WH", false);
2750        assert_eq!(strict(&r, None, b"MOCKjunk", None), over(Chain::Raw));
2751        let chain = over(Chain::Container { container: WHOLE }).unwrap();
2752        assert_eq!(
2753            first_unconfirmed_layer(&r, &chain, b"MOCKjunk", &DecodeOpts::default()),
2754            Some(WHOLE)
2755        );
2756        // Stopped on an error: a cut stream, judged as one.
2757        let r = layered_registry(b"WH", true);
2758        assert_eq!(
2759            strict(&r, None, b"MOCKjunk", None),
2760            over(Chain::Container { container: WHOLE })
2761        );
2762        assert_eq!(
2763            first_unconfirmed_layer(&r, &chain, b"MOCKjunk", &DecodeOpts::default()),
2764            None
2765        );
2766    }
2767
2768    #[test]
2769    fn decoded_bytes_with_no_magic_take_the_paths_container_when_it_confirms() {
2770        let plain = Chain::Container { container: PLAIN };
2771        let r = layered_registry(b"PLAIN DATA", false);
2772        assert_eq!(
2773            strict(&r, Some("x.pln.mock"), b"MOCKjunk", None),
2774            over(plain.clone()),
2775            "a magicless inner layer the name gives and the bytes confirm"
2776        );
2777        assert_eq!(
2778            strict(&r, None, b"MOCKjunk", None),
2779            over(Chain::Raw),
2780            "no name"
2781        );
2782        assert_eq!(
2783            strict(&r, Some("x.mock"), b"MOCKjunk", None),
2784            over(Chain::Raw),
2785            "a name with nothing inside"
2786        );
2787        let r = layered_registry(b"just text", false);
2788        assert_eq!(
2789            strict(&r, Some("x.pln.mock"), b"MOCKjunk", None),
2790            over(Chain::Raw),
2791            "the name alone is not enough when the check refuses the bytes"
2792        );
2793        // `info`'s chain for the same file names the same layer as refused.
2794        let (chain, _) =
2795            resolve_chain_checked(&r, Some(Path::new("x.pln.mock")), b"MOCKjunk").unwrap();
2796        assert_eq!(chain, over(plain).unwrap());
2797        assert_eq!(
2798            first_unconfirmed_layer(&r, &chain, b"MOCKjunk", &DecodeOpts::default()),
2799            Some(PLAIN)
2800        );
2801    }
2802}