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