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<'a>(
1637            &self,
1638            mut src: Box<dyn Source + 'a>,
1639            _o: &DecodeOpts,
1640        ) -> Result<Box<dyn Source + 'a>> {
1641            let mut discard = vec![0u8; NESTED_MAGIC.len()];
1642            src.read_exact(&mut discard)?;
1643            Ok(Box::new(StreamOnly::new(src)))
1644        }
1645
1646        fn encoder(
1647            &self,
1648            mut dst: Box<dyn Write + Send>,
1649            _o: &EncodeOpts,
1650        ) -> Result<Box<dyn Sink>> {
1651            dst.write_all(NESTED_MAGIC)?;
1652            Ok(Box::new(PassthroughSink(dst)))
1653        }
1654    }
1655
1656    struct PassthroughSink(Box<dyn Write + Send>);
1657
1658    impl Write for PassthroughSink {
1659        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1660            self.0.write(buf)
1661        }
1662        fn flush(&mut self) -> std::io::Result<()> {
1663            self.0.flush()
1664        }
1665    }
1666
1667    impl Sink for PassthroughSink {
1668        fn finish(mut self: Box<Self>) -> Result<()> {
1669            self.0.flush()?;
1670            Ok(())
1671        }
1672    }
1673
1674    fn mock_registry_with_codec_and_container() -> Registry {
1675        const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
1676            offset: 0,
1677            bytes: NESTED_MAGIC,
1678            format: MOCK_CODEC,
1679        }];
1680        const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
1681            offset: 0,
1682            bytes: b"ME",
1683            format: MOCK_CONTAINER,
1684        }];
1685        let mut r = Registry::new();
1686        r.register_codec(
1687            Arc::new(PrefixedMockCodec),
1688            FormatMeta::codec(MOCK_CODEC, &["mock"], CODEC_MAGIC),
1689        );
1690        r.register_container(
1691            Arc::new(MockContainer),
1692            FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC),
1693        );
1694        r
1695    }
1696
1697    /// Wraps `payload` in one `PrefixedMockCodec` layer, via the codec's own
1698    /// `encoder` rather than hand-building the bytes, so the fixture is
1699    /// provably decodable by the exact codec `resolve_chain_deep` will call.
1700    fn mock_codec_encode(payload: &[u8]) -> Vec<u8> {
1701        let sink = SharedBuf::new();
1702        let mut s = PrefixedMockCodec
1703            .encoder(Box::new(sink.clone()), &EncodeOpts::default())
1704            .unwrap();
1705        s.write_all(payload).unwrap();
1706        s.finish().unwrap();
1707        sink.contents()
1708    }
1709
1710    #[test]
1711    fn a_container_inside_a_codec_resolves_from_the_stream_with_no_path() {
1712        let reg = mock_registry_with_codec_and_container();
1713        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
1714        let outer = mock_codec_encode(&inner);
1715
1716        let (chain, _src) = resolve_chain_deep(
1717            &reg,
1718            None,
1719            Box::new(ReaderSource::new(io::Cursor::new(outer))),
1720        )
1721        .expect("resolve");
1722
1723        // Before this task the inner layer was Chain::Raw, because no path meant
1724        // no extension to consult. The prefix of the DECODED stream now decides.
1725        assert_eq!(
1726            chain,
1727            Chain::Codec {
1728                codec: MOCK_CODEC,
1729                inner: Box::new(Chain::Container {
1730                    container: MOCK_CONTAINER
1731                }),
1732            },
1733            "a container nested inside a codec must be found by peeking the decoded stream"
1734        );
1735    }
1736
1737    #[test]
1738    fn nesting_beyond_the_depth_bound_is_a_typed_error_naming_the_depth() {
1739        let reg = mock_registry_with_codec_and_container();
1740        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1741        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1742            bytes = mock_codec_encode(&bytes);
1743        }
1744
1745        // Not `.expect_err(..)`: the `Ok` side is `(Chain, Box<dyn Source>)`,
1746        // and `Source` is not `Debug`, so `Result::expect_err`'s `T: Debug`
1747        // bound can't be satisfied here — same reason as
1748        // `a_source_that_claims_seekable_but_has_no_seek_view_errors_instead_of_panicking`
1749        // above.
1750        match resolve_chain_deep(
1751            &reg,
1752            None,
1753            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1754        ) {
1755            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1756            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1757            Ok(_) => panic!("nesting past the bound must be refused"),
1758        }
1759    }
1760
1761    #[test]
1762    fn nesting_beyond_the_depth_bound_is_refused_via_the_path_route_too() {
1763        // A path is a name, not a proof: `a.gz.gz.gz.gz.gz` must be refused
1764        // exactly like the identical bytes arriving with no path at all.
1765        // `mock_registry_with_codec_and_container` only ever exposes a
1766        // single extension per format ("mock"), so a path naming it gives
1767        // `inner_from_path` nothing to find a container with — the chain
1768        // still bottoms out at Raw, and peeking (and the bound) must engage
1769        // all the same.
1770        let reg = mock_registry_with_codec_and_container();
1771        let mut bytes = mock_archive_bytes(&[("a.txt", b"alpha")]);
1772        for _ in 0..MAX_CHAIN_DEPTH + 2 {
1773            bytes = mock_codec_encode(&bytes);
1774        }
1775
1776        match resolve_chain_deep(
1777            &reg,
1778            Some(Path::new("nested.mock")),
1779            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1780        ) {
1781            Err(Error::ChainTooDeep { depth }) => assert_eq!(depth, MAX_CHAIN_DEPTH),
1782            Err(other) => panic!("expected ChainTooDeep, got {other:?}"),
1783            Ok(_) => panic!("nesting past the bound must be refused, path or no path"),
1784        }
1785    }
1786
1787    // --- Task 4b: a decoder-side io::Error must reclassify; a raw-source
1788    // one must not -------------------------------------------------------
1789
1790    /// A `Source` whose every read raises `InvalidData` — used two ways
1791    /// below: wrapped as the return of a codec's own `decoder()` (so the
1792    /// failure looks exactly like a corrupt payload discovered only once
1793    /// something reads the decoded bytes), and handed directly to
1794    /// `resolve_chain_deep` as the RAW top-level source (so the identical
1795    /// error kind arises before any codec is even involved). The two tests
1796    /// using it must disagree on the resulting `Error` variant — that
1797    /// disagreement is the whole point of Task 4b's fix.
1798    struct AlwaysInvalidData;
1799
1800    impl Read for AlwaysInvalidData {
1801        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1802            Err(std::io::Error::new(
1803                std::io::ErrorKind::InvalidData,
1804                "decoded bytes are malformed",
1805            ))
1806        }
1807    }
1808
1809    impl Source for AlwaysInvalidData {
1810        fn caps(&self) -> crate::source::SourceCaps {
1811            crate::source::SourceCaps {
1812                seekable: false,
1813                len: None,
1814            }
1815        }
1816
1817        fn as_seek(&mut self) -> Option<&mut dyn crate::source::SeekRead> {
1818            None
1819        }
1820    }
1821
1822    /// A codec whose `decoder()` construction always succeeds but whose FIRST
1823    /// read fails with `InvalidData`, standing in for a corrupt payload behind
1824    /// an intact-looking header.
1825    ///
1826    /// `zlib.rs` is the lazy shape this models. Do NOT read it as "every real
1827    /// codec constructs lazily" — `xz_pure.rs` and `lzip.rs` both read at
1828    /// construction (`fill_buf()`), and reach the same place only because they
1829    /// end in `.unwrap_or(&[])`, which swallows the failure and defers it to
1830    /// the re-probe read this module classifies. An earlier version of this
1831    /// comment named `lzip.rs` as an example of the convention it contradicts.
1832    struct FailingDecodeCodec;
1833
1834    impl Codec for FailingDecodeCodec {
1835        fn id(&self) -> FormatId {
1836            FormatId::new("always-fails-to-decode")
1837        }
1838
1839        fn caps(&self) -> CodecCaps {
1840            CodecCaps {
1841                encode: false,
1842                decode: true,
1843                ..Default::default()
1844            }
1845        }
1846
1847        fn decoder<'a>(
1848            &self,
1849            _src: Box<dyn Source + 'a>,
1850            _o: &DecodeOpts,
1851        ) -> Result<Box<dyn Source + 'a>> {
1852            Ok(Box::new(AlwaysInvalidData))
1853        }
1854
1855        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
1856            unimplemented!("this test never encodes")
1857        }
1858    }
1859
1860    /// The fix itself: a codec stream with no container above it (so
1861    /// resolution bottoms out at `Chain::Raw` and re-probes the decoded
1862    /// bytes, per `resolve_chain_deep_with`'s own doc comment) whose decoded
1863    /// bytes turn out to be malformed must report `Error::Corrupt` — exit 5
1864    /// — not `Error::Io`. Before Task 4b's fix this was `Err(Error::Io(_))`,
1865    /// the identical bug `list_reports_a_truncated_codec_stream_as_corrupt_
1866    /// not_as_an_io_failure` and `list_reports_an_empty_lzip_stream_as_
1867    /// corrupt_not_as_an_io_failure` pin at the CLI level with the real
1868    /// zlib and lzip codecs.
1869    #[test]
1870    fn a_decoder_side_io_error_while_re_probing_becomes_corrupt() {
1871        let mut reg = Registry::new();
1872        const FAILING: FormatId = FormatId::new("always-fails-to-decode");
1873        const FAILING_MAGIC: &[MagicRule] = &[MagicRule {
1874            offset: 0,
1875            bytes: &[0xab, 0xcd],
1876            format: FAILING,
1877        }];
1878        reg.register_codec(
1879            Arc::new(FailingDecodeCodec),
1880            FormatMeta::codec(FAILING, &["fail"], FAILING_MAGIC),
1881        );
1882
1883        // Real, readable bytes for the RAW top-level probe to sniff magic
1884        // from — only the DECODED stream (`AlwaysInvalidData`) ever fails.
1885        let bytes = vec![0xab, 0xcd, 0x00, 0x00];
1886        match resolve_chain_deep(
1887            &reg,
1888            None,
1889            Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1890        ) {
1891            Err(Error::Corrupt(msg)) => assert!(
1892                msg.contains("malformed"),
1893                "must carry the decoder's own message via Error::from_decode_io: {msg}"
1894            ),
1895            Err(other) => panic!("expected Corrupt (a decoder-side read failed), got {other:?}"),
1896            Ok(_) => panic!("a decoder that always fails to read cannot resolve successfully"),
1897        }
1898    }
1899
1900    /// A codec that validates its header inside `decoder()` and REFUSES
1901    /// there — before a single decoded byte exists, so before
1902    /// [`crate::source::DecodeSideSource`] can mark anything — must still
1903    /// report `Error::Corrupt`.
1904    ///
1905    /// No registered codec takes this branch today; `.Z` parses its header
1906    /// lazily and every other decoder here is a `Read` adapter built without
1907    /// touching the source. It is pinned anyway, and `build_decoder` folds it
1908    /// anyway, because "unreached and wrong by default" is precisely the
1909    /// shape this class keeps recurring in: Task 4b closed the re-probe call,
1910    /// the ladder's spool was still open, and the enumeration that found the
1911    /// spool found this too. `CLAUDE.md`'s Phase 3c backlog records the same
1912    /// gap from the other side, against `conformance.rs:393`.
1913    #[test]
1914    fn a_decoder_that_refuses_at_construction_is_corrupt_not_an_io_failure() {
1915        struct RefusesToBuild;
1916
1917        impl Codec for RefusesToBuild {
1918            fn id(&self) -> FormatId {
1919                FormatId::new("refuses-to-build")
1920            }
1921
1922            fn caps(&self) -> CodecCaps {
1923                CodecCaps {
1924                    encode: false,
1925                    decode: true,
1926                    ..Default::default()
1927                }
1928            }
1929
1930            fn decoder<'a>(
1931                &self,
1932                _src: Box<dyn Source + 'a>,
1933                _o: &DecodeOpts,
1934            ) -> Result<Box<dyn Source + 'a>> {
1935                Err(Error::Io(io::Error::new(
1936                    io::ErrorKind::InvalidData,
1937                    "the header is malformed and I will not build a decoder for it",
1938                )))
1939            }
1940
1941            fn encoder(
1942                &self,
1943                _dst: Box<dyn Write + Send>,
1944                _o: &EncodeOpts,
1945            ) -> Result<Box<dyn Sink>> {
1946                unimplemented!("this test never encodes")
1947            }
1948        }
1949
1950        const REFUSES: FormatId = FormatId::new("refuses-to-build");
1951        const REFUSES_MAGIC: &[MagicRule] = &[MagicRule {
1952            offset: 0,
1953            bytes: &[0xab, 0xcd],
1954            format: REFUSES,
1955        }];
1956
1957        let mut reg = Registry::new();
1958        reg.register_codec(
1959            Arc::new(RefusesToBuild),
1960            FormatMeta::codec(REFUSES, &["refuse"], REFUSES_MAGIC),
1961        );
1962
1963        // Both routes through `resolve_chain_deep_with` build a decoder — the
1964        // path route via `decode_through_chain`, the pathless one via the
1965        // re-probe loop — so both are checked, and both go through
1966        // `build_decoder`.
1967        for path in [None, Some(Path::new("thing.refuse"))] {
1968            let bytes = vec![0xab, 0xcd, 0x00, 0x00];
1969            match resolve_chain_deep(
1970                &reg,
1971                path,
1972                Box::new(ReaderSource::new(io::Cursor::new(bytes))),
1973            ) {
1974                Err(Error::Corrupt(msg)) => assert!(
1975                    msg.contains("malformed"),
1976                    "must carry the codec's own message: {msg}"
1977                ),
1978                Err(other) => panic!("expected Corrupt for path {path:?}, got {other:?}"),
1979                Ok(_) => panic!("a codec that refuses to build cannot resolve successfully"),
1980            }
1981        }
1982    }
1983
1984    /// The hazard side: the SAME `InvalidData` error kind, but raised by the
1985    /// RAW source before any codec is even chosen, must stay `Error::Io` —
1986    /// exit 1 — never `Error::Corrupt`. This is what a fix that reclassified
1987    /// too broadly (inside `probe`/`PeekSource::fill` themselves, or at
1988    /// `resolve_chain_deep_with`'s own top-level `probe(src)` call) would
1989    /// get wrong: it has nothing to do with the error's `ErrorKind`, only
1990    /// with WHICH read produced it, so this guard cannot be satisfied by
1991    /// checking the kind alone the way the CLI-level directory test does.
1992    #[test]
1993    fn a_raw_source_io_error_of_the_identical_kind_stays_io_not_corrupt() {
1994        let reg = mock_registry_with_codec_and_container();
1995        match resolve_chain_deep(&reg, None, Box::new(AlwaysInvalidData)) {
1996            Err(Error::Io(io_err)) => assert_eq!(
1997                io_err.kind(),
1998                std::io::ErrorKind::InvalidData,
1999                "sanity: this must be the same error kind the test above reclassifies, so the \
2000                 two tests differ only in WHERE the read happened"
2001            ),
2002            Err(other) => panic!(
2003                "a raw-source failure must stay Io, not become {other:?} — reclassifying it \
2004                 would be a lie in the opposite direction from Task 4b's bug"
2005            ),
2006            Ok(_) => panic!("a raw source that always errors cannot resolve successfully"),
2007        }
2008    }
2009
2010    #[test]
2011    fn the_returned_source_is_decoded_through_every_codec_layer_on_both_routes() {
2012        // The defect this pins: the path route used to return the PRISTINE,
2013        // undecoded source (resolve_chain alone tells you the shape, nothing
2014        // decodes anything), while the no-path route returned the source
2015        // already peeled past every codec layer. A caller of
2016        // `resolve_chain_deep` cannot be expected to inspect `path.is_some()`
2017        // itself to know which kind of source it was just handed — both
2018        // routes must leave the source in the identical state: decoded
2019        // through every codec layer named in the returned `Chain`, ready to
2020        // hand straight to the container.
2021        //
2022        // "thing.mar.mock" mirrors `.tar.gz`: `inner_from_path` finds the
2023        // container ("mar") one extension in from the codec ("mock"), so
2024        // this path resolves the FULL chain in one `resolve_chain` call,
2025        // with no peeking at all — the route this test must prove decodes
2026        // its codec layer exactly as the peeling route does.
2027        let reg = mock_registry_with_codec_and_container();
2028        let inner = mock_archive_bytes(&[("a.txt", b"alpha")]);
2029        let outer = mock_codec_encode(&inner);
2030
2031        let (chain_with_path, mut src_with_path) = resolve_chain_deep(
2032            &reg,
2033            Some(Path::new("thing.mar.mock")),
2034            Box::new(ReaderSource::new(io::Cursor::new(outer.clone()))),
2035        )
2036        .expect("resolve with path");
2037        let (chain_no_path, mut src_no_path) = resolve_chain_deep(
2038            &reg,
2039            None,
2040            Box::new(ReaderSource::new(io::Cursor::new(outer))),
2041        )
2042        .expect("resolve without path");
2043
2044        let expected_chain = Chain::Codec {
2045            codec: MOCK_CODEC,
2046            inner: Box::new(Chain::Container {
2047                container: MOCK_CONTAINER,
2048            }),
2049        };
2050        assert_eq!(chain_with_path, expected_chain);
2051        assert_eq!(chain_no_path, expected_chain);
2052
2053        let mut bytes_with_path = Vec::new();
2054        src_with_path.read_to_end(&mut bytes_with_path).unwrap();
2055        let mut bytes_no_path = Vec::new();
2056        src_no_path.read_to_end(&mut bytes_no_path).unwrap();
2057
2058        assert_eq!(
2059            bytes_with_path, inner,
2060            "the path route must decode through the codec layer, not return raw bytes"
2061        );
2062        assert_eq!(
2063            bytes_with_path, bytes_no_path,
2064            "both routes must leave the source in the identical, already-decoded state"
2065        );
2066    }
2067}
2068
2069#[cfg(test)]
2070mod truthful_identification_tests {
2071    //! The confirmation hook, [`Evidence`], [`identify_strict`] and the
2072    //! foreign tail. Mock formats only: core has no real format to confirm.
2073    use super::*;
2074    use crate::archive::{Codec, DecodeOpts, EncodeOpts, Sink};
2075    use crate::format::{CodecCaps, ConfirmFn, FormatId, FormatMeta, MagicRule};
2076    use crate::registry::Registry;
2077    use crate::source::{ReaderSource, SeekRead, Source, SourceCaps, StreamOnly};
2078    use crate::testing::{
2079        MOCK_CODEC, MOCK_CONTAINER, MockCodec, MockContainer, mock_archive_bytes,
2080    };
2081    use std::io::{self, Read, Write};
2082    use std::path::Path;
2083    use std::sync::Arc;
2084
2085    fn yes(_: &[u8], _: bool) -> bool {
2086        true
2087    }
2088
2089    fn no(_: &[u8], _: bool) -> bool {
2090        false
2091    }
2092
2093    /// The UDIF trailer, as the spec states it: `koly`, big-endian version 4,
2094    /// big-endian header size 512, zeros for the rest.
2095    fn koly(version: u32, size: u32) -> Vec<u8> {
2096        let mut t = vec![0u8; TAIL_LEN];
2097        t[0..4].copy_from_slice(b"koly");
2098        t[4..8].copy_from_slice(&version.to_be_bytes());
2099        t[8..12].copy_from_slice(&size.to_be_bytes());
2100        t
2101    }
2102
2103    /// An in-memory seekable source: `FileSource` without the file.
2104    struct SeekCursor(io::Cursor<Vec<u8>>);
2105
2106    impl Read for SeekCursor {
2107        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2108            self.0.read(buf)
2109        }
2110    }
2111
2112    impl Source for SeekCursor {
2113        fn caps(&self) -> SourceCaps {
2114            SourceCaps {
2115                seekable: true,
2116                len: Some(self.0.get_ref().len() as u64),
2117            }
2118        }
2119
2120        fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
2121            Some(&mut self.0)
2122        }
2123    }
2124
2125    fn zlib_ish_image() -> Vec<u8> {
2126        let mut bytes = vec![0x78, 0x9c];
2127        bytes.extend(std::iter::repeat_n(0xa5, 1000));
2128        bytes.extend(koly(4, 512));
2129        bytes
2130    }
2131
2132    #[test]
2133    fn foreign_tail_needs_signature_version_and_size() {
2134        assert_eq!(
2135            foreign_from_tail(&koly(4, 512)),
2136            Some(Foreign::AppleDiskImage)
2137        );
2138        assert_eq!(foreign_from_tail(&koly(3, 512)), None, "version 3");
2139        assert_eq!(foreign_from_tail(&koly(4, 256)), None, "header size 256");
2140        assert_eq!(foreign_from_tail(&koly(4, 512)[..511]), None, "511 bytes");
2141        let mut kolx = koly(4, 512);
2142        kolx[3] = b'x';
2143        assert_eq!(foreign_from_tail(&kolx), None, "kolx");
2144        assert_eq!(
2145            Foreign::AppleDiskImage.to_string(),
2146            "an Apple disk image (UDIF)"
2147        );
2148    }
2149
2150    #[test]
2151    fn probe_refuses_a_seekable_source_with_a_koly_tail() {
2152        let src: Box<dyn Source> = Box::new(SeekCursor(io::Cursor::new(zlib_ish_image())));
2153        match probe(src) {
2154            Err(e) => {
2155                assert!(
2156                    matches!(e, Error::ForeignFormat(Foreign::AppleDiskImage)),
2157                    "{e:?}"
2158                );
2159                assert_eq!(e.exit_code(), 3);
2160                assert_eq!(
2161                    e.to_string(),
2162                    "this is an Apple disk image (UDIF), which stuffr cannot open"
2163                );
2164            }
2165            Ok(_) => panic!("a koly tail must be refused"),
2166        }
2167    }
2168
2169    #[test]
2170    fn probe_does_not_read_the_tail_of_a_pipe() {
2171        let src: Box<dyn Source> = Box::new(ReaderSource::new(io::Cursor::new(zlib_ish_image())));
2172        let Ok((prefix, _)) = probe(src) else {
2173            panic!("a pipe has no tail to read, so it must probe as before");
2174        };
2175        assert_eq!(&prefix[..2], &[0x78, 0x9c]);
2176    }
2177
2178    #[test]
2179    fn read_tail_leaves_a_seekable_source_at_zero() {
2180        let mut bytes = b"HEAD".to_vec();
2181        bytes.extend(vec![7u8; 600]);
2182        let mut src = SeekCursor(io::Cursor::new(bytes.clone()));
2183        let tail = read_tail(&mut src)
2184            .unwrap()
2185            .expect("600+ bytes have a tail");
2186        assert_eq!(tail.len(), TAIL_LEN);
2187        assert_eq!(tail, bytes[bytes.len() - TAIL_LEN..]);
2188        let mut all = Vec::new();
2189        src.read_to_end(&mut all).unwrap();
2190        assert_eq!(all, bytes, "the position must be back at byte zero");
2191
2192        // Shorter than a tail: nothing read, still at zero.
2193        let mut short = SeekCursor(io::Cursor::new(b"tiny".to_vec()));
2194        assert_eq!(read_tail(&mut short).unwrap(), None);
2195        let mut all = Vec::new();
2196        short.read_to_end(&mut all).unwrap();
2197        assert_eq!(all, b"tiny");
2198
2199        // A pipe: nothing read.
2200        let mut pipe = ReaderSource::new(io::Cursor::new(bytes));
2201        assert_eq!(read_tail(&mut pipe).unwrap(), None);
2202    }
2203
2204    const ALPHA: FormatId = FormatId::new("alpha");
2205    const BETA: FormatId = FormatId::new("beta");
2206    const SHARED_MAGIC: &[MagicRule] = &[
2207        MagicRule {
2208            offset: 0,
2209            bytes: b"SH",
2210            format: ALPHA,
2211        },
2212        MagicRule {
2213            offset: 0,
2214            bytes: b"SH",
2215            format: BETA,
2216        },
2217    ];
2218
2219    #[test]
2220    fn confirmed_candidate_beats_an_unconfirmed_one() {
2221        // Equal priority and no path: without confirmation this is an
2222        // `AmbiguousFormat`. Each way round, so the answer is not an accident
2223        // of id order.
2224        for (alpha_ok, winner) in [(true, ALPHA), (false, BETA)] {
2225            let mut r = Registry::new();
2226            let (a, b): (ConfirmFn, ConfirmFn) = if alpha_ok { (yes, no) } else { (no, yes) };
2227            r.register_codec(
2228                Arc::new(MockCodec),
2229                FormatMeta::codec(ALPHA, &[], SHARED_MAGIC).with_confirm(a),
2230            );
2231            r.register_codec(
2232                Arc::new(MockCodec),
2233                FormatMeta::codec(BETA, &[], SHARED_MAGIC).with_confirm(b),
2234            );
2235            let (chain, ev) = resolve_chain_checked(&r, None, b"SH..").expect("no ambiguity");
2236            assert_eq!(chain.outermost_codec(), Some(winner));
2237            assert_eq!(ev, Evidence::Confirmed);
2238        }
2239    }
2240
2241    #[test]
2242    fn a_format_with_no_confirm_function_counts_as_confirmed() {
2243        let mut r = Registry::new();
2244        r.register_codec(
2245            Arc::new(MockCodec),
2246            FormatMeta::codec(ALPHA, &[], SHARED_MAGIC).with_confirm(no),
2247        );
2248        r.register_codec(
2249            Arc::new(MockCodec),
2250            FormatMeta::codec(BETA, &[], SHARED_MAGIC),
2251        );
2252        let (chain, ev) = resolve_chain_checked(&r, None, b"SH..").unwrap();
2253        assert_eq!(chain.outermost_codec(), Some(BETA));
2254        assert_eq!(ev, Evidence::Confirmed);
2255    }
2256
2257    #[test]
2258    fn unconfirmed_magic_still_resolves_leniently() {
2259        let mut r = Registry::new();
2260        r.register_codec(
2261            Arc::new(MockCodec),
2262            FormatMeta::codec(ALPHA, &["al"], &SHARED_MAGIC[..1]).with_confirm(no),
2263        );
2264        let chain = resolve_chain(&r, None, b"SH..").expect("lenient resolution keeps it");
2265        assert_eq!(chain.outermost_codec(), Some(ALPHA));
2266        let (_, ev) = resolve_chain_checked(&r, None, b"SH..").unwrap();
2267        assert_eq!(ev, Evidence::Unconfirmed);
2268    }
2269
2270    #[test]
2271    fn extension_without_confirm_is_by_name() {
2272        let mut r = Registry::new();
2273        r.register_codec(Arc::new(MockCodec), FormatMeta::codec(ALPHA, &["al"], &[]));
2274        let (chain, ev) = resolve_chain_checked(&r, Some(Path::new("x.al")), b"whatever").unwrap();
2275        assert_eq!(chain.outermost_codec(), Some(ALPHA));
2276        assert_eq!(ev, Evidence::ByName);
2277    }
2278
2279    #[test]
2280    fn extension_with_failing_confirm_is_unconfirmed() {
2281        let mut r = Registry::new();
2282        r.register_codec(
2283            Arc::new(MockCodec),
2284            FormatMeta::codec(ALPHA, &["al"], &[]).with_confirm(no),
2285        );
2286        let (_, ev) = resolve_chain_checked(&r, Some(Path::new("x.al")), b"whatever").unwrap();
2287        assert_eq!(ev, Evidence::Unconfirmed);
2288
2289        let mut r = Registry::new();
2290        r.register_codec(
2291            Arc::new(MockCodec),
2292            FormatMeta::codec(ALPHA, &["al"], &[]).with_confirm(yes),
2293        );
2294        let (_, ev) = resolve_chain_checked(&r, Some(Path::new("x.al")), b"whatever").unwrap();
2295        assert_eq!(ev, Evidence::Confirmed);
2296    }
2297
2298    #[test]
2299    fn errors_are_unchanged() {
2300        let r = Registry::new();
2301        assert!(matches!(
2302            resolve_chain_checked(&r, None, b"??"),
2303            Err(Error::UnknownFormat { .. })
2304        ));
2305        let mut r = Registry::new();
2306        r.register_codec(
2307            Arc::new(MockCodec),
2308            FormatMeta::codec(ALPHA, &[], SHARED_MAGIC).with_confirm(no),
2309        );
2310        r.register_codec(
2311            Arc::new(MockCodec),
2312            FormatMeta::codec(BETA, &[], SHARED_MAGIC).with_confirm(no),
2313        );
2314        assert!(matches!(
2315            resolve_chain_checked(&r, None, b"SH"),
2316            Err(Error::AmbiguousFormat { .. })
2317        ));
2318    }
2319
2320    // --- identify_strict -------------------------------------------------
2321
2322    const LAYER_MAGIC: &[u8] = b"MOCK";
2323
2324    /// Strips `LAYER_MAGIC` and passes the rest through — one layer peeled
2325    /// per decode, however many are stacked (`MockCodec`'s XOR would undo
2326    /// itself every second layer).
2327    struct LayerCodec;
2328
2329    impl Codec for LayerCodec {
2330        fn id(&self) -> FormatId {
2331            MOCK_CODEC
2332        }
2333
2334        fn caps(&self) -> CodecCaps {
2335            CodecCaps::round_trip()
2336        }
2337
2338        fn decoder<'a>(
2339            &self,
2340            mut src: Box<dyn Source + 'a>,
2341            _o: &DecodeOpts,
2342        ) -> Result<Box<dyn Source + 'a>> {
2343            let mut discard = [0u8; 4];
2344            src.read_exact(&mut discard)?;
2345            Ok(Box::new(StreamOnly::new(src)))
2346        }
2347
2348        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
2349            unreachable!("identification never encodes")
2350        }
2351    }
2352
2353    /// The mock container's record header is `ME` + a little-endian u16 name
2354    /// length; a name that runs past the prefix is not a mock archive.
2355    fn mock_archive_confirms(p: &[u8], _whole: bool) -> bool {
2356        p.len() >= 4 && {
2357            let n = u16::from_le_bytes([p[2], p[3]]) as usize;
2358            n > 0 && 4 + n <= p.len()
2359        }
2360    }
2361
2362    fn strict_registry() -> Registry {
2363        const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
2364            offset: 0,
2365            bytes: LAYER_MAGIC,
2366            format: MOCK_CODEC,
2367        }];
2368        const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
2369            offset: 0,
2370            bytes: b"ME",
2371            format: MOCK_CONTAINER,
2372        }];
2373        let mut r = Registry::new();
2374        r.register_codec(
2375            Arc::new(LayerCodec),
2376            FormatMeta::codec(MOCK_CODEC, &["mock"], CODEC_MAGIC)
2377                .with_confirm(|p, _| p.starts_with(LAYER_MAGIC)),
2378        );
2379        r.register_container(
2380            Arc::new(MockContainer),
2381            FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC)
2382                .with_confirm(mock_archive_confirms),
2383        );
2384        r
2385    }
2386
2387    fn layered(layers: usize, inner: &[u8]) -> Vec<u8> {
2388        let mut out = LAYER_MAGIC.repeat(layers);
2389        out.extend_from_slice(inner);
2390        out
2391    }
2392
2393    fn strict(
2394        r: &Registry,
2395        path: Option<&str>,
2396        prefix: &[u8],
2397        tail: Option<&[u8]>,
2398    ) -> Option<Chain> {
2399        identify_strict(r, path.map(Path::new), prefix, tail, &DecodeOpts::default())
2400    }
2401
2402    #[test]
2403    fn identify_strict_rejects_unconfirmed_and_foreign() {
2404        let r = strict_registry();
2405        assert_eq!(
2406            strict(&r, None, b"ME\xff\xffabc", None),
2407            None,
2408            "a magic hit whose confirmation fails is not openable"
2409        );
2410        let archive = mock_archive_bytes(&[("a.txt", b"alpha")]);
2411        assert_eq!(
2412            strict(&r, None, &archive, None),
2413            Some(Chain::Container {
2414                container: MOCK_CONTAINER
2415            })
2416        );
2417        assert_eq!(
2418            strict(&r, None, &archive, Some(&koly(4, 512))),
2419            None,
2420            "a koly tail outranks a confirmed prefix"
2421        );
2422        assert_eq!(strict(&r, None, b"nothing", None), None, "unknown");
2423    }
2424
2425    #[test]
2426    fn identify_strict_decodes_the_inner_layer_from_the_prefix() {
2427        let r = strict_registry();
2428        let archive = mock_archive_bytes(&[("a.txt", b"alpha")]);
2429        assert_eq!(
2430            strict(&r, None, &layered(1, &archive), None),
2431            Some(Chain::Codec {
2432                codec: MOCK_CODEC,
2433                inner: Box::new(Chain::Container {
2434                    container: MOCK_CONTAINER
2435                }),
2436            })
2437        );
2438        assert_eq!(
2439            strict(&r, Some("x.mar.mock"), &layered(1, b"ME\xff\xffabc"), None),
2440            Some(Chain::Codec {
2441                codec: MOCK_CODEC,
2442                inner: Box::new(Chain::Raw),
2443            }),
2444            "an inner layer failing confirmation is Raw, whatever the path says"
2445        );
2446    }
2447
2448    #[test]
2449    fn identify_strict_bounds_the_nesting() {
2450        let r = strict_registry();
2451        let archive = mock_archive_bytes(&[("a.txt", b"alpha")]);
2452        let depth = |c: &Chain| {
2453            let mut n = 0;
2454            let mut c = c;
2455            while let Chain::Codec { inner, .. } = c {
2456                n += 1;
2457                c = inner;
2458            }
2459            n
2460        };
2461        let three = strict(&r, None, &layered(3, &archive), None).unwrap();
2462        assert_eq!(depth(&three), 3);
2463        assert_eq!(three.container(), Some(MOCK_CONTAINER));
2464        let many = strict(&r, None, &layered(10, &archive), None).unwrap();
2465        assert!(depth(&many) < MAX_CHAIN_DEPTH, "{many:?}");
2466        assert_eq!(many.container(), None, "stopped with Raw at the bound");
2467    }
2468
2469    /// A codec whose decoder yields `yields` and then either errors or ends.
2470    struct Scripted {
2471        yields: &'static [u8],
2472        then_error: bool,
2473    }
2474
2475    struct ScriptedSource {
2476        rest: &'static [u8],
2477        then_error: bool,
2478    }
2479
2480    impl Read for ScriptedSource {
2481        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2482            if self.rest.is_empty() {
2483                return if self.then_error {
2484                    Err(io::Error::new(io::ErrorKind::InvalidData, "bad stream"))
2485                } else {
2486                    Ok(0)
2487                };
2488            }
2489            let n = buf.len().min(self.rest.len());
2490            buf[..n].copy_from_slice(&self.rest[..n]);
2491            self.rest = &self.rest[n..];
2492            Ok(n)
2493        }
2494    }
2495
2496    impl Source for ScriptedSource {
2497        fn caps(&self) -> SourceCaps {
2498            SourceCaps::default()
2499        }
2500
2501        fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
2502            None
2503        }
2504    }
2505
2506    impl Codec for Scripted {
2507        fn id(&self) -> FormatId {
2508            MOCK_CODEC
2509        }
2510
2511        fn caps(&self) -> CodecCaps {
2512            CodecCaps::decode_only()
2513        }
2514
2515        fn decoder<'a>(
2516            &self,
2517            _src: Box<dyn Source + 'a>,
2518            _o: &DecodeOpts,
2519        ) -> Result<Box<dyn Source + 'a>> {
2520            Ok(Box::new(ScriptedSource {
2521                rest: self.yields,
2522                then_error: self.then_error,
2523            }))
2524        }
2525
2526        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
2527            unreachable!("identification never encodes")
2528        }
2529    }
2530
2531    fn scripted_registry(yields: &'static [u8], then_error: bool) -> Registry {
2532        let mut r = strict_registry();
2533        r.register_codec(
2534            Arc::new(Scripted { yields, then_error }),
2535            FormatMeta::codec(
2536                MOCK_CODEC,
2537                &["mock"],
2538                &[MagicRule {
2539                    offset: 0,
2540                    bytes: LAYER_MAGIC,
2541                    format: MOCK_CODEC,
2542                }],
2543            )
2544            .with_confirm(yes),
2545        );
2546        r
2547    }
2548
2549    #[test]
2550    fn identify_strict_drops_a_codec_that_decodes_nothing() {
2551        let raw = Some(Chain::Codec {
2552            codec: MOCK_CODEC,
2553            inner: Box::new(Chain::Raw),
2554        });
2555        assert_eq!(
2556            strict(&scripted_registry(b"", true), None, b"MOCKjunk", None),
2557            None,
2558            "an error before the first byte: nothing here decodes"
2559        );
2560        assert_eq!(
2561            strict(&scripted_registry(b"", false), None, b"MOCKjunk", None),
2562            raw,
2563            "a clean, empty stream is still a stream"
2564        );
2565        assert_eq!(
2566            strict(
2567                &scripted_registry(b"plain text", true),
2568                None,
2569                b"MOCKjunk",
2570                None
2571            ),
2572            raw,
2573            "what arrived before the error is kept"
2574        );
2575    }
2576
2577    /// A codec whose first block is larger than the probe window: its
2578    /// decoder drains the source and, short of `NEEDS` bytes, yields nothing
2579    /// and errors — bzip2's and zstd's behaviour on a 4096-byte prefix of a
2580    /// real file.
2581    struct NeedsMore;
2582
2583    const NEEDS: usize = PROBE_LEN * 2;
2584    const SLOW: FormatId = FormatId::new("slow");
2585    const TAR: FormatId = FormatId::new("tar");
2586
2587    impl Codec for NeedsMore {
2588        fn id(&self) -> FormatId {
2589            SLOW
2590        }
2591
2592        fn caps(&self) -> CodecCaps {
2593            CodecCaps::decode_only()
2594        }
2595
2596        fn decoder<'a>(
2597            &self,
2598            mut src: Box<dyn Source + 'a>,
2599            _o: &DecodeOpts,
2600        ) -> Result<Box<dyn Source + 'a>> {
2601            let mut all = Vec::new();
2602            src.read_to_end(&mut all)?;
2603            Ok(Box::new(ScriptedSource {
2604                rest: if all.len() >= NEEDS { b"payload" } else { b"" },
2605                then_error: all.len() < NEEDS,
2606            }))
2607        }
2608
2609        fn encoder(&self, _dst: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
2610            unreachable!("identification never encodes")
2611        }
2612    }
2613
2614    fn needs_more_registry() -> Registry {
2615        let mut r = strict_registry();
2616        r.register_codec(
2617            Arc::new(NeedsMore),
2618            FormatMeta::codec(
2619                SLOW,
2620                &["slow"],
2621                &[MagicRule {
2622                    offset: 0,
2623                    bytes: b"SLOW",
2624                    format: SLOW,
2625                }],
2626            )
2627            .with_confirm(yes),
2628        );
2629        r.register_container(
2630            Arc::new(MockContainer),
2631            FormatMeta::container(TAR, &["tar"], &[]),
2632        );
2633        r
2634    }
2635
2636    fn slow_prefix(len: usize) -> Vec<u8> {
2637        let mut p = b"SLOW".to_vec();
2638        p.resize(len, 0x5a);
2639        p
2640    }
2641
2642    #[test]
2643    fn a_cut_off_prefix_that_decodes_nothing_keeps_the_codec_over_the_paths_inner() {
2644        let r = needs_more_registry();
2645        assert_eq!(
2646            strict(&r, Some("big.tar.slow"), &slow_prefix(PROBE_LEN), None),
2647            Some(Chain::Codec {
2648                codec: SLOW,
2649                inner: Box::new(Chain::Container { container: TAR }),
2650            }),
2651            "the window ran out, not the file: as 0.21.0 listed it"
2652        );
2653        assert_eq!(
2654            strict(&r, Some("big.slow"), &slow_prefix(PROBE_LEN), None),
2655            Some(Chain::Codec {
2656                codec: SLOW,
2657                inner: Box::new(Chain::Raw),
2658            }),
2659        );
2660    }
2661
2662    #[test]
2663    fn a_whole_file_that_decodes_nothing_before_an_error_is_not_openable() {
2664        let r = needs_more_registry();
2665        assert_eq!(
2666            strict(&r, Some("small.tar.slow"), &slow_prefix(100), None),
2667            None,
2668            "the prefix is the whole file, and it decodes to nothing but an error"
2669        );
2670    }
2671
2672    #[test]
2673    fn an_extension_naming_a_refused_hit_falls_to_the_confirmed_ones() {
2674        const ZIP: FormatId = FormatId::new("zip");
2675        const JAR: FormatId = FormatId::new("jar");
2676        const APK: FormatId = FormatId::new("apk");
2677        const PK: &[MagicRule] = &[
2678            MagicRule {
2679                offset: 0,
2680                bytes: b"PK",
2681                format: ZIP,
2682            },
2683            MagicRule {
2684                offset: 0,
2685                bytes: b"PK",
2686                format: JAR,
2687            },
2688            MagicRule {
2689                offset: 0,
2690                bytes: b"PK",
2691                format: APK,
2692            },
2693        ];
2694        let mut r = Registry::new();
2695        r.register_container(
2696            Arc::new(MockContainer),
2697            FormatMeta::container(ZIP, &["zip"], PK)
2698                .with_priority(1)
2699                .with_confirm(yes),
2700        );
2701        r.register_container(
2702            Arc::new(MockContainer),
2703            FormatMeta::container(JAR, &["jar"], PK).with_confirm(yes),
2704        );
2705        r.register_container(
2706            Arc::new(MockContainer),
2707            FormatMeta::container(APK, &["apk"], PK).with_confirm(no),
2708        );
2709        let (chain, ev) = resolve_chain_checked(&r, Some(Path::new("x.apk")), b"PK..").unwrap();
2710        assert_eq!(chain, Chain::Container { container: ZIP });
2711        assert_eq!(ev, Evidence::Confirmed);
2712    }
2713    // --- whole streams ----------------------------------------------------
2714
2715    /// Refuses exactly what is whole and shorter than eight bytes.
2716    fn eight_when_whole(p: &[u8], whole: bool) -> bool {
2717        !whole || p.len() >= 8
2718    }
2719
2720    #[test]
2721    fn a_probe_prefix_short_of_the_window_is_confirmed_as_the_whole_stream() {
2722        let mut r = Registry::new();
2723        r.register_codec(
2724            Arc::new(MockCodec),
2725            FormatMeta::codec(ALPHA, &[], &SHARED_MAGIC[..1]).with_confirm(eight_when_whole),
2726        );
2727        let ev = |prefix: &[u8]| resolve_chain_checked(&r, None, prefix).unwrap().1;
2728        assert_eq!(ev(b"SH"), Evidence::Unconfirmed, "a two-byte file is whole");
2729        assert_eq!(ev(b"SH345678"), Evidence::Confirmed);
2730        // A full window is never whole, however little of it the check reads.
2731        let mut window = vec![0u8; PROBE_LEN];
2732        window[..2].copy_from_slice(b"SH");
2733        assert_eq!(ev(&window), Evidence::Confirmed);
2734    }
2735
2736    const PLAIN: FormatId = FormatId::new("plain");
2737    const WHOLE: FormatId = FormatId::new("wholecheck");
2738
2739    /// `scripted_registry` plus a magicless container (`.pln`, confirmed by
2740    /// a leading `PL`) and one whose magic `WH` is refused only when whole.
2741    fn layered_registry(yields: &'static [u8], then_error: bool) -> Registry {
2742        let mut r = scripted_registry(yields, then_error);
2743        r.register_container(
2744            Arc::new(MockContainer),
2745            FormatMeta::container(PLAIN, &["pln"], &[]).with_confirm(|p, _| p.starts_with(b"PL")),
2746        );
2747        r.register_container(
2748            Arc::new(MockContainer),
2749            FormatMeta::container(
2750                WHOLE,
2751                &["wh"],
2752                &[MagicRule {
2753                    offset: 0,
2754                    bytes: b"WH",
2755                    format: WHOLE,
2756                }],
2757            )
2758            .with_confirm(eight_when_whole),
2759        );
2760        r
2761    }
2762
2763    fn over(inner: Chain) -> Option<Chain> {
2764        Some(Chain::Codec {
2765            codec: MOCK_CODEC,
2766            inner: Box::new(inner),
2767        })
2768    }
2769
2770    #[test]
2771    fn a_decoded_layer_is_whole_only_when_it_ended_cleanly_inside_the_window() {
2772        // Ended cleanly at two bytes: the whole inner stream, refused.
2773        let r = layered_registry(b"WH", false);
2774        assert_eq!(strict(&r, None, b"MOCKjunk", None), over(Chain::Raw));
2775        let chain = over(Chain::Container { container: WHOLE }).unwrap();
2776        assert_eq!(
2777            first_unconfirmed_layer(&r, &chain, b"MOCKjunk", &DecodeOpts::default()),
2778            Some(WHOLE)
2779        );
2780        // Stopped on an error: a cut stream, judged as one.
2781        let r = layered_registry(b"WH", true);
2782        assert_eq!(
2783            strict(&r, None, b"MOCKjunk", None),
2784            over(Chain::Container { container: WHOLE })
2785        );
2786        assert_eq!(
2787            first_unconfirmed_layer(&r, &chain, b"MOCKjunk", &DecodeOpts::default()),
2788            None
2789        );
2790    }
2791
2792    #[test]
2793    fn decoded_bytes_with_no_magic_take_the_paths_container_when_it_confirms() {
2794        let plain = Chain::Container { container: PLAIN };
2795        let r = layered_registry(b"PLAIN DATA", false);
2796        assert_eq!(
2797            strict(&r, Some("x.pln.mock"), b"MOCKjunk", None),
2798            over(plain.clone()),
2799            "a magicless inner layer the name gives and the bytes confirm"
2800        );
2801        assert_eq!(
2802            strict(&r, None, b"MOCKjunk", None),
2803            over(Chain::Raw),
2804            "no name"
2805        );
2806        assert_eq!(
2807            strict(&r, Some("x.mock"), b"MOCKjunk", None),
2808            over(Chain::Raw),
2809            "a name with nothing inside"
2810        );
2811        let r = layered_registry(b"just text", false);
2812        assert_eq!(
2813            strict(&r, Some("x.pln.mock"), b"MOCKjunk", None),
2814            over(Chain::Raw),
2815            "the name alone is not enough when the check refuses the bytes"
2816        );
2817        // `info`'s chain for the same file names the same layer as refused.
2818        let (chain, _) =
2819            resolve_chain_checked(&r, Some(Path::new("x.pln.mock")), b"MOCKjunk").unwrap();
2820        assert_eq!(chain, over(plain).unwrap());
2821        assert_eq!(
2822            first_unconfirmed_layer(&r, &chain, b"MOCKjunk", &DecodeOpts::default()),
2823            Some(PLAIN)
2824        );
2825    }
2826}