Skip to main content

stuffr_core/
salvage.rs

1//! The salvage engine: format-agnostic scanning, bounding and collection.
2//!
3//! Salvage recovers entries from a damaged archive by scanning for records
4//! directly, rather than trusting a single index (a central directory, an
5//! EOCD) that may be truncated, zeroed, or — the motivating case — may
6//! shadow real records behind a repeated name. The per-format part of that
7//! (recognising a local header, computing a checksum) is a
8//! [`SalvageScan`] implementation; this module is everything that does not
9//! need to know which format it is scanning.
10//!
11//! **This module implements no per-format scanning at all.** It is proven
12//! entirely against mocks — [`SalvageScan`] is the seam Task 2 onward builds
13//! real scanners against (starting with zip).
14//!
15//! ## Verification and shadowing (Task 4)
16//!
17//! Two things this module now does, both format-agnostic:
18//!
19//! - **Deciding [`SalvageStatus`]** is delegated to [`SalvageScan::verify`],
20//!   called once per candidate in [`annotate_candidates`]. The default
21//!   answers [`SalvageStatus::Unattested`] — "nothing attests that this
22//!   candidate is an entry at all" — which is the honest reading of a method
23//!   that reads nothing, and the right answer for a mock with no
24//!   [`Candidate::verifier`] and for a real format with neither a checksum
25//!   nor a self-verifying header (cpio, ar). A format with a self-verifying
26//!   header and no content checksum (tar) overrides it to answer
27//!   [`SalvageStatus::Complete`]. A format that carries a content checksum
28//!   (zip's CRC-32) overrides `verify` to
29//!   decode the payload and compare it, answering `Intact` on agreement and
30//!   `Partial` otherwise — never `Complete`, which is reserved for "no way
31//!   to prove", not "tried and it disagreed". Earlier revisions of this
32//!   module recorded every candidate as `Complete` unconditionally; that was
33//!   a placeholder, not a claim about zip, and it was wrong for every zip
34//!   entry the moment a real scanner carried a CRC-32.
35//! - **Detecting a shadowed record** (setting [`SalvagedEntry::shadows`]) is
36//!   generic too: [`annotate_candidates`] remembers the first candidate to
37//!   report each distinct `(name, declared_len, verifier)` triple and marks
38//!   any LATER candidate reporting the identical triple as shadowing it.
39//!   This is a MEASUREMENT, never an inference from [`EntryMeta::name`]
40//!   alone repeating: two different files that happen to share a name must
41//!   not be linked this way, only two records whose original writer
42//!   computed the same checksum, over the same declared length, under the
43//!   same name. The checksum alone is not enough either — fixed after this
44//!   module's own fix-round review measured it against real archives: every
45//!   empty file and every directory entry in a zip has `Crc32(0)` — a
46//!   structural certainty for a zero-byte payload, not a collision — so a
47//!   checksum-only match reported an ordinary Python package's dozen
48//!   `__init__.py` files, and its directories, as duplicates of one another.
49//!   The degenerate case is refused outright rather than papered over by the
50//!   wider conjunction: a candidate with `declared_len == Some(0)` never
51//!   shadows and is never shadowed, because a checksum over zero bytes is
52//!   the same value for every empty entry that ever existed and proves
53//!   nothing about any of them being copies of each other.
54//! - **Detecting a repeated NAME** ([`SalvagedEntry::collides_with`]) is a
55//!   second, deliberately separate annotation, added after the final
56//!   whole-branch review found the word "shadow" doing two jobs. The two
57//!   facts are different and both worth printing: a shadow is a record
58//!   MEASURED to be a copy of an earlier one, a collision is a record whose
59//!   name an earlier one already used and whose content this scan could not
60//!   prove identical. `stuffr list`'s own `Fidelity::EntryCountMismatch`
61//!   warning is about the second, so on an archive whose duplicates differ,
62//!   `list` reported two records shadowed while `salvage --list` marked
63//!   none — one tool telling a user two contradictory things about the same
64//!   eight records. None of the guards on shadow detection above apply to a
65//!   collision, and applying them would be wrong: they exist because
66//!   claiming two records are COPIES needs proof, and a collision claims
67//!   nothing about content at all. The two are mutually exclusive — a
68//!   proven copy is reported as a shadow and nothing else, since that is
69//!   the strictly more informative claim.
70//!
71//! [`collect_candidates`] (the resync loop: find a candidate, advance) and
72//! [`annotate_candidates`] (bound + verify + shadow, above) are both
73//! exposed separately from [`salvage_all`] — which is just the two of them
74//! run in sequence — so a format that reconciles more than one candidate
75//! source (zip's central-directory fallback, `zip_salvage.rs`) can collect
76//! from each source, merge, and annotate the merged list exactly once
77//! rather than annotating twice and reconciling two already-decided
78//! outcomes. The run's ceiling on a single entry is applied once, in
79//! `annotate_candidates`, to whatever the merge produced — see
80//! [`UnverifiedCause::OverEntryCeiling`] for why it is a status there
81//! rather than a [`crate::Error`] anywhere.
82
83use std::io::{Read, Write};
84use std::path::Path;
85
86use crate::archive::EntryMeta;
87use crate::error::{Error, Result};
88use crate::format::FormatId;
89use crate::source::SeekRead;
90
91/// 4 GiB. A scanned length is untrustworthy TWICE over — attacker-controlled
92/// AND possibly corrupt — so this is a structural ceiling, not a budget.
93///
94/// Applied per ENTRY, never to the run: an entry over it is reported
95/// [`UnverifiedCause::OverEntryCeiling`] and skipped, and the rest of the
96/// archive is recovered as usual. It used to abort the run outright, which
97/// meant one absurd declaration cost every entry around it.
98pub const MAX_SALVAGE_ENTRY: u64 = 4 * 1024 * 1024 * 1024;
99
100/// What a format can prove about a candidate it found.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Verifier {
103    /// CRC-32, as zip, arj and gzip compute it.
104    Crc32(u32),
105    /// CRC-16/ARC, as lha, arc and zoo compute it.
106    Crc16(u16),
107}
108
109/// A record a scanner believes it found, before anything is decoded.
110///
111/// # Why this is `#[non_exhaustive]` AND has a constructor
112///
113/// Ruling S-I, deferred from Salvage Stage 2 Task 1 to Task 9 on purpose:
114/// this struct is the one shape [`SalvageScan::next_candidate`] *forces* an
115/// external implementor to build, so every field added to it used to break
116/// every out-of-crate scanner at once — and at `0.5.0` all four crates are
117/// published, so "out-of-crate" is not hypothetical. The attribute alone
118/// would have been the wrong fix (`CONTRIBUTING.md`'s `#[non_exhaustive]`
119/// section says why: it forbids `..` construction from another crate, which
120/// is exactly what an implementor must do), which is why it waited for
121/// [`Candidate::new`] — and for all five in-tree scanners to exist, so the
122/// constructor's shape could be read off real callers rather than guessed.
123///
124/// The three arguments [`Candidate::new`] takes are the three facts every
125/// scanner knows the instant it recognises a record. The four `with_*`
126/// setters cover the rest, each taking the field's own type unchanged so
127/// nothing is hidden behind a conversion.
128#[derive(Debug)]
129#[non_exhaustive]
130pub struct Candidate {
131    /// Byte offset of the record's header in the archive.
132    pub offset: u64,
133    /// Byte offset where this record's PAYLOAD begins — past whatever
134    /// fixed and variable-length header fields this format's record
135    /// carries (a zip local header's name/extra fields, ARC's fixed
136    /// 28-byte record, and so on).
137    ///
138    /// Task 3c added this field after `entries.rs`'s write path hardcoded
139    /// a ZIP local header's own 30-byte-plus-name-plus-extra layout as
140    /// the way to locate every format's payload — which crashed
141    /// (`Error::Io`, exit 1, the wildcard this project treats as a
142    /// defect) the moment a non-zip scanner (ARC) reported a candidate
143    /// near the end of the file, because reading 30 bytes from ITS offset
144    /// ran past EOF. Each scanner already computes this value once, at
145    /// discovery, to know where ITS OWN payload begins — this field
146    /// carries that computation forward instead of a consumer re-deriving
147    /// (and mis-deriving) it later. A future scanner (zoo, lha, arj) fills
148    /// this the same way; nothing outside the scanner that discovered a
149    /// candidate needs to know its record's own layout at all.
150    pub payload_start: u64,
151    pub meta: EntryMeta,
152    /// Declared payload length. `None` when the header does not carry one.
153    pub declared_len: Option<u64>,
154    /// The checksum the ORIGINAL writer computed, when the format has one.
155    /// `None` is what produces the intact/complete split honestly, rather
156    /// than by a per-format convention someone has to remember.
157    pub verifier: Option<Verifier>,
158    /// How many of `declared_len`'s bytes the SOURCE actually holds, when
159    /// it holds fewer — the truncated-tail case. `None` means the declared
160    /// payload is entirely present (the ordinary case) or nothing was
161    /// declared at all; `Some(n)` always means `n < declared_len`.
162    ///
163    /// # Why a scanner reports this instead of dropping the candidate
164    ///
165    /// A truncated archive — an interrupted download — is the single most
166    /// common damaged zip there is, and it was the one shape salvage said
167    /// nothing at all about. `zip_salvage.rs`'s validation gate refused any
168    /// candidate whose declared payload ran past the end of the file, which
169    /// is a correct refusal to INVENT the missing bytes (`zip -FF`
170    /// fabricates them and reports success) implemented as SILENCE: no row,
171    /// no note, no non-zero exit. Swept across six truncation points inside
172    /// a three-header archive's last entry, every one reported "2 scanned"
173    /// at exit 0 — the third header was not even counted.
174    ///
175    /// Reporting the candidate with this field set is what turns the
176    /// refusal into a statement. Nothing is invented: the declared length
177    /// stays exactly what the header said, so a reader can see both figures,
178    /// and the bytes that ARE present are still a genuine prefix. What a
179    /// scanner must NOT do is fold the shortfall into `declared_len` — that
180    /// would make a truncated entry indistinguishable from a whole one of
181    /// the smaller size, which is the same mistake in a different place.
182    pub available_len: Option<u64>,
183    /// The archive's own record marks this entry DELETED — the format's
184    /// owner removed it and the writer left the record, and its payload,
185    /// in place.
186    ///
187    /// `false` for every format that has no such flag (zip, tar, ar, cpio,
188    /// arc), which is why it is a plain `bool` rather than an `Option`:
189    /// "this format cannot express deletion" and "this record is not
190    /// deleted" lead to the identical, unannotated row, and a third state
191    /// would be a distinction no consumer could act on.
192    ///
193    /// # Why a scanner reports one rather than dropping it (Ruling S-R)
194    ///
195    /// Salvage Stage 2 Task 4, for ZOO — the first format here with the
196    /// flag. `zoo d` marks an entry `deleted = 1` and leaves it whole in the
197    /// file; `zoolist.c` neither lists nor extracts it, and `legacy::zoo`'s
198    /// reader follows that exactly. The flag is ONE BYTE, so in a damaged
199    /// archive a bit flip turns a live entry into one no ordinary verb will
200    /// ever hand back — which is precisely the archive this verb exists for.
201    /// So the scanner reports it.
202    ///
203    /// **And the report carries a marker, which is the part that took a
204    /// review to get right.** Everywhere else salvage accepts less than an
205    /// ordinary entry it says so on the artifact or in the row: a `Partial`
206    /// lands as `NAME.partial` and never under its real name, a second
207    /// record under a taken name lands as `NAME.salvaged-N`, a proven copy
208    /// prints `[shadowed]`. A deleted record reported `Intact` under its
209    /// real name at exit 0 was the one place that leniency was invisible —
210    /// four verbs call the archive empty and the fifth writes the file, with
211    /// nothing telling a user which it was.
212    ///
213    /// **The exit code deliberately does NOT move.** Recovering a deleted
214    /// record is this verb working as designed, not degraded fidelity — see
215    /// `stuffr::entries`'s own exit-code aggregation, which this field is
216    /// invisible to.
217    pub marked_deleted: bool,
218}
219
220impl Candidate {
221    /// A candidate at `offset`, whose payload begins at `payload_start`,
222    /// carrying `meta`.
223    ///
224    /// Nothing else is claimed. The four fields this does not take default
225    /// to the answers that assert the least:
226    ///
227    /// * `declared_len: None` — the header carries no length, so nothing
228    ///   bounds the payload and [`annotate_candidates`] reports
229    ///   [`UnverifiedCause::NoDeclaredLength`] rather than reading it.
230    /// * `verifier: None` — the format offers nothing to prove the content
231    ///   with, so no shadow is ever claimed for this record (see
232    ///   [`SalvagedEntry::shadows`], which needs a verifier to compare).
233    /// * `available_len: None` — every declared byte is present, which is
234    ///   the ordinary case; a scanner sets this only when it has MEASURED a
235    ///   shortfall.
236    /// * `marked_deleted: false` — the format has no deleted flag, or this
237    ///   record is not marked. Four of the five in-tree scanners leave it.
238    ///
239    /// One default is worth naming because it decides a STATUS, not just a
240    /// field: with no `verifier`, [`SalvageScan::verify`]'s own default
241    /// answers [`SalvageStatus::Unattested`] — the tier that asserts the
242    /// least, matching the discipline of the four above. It answered
243    /// [`SalvageStatus::Complete`] until Salvage Stage 3, which was the
244    /// lenient direction: `Complete` claims a header self-check the default
245    /// never performs, and routes to exit 0. A scanner carrying a checksum
246    /// has to write its own `verify` regardless (the default checks
247    /// nothing), and that `verify` reads this very field, so the omission
248    /// fails in the scanner's own first test rather than silently.
249    pub fn new(offset: u64, payload_start: u64, meta: EntryMeta) -> Self {
250        Self {
251            offset,
252            payload_start,
253            meta,
254            declared_len: None,
255            verifier: None,
256            available_len: None,
257            marked_deleted: false,
258        }
259    }
260
261    /// Sets [`Self::declared_len`].
262    #[must_use]
263    pub fn with_declared_len(mut self, declared_len: Option<u64>) -> Self {
264        self.declared_len = declared_len;
265        self
266    }
267
268    /// Sets [`Self::verifier`].
269    #[must_use]
270    pub fn with_verifier(mut self, verifier: Option<Verifier>) -> Self {
271        self.verifier = verifier;
272        self
273    }
274
275    /// Sets [`Self::available_len`] — the truncated-tail case, and only
276    /// when the shortfall was measured. See that field's doc for why a
277    /// scanner must never fold it into [`Self::declared_len`] instead.
278    #[must_use]
279    pub fn with_available_len(mut self, available_len: Option<u64>) -> Self {
280        self.available_len = available_len;
281        self
282    }
283
284    /// Sets [`Self::marked_deleted`] — Ruling S-R. See that field's doc.
285    #[must_use]
286    pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
287        self.marked_deleted = marked_deleted;
288        self
289    }
290}
291
292/// Why an entry is [`SalvageStatus::Unverified`].
293///
294/// Three causes, and the STATUS does not distinguish them because the
295/// decision they lead to is identical (listed, not written, exit 3) — the
296/// same split [`crate::salvage`]'s consumers apply to
297/// [`SalvageStatus::Partial`] via their own cause type (`entries.rs`'s
298/// `PartialCause`, one crate up): **a tier carries a decision, a message
299/// carries a cause.**
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum UnverifiedCause {
302    /// This build recognises the entry's compression method but cannot
303    /// decode it (zip method 99 is AES, 14 is LZMA — recognised, not
304    /// decoded).
305    UndecodableMethod,
306    /// No length was available to bound the payload, so it was never read —
307    /// a data-descriptor entry the raw scan found with no central-directory
308    /// record to reconcile against. A CD-reconciled data-descriptor entry
309    /// gets a real declared length and CRC from the central directory and
310    /// does NOT reach this cause; see `zip_salvage.rs`'s
311    /// `candidate_from_cd_record`.
312    NoDeclaredLength,
313    /// The bytes this entry would have required are past the ceiling this
314    /// run will read for ONE entry — `policy.max_entry` narrowed by the
315    /// scanner's own [`SalvageScan::max_whole_entry`] — so nothing was read
316    /// and nothing was decoded. See [`annotate_candidates`], which is the
317    /// one place that decides this, for the whole rule.
318    ///
319    /// # Why this is a cause and not an `Err`
320    ///
321    /// Salvage Stage 2 Task 3c's fix round 3. This refusal used to be an
322    /// [`crate::Error::ResourceLimit`] raised from two different places — the
323    /// engine's own [`collect_candidates`], and (for ARC) the scanner's own
324    /// `verify` — and an `Err` at either site **aborts the whole run**:
325    /// measured at the CLI on a two-entry archive whose second entry was
326    /// over the ceiling, `stuffr salvage --list` printed no rows at all and
327    /// exited 6, and `salvage -C out` left the destination empty, losing a
328    /// first entry that decodes perfectly. That is the one thing this verb
329    /// exists not to do. Three fix rounds chased the shape from site to
330    /// site; what actually closes it is that an over-ceiling entry has a
331    /// STATUS, so there is no `Err` left to propagate.
332    ///
333    /// Distinct from [`SalvageStatus::Partial`], which is what this used to
334    /// collapse into once the `Err` was folded away at the write layer: a
335    /// truncated entry's genuine prefix IS recovered (as `NAME.partial`),
336    /// while an over-ceiling entry has real bytes nobody read. Reporting it
337    /// as `Partial (truncated)` stated two false things — the entry is not
338    /// truncated, and the empty `NAME.partial` it produced said nothing
339    /// survived.
340    ///
341    /// # Why it carries both figures (fix round 4, NEW-F)
342    ///
343    /// The `Err` this replaced carried a sentence naming the declared size
344    /// and the ceiling; the first version of this variant was a unit, so a
345    /// user meeting a container's own fixed ceiling was told neither number
346    /// and could not tell whether `--max-entry` would help. The cause is
347    /// the only place that knows both, so it carries both and the CLI row
348    /// prints them — the project's own "a message carries a cause", with
349    /// the numbers that make the message actionable.
350    OverEntryCeiling {
351        /// Bytes reading this entry would have required: the BOUNDED
352        /// figure the comparison actually used — `available_len` when the
353        /// payload is truncated, the header's `declared_len` otherwise —
354        /// never the raw declaration when the file holds less than it.
355        needed: u64,
356        /// The ceiling in force for this run: [`SalvagePolicy::max_entry`]
357        /// narrowed by [`SalvageScan::max_whole_entry`]. Equal to
358        /// `max_entry` means `--max-entry` can raise it; below it means a
359        /// whole-decoding container's own fixed ceiling decided, and no
360        /// flag moves that.
361        ceiling: u64,
362    },
363}
364
365/// What was proven about an entry AFTER decoding it.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum SalvageStatus {
368    /// A checksum the original writer computed agrees.
369    Intact,
370    /// Every declared byte was present and the header self-verified, but the
371    /// format offers NO CHECKSUM AT ALL to prove the content — `tar`, in
372    /// this project's own formats. Never the right answer for a format that
373    /// DOES carry one; see [`SalvageStatus::Unverified`] for that case,
374    /// which this status is not permitted to stand in for.
375    ///
376    /// # `cpio` and `ar` are NOT this tier, and were named here until Stage 3
377    ///
378    /// This doc said "tar, cpio and ar" from Stage 1 until Salvage Stage 3,
379    /// which is where the sentence's own *"and the header self-verified"*
380    /// clause was measured against the three formats it named. `tar` has a
381    /// header checksum and really does self-verify. **`cpio` and `ar` have
382    /// neither a content checksum nor a self-verifying header**, so for
383    /// those two the clause is not merely unproven — there is nothing in the
384    /// format it could ever be true of, and a scanner answering `Complete`
385    /// for one would be asserting a header self-check that does not exist.
386    /// They are [`SalvageStatus::Unattested`].
387    ///
388    /// `stuffr_core::testing::check_salvage_claim` is what holds the line: this
389    /// tier requires a format whose only checksum covers the HEADER
390    /// (`Attestation::HeaderChecksumOnly`) AND that checksum to have been
391    /// checked — so the checksumless pair cannot reach it, and neither can a
392    /// format with a content checksum, whose honest tiers are `Intact`,
393    /// `Partial` and `Unverified`.
394    Complete,
395    /// Nothing attests that this candidate is an entry AT ALL: the format
396    /// carries **no checksum and no self-verifying header**, so a
397    /// coincidental header match and a real entry are indistinguishable in
398    /// the bytes — `cpio` and `ar`, in this project's own formats.
399    ///
400    /// The entry is still WRITTEN, under its real name: `salvage` is the
401    /// recovery-biased verb, and a tier that refused to write would make
402    /// these two formats unsalvageable rather than honestly salvaged. What
403    /// the tier buys is that the run says so — `salvage_exit_code` answers
404    /// **4** for it (degraded fidelity, never 3: nothing was refused and no
405    /// capability was missing), and the `--list` row carries the reason
406    /// beside the entry. There is no flag to opt into or out of this; the
407    /// formats' own shape decides it.
408    ///
409    /// # Not [`Self::Unverified`] — and the near-miss name is the hazard
410    ///
411    /// The variant deliberately is NOT called `Unverifiable`: that is two
412    /// letters from the [`Self::Unverified`] this enum already has, in an
413    /// enum whose variants select exit codes, and a reviewer skimming match
414    /// arms would not reliably see the difference. The distinction, stated
415    /// wherever both appear:
416    ///
417    /// * [`Self::Unverified`] — the format *has* a checksum; this build
418    ///   could not use it (an undecodable method), or the header declared
419    ///   nothing to check.
420    /// * `Unattested` — the format has **no checksum and no self-verifying
421    ///   header**, so nothing attests that this candidate is an entry at
422    ///   all.
423    ///
424    /// The consequences differ as much as the claims do: `Unverified` is
425    /// NOT written and exits 3 (this build could not do it — a rebuild may
426    /// fix it), `Unattested` IS written and exits 4 (nothing can fix it;
427    /// the format carries no evidence to find).
428    Unattested,
429    /// Nothing about this entry's content was verified.
430    ///
431    /// Two causes ([`UnverifiedCause`]), and the tier does not distinguish
432    /// them because the decision is identical — the entry is listed, not
433    /// written, and the run exits 3:
434    ///   * this build could not decode the method (zip 99 is AES, 14 is
435    ///     LZMA);
436    ///   * no length was available to bound the payload, so it was never
437    ///     read (a data-descriptor entry the raw scan found with no
438    ///     central-directory record to reconcile against).
439    ///
440    /// Distinct from [`Self::Complete`], which asserts every DECLARED byte
441    /// was present — a claim that requires something to have been declared
442    /// in the first place. Reporting `Complete` for either cause above was
443    /// this enum's third instance of "nothing to disprove" being read as
444    /// "proven" (after the Task 1 hardcoded placeholder and the Task 4
445    /// undecodable-method case this variant was originally added for) — the
446    /// exit-code table this status exists to keep honest reserves exit 3 for
447    /// both causes, never exit 0, which is what `Complete` would have routed
448    /// either one to.
449    Unverified(UnverifiedCause),
450    /// The payload ran out, the decoder failed mid-stream, or the content
451    /// decoded whole and disagreed with the checksum the original writer
452    /// computed. All three are the same fact from a caller's perspective —
453    /// this build could not confirm the content is what was written — and
454    /// none of them is `Complete` (something WAS checked) or `Intact` (it
455    /// did not agree, or never finished).
456    Partial,
457}
458
459/// Which class of evidence a FORMAT offers for one of its entries — a
460/// per-format constant, and the third argument to the honesty oracle
461/// `stuffr_core::testing::check_salvage_claim`.
462///
463/// **Always compiled, unlike the oracle**, since Salvage Stage 3 Task 5:
464/// `stuffr formats`' SALVAGE column renders it (`yes` only for
465/// [`Attestation::ContentChecksum`]), so it cannot live behind the
466/// `testing` feature with the oracle that consumes it. Which format has
467/// which class is stated once, beside the scanner dispatch
468/// (`stuffr::entries::salvage_attestation`); the fuzz target and the corpus
469/// generator read it from there rather than keeping a table of their own.
470///
471/// **Three classes, which is why this is an enum and not a `bool`.** Salvage
472/// Stage 3 Task 1 first shipped the argument as `format_offers_verifier:
473/// bool`, and its fix round's review showed with a truth table that no
474/// reading of a two-valued flag can hold the boundary: read as "a content
475/// checksum", it let `cpio` claim [`SalvageStatus::Complete`]; read as "a
476/// content checksum OR a self-verifying header" (the shipped reading), it
477/// let `tar` claim [`SalvageStatus::Intact`] and — live against the five
478/// scanners that shipped before this stage — let `zip`, `arc`, `zoo`, `lha`
479/// or `arj` answer `Complete` for an entry whose CRC was compared and
480/// DISAGREED, turning an exit-4 `Partial` into an exit-0 clean recovery.
481/// Both false claims sat in the one cell (`checked`, `offers`) that tar's
482/// honest `Complete` also occupies, so no function of those inputs could
483/// refuse them and permit it. Ruling 3-G.
484///
485/// Two bools (`offers_content_checksum`, `header_self_verifies`) were
486/// considered and refused: four states for three classes, one of them
487/// meaningless, and the split pushed back onto every caller. Here each
488/// scanner NAMES its class.
489///
490/// **Deliberately NOT `#[non_exhaustive]`**, for the reason Ruling 3-D kept
491/// it off [`SalvageStatus`]: a fourth class of evidence must fail to compile
492/// at every `match`, `check_salvage_claim`'s own included, rather than be
493/// absorbed by a wildcard arm that decides what it permits by accident.
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum Attestation {
496    /// A checksum over the entry's CONTENT, computed by the original writer —
497    /// `zip`, `arc`, `zoo`, `lha`, `arj`. The only class that can earn
498    /// [`SalvageStatus::Intact`].
499    ContentChecksum,
500    /// A checksum over the HEADER only; the content has none — `tar`. The
501    /// only class that can earn [`SalvageStatus::Complete`]: "every declared
502    /// byte was present and the header self-verified" is exactly what this
503    /// class can prove and all it can prove.
504    HeaderChecksumOnly,
505    /// Neither — `cpio`, `ar`. A coincidental header match and a real entry
506    /// are indistinguishable in the bytes, so the only honest tier this
507    /// class can reach on a whole entry is [`SalvageStatus::Unattested`].
508    Nothing,
509}
510
511/// One entry the scan recovered.
512///
513/// `#[non_exhaustive]` plus [`SalvagedEntry::new`], for the same reason
514/// [`Candidate`] carries both — see that type's own doc. This one is built
515/// by [`annotate_candidates`] in ordinary use, but every per-format scanner
516/// crate builds one directly to drive its `write_payload` seam, so the
517/// literal it used to need was an out-of-crate literal all the same.
518#[derive(Debug)]
519#[non_exhaustive]
520pub struct SalvagedEntry {
521    /// This entry's position in SCAN order — not an index any container's
522    /// own format assigns, and never renumbered by what `list` would show.
523    /// A later task's CLI surfaces this distinction explicitly (`--index`
524    /// on `salvage` names a scan position, not a list index).
525    pub scan_position: usize,
526    pub offset: u64,
527    /// Carried forward from [`Candidate::payload_start`] — see that
528    /// field's doc for why a consumer must use this rather than
529    /// re-deriving a payload's location from `offset` itself.
530    pub payload_start: u64,
531    pub meta: EntryMeta,
532    pub status: SalvageStatus,
533    /// Set when this record's `(name, declared_len, verifier)` all agree
534    /// with an EARLIER record's — a measurement, never inferred from a
535    /// repeated name alone, and never set for a degenerate zero-length
536    /// checksum (every empty file and every directory entry shares one).
537    /// See [`annotate_candidates`] for where this is computed.
538    ///
539    /// **This is the byte-identical duplicate, and ONLY that.** The other,
540    /// weaker fact — an earlier record under the same name whose content
541    /// this scan could not prove identical — is [`Self::collides_with`], and
542    /// the two are mutually exclusive by construction. They were one word
543    /// ("shadow") until the final whole-branch review measured what that
544    /// cost: `stuffr list`'s own fidelity warning uses "shadowed" for a
545    /// repeated NAME, so on an archive whose duplicates differ, `list` said
546    /// two records were shadowed and `salvage --list` marked none.
547    pub shadows: Option<usize>,
548    /// Set when an EARLIER record carries the same `meta.name` and this one
549    /// is not that record's [`Self::shadows`] — i.e. the name repeats but
550    /// the content could not be proven identical.
551    ///
552    /// This is the fact `stuffr list`'s `Fidelity::EntryCountMismatch`
553    /// warning is about: a later record repeating a name is what makes an
554    /// earlier one unreachable through a central directory's name index,
555    /// whatever the bytes under it. It is also the fact that decides
556    /// anything on the WRITE side, because two records under one name and
557    /// two different payloads cannot both land on one path.
558    ///
559    /// Deliberately NOT gated on a verifier, a declared length or the
560    /// degenerate zero-length case the way [`Self::shadows`] is: those
561    /// guards exist because claiming two records are COPIES of each other
562    /// needs proof, and this annotation claims no such thing. A repeated
563    /// name is directly observed, and `None` here means only that no
564    /// earlier record used this name — never that one did and this could
565    /// not tell.
566    pub collides_with: Option<usize>,
567    /// Carried forward unchanged from [`Candidate::marked_deleted`] — see
568    /// that field for the whole ruling. Unlike [`Self::shadows`] and
569    /// [`Self::collides_with`], this is not something this module MEASURES:
570    /// it is a fact the archive's own record states, which only the scanner
571    /// that read that record can know.
572    pub marked_deleted: bool,
573}
574
575impl SalvagedEntry {
576    /// An entry at scan position `scan_position`, found at `offset`, whose
577    /// payload begins at `payload_start`, carrying `meta` and proven to
578    /// `status`.
579    ///
580    /// The three fields this does not take default to "nothing was
581    /// observed": no shadow, no name collision, not marked deleted. All
582    /// three are annotations [`annotate_candidates`] adds (or, for
583    /// `marked_deleted`, carries forward from [`Candidate`]), so a caller
584    /// building one directly — every per-format `write_payload` test does —
585    /// wants exactly those defaults.
586    pub fn new(
587        scan_position: usize,
588        offset: u64,
589        payload_start: u64,
590        meta: EntryMeta,
591        status: SalvageStatus,
592    ) -> Self {
593        Self {
594            scan_position,
595            offset,
596            payload_start,
597            meta,
598            status,
599            shadows: None,
600            collides_with: None,
601            marked_deleted: false,
602        }
603    }
604
605    /// Sets [`Self::shadows`] — the byte-identical duplicate, and only
606    /// that. See the field's own doc for what separates it from
607    /// [`Self::collides_with`].
608    #[must_use]
609    pub fn with_shadows(mut self, shadows: Option<usize>) -> Self {
610        self.shadows = shadows;
611        self
612    }
613
614    /// Sets [`Self::collides_with`] — the weaker, name-only annotation.
615    #[must_use]
616    pub fn with_collides_with(mut self, collides_with: Option<usize>) -> Self {
617        self.collides_with = collides_with;
618        self
619    }
620
621    /// Sets [`Self::marked_deleted`], carried forward from
622    /// [`Candidate::marked_deleted`] rather than measured here.
623    #[must_use]
624    pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
625        self.marked_deleted = marked_deleted;
626        self
627    }
628}
629
630/// The result of a scan: every entry the scanner recovered, in scan order.
631///
632/// Later tasks may extend this struct; the `entries` field stays public,
633/// and `#[non_exhaustive]` is what makes "extend" honest. No constructor is
634/// paired with it, unlike [`Candidate`] and [`SalvagedEntry`]: this struct
635/// is built by [`salvage_all`] alone, in this crate, and no scanner — in
636/// this workspace or outside it — has any reason to build one.
637#[derive(Debug)]
638#[non_exhaustive]
639pub struct SalvageOutcome {
640    pub entries: Vec<SalvagedEntry>,
641    /// Header shapes the scanner recognised and could not gate, in scan
642    /// order — see [`Sighting`]. Always empty from [`salvage_all`]: the
643    /// engine cannot know what its scanner refused, so a format's own
644    /// `salvage_*` entry point fills it in after the scan. A sighting is
645    /// never an entry; the facade's `salvage_exit_code` counts a
646    /// non-selective run that has one as degraded (exit 4) since the
647    /// Salvage Stage 3 final review's I-1, which reversed Ruling S-X there.
648    pub sightings: Vec<Sighting>,
649    /// Where a SEQUENTIAL-ONLY scanner's walk stopped short of the end of
650    /// the source, if it did — see [`WalkStop`]. Always `None` from
651    /// [`salvage_all`], for the same reason [`Self::sightings`] is always
652    /// empty there: only the scanner knows its walk ended early, so its own
653    /// `salvage_*` entry point fills this in. Like a sighting it is never an
654    /// entry and never touches the run's exit code.
655    pub walk_stop: Option<WalkStop>,
656}
657
658/// A header a scanner SAW and has no gate for (Rulings S-V and 3-J): its
659/// structure is a header's, and something about it — a field this build
660/// cannot check, a shape a shifted copy also takes — leaves the scan unable
661/// to tell it from a phantom. So it is counted here and never becomes a
662/// [`Candidate`], because the engine advances past a candidate by its
663/// declared length and a phantom's length is how a scan jumps a real header.
664///
665/// **Why it has to be reported at all.** A run that recovers nothing while
666/// seeing one refuses with [`Error::Unsupported`] (exit 3) — the scanner's own
667/// job, since only it can name the shape. A run that recovered OTHER entries
668/// reports them at its ordinary exit code (Ruling S-X), and until Salvage
669/// Stage 3's Task 2-N it said nothing else: an entry `stuffr list` shows was
670/// simply absent from `salvage --list`, at exit 0, with an empty stderr. The
671/// sightings recorded here are what the caller prints instead
672/// ([`describe_sightings`]), in a mixed run as in any other — and since the
673/// stage's final review (I-1, reversing S-X for this case) a non-selective
674/// mixed run also exits 4, not 0: a `salvage -C out && rm` script must not
675/// delete an archive holding an entry `stuffr list` reads.
676#[derive(Debug, Clone, PartialEq, Eq)]
677#[non_exhaustive]
678pub struct Sighting {
679    /// The scanner's format, for the sentence [`describe_sightings`] writes.
680    pub format: FormatId,
681    /// Where the header was seen.
682    pub offset: u64,
683    /// What was seen, as a plural noun phrase that follows a count —
684    /// `"header(s) whose magic field … is neither \`ustar\` nor blank"`. One
685    /// phrase per shape, shared with the scanner's own exit-3 refusal, so
686    /// the two can never describe one shape two ways.
687    pub shape: &'static str,
688}
689
690impl Sighting {
691    pub fn new(format: FormatId, offset: u64, shape: &'static str) -> Self {
692        Self {
693            format,
694            offset,
695            shape,
696        }
697    }
698}
699
700/// The most offsets [`describe_sightings`] lists per shape before it says
701/// how many more there were. The count itself is always the complete figure.
702const SIGHTING_OFFSETS_SHOWN: usize = 8;
703
704/// The one sentence a caller prints for `sightings`, or `None` when there
705/// are none — how many headers of each shape were seen, where, and that the
706/// ordinary verbs may read them or name the variant they are in. It is the
707/// same wording family as each scanner's exit-3 refusal (its shape phrases
708/// are the scanner's own), and it is written here, once, so every scanner
709/// that records a [`Sighting`] gets the same report without a line of its
710/// own.
711///
712/// **"Or name the variant" is cpio's clause (Salvage Stage 3 Task 3).** The
713/// tail said only "may read these headers normally", which is true of a tar
714/// header the scan cannot gate and false of a cpio `odc` or `newc-crc`
715/// header: `stuffr list` refuses both variants at exit 3, naming them. A note
716/// telling a user to expect `list` to read them would be the false "list
717/// reads it" claim Task 2-N's fix round removed from tar.
718pub fn describe_sightings(sightings: &[Sighting]) -> Option<String> {
719    if sightings.is_empty() {
720        return None;
721    }
722    // Grouped by (format, shape) in first-seen order: a handful of shapes
723    // at most, so a linear search beats a map and keeps the order stable.
724    let mut groups: Vec<((FormatId, &'static str), Vec<u64>)> = Vec::new();
725    for s in sightings {
726        let key = (s.format, s.shape);
727        match groups.iter_mut().find(|(k, _)| *k == key) {
728            Some((_, offsets)) => offsets.push(s.offset),
729            None => groups.push((key, vec![s.offset])),
730        }
731    }
732    let parts: Vec<String> = groups
733        .iter()
734        .map(|((format, shape), offsets)| {
735            let mut shown: Vec<String> = offsets
736                .iter()
737                .take(SIGHTING_OFFSETS_SHOWN)
738                .map(u64::to_string)
739                .collect();
740            if offsets.len() > SIGHTING_OFFSETS_SHOWN {
741                shown.push(format!(
742                    "and {} more",
743                    offsets.len() - SIGHTING_OFFSETS_SHOWN
744                ));
745            }
746            format!(
747                "{} {format} {shape} at offset(s) {}",
748                offsets.len(),
749                shown.join(", ")
750            )
751        })
752        .collect();
753    Some(format!(
754        "this build's salvage scanner saw {} — each with the structure of a header, but in a \
755         shape it has no gate for, so none is listed or recovered; `stuffr list` and \
756         `stuffr unpack` may read these headers normally, or name the variant they are in, \
757         and it is the SCAN that stops there",
758        parts.join("; and ")
759    ))
760}
761
762/// Where a scanner that can ONLY walk its format in order — record after
763/// record from the start, the way the ordinary reader does — had to stop
764/// before the end of the source (Salvage Stage 3 Task 4).
765///
766/// # Why a walk stop is not a [`Sighting`]
767///
768/// A sighting is a header the scan SAW, whose structure is a header's and
769/// which it cannot gate. A walk stop is the opposite: a place where the
770/// walk found nothing it could read as a record at all, and — because the
771/// format carries nothing strong enough to search for (an `ar` member's
772/// only marker is two bytes) — cannot look any further. Folding it into
773/// [`Sighting`] would make "saw a real header" and "could see nothing" one
774/// channel, and [`describe_sightings`]'s own sentence ("`stuffr list` may
775/// read these headers normally") would then be false about it.
776///
777/// # What it must say, and why it has to be said at all
778///
779/// The entries a sequential walk cannot reach are **unreachable by
780/// construction, not absent**: a run that recovers three entries from a
781/// ten-entry archive with a hole after the third reports three rows, and
782/// nothing in those rows says seven more may sit behind the hole. So the
783/// caller prints [`describe_walk_stop`] of this on every run that has one,
784/// and the offset it names is where a user with a hex editor would look.
785#[derive(Debug, Clone, PartialEq, Eq)]
786#[non_exhaustive]
787pub struct WalkStop {
788    /// The scanner's format, for the sentence [`describe_walk_stop`] writes.
789    pub format: FormatId,
790    /// Where the record the walk could not read begins — the first byte
791    /// after the last record it did read.
792    pub offset: u64,
793    /// Bytes from [`Self::offset`] to the end of the source.
794    pub remaining: u64,
795    /// Which of the two things this stop is — see [`WalkStopKind`].
796    pub kind: WalkStopKind,
797    /// The ordinary reader's own words for why that record would not read,
798    /// so the note and `stuffr list`'s refusal of the same bytes agree.
799    pub cause: String,
800}
801
802/// The three stops a sequential walk can make, each told apart by what the
803/// scanner measured rather than guessed.
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub enum WalkStopKind {
806    /// The file ends inside the record at the stop point — the reader ran
807    /// out of bytes mid-record, or fewer bytes remain than the format's
808    /// smallest record header. Everything from the stop point on belongs to
809    /// that incomplete record, so no entry can be hiding after it — the
810    /// common damage, and the one a sequential walk handles fully.
811    CutShort,
812    /// Enough bytes remain to hold further records, and the walk could not
813    /// read the one at the stop point. Whatever those bytes hold is
814    /// unreachable by construction.
815    Unreadable,
816    /// The record at the stop point is one the scanner RECOGNISES — its
817    /// [`WalkStop::cause`] names it — in a shape this build cannot read (Task
818    /// 4 fix round 1: GNU `ar`'s Mach-O `__.SYMDEF` with a blank mode). Not
819    /// evidence of damage; what follows is unreachable to this scanner.
820    UnsupportedShape,
821}
822
823impl WalkStop {
824    pub fn new(
825        format: FormatId,
826        offset: u64,
827        remaining: u64,
828        kind: WalkStopKind,
829        cause: impl Into<String>,
830    ) -> Self {
831        Self {
832            format,
833            offset,
834            remaining,
835            kind,
836            cause: cause.into(),
837        }
838    }
839}
840
841/// The one sentence a caller prints for a [`WalkStop`] — written here, once,
842/// so every sequential-only scanner gets the same report without a line of
843/// its own, and so its kinds can never be described two ways each.
844///
845/// An [`WalkStopKind::Unreadable`] stop says the entries after it are
846/// *unreachable by construction, not absent*, with the offset and the byte
847/// count; a [`WalkStopKind::CutShort`] one says that nothing after it is
848/// missing from the report, which is exactly as true — the file ended inside
849/// that record; an [`WalkStopKind::UnsupportedShape`] one names the shape and
850/// says it is not evidence of damage.
851///
852/// Two of the sentences say the walk goes "as `stuffr list` does". That is a
853/// contract on every scanner that reports a [`WalkStop`]: it walks with its
854/// format's ORDINARY reader, over the file's own bytes, repairing none of
855/// them. `ar`'s walk served its global header as `!<arch>\n` until Stage 3
856/// Task 4's fix round 1, which made the phrase false (a bad magic `list`
857/// refuses was walked past) — a scanner that needs a repair like that must
858/// get its own sentence here rather than borrow this one.
859pub fn describe_walk_stop(stop: &WalkStop) -> String {
860    let WalkStop {
861        format,
862        offset,
863        remaining,
864        kind,
865        cause,
866    } = stop;
867    let bytes = if *remaining == 1 { "byte" } else { "bytes" };
868    match kind {
869        WalkStopKind::Unreadable => format!(
870            "this build's {format} salvage scanner walks the archive in order from its start, \
871             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
872             to go: {cause}. The {format} format marks its records with nothing strong enough \
873             to search for, so the scanner cannot find its way past a record it cannot read; \
874             any entries in those {remaining} {bytes} are unreachable by construction — not \
875             absent, and not counted in this report"
876        ),
877        WalkStopKind::UnsupportedShape => format!(
878            "this build's {format} salvage scanner walks the archive in order from its start, \
879             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
880             to go: {cause}. That is a record shape this build does not read, not evidence of \
881             damage; any entries in those {remaining} {bytes} are unreachable to this scanner \
882             and not counted in this report"
883        ),
884        WalkStopKind::CutShort => format!(
885            "this build's {format} salvage scanner walks the archive in order from its start, \
886             and the file ends {remaining} {bytes} into the record at offset {offset} ({cause}): \
887             too few for any further entry, so the archive is cut short there and nothing \
888             after that point is missing from this report"
889        ),
890    }
891}
892
893#[derive(Debug, Clone, Copy, PartialEq, Eq)]
894pub enum PartialPolicy {
895    Keep,
896    Skip,
897    Ask,
898}
899
900pub struct SalvagePolicy {
901    /// Recovery-biased by default: salvage is the one permissive verb, and
902    /// the strict path is the entire rest of the tool.
903    pub partial: PartialPolicy,
904    /// Structural ceiling on any one entry's length, refused BEFORE
905    /// allocation — and refused as a per-entry
906    /// [`UnverifiedCause::OverEntryCeiling`], never as an error that ends
907    /// the run. [`SalvageScan::max_whole_entry`] narrows it further for a
908    /// format that decodes an entry whole; the two compose as a minimum and
909    /// are applied together, once, in [`annotate_candidates`].
910    pub max_entry: u64,
911    /// Demand proof: every [`SalvageStatus::Partial`] entry is skipped,
912    /// whatever [`Self::partial`] says, and so is every
913    /// [`SalvageStatus::Unattested`] one (Ruling 3-H, Salvage Stage 3).
914    ///
915    /// The second clause is this flag's doing, unlike the two refused
916    /// below: an `Unattested` entry is WRITTEN by default, under its real
917    /// name, and only `strict` declines it — the format carries nothing
918    /// that could prove it, which is exactly what "demand proof" asks for.
919    ///
920    /// **Those two are the whole of it, and this doc claimed three things
921    /// until the final whole-branch review's F5.** It read "partial skipped,
922    /// ceiling fixed, nothing unverifiable", and only the first clause was
923    /// this flag's doing. The ceiling is NOT fixed under `strict`:
924    /// [`Self::max_entry`] applies identically with and without it, and
925    /// `--max-entry` is honoured either way. And an
926    /// [`SalvageStatus::Unverified`] entry is never written under any
927    /// policy at all — that is the ops layer's unconditional rule, not
928    /// something `strict` causes — so crediting it here made a flag look
929    /// load-bearing for a guarantee that holds without it.
930    ///
931    /// The true clauses are enough to justify the flag: `salvage` is this
932    /// tool's single recovery-biased verb, so `strict` is how a caller asks
933    /// for the discipline every other verb applies by default.
934    pub strict: bool,
935}
936
937impl Default for SalvagePolicy {
938    fn default() -> Self {
939        Self {
940            partial: PartialPolicy::Keep,
941            max_entry: MAX_SALVAGE_ENTRY,
942            strict: false,
943        }
944    }
945}
946
947/// The per-format seam. A scanner recognises its own format's record shape
948/// (a local header's magic, a member's checksum field) and reports what it
949/// finds as a stream of [`Candidate`]s; [`salvage_all`] supplies everything
950/// that does not depend on which format that is.
951pub trait SalvageScan {
952    /// Find the next candidate at or after `from`. `Ok(None)` ends the scan.
953    fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;
954
955    /// The largest single entry this scanner is willing to have read for it
956    /// — the ceiling [`annotate_candidates`] applies, narrowed further by
957    /// `policy.max_entry`, before it calls [`Self::verify`] at all.
958    ///
959    /// `u64::MAX` (the default) means "this scanner imposes none of its
960    /// own": zip streams every payload it verifies through a `take`, so the
961    /// only figure that bounds it is the policy's. A format that **decodes
962    /// an entry whole** — ARC, and the legacy containers still to come —
963    /// overrides this with its own container-side ceiling
964    /// (`arc.rs`'s `MAX_ARC_ENTRY_LEN`, 256 MiB), because for those the
965    /// figure really does become a single allocation.
966    ///
967    /// **Declaring a ceiling here is the ONLY way a scanner refuses an
968    /// entry for its size**, and that is the point: the refusal is then a
969    /// [`SalvageStatus`] the engine assigns
970    /// ([`UnverifiedCause::OverEntryCeiling`]), not a [`crate::Error`] the
971    /// scanner raises, so it can never abort a run over one entry. A
972    /// scanner that instead checks a size inside [`Self::verify`] and
973    /// `?`-propagates the failure reintroduces the exact defect three fix
974    /// rounds of Task 3c chased.
975    ///
976    /// **That is documented, not enforced, and saying so is more useful
977    /// than claiming otherwise.** An earlier wording here called the defect
978    /// "unrepresentable"; it is not. [`Self::verify`] still returns
979    /// `Result` — it must, so a scanner CAN report a genuine run-level
980    /// fault — so the wrong thing is still expressible. What changed is
981    /// that the right thing now exists, costs one method, and is written
982    /// into [`Self::verify`]'s contract below.
983    fn max_whole_entry(&self) -> u64 {
984        u64::MAX
985    }
986
987    /// Decide what was actually proven about a candidate's payload, after
988    /// discovery — called once per candidate, in file order, by
989    /// [`annotate_candidates`].
990    ///
991    /// # The contract on `Err`
992    ///
993    /// **`Err` here aborts the WHOLE run**, discarding every entry already
994    /// recovered, so it is reserved for a fault that really is about the
995    /// run and not about this one entry (the destination is gone, the
996    /// source handle itself failed). Everything a single candidate can be
997    /// wrong about — a short read, a bad checksum, a method this build
998    /// cannot decode, a device error partway through one payload — is a
999    /// [`SalvageStatus`], never an `Err`. `zip_salvage.rs`'s own
1000    /// `verify_candidate` has stated this in prose since Stage 1 and folds
1001    /// every one of those; ARC's did not, for one case (its size ceiling),
1002    /// and that single `?` cost three fix rounds. The size ceiling now
1003    /// belongs to [`Self::max_whole_entry`], which the engine applies, so
1004    /// there is nothing left for an implementation to `?` on.
1005    ///
1006    /// # What an implementation may assume
1007    ///
1008    /// The candidate's bounded length (`available_len` if the payload was
1009    /// truncated, else `declared_len`) is already **at or below**
1010    /// [`Self::max_whole_entry`] — [`annotate_candidates`] answers
1011    /// [`UnverifiedCause::OverEntryCeiling`] itself, without calling this,
1012    /// for anything above it. So an implementation that decodes whole may
1013    /// size a buffer from that length without re-checking it.
1014    ///
1015    /// The default answers [`SalvageStatus::Unattested`] unconditionally,
1016    /// which is the least this method can claim: it reads nothing, decodes
1017    /// nothing and compares nothing, so "nothing attests that this candidate
1018    /// is an entry at all" is exactly what happened. A format that carries a
1019    /// checksum overrides this to decode the payload — reusing its own
1020    /// existing codec machinery, never inventing a new one here — and
1021    /// compare it against [`Candidate::verifier`]; a format that carries a
1022    /// self-verifying HEADER and no content checksum (tar) overrides it to
1023    /// check that header and answer [`SalvageStatus::Complete`].
1024    ///
1025    /// **It answered `Complete` until Salvage Stage 3**, and the change is
1026    /// not cosmetic. `Complete` asserts that every declared byte was present
1027    /// *and the header self-verified* — two claims this default has never
1028    /// been in a position to make — and it routes to **exit 0**, so a
1029    /// checksumless scanner that simply forgot to override was reporting a
1030    /// clean recovery over candidates nothing had examined. `Unattested`
1031    /// routes to exit 4 and says why on the row. The same argument
1032    /// [`Candidate::new`] makes for its own four defaults: the default is
1033    /// the answer that asserts the LEAST, so forgetting to override is
1034    /// visible rather than flattering.
1035    fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
1036        Ok(SalvageStatus::Unattested)
1037    }
1038
1039    /// Decode one salvaged entry's payload out of the archive at
1040    /// `archive_path` and into `out`, answering whether the entry's whole
1041    /// declared length was produced.
1042    ///
1043    /// # Why this is on the trait at all (final whole-branch review, F2)
1044    ///
1045    /// `0.6.0` spends a breaking release making this trait implementable
1046    /// from outside. It was implementable and not USABLE: the ops layer's
1047    /// payload dispatch was a private `match` on a [`crate::FormatId`]
1048    /// string, so a scanner written outside this workspace got
1049    /// [`salvage_all`] and none of the behaviour that makes `salvage` what
1050    /// it is. This method is where a scanner's write half now DECLARES
1051    /// itself, beside the scan half, in one place a reader can see both.
1052    ///
1053    /// **It does not by itself make an out-of-tree scanner reachable from
1054    /// `stuffr salvage`, and pretending otherwise would be the same
1055    /// over-claim F2 named.** The ops layer still resolves a format to a
1056    /// scanner by name, with no registration point for a third party to add
1057    /// one; closing that needs a salvage registry and is Stage 3's. What
1058    /// changed is that the seam is now declared rather than scattered — the
1059    /// five in-tree scanners implement this method and the ops layer calls
1060    /// it, so the next one has a signature to fit rather than a convention
1061    /// to guess.
1062    ///
1063    /// # The contract
1064    ///
1065    /// `Ok(true)` means every declared byte was produced; `Ok(false)` means
1066    /// the payload ran short or the decoder stopped early, which the caller
1067    /// renders as a `.partial` write. **`Err` is reserved for the same
1068    /// narrow set [`Self::verify`] reserves it for** — with one addition
1069    /// the ops layer relies on: [`crate::Error::Io`] here is treated as a
1070    /// fact about THIS entry (the archive could not be re-opened or read
1071    /// for it, the destination refused the bytes) and folded into that
1072    /// entry's own outcome, never the run's. A size refusal is never an
1073    /// `Err`: it belongs to [`Self::max_whole_entry`], which the engine
1074    /// applies before `verify` is ever called.
1075    ///
1076    /// The default refuses. A scanner that only ever reports
1077    /// [`SalvageStatus::Unverified`] has nothing to write, and inheriting a
1078    /// refusal is the honest answer for it; the alternative — a required
1079    /// method — would be a breaking change to every implementation for no
1080    /// gain to one that cannot write anything anyway.
1081    fn write_payload(
1082        &self,
1083        _archive_path: &Path,
1084        _entry: &SalvagedEntry,
1085        _compressed_len: u64,
1086        _out: &mut dyn Write,
1087    ) -> Result<bool> {
1088        Err(Error::Unsupported(
1089            "this salvage scanner has no payload writer".to_string(),
1090        ))
1091    }
1092}
1093
1094/// Walk `scan` over `src`, collecting every candidate it reports into a
1095/// [`SalvageOutcome`].
1096///
1097/// **Requires a seekable source.** Scanning searches back and forth over the
1098/// archive rather than reading it once in order, so this takes
1099/// `&mut dyn SeekRead` rather than the ladder's `Box<dyn Source>` (whose
1100/// `as_seek()` may be `None` for a pipe). A caller sitting on a
1101/// non-seekable source must spool it first — exactly as the `arj` and `zoo`
1102/// containers already do, reporting `Rung::Spilled` — which is a later
1103/// task's ops-layer concern, not this function's.
1104///
1105/// Every candidate's length is bounded against the run's entry ceiling
1106/// (`policy.max_entry`, narrowed by [`SalvageScan::max_whole_entry`])
1107/// **before** anything is sized from it — in [`annotate_candidates`], one
1108/// step before the only place that could size anything, and as a per-entry
1109/// [`UnverifiedCause::OverEntryCeiling`] rather than a [`crate::Error`] that ends
1110/// the run.
1111pub fn salvage_all(
1112    scan: &mut dyn SalvageScan,
1113    src: &mut dyn SeekRead,
1114    policy: &SalvagePolicy,
1115) -> Result<SalvageOutcome> {
1116    let candidates = collect_candidates(scan, src)?;
1117    annotate_candidates(&*scan, src, candidates, policy)
1118}
1119
1120/// The resync loop alone: find a candidate, advance strictly past it,
1121/// repeat. Never reads a payload, never sizes anything from a declared
1122/// length, and never calls [`SalvageScan::verify`] — see
1123/// [`annotate_candidates`] for both the entry ceiling and the verification.
1124///
1125/// Exposed separately from [`salvage_all`] so a format that reconciles more
1126/// than one candidate source (zip's central-directory fallback) can collect
1127/// from each source before merging, and annotate the merged list once.
1128///
1129/// # The entry ceiling used to be enforced here, and was an `Err`
1130///
1131/// Salvage Stage 2 Task 3c's fix round 3 moved it to
1132/// [`annotate_candidates`] and turned it into a status. Two things were
1133/// wrong with it here, and only the second is obvious:
1134///
1135/// 1. **It refused a whole archive over one entry.** Measured at the CLI on
1136///    a 78-byte two-entry ARC, `stuffr salvage --list --max-entry 4`
1137///    printed no rows and exited 6 — the healthy first entry, which the
1138///    same binary recovers byte-for-byte at the default ceiling, simply
1139///    lost. That is the same defect the ARC scanner's own ceiling had, in
1140///    the engine rather than in a format.
1141/// 2. **It was one of two ceilings, checked in two places**, so which
1142///    disposition an over-ceiling entry got depended on which check saw it
1143///    first. There is now one rule, applied once, to both figures.
1144///
1145/// Nothing is weakened by the move: this loop sizes nothing from a declared
1146/// length (it only advances `from` by it), so "bounded before anything is
1147/// sized from it" holds exactly as before — the bound simply sits one
1148/// function later, still strictly before the only allocation there is.
1149pub fn collect_candidates(
1150    scan: &mut dyn SalvageScan,
1151    src: &mut dyn SeekRead,
1152) -> Result<Vec<Candidate>> {
1153    let mut candidates = Vec::new();
1154    let mut from = 0u64;
1155
1156    while let Some(candidate) = scan.next_candidate(src, from)? {
1157        // Advance strictly past this candidate so the scan always makes
1158        // progress — but NEVER by a length this candidate merely CLAIMED.
1159        // `available_len.is_some()` means the header's own declared length
1160        // disagreed with the file (there were fewer bytes left than it
1161        // said), and a header that lied about its size once cannot be
1162        // trusted to say how much of the source its "payload" consumed
1163        // either. This used to advance by `available_len` — the bytes
1164        // actually present, not the declared figure — on the reasoning that
1165        // a truncated entry has nothing worth finding after it anyway. That
1166        // is true for a genuinely truncated REAL last entry, but the
1167        // engine cannot tell that case apart from a coincidental
1168        // false-positive header sitting on top of noise with a garbage
1169        // multi-hundred-megabyte declared length: `available_len` is the
1170        // same "everything left in the file" figure either way. Salvage
1171        // Stage 2 Task 3's fix round 1 found exactly this — one
1172        // coincidental marker+name match in an ARC noise corpus, with a
1173        // declared length far past the file, jumped the scan straight to
1174        // EOF in one step and silently dropped every real entry that
1175        // happened to sit between it and the end of the file: no error, no
1176        // `Partial`, no note, in the one verb whose entire job is not
1177        // losing entries silently. So a truncated candidate now advances
1178        // minimally — past its own marker byte only — so the very next
1179        // byte onward is examined for another header. That costs a few
1180        // bytes of re-scanning after a genuinely truncated real entry
1181        // (which has nothing left to find anyway, so the outcome is
1182        // unchanged); it is the entire fix for a phantom, whose lie no
1183        // longer costs the rest of the archive. An UNTRUNCATED candidate's
1184        // declared length was just checked against the file two lines
1185        // above — it fits, so it is not a lie, and advancing by it is
1186        // unchanged.
1187        let advance = if candidate.available_len.is_some() {
1188            1
1189        } else {
1190            candidate.declared_len.unwrap_or(1).max(1)
1191        };
1192        from = candidate.offset.saturating_add(advance);
1193
1194        candidates.push(candidate);
1195    }
1196
1197    Ok(candidates)
1198}
1199
1200/// Turns already-discovered candidates — in file order — into the bounded,
1201/// verified, shadow-annotated entries a caller receives. See this module's
1202/// doc comment for what the passes (bound, verify, shadow) mean and why
1203/// they live here rather than per-format.
1204///
1205/// # The entry ceiling, in one place (fix round 3, Task 3c)
1206///
1207/// `policy.max_entry` narrowed by [`SalvageScan::max_whole_entry`] is the
1208/// run's ceiling on ONE entry, and this is the only place it is applied. A
1209/// candidate whose bounded length (`available_len` if the payload is
1210/// truncated, else `declared_len`) is over it is reported
1211/// [`SalvageStatus::Unverified`]`(`[`UnverifiedCause::OverEntryCeiling`]`)`
1212/// and [`SalvageScan::verify`] is **not called for it at all** — so nothing
1213/// is read and nothing is allocated, which was the whole purpose of the two
1214/// `Err`-raising checks this replaced, without their cost: neither the
1215/// engine's own nor a scanner's could refuse one entry without ending the
1216/// run and discarding every entry already recovered.
1217///
1218/// The bounded length, never the declared one, is what is compared — the
1219/// rule [`Candidate::available_len`]'s own doc argues for and the reason a
1220/// truncated tail's garbage declaration cannot decide anything: a candidate
1221/// whose payload ran out can never cost more than the bytes that exist.
1222pub fn annotate_candidates(
1223    scan: &dyn SalvageScan,
1224    src: &mut dyn SeekRead,
1225    candidates: Vec<Candidate>,
1226    policy: &SalvagePolicy,
1227) -> Result<SalvageOutcome> {
1228    let ceiling = policy.max_entry.min(scan.max_whole_entry());
1229    let mut entries = Vec::with_capacity(candidates.len());
1230    // Earliest scan_position to report each distinct (name, declared_len,
1231    // verifier) triple seen so far — a candidate reporting one already in
1232    // here is shadowing that position. A degenerate zero-length checksum
1233    // (every empty file, every directory entry) is never pushed here and
1234    // never looked up here — see `is_degenerate` below.
1235    let mut seen: Vec<(String, Option<u64>, Verifier, usize)> = Vec::new();
1236    // Earliest scan_position to report each distinct NAME, regardless of
1237    // anything else about the record — the weaker, directly-observed fact
1238    // `SalvagedEntry::collides_with` reports, and the one `stuffr list`'s
1239    // own fidelity warning is about. Kept separate from `seen` above rather
1240    // than folded into it: that list is deliberately full of guards against
1241    // over-claiming a COPY (a verifier must exist, the declared length must
1242    // agree, a zero-length checksum never counts), and every one of them
1243    // would be wrong here, where nothing about content is being claimed.
1244    let mut names_seen: Vec<(String, usize)> = Vec::new();
1245
1246    for (scan_position, candidate) in candidates.into_iter().enumerate() {
1247        // Bounded length, then the ceiling, then — only if it fits —
1248        // verification. `verify` is what reads and (for a whole-decoding
1249        // format) allocates, so refusing above it is refusing before the
1250        // allocation, which is the property the two `Err`s this replaced
1251        // existed for.
1252        let bounded_len = candidate.available_len.or(candidate.declared_len);
1253        let status = match bounded_len {
1254            Some(needed) if needed > ceiling => {
1255                SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { needed, ceiling })
1256            }
1257            _ => scan.verify(src, &candidate)?,
1258        };
1259
1260        let declared_len = candidate.declared_len;
1261        let verifier = candidate.verifier;
1262        // A checksum over zero declared bytes is the same value for every
1263        // empty entry that ever existed (CRC-32 and CRC-16/ARC of an empty
1264        // input are both fixed constants) and proves nothing about any two
1265        // of them being copies of each other — measured against a real
1266        // archive during this task's fix round: every `__init__.py` and
1267        // every directory entry collapsed onto one shadow chain under a
1268        // checksum-only match.
1269        let is_degenerate = declared_len == Some(0);
1270
1271        let shadows = if is_degenerate {
1272            None
1273        } else {
1274            verifier.and_then(|v| {
1275                seen.iter()
1276                    .find(|&&(ref seen_name, seen_len, seen_verifier, _)| {
1277                        seen_verifier == v
1278                            && seen_len == declared_len
1279                            && *seen_name == candidate.meta.name
1280                    })
1281                    .map(|&(_, _, _, earlier_position)| earlier_position)
1282            })
1283        };
1284
1285        if !is_degenerate
1286            && shadows.is_none()
1287            && let Some(v) = verifier
1288        {
1289            seen.push((candidate.meta.name.clone(), declared_len, v, scan_position));
1290        }
1291
1292        // The two annotations are mutually exclusive: a record proven to be
1293        // a copy is reported as a shadow and nothing else, because "this is
1294        // a duplicate of #2" is strictly more informative than "something
1295        // earlier used this name". Everything else that repeats a name is a
1296        // collision.
1297        let earliest_with_name = names_seen
1298            .iter()
1299            .find(|(name, _)| *name == candidate.meta.name)
1300            .map(|&(_, position)| position);
1301        let collides_with = if shadows.is_some() {
1302            None
1303        } else {
1304            earliest_with_name
1305        };
1306        if earliest_with_name.is_none() {
1307            names_seen.push((candidate.meta.name.clone(), scan_position));
1308        }
1309
1310        let marked_deleted = candidate.marked_deleted;
1311        entries.push(
1312            SalvagedEntry::new(
1313                scan_position,
1314                candidate.offset,
1315                candidate.payload_start,
1316                candidate.meta,
1317                status,
1318            )
1319            .with_shadows(shadows)
1320            .with_collides_with(collides_with)
1321            .with_marked_deleted(marked_deleted),
1322        );
1323    }
1324
1325    Ok(SalvageOutcome {
1326        entries,
1327        sightings: Vec::new(),
1328        walk_stop: None,
1329    })
1330}
1331
1332/// Streams `reader` into `out`, bounded to `expected_len` bytes — the
1333/// entry's own declared uncompressed size — so a small compressed input
1334/// cannot expand arbitrarily far past what its own header claims. Never
1335/// buffers a growable copy of the payload, only a fixed 64 KiB window, and
1336/// never trusts the decoder to stop on its own.
1337///
1338/// Returns `true` when exactly `expected_len` bytes were produced, `false`
1339/// when `reader` ran out or errored first — [`crate`]'s own convention
1340/// throughout this module: "did not fully recover" is a fact a caller
1341/// reports (as [`SalvageStatus::Partial`]'s own two causes already are),
1342/// never a [`crate::Error`] that aborts the whole run over one entry.
1343///
1344/// Lives here, rather than duplicated once per format module, because it
1345/// has no format-specific knowledge at all — only `Read`/`Write` and a byte
1346/// count. Task 3c moved it up from `entries.rs`, where it was zip-shaped
1347/// plumbing bolted onto a zip-only write path; every [`SalvageScan`]
1348/// implementation's own `write_payload` (zip's, ARC's, and any later
1349/// format's) calls this same function once it has a decoded reader in
1350/// hand, so a truncated or overrunning decode is reported identically
1351/// regardless of which format produced it.
1352pub fn stream_bounded_copy(
1353    mut reader: impl Read,
1354    expected_len: u64,
1355    out: &mut dyn Write,
1356) -> Result<bool> {
1357    let mut buf = [0u8; 64 * 1024];
1358    let mut produced = 0u64;
1359    loop {
1360        if produced >= expected_len {
1361            return Ok(true);
1362        }
1363        let want = ((expected_len - produced) as usize).min(buf.len());
1364        let n = match reader.read(&mut buf[..want]) {
1365            Ok(0) => return Ok(false),
1366            Ok(n) => n,
1367            Err(_) => return Ok(false),
1368        };
1369        out.write_all(&buf[..n])?;
1370        produced += n as u64;
1371    }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376    use std::io::{Cursor, Read, Seek, SeekFrom};
1377
1378    use super::*;
1379
1380    fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
1381        Cursor::new(data.to_vec())
1382    }
1383
1384    /// A reader that panics if asked to read more bytes in one call than its
1385    /// backing buffer holds. `salvage_all`'s own loop never reads a
1386    /// candidate's payload at all (see the module doc), so a correct
1387    /// implementation never trips this — it exists to catch a regression
1388    /// that reads before bounding, the exact shape this project has shipped
1389    /// six times across `cpio`, `ar` and `zip`.
1390    struct PanickingReader(Cursor<Vec<u8>>);
1391
1392    impl Read for PanickingReader {
1393        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1394            let backing_len = self.0.get_ref().len();
1395            assert!(
1396                buf.len() <= backing_len,
1397                "oversized read requested: {} bytes against a {backing_len}-byte source \
1398                 — an allocation or read was sized from a declared length before it was bounded",
1399                buf.len()
1400            );
1401            self.0.read(buf)
1402        }
1403    }
1404
1405    impl Seek for PanickingReader {
1406        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
1407            self.0.seek(pos)
1408        }
1409    }
1410
1411    fn panicking_reader(len: usize) -> PanickingReader {
1412        PanickingReader(Cursor::new(vec![0u8; len]))
1413    }
1414
1415    /// A scanner that never finds anything, however many times it is asked.
1416    struct NeverFinds;
1417
1418    impl SalvageScan for NeverFinds {
1419        fn next_candidate(
1420            &mut self,
1421            _src: &mut dyn SeekRead,
1422            _from: u64,
1423        ) -> Result<Option<Candidate>> {
1424            Ok(None)
1425        }
1426    }
1427
1428    /// A scanner that reports exactly one candidate, declaring the given
1429    /// length, then ends the scan. It never touches `src` itself — it
1430    /// exists purely to hand `salvage_all` a declared length to bound.
1431    struct DeclaresLength(u64);
1432
1433    impl SalvageScan for DeclaresLength {
1434        fn next_candidate(
1435            &mut self,
1436            _src: &mut dyn SeekRead,
1437            from: u64,
1438        ) -> Result<Option<Candidate>> {
1439            if from > 0 {
1440                return Ok(None);
1441            }
1442            Ok(Some(Candidate {
1443                offset: 0,
1444                payload_start: 0,
1445                meta: EntryMeta::file("absurd"),
1446                declared_len: Some(self.0),
1447                verifier: None,
1448                available_len: None,
1449                marked_deleted: false,
1450            }))
1451        }
1452    }
1453
1454    /// A scanner reporting one candidate whose header DECLARES `declared`
1455    /// bytes while the source holds only `available` of them — the
1456    /// truncated-tail shape, the one a real scanner reports by setting
1457    /// [`Candidate::available_len`].
1458    struct DeclaresMoreThanIsThere {
1459        declared: u64,
1460        available: u64,
1461    }
1462
1463    impl SalvageScan for DeclaresMoreThanIsThere {
1464        fn next_candidate(
1465            &mut self,
1466            _src: &mut dyn SeekRead,
1467            from: u64,
1468        ) -> Result<Option<Candidate>> {
1469            if from > 0 {
1470                return Ok(None);
1471            }
1472            Ok(Some(Candidate {
1473                offset: 0,
1474                payload_start: 0,
1475                meta: EntryMeta::file("cut-short"),
1476                declared_len: Some(self.declared),
1477                verifier: Some(Verifier::Crc32(1)),
1478                available_len: Some(self.available),
1479                marked_deleted: false,
1480            }))
1481        }
1482    }
1483
1484    #[test]
1485    fn a_scanner_that_finds_nothing_yields_no_entries() {
1486        let out = salvage_all(
1487            &mut NeverFinds,
1488            &mut bytes(&[0u8; 4096]),
1489            &SalvagePolicy::default(),
1490        );
1491        assert!(
1492            out.unwrap().entries.is_empty(),
1493            "a scanner finding nothing must yield nothing"
1494        );
1495    }
1496
1497    /// The ceiling test that matters most: a reader that PANICS on an
1498    /// oversized read, so this test fails if the read happens at all — not
1499    /// merely if the wrong status comes back. See this module's
1500    /// falsification note in the task report for what happens when the
1501    /// bound is moved below a read.
1502    ///
1503    /// Fix round 3 (Task 3c): the refusal is a per-entry STATUS now, not an
1504    /// `Err` — so this asserts both halves, that nothing was read AND that
1505    /// the run completed with the entry reported. The old assertion
1506    /// (`unwrap_err()`, exit 6) proved only the first and was satisfied by
1507    /// the behaviour that lost every other entry in the archive.
1508    #[test]
1509    fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
1510        let mut scan = DeclaresLength(u64::MAX);
1511        let out = salvage_all(
1512            &mut scan,
1513            &mut panicking_reader(4096),
1514            &SalvagePolicy::default(),
1515        )
1516        .expect("an entry over the ceiling must not abort the run");
1517        assert_eq!(out.entries.len(), 1, "the entry is still reported");
1518        assert_eq!(
1519            out.entries[0].status,
1520            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1521                needed: u64::MAX,
1522                ceiling: MAX_SALVAGE_ENTRY,
1523            })
1524        );
1525    }
1526
1527    /// The other half of the same rule, and the one the CLI reaches:
1528    /// everything AROUND an over-ceiling entry is still recovered. Measured
1529    /// before this fix as `stuffr salvage --list --max-entry 4` over a
1530    /// 78-byte ARC — no rows at all, exit 6.
1531    #[test]
1532    fn an_entry_over_the_ceiling_does_not_cost_the_entries_around_it() {
1533        struct TwoCandidates;
1534        impl SalvageScan for TwoCandidates {
1535            fn next_candidate(
1536                &mut self,
1537                _src: &mut dyn SeekRead,
1538                from: u64,
1539            ) -> Result<Option<Candidate>> {
1540                let (offset, len, name) = match from {
1541                    0 => (0u64, 16u64, "small-first"),
1542                    1..=16 => (17u64, 4096u64, "huge-second"),
1543                    _ => return Ok(None),
1544                };
1545                Ok(Some(Candidate {
1546                    offset,
1547                    payload_start: offset,
1548                    meta: EntryMeta::file(name),
1549                    declared_len: Some(len),
1550                    verifier: Some(Verifier::Crc32(len as u32)),
1551                    available_len: None,
1552                    marked_deleted: false,
1553                }))
1554            }
1555        }
1556
1557        let out = salvage_all(
1558            &mut TwoCandidates,
1559            &mut bytes(&[0u8; 8192]),
1560            &SalvagePolicy {
1561                max_entry: 64,
1562                ..SalvagePolicy::default()
1563            },
1564        )
1565        .expect("one over-ceiling entry must never abort the run");
1566        assert_eq!(out.entries.len(), 2);
1567        // `TwoCandidates` does not override `verify`, so the first entry
1568        // takes the trait's default — `Unattested` since Salvage Stage 3,
1569        // where it was `Complete`. Neither figure is what this test is
1570        // about: the point is that the SECOND entry's ceiling refusal did
1571        // not cost the first entry its row.
1572        assert_eq!(out.entries[0].status, SalvageStatus::Unattested);
1573        assert_eq!(
1574            out.entries[1].status,
1575            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1576                needed: 4096,
1577                ceiling: 64,
1578            })
1579        );
1580    }
1581
1582    /// A scanner's own [`SalvageScan::max_whole_entry`] narrows the policy's
1583    /// ceiling — the composition ARC relies on, proven against a mock so it
1584    /// does not depend on ARC's own 256 MiB constant. The candidate here is
1585    /// well under `policy.max_entry` and over the scanner's own figure, so
1586    /// only the `min` can refuse it; the panicking reader proves it is
1587    /// refused without being read.
1588    #[test]
1589    fn a_scanners_own_whole_entry_ceiling_narrows_the_policys() {
1590        struct DecodesWhole;
1591        impl SalvageScan for DecodesWhole {
1592            fn next_candidate(
1593                &mut self,
1594                _src: &mut dyn SeekRead,
1595                from: u64,
1596            ) -> Result<Option<Candidate>> {
1597                if from > 0 {
1598                    return Ok(None);
1599                }
1600                Ok(Some(Candidate {
1601                    offset: 0,
1602                    payload_start: 0,
1603                    meta: EntryMeta::file("whole"),
1604                    declared_len: Some(2048),
1605                    verifier: Some(Verifier::Crc32(7)),
1606                    available_len: None,
1607                    marked_deleted: false,
1608                }))
1609            }
1610            fn max_whole_entry(&self) -> u64 {
1611                1024
1612            }
1613            fn verify(&self, _src: &mut dyn SeekRead, _c: &Candidate) -> Result<SalvageStatus> {
1614                panic!("verify must not run for a candidate over the ceiling");
1615            }
1616        }
1617
1618        let out = salvage_all(
1619            &mut DecodesWhole,
1620            &mut panicking_reader(4096),
1621            &SalvagePolicy::default(),
1622        )
1623        .expect("a scanner ceiling is a per-entry refusal, not a run failure");
1624        assert_eq!(
1625            out.entries[0].status,
1626            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1627                needed: 2048,
1628                // The SCANNER's figure, not the policy's 4 GiB — the `min`
1629                // is what this test is really pinning.
1630                ceiling: 1024,
1631            })
1632        );
1633    }
1634
1635    #[test]
1636    fn a_declared_length_within_the_ceiling_is_collected_rather_than_refused() {
1637        let mut scan = DeclaresLength(4096);
1638        let out = salvage_all(
1639            &mut scan,
1640            &mut panicking_reader(4096),
1641            &SalvagePolicy::default(),
1642        )
1643        .unwrap();
1644        assert_eq!(out.entries.len(), 1);
1645        let entry = &out.entries[0];
1646        assert_eq!(entry.scan_position, 0);
1647        assert_eq!(entry.offset, 0);
1648        // `DeclaresLength` does not override `verify`, so this is the
1649        // trait's default: `Unattested` since Salvage Stage 3, where it was
1650        // `Complete`. The test is about the entry being COLLECTED — its
1651        // declared length sits under the ceiling — not about which tier a
1652        // mock that checks nothing lands in.
1653        assert_eq!(entry.status, SalvageStatus::Unattested);
1654        assert_eq!(entry.shadows, None);
1655        assert_eq!(entry.collides_with, None);
1656    }
1657
1658    /// A truncated candidate is COLLECTED, not dropped — the whole point of
1659    /// [`Candidate::available_len`]. Before it existed, `zip_salvage.rs`
1660    /// refused such a candidate outright and a truncated archive's last
1661    /// entry vanished with no row, no note and exit 0.
1662    #[test]
1663    fn a_candidate_whose_payload_ran_out_is_still_collected() {
1664        let mut scan = DeclaresMoreThanIsThere {
1665            declared: 4000,
1666            available: 1950,
1667        };
1668        let out = salvage_all(
1669            &mut scan,
1670            &mut panicking_reader(4096),
1671            &SalvagePolicy::default(),
1672        )
1673        .unwrap();
1674        assert_eq!(
1675            out.entries.len(),
1676            1,
1677            "a header found but not completable is a fact worth reporting"
1678        );
1679    }
1680
1681    /// The ceiling is checked against what could be READ, not against what
1682    /// a truncated header CLAIMED. Otherwise a garbage length in a tail the
1683    /// scan has already established is not there would abort the entire run
1684    /// at exit 6 — losing every earlier entry over an absent one, in the
1685    /// verb that exists to rescue exactly that archive.
1686    #[test]
1687    fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
1688        let mut scan = DeclaresMoreThanIsThere {
1689            declared: u64::MAX,
1690            available: 2048,
1691        };
1692        let out = salvage_all(
1693            &mut scan,
1694            &mut panicking_reader(4096),
1695            &SalvagePolicy::default(),
1696        )
1697        .expect("an absent tail must not abort a run that recovered real entries");
1698        assert_eq!(out.entries.len(), 1);
1699    }
1700
1701    /// The complement, so the rule above cannot be read as "truncated
1702    /// candidates are never bounded": what IS there is still bounded, and a
1703    /// truncated candidate holding more available bytes than the ceiling
1704    /// allows is refused exactly as an untruncated one would be — reported
1705    /// [`UnverifiedCause::OverEntryCeiling`], never read (the reader
1706    /// panics if it is), and never allowed to end the run.
1707    #[test]
1708    fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
1709        let mut scan = DeclaresMoreThanIsThere {
1710            declared: u64::MAX,
1711            available: 4096,
1712        };
1713        let out = salvage_all(
1714            &mut scan,
1715            &mut panicking_reader(4096),
1716            &SalvagePolicy {
1717                max_entry: 1024,
1718                ..SalvagePolicy::default()
1719            },
1720        )
1721        .expect("a bounded refusal is per-entry, never a run failure");
1722        assert_eq!(
1723            out.entries[0].status,
1724            // `needed` is 4096 — the bytes that ARE there — never the
1725            // `u64::MAX` this candidate's header declared. Fix round 4
1726            // (NEW-F): the figures a user is shown are the ones the
1727            // comparison used.
1728            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1729                needed: 4096,
1730                ceiling: 1024,
1731            }),
1732            "an over-ceiling entry is unverified, not truncated: nobody read it"
1733        );
1734    }
1735
1736    #[test]
1737    fn the_default_policy_is_recovery_biased() {
1738        let policy = SalvagePolicy::default();
1739        assert_eq!(policy.partial, PartialPolicy::Keep);
1740        assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
1741        assert!(!policy.strict);
1742    }
1743
1744    // -----------------------------------------------------------------
1745    // Task 4: `verify` dispatch and shadow detection, proven against a
1746    // mock — same discipline as the rest of this module, since neither
1747    // mechanism needs to know which format it is annotating.
1748    // -----------------------------------------------------------------
1749
1750    /// Reports a fixed, caller-scripted sequence of candidates and answers
1751    /// `verify` from the SAME script rather than decoding anything — this
1752    /// module's engine is proven against mocks, and verification/shadow
1753    /// detection are no exception. Each entry names its own `name` and
1754    /// `declared_len` (rather than a fixed, offset-derived name) so a test
1755    /// can freely vary any one of the three fields shadow detection now
1756    /// requires to agree.
1757    struct ScriptedCandidates {
1758        plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
1759        next: usize,
1760    }
1761
1762    impl SalvageScan for ScriptedCandidates {
1763        fn next_candidate(
1764            &mut self,
1765            _src: &mut dyn SeekRead,
1766            _from: u64,
1767        ) -> Result<Option<Candidate>> {
1768            if self.next >= self.plan.len() {
1769                return Ok(None);
1770            }
1771            let offset = self.next as u64;
1772            let (name, declared_len, verifier, _) = self.plan[self.next];
1773            self.next += 1;
1774            Ok(Some(Candidate {
1775                offset,
1776                payload_start: offset,
1777                meta: EntryMeta::file(name),
1778                declared_len,
1779                verifier,
1780                available_len: None,
1781                marked_deleted: false,
1782            }))
1783        }
1784
1785        fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
1786            Ok(self.plan[candidate.offset as usize].3)
1787        }
1788    }
1789
1790    /// `verify` decides each entry's status — the whole point of Task 4:
1791    /// this used to be a hardcoded `Complete` for every candidate, which was
1792    /// wrong for any format carrying a real checksum.
1793    #[test]
1794    fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
1795        let mut scan = ScriptedCandidates {
1796            plan: vec![
1797                (
1798                    "a",
1799                    Some(1),
1800                    Some(Verifier::Crc32(1)),
1801                    SalvageStatus::Intact,
1802                ),
1803                ("b", Some(1), None, SalvageStatus::Complete),
1804                (
1805                    "c",
1806                    Some(1),
1807                    Some(Verifier::Crc32(2)),
1808                    SalvageStatus::Partial,
1809                ),
1810            ],
1811            next: 0,
1812        };
1813        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1814            .expect("scripted candidates must annotate cleanly");
1815        assert_eq!(out.entries[0].status, SalvageStatus::Intact);
1816        assert_eq!(out.entries[1].status, SalvageStatus::Complete);
1817        assert_eq!(out.entries[2].status, SalvageStatus::Partial);
1818    }
1819
1820    /// A later candidate reporting the SAME name, declared_len AND verifier
1821    /// as an earlier one is its shadow — pointing at the earliest position,
1822    /// not merely "some" earlier one.
1823    #[test]
1824    fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
1825        let mut scan = ScriptedCandidates {
1826            plan: vec![
1827                (
1828                    "dup",
1829                    Some(1),
1830                    Some(Verifier::Crc32(7)),
1831                    SalvageStatus::Intact,
1832                ),
1833                (
1834                    "other",
1835                    Some(1),
1836                    Some(Verifier::Crc32(9)),
1837                    SalvageStatus::Intact,
1838                ),
1839                (
1840                    "dup",
1841                    Some(1),
1842                    Some(Verifier::Crc32(7)),
1843                    SalvageStatus::Intact,
1844                ),
1845            ],
1846            next: 0,
1847        };
1848        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1849            .expect("scripted candidates must annotate cleanly");
1850        assert_eq!(out.entries[0].shadows, None);
1851        assert_eq!(out.entries[1].shadows, None);
1852        assert_eq!(
1853            out.entries[2].shadows,
1854            Some(0),
1855            "must point at the earliest matching position"
1856        );
1857    }
1858
1859    /// A candidate with no verifier at all (a format with no checksum, or a
1860    /// zip data-descriptor entry) must never be reported as shadowing or
1861    /// shadowed — there is nothing to measure, so nothing is claimed.
1862    #[test]
1863    fn candidates_with_no_verifier_never_shadow_each_other() {
1864        let mut scan = ScriptedCandidates {
1865            plan: vec![
1866                ("a", Some(1), None, SalvageStatus::Complete),
1867                ("a", Some(1), None, SalvageStatus::Complete),
1868            ],
1869            next: 0,
1870        };
1871        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1872            .expect("scripted candidates must annotate cleanly");
1873        assert_eq!(out.entries[0].shadows, None);
1874        assert_eq!(out.entries[1].shadows, None);
1875    }
1876
1877    /// Two candidates whose checksum AND declared_len agree but whose NAME
1878    /// differs must never be linked — the fix-round requirement that a
1879    /// checksum match alone is not enough, only the full
1880    /// `(name, declared_len, verifier)` triple.
1881    #[test]
1882    fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
1883        let mut scan = ScriptedCandidates {
1884            plan: vec![
1885                (
1886                    "one.txt",
1887                    Some(4),
1888                    Some(Verifier::Crc32(42)),
1889                    SalvageStatus::Intact,
1890                ),
1891                (
1892                    "two.txt",
1893                    Some(4),
1894                    Some(Verifier::Crc32(42)),
1895                    SalvageStatus::Intact,
1896                ),
1897            ],
1898            next: 0,
1899        };
1900        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1901            .expect("scripted candidates must annotate cleanly");
1902        assert_eq!(
1903            out.entries[1].shadows, None,
1904            "same checksum, different name — not a shadow"
1905        );
1906    }
1907
1908    /// The degenerate case: every empty entry (and, in zip, every directory
1909    /// entry) checksums to the same fixed constant over zero declared
1910    /// bytes. Even under the SAME name, a zero-length checksum must never
1911    /// mark a shadow — the value proves nothing, unlike a real collision.
1912    #[test]
1913    fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
1914        let mut scan = ScriptedCandidates {
1915            plan: vec![
1916                (
1917                    "empty.txt",
1918                    Some(0),
1919                    Some(Verifier::Crc32(0)),
1920                    SalvageStatus::Complete,
1921                ),
1922                (
1923                    "empty.txt",
1924                    Some(0),
1925                    Some(Verifier::Crc32(0)),
1926                    SalvageStatus::Complete,
1927                ),
1928            ],
1929            next: 0,
1930        };
1931        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1932            .expect("scripted candidates must annotate cleanly");
1933        assert_eq!(out.entries[0].shadows, None);
1934        assert_eq!(
1935            out.entries[1].shadows, None,
1936            "a zero-byte checksum must never mark a shadow, even under an identical name"
1937        );
1938        assert_eq!(
1939            out.entries[1].collides_with,
1940            Some(0),
1941            "the NAME still repeats, and that is a directly observed fact no checksum \
1942             guard applies to"
1943        );
1944    }
1945
1946    // -----------------------------------------------------------------
1947    // Name collisions: the second annotation, separate from shadowing.
1948    // -----------------------------------------------------------------
1949
1950    /// The finding this annotation closes: two records under one name whose
1951    /// content does NOT agree are what `stuffr list` calls shadowed, and
1952    /// what salvage marked in no way at all — because its own `shadows`
1953    /// means something stricter.
1954    #[test]
1955    fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
1956        let mut scan = ScriptedCandidates {
1957            plan: vec![
1958                (
1959                    "dup.txt",
1960                    Some(21),
1961                    Some(Verifier::Crc32(7)),
1962                    SalvageStatus::Intact,
1963                ),
1964                (
1965                    "other.txt",
1966                    Some(21),
1967                    Some(Verifier::Crc32(9)),
1968                    SalvageStatus::Intact,
1969                ),
1970                (
1971                    "dup.txt",
1972                    Some(21),
1973                    Some(Verifier::Crc32(11)),
1974                    SalvageStatus::Intact,
1975                ),
1976            ],
1977            next: 0,
1978        };
1979        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1980            .expect("scripted candidates must annotate cleanly");
1981        assert_eq!(out.entries[2].shadows, None, "the content does not agree");
1982        assert_eq!(
1983            out.entries[2].collides_with,
1984            Some(0),
1985            "but the name does, and that is the fact `list` warns about"
1986        );
1987        assert_eq!(out.entries[0].collides_with, None);
1988        assert_eq!(out.entries[1].collides_with, None);
1989    }
1990
1991    /// The two annotations are mutually exclusive: a record PROVEN to be a
1992    /// copy says so, and does not also report the weaker fact. Reporting
1993    /// both would make every shadow print two markers for one relationship.
1994    #[test]
1995    fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
1996        let mut scan = ScriptedCandidates {
1997            plan: vec![
1998                (
1999                    "dup.txt",
2000                    Some(21),
2001                    Some(Verifier::Crc32(7)),
2002                    SalvageStatus::Intact,
2003                ),
2004                (
2005                    "dup.txt",
2006                    Some(21),
2007                    Some(Verifier::Crc32(7)),
2008                    SalvageStatus::Intact,
2009                ),
2010            ],
2011            next: 0,
2012        };
2013        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2014            .expect("scripted candidates must annotate cleanly");
2015        assert_eq!(out.entries[1].shadows, Some(0));
2016        assert_eq!(out.entries[1].collides_with, None);
2017    }
2018
2019    /// A third record under a name two earlier ones already used points at
2020    /// the EARLIEST of them, the same rule `shadows` follows — so a chain of
2021    /// collisions names one origin rather than each pointing at its
2022    /// predecessor.
2023    #[test]
2024    fn a_third_record_under_one_name_names_the_earliest_position() {
2025        let mut scan = ScriptedCandidates {
2026            plan: vec![
2027                (
2028                    "dup",
2029                    Some(4),
2030                    Some(Verifier::Crc32(1)),
2031                    SalvageStatus::Intact,
2032                ),
2033                (
2034                    "dup",
2035                    Some(4),
2036                    Some(Verifier::Crc32(2)),
2037                    SalvageStatus::Intact,
2038                ),
2039                (
2040                    "dup",
2041                    Some(4),
2042                    Some(Verifier::Crc32(3)),
2043                    SalvageStatus::Intact,
2044                ),
2045            ],
2046            next: 0,
2047        };
2048        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2049            .expect("scripted candidates must annotate cleanly");
2050        assert_eq!(out.entries[1].collides_with, Some(0));
2051        assert_eq!(out.entries[2].collides_with, Some(0));
2052    }
2053
2054    /// The negative double: distinct names must never be marked, however
2055    /// much else they share. Without this, an annotation that fired on
2056    /// everything would pass every test above.
2057    #[test]
2058    fn distinct_names_are_never_reported_as_colliding() {
2059        let mut scan = ScriptedCandidates {
2060            plan: vec![
2061                (
2062                    "one.txt",
2063                    Some(4),
2064                    Some(Verifier::Crc32(42)),
2065                    SalvageStatus::Intact,
2066                ),
2067                (
2068                    "two.txt",
2069                    Some(4),
2070                    Some(Verifier::Crc32(42)),
2071                    SalvageStatus::Intact,
2072                ),
2073            ],
2074            next: 0,
2075        };
2076        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2077            .expect("scripted candidates must annotate cleanly");
2078        assert_eq!(out.entries[0].collides_with, None);
2079        assert_eq!(out.entries[1].collides_with, None);
2080    }
2081
2082    /// A collision needs no verifier at all, unlike a shadow. A format with
2083    /// no checksum (tar, cpio, ar — and a zip data-descriptor entry) can
2084    /// still repeat a name, and that repetition is exactly as real there.
2085    #[test]
2086    fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
2087        let mut scan = ScriptedCandidates {
2088            plan: vec![
2089                ("same", Some(4), None, SalvageStatus::Complete),
2090                ("same", Some(4), None, SalvageStatus::Complete),
2091            ],
2092            next: 0,
2093        };
2094        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2095            .expect("scripted candidates must annotate cleanly");
2096        assert_eq!(
2097            out.entries[1].shadows, None,
2098            "no verifier, so nothing proves they are copies"
2099        );
2100        assert_eq!(
2101            out.entries[1].collides_with,
2102            Some(0),
2103            "but the name repeats, which needs no verifier to observe"
2104        );
2105    }
2106
2107    /// Ruling S-I. `Candidate` is `#[non_exhaustive]`, so an out-of-crate
2108    /// scanner reaches it only through `new` plus the `with_*` setters —
2109    /// which means the DEFAULTS are now public API, not an implementation
2110    /// detail of six struct literals. Each of the four must assert the
2111    /// least: nothing declared, nothing to verify with, nothing missing,
2112    /// nothing deleted.
2113    #[test]
2114    fn a_new_candidate_claims_nothing_it_was_not_told() {
2115        let c = Candidate::new(7, 42, EntryMeta::file("probe"));
2116        assert_eq!(c.offset, 7);
2117        assert_eq!(c.payload_start, 42);
2118        assert_eq!(c.declared_len, None, "no length was declared");
2119        assert_eq!(c.verifier, None, "nothing can prove the content");
2120        assert_eq!(
2121            c.available_len, None,
2122            "a shortfall is MEASURED, never assumed"
2123        );
2124        assert!(!c.marked_deleted, "no record is deleted until one says so");
2125    }
2126
2127    /// The four setters are the only route to those fields from another
2128    /// crate, so each one is pinned rather than trusted to the compiler.
2129    #[test]
2130    fn each_candidate_setter_reaches_its_own_field() {
2131        let c = Candidate::new(0, 0, EntryMeta::file("probe"))
2132            .with_declared_len(Some(9))
2133            .with_verifier(Some(Verifier::Crc16(0xbeef)))
2134            .with_available_len(Some(4))
2135            .with_marked_deleted(true);
2136        assert_eq!(c.declared_len, Some(9));
2137        assert_eq!(c.verifier, Some(Verifier::Crc16(0xbeef)));
2138        assert_eq!(c.available_len, Some(4));
2139        assert!(c.marked_deleted);
2140    }
2141
2142    /// Same ruling, same reason, for the type every per-format
2143    /// `write_payload` test builds directly. Its three defaults are the
2144    /// annotations `annotate_candidates` adds, so an entry built by hand
2145    /// must carry none of them.
2146    #[test]
2147    fn a_new_salvaged_entry_carries_no_annotations() {
2148        let e = SalvagedEntry::new(3, 7, 42, EntryMeta::file("probe"), SalvageStatus::Complete);
2149        assert_eq!(e.scan_position, 3);
2150        assert_eq!(e.offset, 7);
2151        assert_eq!(e.payload_start, 42);
2152        assert_eq!(e.status, SalvageStatus::Complete);
2153        assert_eq!(e.shadows, None);
2154        assert_eq!(e.collides_with, None);
2155        assert!(!e.marked_deleted);
2156
2157        let annotated = SalvagedEntry::new(0, 0, 0, EntryMeta::file("p"), SalvageStatus::Intact)
2158            .with_shadows(Some(1))
2159            .with_collides_with(Some(2))
2160            .with_marked_deleted(true);
2161        assert_eq!(annotated.shadows, Some(1));
2162        assert_eq!(annotated.collides_with, Some(2));
2163        assert!(annotated.marked_deleted);
2164    }
2165
2166    /// Task 2-N: the note a caller prints for its sightings. None for none —
2167    /// a run with nothing refused must print nothing — and otherwise one
2168    /// group per (format, shape), each with its complete count and at most
2169    /// [`SIGHTING_OFFSETS_SHOWN`] offsets, in the order first seen.
2170    #[test]
2171    fn describe_sightings_groups_by_shape_and_bounds_what_it_lists() {
2172        assert_eq!(describe_sightings(&[]), None);
2173
2174        let f = FormatId::new("fmt");
2175        let mut seen: Vec<Sighting> = (0..10u64)
2176            .map(|i| Sighting::new(f, i * 512, "a(s)"))
2177            .collect();
2178        seen.insert(1, Sighting::new(f, 7, "b(s)"));
2179        let note = describe_sightings(&seen).unwrap();
2180        assert!(
2181            note.contains("10 fmt a(s) at offset(s) 0, 512, 1024, 1536, 2048, 2560, 3072, 3584, and 2 more; and 1 fmt b(s) at offset(s) 7"),
2182            "{note}"
2183        );
2184        assert!(note.contains("`stuffr list`"), "{note}");
2185    }
2186
2187    /// Task 4: the walk-stop note. A hole says what the brief requires —
2188    /// the entries after it are unreachable by construction, NOT absent,
2189    /// with the offset and the byte count — and a cut-short tail says the
2190    /// opposite, because nothing can be hiding in too few bytes for a record.
2191    #[test]
2192    fn describe_walk_stop_says_unreachable_for_a_hole_and_complete_for_a_cut() {
2193        let f = FormatId::new("fmt");
2194        let hole = describe_walk_stop(&WalkStop::new(
2195            f,
2196            1234,
2197            5000,
2198            WalkStopKind::Unreadable,
2199            "bad field",
2200        ));
2201        for needle in [
2202            "offset 1234",
2203            "5000 bytes",
2204            "bad field",
2205            "unreachable by construction",
2206            "not absent",
2207            "fmt",
2208        ] {
2209            assert!(hole.contains(needle), "missing {needle:?}: {hole}");
2210        }
2211        let cut = describe_walk_stop(&WalkStop::new(f, 90, 1, WalkStopKind::CutShort, "eof"));
2212        assert!(cut.contains("1 byte into the record at offset 90"), "{cut}");
2213        assert!(cut.contains("cut short"), "{cut}");
2214        assert!(!cut.contains("unreachable"), "{cut}");
2215        let shape = describe_walk_stop(&WalkStop::new(
2216            f,
2217            8,
2218            600,
2219            WalkStopKind::UnsupportedShape,
2220            "the symbol table",
2221        ));
2222        assert!(shape.contains("the symbol table"), "{shape}");
2223        assert!(shape.contains("not evidence of damage"), "{shape}");
2224        assert!(!shape.contains("unreachable by construction"), "{shape}");
2225    }
2226}