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: an `ar` symbol table whose header the reader cannot
819    /// parse; GNU `ar`'s Mach-O `__.SYMDEF` with a blank mode was the first,
820    /// and is read since 0.8.1). Not evidence of damage; what follows is
821    /// unreachable to this scanner.
822    UnsupportedShape,
823}
824
825impl WalkStop {
826    pub fn new(
827        format: FormatId,
828        offset: u64,
829        remaining: u64,
830        kind: WalkStopKind,
831        cause: impl Into<String>,
832    ) -> Self {
833        Self {
834            format,
835            offset,
836            remaining,
837            kind,
838            cause: cause.into(),
839        }
840    }
841}
842
843/// The one sentence a caller prints for a [`WalkStop`] — written here, once,
844/// so every sequential-only scanner gets the same report without a line of
845/// its own, and so its kinds can never be described two ways each.
846///
847/// An [`WalkStopKind::Unreadable`] stop says the entries after it are
848/// *unreachable by construction, not absent*, with the offset and the byte
849/// count; a [`WalkStopKind::CutShort`] one says that nothing after it is
850/// missing from the report, which is exactly as true — the file ended inside
851/// that record; an [`WalkStopKind::UnsupportedShape`] one names the shape and
852/// says it is not evidence of damage.
853///
854/// Two of the sentences say the walk goes "as `stuffr list` does". That is a
855/// contract on every scanner that reports a [`WalkStop`]: it walks with its
856/// format's ORDINARY reader, over the file's own bytes, repairing none of
857/// them. `ar`'s walk served its global header as `!<arch>\n` until Stage 3
858/// Task 4's fix round 1, which made the phrase false (a bad magic `list`
859/// refuses was walked past) — a scanner that needs a repair like that must
860/// get its own sentence here rather than borrow this one.
861pub fn describe_walk_stop(stop: &WalkStop) -> String {
862    let WalkStop {
863        format,
864        offset,
865        remaining,
866        kind,
867        cause,
868    } = stop;
869    let bytes = if *remaining == 1 { "byte" } else { "bytes" };
870    match kind {
871        WalkStopKind::Unreadable => format!(
872            "this build's {format} salvage scanner walks the archive in order from its start, \
873             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
874             to go: {cause}. The {format} format marks its records with nothing strong enough \
875             to search for, so the scanner cannot find its way past a record it cannot read; \
876             any entries in those {remaining} {bytes} are unreachable by construction — not \
877             absent, and not counted in this report"
878        ),
879        WalkStopKind::UnsupportedShape => format!(
880            "this build's {format} salvage scanner walks the archive in order from its start, \
881             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
882             to go: {cause}. That is a record shape this build does not read, not evidence of \
883             damage; any entries in those {remaining} {bytes} are unreachable to this scanner \
884             and not counted in this report"
885        ),
886        WalkStopKind::CutShort => format!(
887            "this build's {format} salvage scanner walks the archive in order from its start, \
888             and the file ends {remaining} {bytes} into the record at offset {offset} ({cause}): \
889             too few for any further entry, so the archive is cut short there and nothing \
890             after that point is missing from this report"
891        ),
892    }
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
896pub enum PartialPolicy {
897    Keep,
898    Skip,
899    Ask,
900}
901
902pub struct SalvagePolicy {
903    /// Recovery-biased by default: salvage is the one permissive verb, and
904    /// the strict path is the entire rest of the tool.
905    pub partial: PartialPolicy,
906    /// Structural ceiling on any one entry's length, refused BEFORE
907    /// allocation — and refused as a per-entry
908    /// [`UnverifiedCause::OverEntryCeiling`], never as an error that ends
909    /// the run. [`SalvageScan::max_whole_entry`] narrows it further for a
910    /// format that decodes an entry whole; the two compose as a minimum and
911    /// are applied together, once, in [`annotate_candidates`].
912    pub max_entry: u64,
913    /// Demand proof: every [`SalvageStatus::Partial`] entry is skipped,
914    /// whatever [`Self::partial`] says, and so is every
915    /// [`SalvageStatus::Unattested`] one (Ruling 3-H, Salvage Stage 3).
916    ///
917    /// The second clause is this flag's doing, unlike the two refused
918    /// below: an `Unattested` entry is WRITTEN by default, under its real
919    /// name, and only `strict` declines it — the format carries nothing
920    /// that could prove it, which is exactly what "demand proof" asks for.
921    ///
922    /// **Those two are the whole of it, and this doc claimed three things
923    /// until the final whole-branch review's F5.** It read "partial skipped,
924    /// ceiling fixed, nothing unverifiable", and only the first clause was
925    /// this flag's doing. The ceiling is NOT fixed under `strict`:
926    /// [`Self::max_entry`] applies identically with and without it, and
927    /// `--max-entry` is honoured either way. And an
928    /// [`SalvageStatus::Unverified`] entry is never written under any
929    /// policy at all — that is the ops layer's unconditional rule, not
930    /// something `strict` causes — so crediting it here made a flag look
931    /// load-bearing for a guarantee that holds without it.
932    ///
933    /// The true clauses are enough to justify the flag: `salvage` is this
934    /// tool's single recovery-biased verb, so `strict` is how a caller asks
935    /// for the discipline every other verb applies by default.
936    pub strict: bool,
937}
938
939impl Default for SalvagePolicy {
940    fn default() -> Self {
941        Self {
942            partial: PartialPolicy::Keep,
943            max_entry: MAX_SALVAGE_ENTRY,
944            strict: false,
945        }
946    }
947}
948
949/// The per-format seam. A scanner recognises its own format's record shape
950/// (a local header's magic, a member's checksum field) and reports what it
951/// finds as a stream of [`Candidate`]s; [`salvage_all`] supplies everything
952/// that does not depend on which format that is.
953pub trait SalvageScan {
954    /// Find the next candidate at or after `from`. `Ok(None)` ends the scan.
955    fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;
956
957    /// The largest single entry this scanner is willing to have read for it
958    /// — the ceiling [`annotate_candidates`] applies, narrowed further by
959    /// `policy.max_entry`, before it calls [`Self::verify`] at all.
960    ///
961    /// `u64::MAX` (the default) means "this scanner imposes none of its
962    /// own": zip streams every payload it verifies through a `take`, so the
963    /// only figure that bounds it is the policy's. A format that **decodes
964    /// an entry whole** — ARC, and the legacy containers still to come —
965    /// overrides this with its own container-side ceiling
966    /// (`arc.rs`'s `MAX_ARC_ENTRY_LEN`, 256 MiB), because for those the
967    /// figure really does become a single allocation.
968    ///
969    /// **Declaring a ceiling here is the ONLY way a scanner refuses an
970    /// entry for its size**, and that is the point: the refusal is then a
971    /// [`SalvageStatus`] the engine assigns
972    /// ([`UnverifiedCause::OverEntryCeiling`]), not a [`crate::Error`] the
973    /// scanner raises, so it can never abort a run over one entry. A
974    /// scanner that instead checks a size inside [`Self::verify`] and
975    /// `?`-propagates the failure reintroduces the exact defect three fix
976    /// rounds of Task 3c chased.
977    ///
978    /// **That is documented, not enforced, and saying so is more useful
979    /// than claiming otherwise.** An earlier wording here called the defect
980    /// "unrepresentable"; it is not. [`Self::verify`] still returns
981    /// `Result` — it must, so a scanner CAN report a genuine run-level
982    /// fault — so the wrong thing is still expressible. What changed is
983    /// that the right thing now exists, costs one method, and is written
984    /// into [`Self::verify`]'s contract below.
985    fn max_whole_entry(&self) -> u64 {
986        u64::MAX
987    }
988
989    /// Decide what was actually proven about a candidate's payload, after
990    /// discovery — called once per candidate, in file order, by
991    /// [`annotate_candidates`].
992    ///
993    /// # The contract on `Err`
994    ///
995    /// **`Err` here aborts the WHOLE run**, discarding every entry already
996    /// recovered, so it is reserved for a fault that really is about the
997    /// run and not about this one entry (the destination is gone, the
998    /// source handle itself failed). Everything a single candidate can be
999    /// wrong about — a short read, a bad checksum, a method this build
1000    /// cannot decode, a device error partway through one payload — is a
1001    /// [`SalvageStatus`], never an `Err`. `zip_salvage.rs`'s own
1002    /// `verify_candidate` has stated this in prose since Stage 1 and folds
1003    /// every one of those; ARC's did not, for one case (its size ceiling),
1004    /// and that single `?` cost three fix rounds. The size ceiling now
1005    /// belongs to [`Self::max_whole_entry`], which the engine applies, so
1006    /// there is nothing left for an implementation to `?` on.
1007    ///
1008    /// # What an implementation may assume
1009    ///
1010    /// The candidate's bounded length (`available_len` if the payload was
1011    /// truncated, else `declared_len`) is already **at or below**
1012    /// [`Self::max_whole_entry`] — [`annotate_candidates`] answers
1013    /// [`UnverifiedCause::OverEntryCeiling`] itself, without calling this,
1014    /// for anything above it. So an implementation that decodes whole may
1015    /// size a buffer from that length without re-checking it.
1016    ///
1017    /// The default answers [`SalvageStatus::Unattested`] unconditionally,
1018    /// which is the least this method can claim: it reads nothing, decodes
1019    /// nothing and compares nothing, so "nothing attests that this candidate
1020    /// is an entry at all" is exactly what happened. A format that carries a
1021    /// checksum overrides this to decode the payload — reusing its own
1022    /// existing codec machinery, never inventing a new one here — and
1023    /// compare it against [`Candidate::verifier`]; a format that carries a
1024    /// self-verifying HEADER and no content checksum (tar) overrides it to
1025    /// check that header and answer [`SalvageStatus::Complete`].
1026    ///
1027    /// **It answered `Complete` until Salvage Stage 3**, and the change is
1028    /// not cosmetic. `Complete` asserts that every declared byte was present
1029    /// *and the header self-verified* — two claims this default has never
1030    /// been in a position to make — and it routes to **exit 0**, so a
1031    /// checksumless scanner that simply forgot to override was reporting a
1032    /// clean recovery over candidates nothing had examined. `Unattested`
1033    /// routes to exit 4 and says why on the row. The same argument
1034    /// [`Candidate::new`] makes for its own four defaults: the default is
1035    /// the answer that asserts the LEAST, so forgetting to override is
1036    /// visible rather than flattering.
1037    fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
1038        Ok(SalvageStatus::Unattested)
1039    }
1040
1041    /// Decode one salvaged entry's payload out of the archive at
1042    /// `archive_path` and into `out`, answering whether the entry's whole
1043    /// declared length was produced.
1044    ///
1045    /// # Why this is on the trait at all (final whole-branch review, F2)
1046    ///
1047    /// `0.6.0` spends a breaking release making this trait implementable
1048    /// from outside. It was implementable and not USABLE: the ops layer's
1049    /// payload dispatch was a private `match` on a [`crate::FormatId`]
1050    /// string, so a scanner written outside this workspace got
1051    /// [`salvage_all`] and none of the behaviour that makes `salvage` what
1052    /// it is. This method is where a scanner's write half now DECLARES
1053    /// itself, beside the scan half, in one place a reader can see both.
1054    ///
1055    /// **It does not by itself make an out-of-tree scanner reachable from
1056    /// `stuffr salvage`, and pretending otherwise would be the same
1057    /// over-claim F2 named.** The ops layer still resolves a format to a
1058    /// scanner by name, with no registration point for a third party to add
1059    /// one; closing that needs a salvage registry and is Stage 3's. What
1060    /// changed is that the seam is now declared rather than scattered — the
1061    /// five in-tree scanners implement this method and the ops layer calls
1062    /// it, so the next one has a signature to fit rather than a convention
1063    /// to guess.
1064    ///
1065    /// # The contract
1066    ///
1067    /// `Ok(true)` means every declared byte was produced; `Ok(false)` means
1068    /// the payload ran short or the decoder stopped early, which the caller
1069    /// renders as a `.partial` write. **`Err` is reserved for the same
1070    /// narrow set [`Self::verify`] reserves it for** — with one addition
1071    /// the ops layer relies on: [`crate::Error::Io`] here is treated as a
1072    /// fact about THIS entry (the archive could not be re-opened or read
1073    /// for it, the destination refused the bytes) and folded into that
1074    /// entry's own outcome, never the run's. A size refusal is never an
1075    /// `Err`: it belongs to [`Self::max_whole_entry`], which the engine
1076    /// applies before `verify` is ever called.
1077    ///
1078    /// The default refuses. A scanner that only ever reports
1079    /// [`SalvageStatus::Unverified`] has nothing to write, and inheriting a
1080    /// refusal is the honest answer for it; the alternative — a required
1081    /// method — would be a breaking change to every implementation for no
1082    /// gain to one that cannot write anything anyway.
1083    fn write_payload(
1084        &self,
1085        _archive_path: &Path,
1086        _entry: &SalvagedEntry,
1087        _compressed_len: u64,
1088        _out: &mut dyn Write,
1089    ) -> Result<bool> {
1090        Err(Error::Unsupported(
1091            "this salvage scanner has no payload writer".to_string(),
1092        ))
1093    }
1094}
1095
1096/// Walk `scan` over `src`, collecting every candidate it reports into a
1097/// [`SalvageOutcome`].
1098///
1099/// **Requires a seekable source.** Scanning searches back and forth over the
1100/// archive rather than reading it once in order, so this takes
1101/// `&mut dyn SeekRead` rather than the ladder's `Box<dyn Source>` (whose
1102/// `as_seek()` may be `None` for a pipe). A caller sitting on a
1103/// non-seekable source must spool it first — exactly as the `arj` and `zoo`
1104/// containers already do, reporting `Rung::Spilled` — which is a later
1105/// task's ops-layer concern, not this function's.
1106///
1107/// Every candidate's length is bounded against the run's entry ceiling
1108/// (`policy.max_entry`, narrowed by [`SalvageScan::max_whole_entry`])
1109/// **before** anything is sized from it — in [`annotate_candidates`], one
1110/// step before the only place that could size anything, and as a per-entry
1111/// [`UnverifiedCause::OverEntryCeiling`] rather than a [`crate::Error`] that ends
1112/// the run.
1113pub fn salvage_all(
1114    scan: &mut dyn SalvageScan,
1115    src: &mut dyn SeekRead,
1116    policy: &SalvagePolicy,
1117) -> Result<SalvageOutcome> {
1118    let candidates = collect_candidates(scan, src)?;
1119    annotate_candidates(&*scan, src, candidates, policy)
1120}
1121
1122/// The resync loop alone: find a candidate, advance strictly past it,
1123/// repeat. Never reads a payload, never sizes anything from a declared
1124/// length, and never calls [`SalvageScan::verify`] — see
1125/// [`annotate_candidates`] for both the entry ceiling and the verification.
1126///
1127/// Exposed separately from [`salvage_all`] so a format that reconciles more
1128/// than one candidate source (zip's central-directory fallback) can collect
1129/// from each source before merging, and annotate the merged list once.
1130///
1131/// # The entry ceiling used to be enforced here, and was an `Err`
1132///
1133/// Salvage Stage 2 Task 3c's fix round 3 moved it to
1134/// [`annotate_candidates`] and turned it into a status. Two things were
1135/// wrong with it here, and only the second is obvious:
1136///
1137/// 1. **It refused a whole archive over one entry.** Measured at the CLI on
1138///    a 78-byte two-entry ARC, `stuffr salvage --list --max-entry 4`
1139///    printed no rows and exited 6 — the healthy first entry, which the
1140///    same binary recovers byte-for-byte at the default ceiling, simply
1141///    lost. That is the same defect the ARC scanner's own ceiling had, in
1142///    the engine rather than in a format.
1143/// 2. **It was one of two ceilings, checked in two places**, so which
1144///    disposition an over-ceiling entry got depended on which check saw it
1145///    first. There is now one rule, applied once, to both figures.
1146///
1147/// Nothing is weakened by the move: this loop sizes nothing from a declared
1148/// length (it only advances `from` by it), so "bounded before anything is
1149/// sized from it" holds exactly as before — the bound simply sits one
1150/// function later, still strictly before the only allocation there is.
1151pub fn collect_candidates(
1152    scan: &mut dyn SalvageScan,
1153    src: &mut dyn SeekRead,
1154) -> Result<Vec<Candidate>> {
1155    let mut candidates = Vec::new();
1156    let mut from = 0u64;
1157
1158    while let Some(candidate) = scan.next_candidate(src, from)? {
1159        // Advance strictly past this candidate so the scan always makes
1160        // progress — but NEVER by a length this candidate merely CLAIMED.
1161        // `available_len.is_some()` means the header's own declared length
1162        // disagreed with the file (there were fewer bytes left than it
1163        // said), and a header that lied about its size once cannot be
1164        // trusted to say how much of the source its "payload" consumed
1165        // either. This used to advance by `available_len` — the bytes
1166        // actually present, not the declared figure — on the reasoning that
1167        // a truncated entry has nothing worth finding after it anyway. That
1168        // is true for a genuinely truncated REAL last entry, but the
1169        // engine cannot tell that case apart from a coincidental
1170        // false-positive header sitting on top of noise with a garbage
1171        // multi-hundred-megabyte declared length: `available_len` is the
1172        // same "everything left in the file" figure either way. Salvage
1173        // Stage 2 Task 3's fix round 1 found exactly this — one
1174        // coincidental marker+name match in an ARC noise corpus, with a
1175        // declared length far past the file, jumped the scan straight to
1176        // EOF in one step and silently dropped every real entry that
1177        // happened to sit between it and the end of the file: no error, no
1178        // `Partial`, no note, in the one verb whose entire job is not
1179        // losing entries silently. So a truncated candidate now advances
1180        // minimally — past its own marker byte only — so the very next
1181        // byte onward is examined for another header. That costs a few
1182        // bytes of re-scanning after a genuinely truncated real entry
1183        // (which has nothing left to find anyway, so the outcome is
1184        // unchanged); it is the entire fix for a phantom, whose lie no
1185        // longer costs the rest of the archive. An UNTRUNCATED candidate's
1186        // declared length was just checked against the file two lines
1187        // above — it fits, so it is not a lie, and advancing by it is
1188        // unchanged.
1189        let advance = if candidate.available_len.is_some() {
1190            1
1191        } else {
1192            candidate.declared_len.unwrap_or(1).max(1)
1193        };
1194        from = candidate.offset.saturating_add(advance);
1195
1196        candidates.push(candidate);
1197    }
1198
1199    Ok(candidates)
1200}
1201
1202/// Turns already-discovered candidates — in file order — into the bounded,
1203/// verified, shadow-annotated entries a caller receives. See this module's
1204/// doc comment for what the passes (bound, verify, shadow) mean and why
1205/// they live here rather than per-format.
1206///
1207/// # The entry ceiling, in one place (fix round 3, Task 3c)
1208///
1209/// `policy.max_entry` narrowed by [`SalvageScan::max_whole_entry`] is the
1210/// run's ceiling on ONE entry, and this is the only place it is applied. A
1211/// candidate whose bounded length (`available_len` if the payload is
1212/// truncated, else `declared_len`) is over it is reported
1213/// [`SalvageStatus::Unverified`]`(`[`UnverifiedCause::OverEntryCeiling`]`)`
1214/// and [`SalvageScan::verify`] is **not called for it at all** — so nothing
1215/// is read and nothing is allocated, which was the whole purpose of the two
1216/// `Err`-raising checks this replaced, without their cost: neither the
1217/// engine's own nor a scanner's could refuse one entry without ending the
1218/// run and discarding every entry already recovered.
1219///
1220/// The bounded length, never the declared one, is what is compared — the
1221/// rule [`Candidate::available_len`]'s own doc argues for and the reason a
1222/// truncated tail's garbage declaration cannot decide anything: a candidate
1223/// whose payload ran out can never cost more than the bytes that exist.
1224pub fn annotate_candidates(
1225    scan: &dyn SalvageScan,
1226    src: &mut dyn SeekRead,
1227    candidates: Vec<Candidate>,
1228    policy: &SalvagePolicy,
1229) -> Result<SalvageOutcome> {
1230    let ceiling = policy.max_entry.min(scan.max_whole_entry());
1231    let mut entries = Vec::with_capacity(candidates.len());
1232    // Earliest scan_position to report each distinct (name, declared_len,
1233    // verifier) triple seen so far — a candidate reporting one already in
1234    // here is shadowing that position. A degenerate zero-length checksum
1235    // (every empty file, every directory entry) is never pushed here and
1236    // never looked up here — see `is_degenerate` below.
1237    let mut seen: Vec<(String, Option<u64>, Verifier, usize)> = Vec::new();
1238    // Earliest scan_position to report each distinct NAME, regardless of
1239    // anything else about the record — the weaker, directly-observed fact
1240    // `SalvagedEntry::collides_with` reports, and the one `stuffr list`'s
1241    // own fidelity warning is about. Kept separate from `seen` above rather
1242    // than folded into it: that list is deliberately full of guards against
1243    // over-claiming a COPY (a verifier must exist, the declared length must
1244    // agree, a zero-length checksum never counts), and every one of them
1245    // would be wrong here, where nothing about content is being claimed.
1246    let mut names_seen: Vec<(String, usize)> = Vec::new();
1247
1248    for (scan_position, candidate) in candidates.into_iter().enumerate() {
1249        // Bounded length, then the ceiling, then — only if it fits —
1250        // verification. `verify` is what reads and (for a whole-decoding
1251        // format) allocates, so refusing above it is refusing before the
1252        // allocation, which is the property the two `Err`s this replaced
1253        // existed for.
1254        let bounded_len = candidate.available_len.or(candidate.declared_len);
1255        let status = match bounded_len {
1256            Some(needed) if needed > ceiling => {
1257                SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { needed, ceiling })
1258            }
1259            _ => scan.verify(src, &candidate)?,
1260        };
1261
1262        let declared_len = candidate.declared_len;
1263        let verifier = candidate.verifier;
1264        // A checksum over zero declared bytes is the same value for every
1265        // empty entry that ever existed (CRC-32 and CRC-16/ARC of an empty
1266        // input are both fixed constants) and proves nothing about any two
1267        // of them being copies of each other — measured against a real
1268        // archive during this task's fix round: every `__init__.py` and
1269        // every directory entry collapsed onto one shadow chain under a
1270        // checksum-only match.
1271        let is_degenerate = declared_len == Some(0);
1272
1273        let shadows = if is_degenerate {
1274            None
1275        } else {
1276            verifier.and_then(|v| {
1277                seen.iter()
1278                    .find(|&&(ref seen_name, seen_len, seen_verifier, _)| {
1279                        seen_verifier == v
1280                            && seen_len == declared_len
1281                            && *seen_name == candidate.meta.name
1282                    })
1283                    .map(|&(_, _, _, earlier_position)| earlier_position)
1284            })
1285        };
1286
1287        if !is_degenerate
1288            && shadows.is_none()
1289            && let Some(v) = verifier
1290        {
1291            seen.push((candidate.meta.name.clone(), declared_len, v, scan_position));
1292        }
1293
1294        // The two annotations are mutually exclusive: a record proven to be
1295        // a copy is reported as a shadow and nothing else, because "this is
1296        // a duplicate of #2" is strictly more informative than "something
1297        // earlier used this name". Everything else that repeats a name is a
1298        // collision.
1299        let earliest_with_name = names_seen
1300            .iter()
1301            .find(|(name, _)| *name == candidate.meta.name)
1302            .map(|&(_, position)| position);
1303        let collides_with = if shadows.is_some() {
1304            None
1305        } else {
1306            earliest_with_name
1307        };
1308        if earliest_with_name.is_none() {
1309            names_seen.push((candidate.meta.name.clone(), scan_position));
1310        }
1311
1312        let marked_deleted = candidate.marked_deleted;
1313        entries.push(
1314            SalvagedEntry::new(
1315                scan_position,
1316                candidate.offset,
1317                candidate.payload_start,
1318                candidate.meta,
1319                status,
1320            )
1321            .with_shadows(shadows)
1322            .with_collides_with(collides_with)
1323            .with_marked_deleted(marked_deleted),
1324        );
1325    }
1326
1327    Ok(SalvageOutcome {
1328        entries,
1329        sightings: Vec::new(),
1330        walk_stop: None,
1331    })
1332}
1333
1334/// Streams `reader` into `out`, bounded to `expected_len` bytes — the
1335/// entry's own declared uncompressed size — so a small compressed input
1336/// cannot expand arbitrarily far past what its own header claims. Never
1337/// buffers a growable copy of the payload, only a fixed 64 KiB window, and
1338/// never trusts the decoder to stop on its own.
1339///
1340/// Returns `true` when exactly `expected_len` bytes were produced, `false`
1341/// when `reader` ran out or errored first — [`crate`]'s own convention
1342/// throughout this module: "did not fully recover" is a fact a caller
1343/// reports (as [`SalvageStatus::Partial`]'s own two causes already are),
1344/// never a [`crate::Error`] that aborts the whole run over one entry.
1345///
1346/// Lives here, rather than duplicated once per format module, because it
1347/// has no format-specific knowledge at all — only `Read`/`Write` and a byte
1348/// count. Task 3c moved it up from `entries.rs`, where it was zip-shaped
1349/// plumbing bolted onto a zip-only write path; every [`SalvageScan`]
1350/// implementation's own `write_payload` (zip's, ARC's, and any later
1351/// format's) calls this same function once it has a decoded reader in
1352/// hand, so a truncated or overrunning decode is reported identically
1353/// regardless of which format produced it.
1354pub fn stream_bounded_copy(
1355    mut reader: impl Read,
1356    expected_len: u64,
1357    out: &mut dyn Write,
1358) -> Result<bool> {
1359    let mut buf = [0u8; 64 * 1024];
1360    let mut produced = 0u64;
1361    loop {
1362        if produced >= expected_len {
1363            return Ok(true);
1364        }
1365        let want = ((expected_len - produced) as usize).min(buf.len());
1366        let n = match reader.read(&mut buf[..want]) {
1367            Ok(0) => return Ok(false),
1368            Ok(n) => n,
1369            Err(_) => return Ok(false),
1370        };
1371        out.write_all(&buf[..n])?;
1372        produced += n as u64;
1373    }
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378    use std::io::{Cursor, Read, Seek, SeekFrom};
1379
1380    use super::*;
1381
1382    fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
1383        Cursor::new(data.to_vec())
1384    }
1385
1386    /// A reader that panics if asked to read more bytes in one call than its
1387    /// backing buffer holds. `salvage_all`'s own loop never reads a
1388    /// candidate's payload at all (see the module doc), so a correct
1389    /// implementation never trips this — it exists to catch a regression
1390    /// that reads before bounding, the exact shape this project has shipped
1391    /// six times across `cpio`, `ar` and `zip`.
1392    struct PanickingReader(Cursor<Vec<u8>>);
1393
1394    impl Read for PanickingReader {
1395        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1396            let backing_len = self.0.get_ref().len();
1397            assert!(
1398                buf.len() <= backing_len,
1399                "oversized read requested: {} bytes against a {backing_len}-byte source \
1400                 — an allocation or read was sized from a declared length before it was bounded",
1401                buf.len()
1402            );
1403            self.0.read(buf)
1404        }
1405    }
1406
1407    impl Seek for PanickingReader {
1408        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
1409            self.0.seek(pos)
1410        }
1411    }
1412
1413    fn panicking_reader(len: usize) -> PanickingReader {
1414        PanickingReader(Cursor::new(vec![0u8; len]))
1415    }
1416
1417    /// A scanner that never finds anything, however many times it is asked.
1418    struct NeverFinds;
1419
1420    impl SalvageScan for NeverFinds {
1421        fn next_candidate(
1422            &mut self,
1423            _src: &mut dyn SeekRead,
1424            _from: u64,
1425        ) -> Result<Option<Candidate>> {
1426            Ok(None)
1427        }
1428    }
1429
1430    /// A scanner that reports exactly one candidate, declaring the given
1431    /// length, then ends the scan. It never touches `src` itself — it
1432    /// exists purely to hand `salvage_all` a declared length to bound.
1433    struct DeclaresLength(u64);
1434
1435    impl SalvageScan for DeclaresLength {
1436        fn next_candidate(
1437            &mut self,
1438            _src: &mut dyn SeekRead,
1439            from: u64,
1440        ) -> Result<Option<Candidate>> {
1441            if from > 0 {
1442                return Ok(None);
1443            }
1444            Ok(Some(Candidate {
1445                offset: 0,
1446                payload_start: 0,
1447                meta: EntryMeta::file("absurd"),
1448                declared_len: Some(self.0),
1449                verifier: None,
1450                available_len: None,
1451                marked_deleted: false,
1452            }))
1453        }
1454    }
1455
1456    /// A scanner reporting one candidate whose header DECLARES `declared`
1457    /// bytes while the source holds only `available` of them — the
1458    /// truncated-tail shape, the one a real scanner reports by setting
1459    /// [`Candidate::available_len`].
1460    struct DeclaresMoreThanIsThere {
1461        declared: u64,
1462        available: u64,
1463    }
1464
1465    impl SalvageScan for DeclaresMoreThanIsThere {
1466        fn next_candidate(
1467            &mut self,
1468            _src: &mut dyn SeekRead,
1469            from: u64,
1470        ) -> Result<Option<Candidate>> {
1471            if from > 0 {
1472                return Ok(None);
1473            }
1474            Ok(Some(Candidate {
1475                offset: 0,
1476                payload_start: 0,
1477                meta: EntryMeta::file("cut-short"),
1478                declared_len: Some(self.declared),
1479                verifier: Some(Verifier::Crc32(1)),
1480                available_len: Some(self.available),
1481                marked_deleted: false,
1482            }))
1483        }
1484    }
1485
1486    #[test]
1487    fn a_scanner_that_finds_nothing_yields_no_entries() {
1488        let out = salvage_all(
1489            &mut NeverFinds,
1490            &mut bytes(&[0u8; 4096]),
1491            &SalvagePolicy::default(),
1492        );
1493        assert!(
1494            out.unwrap().entries.is_empty(),
1495            "a scanner finding nothing must yield nothing"
1496        );
1497    }
1498
1499    /// The ceiling test that matters most: a reader that PANICS on an
1500    /// oversized read, so this test fails if the read happens at all — not
1501    /// merely if the wrong status comes back. See this module's
1502    /// falsification note in the task report for what happens when the
1503    /// bound is moved below a read.
1504    ///
1505    /// Fix round 3 (Task 3c): the refusal is a per-entry STATUS now, not an
1506    /// `Err` — so this asserts both halves, that nothing was read AND that
1507    /// the run completed with the entry reported. The old assertion
1508    /// (`unwrap_err()`, exit 6) proved only the first and was satisfied by
1509    /// the behaviour that lost every other entry in the archive.
1510    #[test]
1511    fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
1512        let mut scan = DeclaresLength(u64::MAX);
1513        let out = salvage_all(
1514            &mut scan,
1515            &mut panicking_reader(4096),
1516            &SalvagePolicy::default(),
1517        )
1518        .expect("an entry over the ceiling must not abort the run");
1519        assert_eq!(out.entries.len(), 1, "the entry is still reported");
1520        assert_eq!(
1521            out.entries[0].status,
1522            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1523                needed: u64::MAX,
1524                ceiling: MAX_SALVAGE_ENTRY,
1525            })
1526        );
1527    }
1528
1529    /// The other half of the same rule, and the one the CLI reaches:
1530    /// everything AROUND an over-ceiling entry is still recovered. Measured
1531    /// before this fix as `stuffr salvage --list --max-entry 4` over a
1532    /// 78-byte ARC — no rows at all, exit 6.
1533    #[test]
1534    fn an_entry_over_the_ceiling_does_not_cost_the_entries_around_it() {
1535        struct TwoCandidates;
1536        impl SalvageScan for TwoCandidates {
1537            fn next_candidate(
1538                &mut self,
1539                _src: &mut dyn SeekRead,
1540                from: u64,
1541            ) -> Result<Option<Candidate>> {
1542                let (offset, len, name) = match from {
1543                    0 => (0u64, 16u64, "small-first"),
1544                    1..=16 => (17u64, 4096u64, "huge-second"),
1545                    _ => return Ok(None),
1546                };
1547                Ok(Some(Candidate {
1548                    offset,
1549                    payload_start: offset,
1550                    meta: EntryMeta::file(name),
1551                    declared_len: Some(len),
1552                    verifier: Some(Verifier::Crc32(len as u32)),
1553                    available_len: None,
1554                    marked_deleted: false,
1555                }))
1556            }
1557        }
1558
1559        let out = salvage_all(
1560            &mut TwoCandidates,
1561            &mut bytes(&[0u8; 8192]),
1562            &SalvagePolicy {
1563                max_entry: 64,
1564                ..SalvagePolicy::default()
1565            },
1566        )
1567        .expect("one over-ceiling entry must never abort the run");
1568        assert_eq!(out.entries.len(), 2);
1569        // `TwoCandidates` does not override `verify`, so the first entry
1570        // takes the trait's default — `Unattested` since Salvage Stage 3,
1571        // where it was `Complete`. Neither figure is what this test is
1572        // about: the point is that the SECOND entry's ceiling refusal did
1573        // not cost the first entry its row.
1574        assert_eq!(out.entries[0].status, SalvageStatus::Unattested);
1575        assert_eq!(
1576            out.entries[1].status,
1577            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1578                needed: 4096,
1579                ceiling: 64,
1580            })
1581        );
1582    }
1583
1584    /// A scanner's own [`SalvageScan::max_whole_entry`] narrows the policy's
1585    /// ceiling — the composition ARC relies on, proven against a mock so it
1586    /// does not depend on ARC's own 256 MiB constant. The candidate here is
1587    /// well under `policy.max_entry` and over the scanner's own figure, so
1588    /// only the `min` can refuse it; the panicking reader proves it is
1589    /// refused without being read.
1590    #[test]
1591    fn a_scanners_own_whole_entry_ceiling_narrows_the_policys() {
1592        struct DecodesWhole;
1593        impl SalvageScan for DecodesWhole {
1594            fn next_candidate(
1595                &mut self,
1596                _src: &mut dyn SeekRead,
1597                from: u64,
1598            ) -> Result<Option<Candidate>> {
1599                if from > 0 {
1600                    return Ok(None);
1601                }
1602                Ok(Some(Candidate {
1603                    offset: 0,
1604                    payload_start: 0,
1605                    meta: EntryMeta::file("whole"),
1606                    declared_len: Some(2048),
1607                    verifier: Some(Verifier::Crc32(7)),
1608                    available_len: None,
1609                    marked_deleted: false,
1610                }))
1611            }
1612            fn max_whole_entry(&self) -> u64 {
1613                1024
1614            }
1615            fn verify(&self, _src: &mut dyn SeekRead, _c: &Candidate) -> Result<SalvageStatus> {
1616                panic!("verify must not run for a candidate over the ceiling");
1617            }
1618        }
1619
1620        let out = salvage_all(
1621            &mut DecodesWhole,
1622            &mut panicking_reader(4096),
1623            &SalvagePolicy::default(),
1624        )
1625        .expect("a scanner ceiling is a per-entry refusal, not a run failure");
1626        assert_eq!(
1627            out.entries[0].status,
1628            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1629                needed: 2048,
1630                // The SCANNER's figure, not the policy's 4 GiB — the `min`
1631                // is what this test is really pinning.
1632                ceiling: 1024,
1633            })
1634        );
1635    }
1636
1637    #[test]
1638    fn a_declared_length_within_the_ceiling_is_collected_rather_than_refused() {
1639        let mut scan = DeclaresLength(4096);
1640        let out = salvage_all(
1641            &mut scan,
1642            &mut panicking_reader(4096),
1643            &SalvagePolicy::default(),
1644        )
1645        .unwrap();
1646        assert_eq!(out.entries.len(), 1);
1647        let entry = &out.entries[0];
1648        assert_eq!(entry.scan_position, 0);
1649        assert_eq!(entry.offset, 0);
1650        // `DeclaresLength` does not override `verify`, so this is the
1651        // trait's default: `Unattested` since Salvage Stage 3, where it was
1652        // `Complete`. The test is about the entry being COLLECTED — its
1653        // declared length sits under the ceiling — not about which tier a
1654        // mock that checks nothing lands in.
1655        assert_eq!(entry.status, SalvageStatus::Unattested);
1656        assert_eq!(entry.shadows, None);
1657        assert_eq!(entry.collides_with, None);
1658    }
1659
1660    /// A truncated candidate is COLLECTED, not dropped — the whole point of
1661    /// [`Candidate::available_len`]. Before it existed, `zip_salvage.rs`
1662    /// refused such a candidate outright and a truncated archive's last
1663    /// entry vanished with no row, no note and exit 0.
1664    #[test]
1665    fn a_candidate_whose_payload_ran_out_is_still_collected() {
1666        let mut scan = DeclaresMoreThanIsThere {
1667            declared: 4000,
1668            available: 1950,
1669        };
1670        let out = salvage_all(
1671            &mut scan,
1672            &mut panicking_reader(4096),
1673            &SalvagePolicy::default(),
1674        )
1675        .unwrap();
1676        assert_eq!(
1677            out.entries.len(),
1678            1,
1679            "a header found but not completable is a fact worth reporting"
1680        );
1681    }
1682
1683    /// The ceiling is checked against what could be READ, not against what
1684    /// a truncated header CLAIMED. Otherwise a garbage length in a tail the
1685    /// scan has already established is not there would abort the entire run
1686    /// at exit 6 — losing every earlier entry over an absent one, in the
1687    /// verb that exists to rescue exactly that archive.
1688    #[test]
1689    fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
1690        let mut scan = DeclaresMoreThanIsThere {
1691            declared: u64::MAX,
1692            available: 2048,
1693        };
1694        let out = salvage_all(
1695            &mut scan,
1696            &mut panicking_reader(4096),
1697            &SalvagePolicy::default(),
1698        )
1699        .expect("an absent tail must not abort a run that recovered real entries");
1700        assert_eq!(out.entries.len(), 1);
1701    }
1702
1703    /// The complement, so the rule above cannot be read as "truncated
1704    /// candidates are never bounded": what IS there is still bounded, and a
1705    /// truncated candidate holding more available bytes than the ceiling
1706    /// allows is refused exactly as an untruncated one would be — reported
1707    /// [`UnverifiedCause::OverEntryCeiling`], never read (the reader
1708    /// panics if it is), and never allowed to end the run.
1709    #[test]
1710    fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
1711        let mut scan = DeclaresMoreThanIsThere {
1712            declared: u64::MAX,
1713            available: 4096,
1714        };
1715        let out = salvage_all(
1716            &mut scan,
1717            &mut panicking_reader(4096),
1718            &SalvagePolicy {
1719                max_entry: 1024,
1720                ..SalvagePolicy::default()
1721            },
1722        )
1723        .expect("a bounded refusal is per-entry, never a run failure");
1724        assert_eq!(
1725            out.entries[0].status,
1726            // `needed` is 4096 — the bytes that ARE there — never the
1727            // `u64::MAX` this candidate's header declared. Fix round 4
1728            // (NEW-F): the figures a user is shown are the ones the
1729            // comparison used.
1730            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1731                needed: 4096,
1732                ceiling: 1024,
1733            }),
1734            "an over-ceiling entry is unverified, not truncated: nobody read it"
1735        );
1736    }
1737
1738    #[test]
1739    fn the_default_policy_is_recovery_biased() {
1740        let policy = SalvagePolicy::default();
1741        assert_eq!(policy.partial, PartialPolicy::Keep);
1742        assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
1743        assert!(!policy.strict);
1744    }
1745
1746    // -----------------------------------------------------------------
1747    // Task 4: `verify` dispatch and shadow detection, proven against a
1748    // mock — same discipline as the rest of this module, since neither
1749    // mechanism needs to know which format it is annotating.
1750    // -----------------------------------------------------------------
1751
1752    /// Reports a fixed, caller-scripted sequence of candidates and answers
1753    /// `verify` from the SAME script rather than decoding anything — this
1754    /// module's engine is proven against mocks, and verification/shadow
1755    /// detection are no exception. Each entry names its own `name` and
1756    /// `declared_len` (rather than a fixed, offset-derived name) so a test
1757    /// can freely vary any one of the three fields shadow detection now
1758    /// requires to agree.
1759    struct ScriptedCandidates {
1760        plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
1761        next: usize,
1762    }
1763
1764    impl SalvageScan for ScriptedCandidates {
1765        fn next_candidate(
1766            &mut self,
1767            _src: &mut dyn SeekRead,
1768            _from: u64,
1769        ) -> Result<Option<Candidate>> {
1770            if self.next >= self.plan.len() {
1771                return Ok(None);
1772            }
1773            let offset = self.next as u64;
1774            let (name, declared_len, verifier, _) = self.plan[self.next];
1775            self.next += 1;
1776            Ok(Some(Candidate {
1777                offset,
1778                payload_start: offset,
1779                meta: EntryMeta::file(name),
1780                declared_len,
1781                verifier,
1782                available_len: None,
1783                marked_deleted: false,
1784            }))
1785        }
1786
1787        fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
1788            Ok(self.plan[candidate.offset as usize].3)
1789        }
1790    }
1791
1792    /// `verify` decides each entry's status — the whole point of Task 4:
1793    /// this used to be a hardcoded `Complete` for every candidate, which was
1794    /// wrong for any format carrying a real checksum.
1795    #[test]
1796    fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
1797        let mut scan = ScriptedCandidates {
1798            plan: vec![
1799                (
1800                    "a",
1801                    Some(1),
1802                    Some(Verifier::Crc32(1)),
1803                    SalvageStatus::Intact,
1804                ),
1805                ("b", Some(1), None, SalvageStatus::Complete),
1806                (
1807                    "c",
1808                    Some(1),
1809                    Some(Verifier::Crc32(2)),
1810                    SalvageStatus::Partial,
1811                ),
1812            ],
1813            next: 0,
1814        };
1815        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1816            .expect("scripted candidates must annotate cleanly");
1817        assert_eq!(out.entries[0].status, SalvageStatus::Intact);
1818        assert_eq!(out.entries[1].status, SalvageStatus::Complete);
1819        assert_eq!(out.entries[2].status, SalvageStatus::Partial);
1820    }
1821
1822    /// A later candidate reporting the SAME name, declared_len AND verifier
1823    /// as an earlier one is its shadow — pointing at the earliest position,
1824    /// not merely "some" earlier one.
1825    #[test]
1826    fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
1827        let mut scan = ScriptedCandidates {
1828            plan: vec![
1829                (
1830                    "dup",
1831                    Some(1),
1832                    Some(Verifier::Crc32(7)),
1833                    SalvageStatus::Intact,
1834                ),
1835                (
1836                    "other",
1837                    Some(1),
1838                    Some(Verifier::Crc32(9)),
1839                    SalvageStatus::Intact,
1840                ),
1841                (
1842                    "dup",
1843                    Some(1),
1844                    Some(Verifier::Crc32(7)),
1845                    SalvageStatus::Intact,
1846                ),
1847            ],
1848            next: 0,
1849        };
1850        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1851            .expect("scripted candidates must annotate cleanly");
1852        assert_eq!(out.entries[0].shadows, None);
1853        assert_eq!(out.entries[1].shadows, None);
1854        assert_eq!(
1855            out.entries[2].shadows,
1856            Some(0),
1857            "must point at the earliest matching position"
1858        );
1859    }
1860
1861    /// A candidate with no verifier at all (a format with no checksum, or a
1862    /// zip data-descriptor entry) must never be reported as shadowing or
1863    /// shadowed — there is nothing to measure, so nothing is claimed.
1864    #[test]
1865    fn candidates_with_no_verifier_never_shadow_each_other() {
1866        let mut scan = ScriptedCandidates {
1867            plan: vec![
1868                ("a", Some(1), None, SalvageStatus::Complete),
1869                ("a", Some(1), None, SalvageStatus::Complete),
1870            ],
1871            next: 0,
1872        };
1873        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1874            .expect("scripted candidates must annotate cleanly");
1875        assert_eq!(out.entries[0].shadows, None);
1876        assert_eq!(out.entries[1].shadows, None);
1877    }
1878
1879    /// Two candidates whose checksum AND declared_len agree but whose NAME
1880    /// differs must never be linked — the fix-round requirement that a
1881    /// checksum match alone is not enough, only the full
1882    /// `(name, declared_len, verifier)` triple.
1883    #[test]
1884    fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
1885        let mut scan = ScriptedCandidates {
1886            plan: vec![
1887                (
1888                    "one.txt",
1889                    Some(4),
1890                    Some(Verifier::Crc32(42)),
1891                    SalvageStatus::Intact,
1892                ),
1893                (
1894                    "two.txt",
1895                    Some(4),
1896                    Some(Verifier::Crc32(42)),
1897                    SalvageStatus::Intact,
1898                ),
1899            ],
1900            next: 0,
1901        };
1902        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1903            .expect("scripted candidates must annotate cleanly");
1904        assert_eq!(
1905            out.entries[1].shadows, None,
1906            "same checksum, different name — not a shadow"
1907        );
1908    }
1909
1910    /// The degenerate case: every empty entry (and, in zip, every directory
1911    /// entry) checksums to the same fixed constant over zero declared
1912    /// bytes. Even under the SAME name, a zero-length checksum must never
1913    /// mark a shadow — the value proves nothing, unlike a real collision.
1914    #[test]
1915    fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
1916        let mut scan = ScriptedCandidates {
1917            plan: vec![
1918                (
1919                    "empty.txt",
1920                    Some(0),
1921                    Some(Verifier::Crc32(0)),
1922                    SalvageStatus::Complete,
1923                ),
1924                (
1925                    "empty.txt",
1926                    Some(0),
1927                    Some(Verifier::Crc32(0)),
1928                    SalvageStatus::Complete,
1929                ),
1930            ],
1931            next: 0,
1932        };
1933        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1934            .expect("scripted candidates must annotate cleanly");
1935        assert_eq!(out.entries[0].shadows, None);
1936        assert_eq!(
1937            out.entries[1].shadows, None,
1938            "a zero-byte checksum must never mark a shadow, even under an identical name"
1939        );
1940        assert_eq!(
1941            out.entries[1].collides_with,
1942            Some(0),
1943            "the NAME still repeats, and that is a directly observed fact no checksum \
1944             guard applies to"
1945        );
1946    }
1947
1948    // -----------------------------------------------------------------
1949    // Name collisions: the second annotation, separate from shadowing.
1950    // -----------------------------------------------------------------
1951
1952    /// The finding this annotation closes: two records under one name whose
1953    /// content does NOT agree are what `stuffr list` calls shadowed, and
1954    /// what salvage marked in no way at all — because its own `shadows`
1955    /// means something stricter.
1956    #[test]
1957    fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
1958        let mut scan = ScriptedCandidates {
1959            plan: vec![
1960                (
1961                    "dup.txt",
1962                    Some(21),
1963                    Some(Verifier::Crc32(7)),
1964                    SalvageStatus::Intact,
1965                ),
1966                (
1967                    "other.txt",
1968                    Some(21),
1969                    Some(Verifier::Crc32(9)),
1970                    SalvageStatus::Intact,
1971                ),
1972                (
1973                    "dup.txt",
1974                    Some(21),
1975                    Some(Verifier::Crc32(11)),
1976                    SalvageStatus::Intact,
1977                ),
1978            ],
1979            next: 0,
1980        };
1981        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1982            .expect("scripted candidates must annotate cleanly");
1983        assert_eq!(out.entries[2].shadows, None, "the content does not agree");
1984        assert_eq!(
1985            out.entries[2].collides_with,
1986            Some(0),
1987            "but the name does, and that is the fact `list` warns about"
1988        );
1989        assert_eq!(out.entries[0].collides_with, None);
1990        assert_eq!(out.entries[1].collides_with, None);
1991    }
1992
1993    /// The two annotations are mutually exclusive: a record PROVEN to be a
1994    /// copy says so, and does not also report the weaker fact. Reporting
1995    /// both would make every shadow print two markers for one relationship.
1996    #[test]
1997    fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
1998        let mut scan = ScriptedCandidates {
1999            plan: vec![
2000                (
2001                    "dup.txt",
2002                    Some(21),
2003                    Some(Verifier::Crc32(7)),
2004                    SalvageStatus::Intact,
2005                ),
2006                (
2007                    "dup.txt",
2008                    Some(21),
2009                    Some(Verifier::Crc32(7)),
2010                    SalvageStatus::Intact,
2011                ),
2012            ],
2013            next: 0,
2014        };
2015        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2016            .expect("scripted candidates must annotate cleanly");
2017        assert_eq!(out.entries[1].shadows, Some(0));
2018        assert_eq!(out.entries[1].collides_with, None);
2019    }
2020
2021    /// A third record under a name two earlier ones already used points at
2022    /// the EARLIEST of them, the same rule `shadows` follows — so a chain of
2023    /// collisions names one origin rather than each pointing at its
2024    /// predecessor.
2025    #[test]
2026    fn a_third_record_under_one_name_names_the_earliest_position() {
2027        let mut scan = ScriptedCandidates {
2028            plan: vec![
2029                (
2030                    "dup",
2031                    Some(4),
2032                    Some(Verifier::Crc32(1)),
2033                    SalvageStatus::Intact,
2034                ),
2035                (
2036                    "dup",
2037                    Some(4),
2038                    Some(Verifier::Crc32(2)),
2039                    SalvageStatus::Intact,
2040                ),
2041                (
2042                    "dup",
2043                    Some(4),
2044                    Some(Verifier::Crc32(3)),
2045                    SalvageStatus::Intact,
2046                ),
2047            ],
2048            next: 0,
2049        };
2050        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2051            .expect("scripted candidates must annotate cleanly");
2052        assert_eq!(out.entries[1].collides_with, Some(0));
2053        assert_eq!(out.entries[2].collides_with, Some(0));
2054    }
2055
2056    /// The negative double: distinct names must never be marked, however
2057    /// much else they share. Without this, an annotation that fired on
2058    /// everything would pass every test above.
2059    #[test]
2060    fn distinct_names_are_never_reported_as_colliding() {
2061        let mut scan = ScriptedCandidates {
2062            plan: vec![
2063                (
2064                    "one.txt",
2065                    Some(4),
2066                    Some(Verifier::Crc32(42)),
2067                    SalvageStatus::Intact,
2068                ),
2069                (
2070                    "two.txt",
2071                    Some(4),
2072                    Some(Verifier::Crc32(42)),
2073                    SalvageStatus::Intact,
2074                ),
2075            ],
2076            next: 0,
2077        };
2078        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2079            .expect("scripted candidates must annotate cleanly");
2080        assert_eq!(out.entries[0].collides_with, None);
2081        assert_eq!(out.entries[1].collides_with, None);
2082    }
2083
2084    /// A collision needs no verifier at all, unlike a shadow. A format with
2085    /// no checksum (tar, cpio, ar — and a zip data-descriptor entry) can
2086    /// still repeat a name, and that repetition is exactly as real there.
2087    #[test]
2088    fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
2089        let mut scan = ScriptedCandidates {
2090            plan: vec![
2091                ("same", Some(4), None, SalvageStatus::Complete),
2092                ("same", Some(4), None, SalvageStatus::Complete),
2093            ],
2094            next: 0,
2095        };
2096        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2097            .expect("scripted candidates must annotate cleanly");
2098        assert_eq!(
2099            out.entries[1].shadows, None,
2100            "no verifier, so nothing proves they are copies"
2101        );
2102        assert_eq!(
2103            out.entries[1].collides_with,
2104            Some(0),
2105            "but the name repeats, which needs no verifier to observe"
2106        );
2107    }
2108
2109    /// Ruling S-I. `Candidate` is `#[non_exhaustive]`, so an out-of-crate
2110    /// scanner reaches it only through `new` plus the `with_*` setters —
2111    /// which means the DEFAULTS are now public API, not an implementation
2112    /// detail of six struct literals. Each of the four must assert the
2113    /// least: nothing declared, nothing to verify with, nothing missing,
2114    /// nothing deleted.
2115    #[test]
2116    fn a_new_candidate_claims_nothing_it_was_not_told() {
2117        let c = Candidate::new(7, 42, EntryMeta::file("probe"));
2118        assert_eq!(c.offset, 7);
2119        assert_eq!(c.payload_start, 42);
2120        assert_eq!(c.declared_len, None, "no length was declared");
2121        assert_eq!(c.verifier, None, "nothing can prove the content");
2122        assert_eq!(
2123            c.available_len, None,
2124            "a shortfall is MEASURED, never assumed"
2125        );
2126        assert!(!c.marked_deleted, "no record is deleted until one says so");
2127    }
2128
2129    /// The four setters are the only route to those fields from another
2130    /// crate, so each one is pinned rather than trusted to the compiler.
2131    #[test]
2132    fn each_candidate_setter_reaches_its_own_field() {
2133        let c = Candidate::new(0, 0, EntryMeta::file("probe"))
2134            .with_declared_len(Some(9))
2135            .with_verifier(Some(Verifier::Crc16(0xbeef)))
2136            .with_available_len(Some(4))
2137            .with_marked_deleted(true);
2138        assert_eq!(c.declared_len, Some(9));
2139        assert_eq!(c.verifier, Some(Verifier::Crc16(0xbeef)));
2140        assert_eq!(c.available_len, Some(4));
2141        assert!(c.marked_deleted);
2142    }
2143
2144    /// Same ruling, same reason, for the type every per-format
2145    /// `write_payload` test builds directly. Its three defaults are the
2146    /// annotations `annotate_candidates` adds, so an entry built by hand
2147    /// must carry none of them.
2148    #[test]
2149    fn a_new_salvaged_entry_carries_no_annotations() {
2150        let e = SalvagedEntry::new(3, 7, 42, EntryMeta::file("probe"), SalvageStatus::Complete);
2151        assert_eq!(e.scan_position, 3);
2152        assert_eq!(e.offset, 7);
2153        assert_eq!(e.payload_start, 42);
2154        assert_eq!(e.status, SalvageStatus::Complete);
2155        assert_eq!(e.shadows, None);
2156        assert_eq!(e.collides_with, None);
2157        assert!(!e.marked_deleted);
2158
2159        let annotated = SalvagedEntry::new(0, 0, 0, EntryMeta::file("p"), SalvageStatus::Intact)
2160            .with_shadows(Some(1))
2161            .with_collides_with(Some(2))
2162            .with_marked_deleted(true);
2163        assert_eq!(annotated.shadows, Some(1));
2164        assert_eq!(annotated.collides_with, Some(2));
2165        assert!(annotated.marked_deleted);
2166    }
2167
2168    /// Task 2-N: the note a caller prints for its sightings. None for none —
2169    /// a run with nothing refused must print nothing — and otherwise one
2170    /// group per (format, shape), each with its complete count and at most
2171    /// [`SIGHTING_OFFSETS_SHOWN`] offsets, in the order first seen.
2172    #[test]
2173    fn describe_sightings_groups_by_shape_and_bounds_what_it_lists() {
2174        assert_eq!(describe_sightings(&[]), None);
2175
2176        let f = FormatId::new("fmt");
2177        let mut seen: Vec<Sighting> = (0..10u64)
2178            .map(|i| Sighting::new(f, i * 512, "a(s)"))
2179            .collect();
2180        seen.insert(1, Sighting::new(f, 7, "b(s)"));
2181        let note = describe_sightings(&seen).unwrap();
2182        assert!(
2183            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"),
2184            "{note}"
2185        );
2186        assert!(note.contains("`stuffr list`"), "{note}");
2187    }
2188
2189    /// Task 4: the walk-stop note. A hole says what the brief requires —
2190    /// the entries after it are unreachable by construction, NOT absent,
2191    /// with the offset and the byte count — and a cut-short tail says the
2192    /// opposite, because nothing can be hiding in too few bytes for a record.
2193    #[test]
2194    fn describe_walk_stop_says_unreachable_for_a_hole_and_complete_for_a_cut() {
2195        let f = FormatId::new("fmt");
2196        let hole = describe_walk_stop(&WalkStop::new(
2197            f,
2198            1234,
2199            5000,
2200            WalkStopKind::Unreadable,
2201            "bad field",
2202        ));
2203        for needle in [
2204            "offset 1234",
2205            "5000 bytes",
2206            "bad field",
2207            "unreachable by construction",
2208            "not absent",
2209            "fmt",
2210        ] {
2211            assert!(hole.contains(needle), "missing {needle:?}: {hole}");
2212        }
2213        let cut = describe_walk_stop(&WalkStop::new(f, 90, 1, WalkStopKind::CutShort, "eof"));
2214        assert!(cut.contains("1 byte into the record at offset 90"), "{cut}");
2215        assert!(cut.contains("cut short"), "{cut}");
2216        assert!(!cut.contains("unreachable"), "{cut}");
2217        let shape = describe_walk_stop(&WalkStop::new(
2218            f,
2219            8,
2220            600,
2221            WalkStopKind::UnsupportedShape,
2222            "the symbol table",
2223        ));
2224        assert!(shape.contains("the symbol table"), "{shape}");
2225        assert!(shape.contains("not evidence of damage"), "{shape}");
2226        assert!(!shape.contains("unreachable by construction"), "{shape}");
2227    }
2228}