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::collections::HashMap;
84use std::io::{self, Read, SeekFrom, Write};
85use std::path::Path;
86
87use crate::archive::EntryMeta;
88use crate::error::{Error, Result};
89use crate::format::FormatId;
90use crate::source::SeekRead;
91
92/// 4 GiB. A scanned length is untrustworthy TWICE over — attacker-controlled
93/// AND possibly corrupt — so this is a structural ceiling, not a budget.
94///
95/// Applied per ENTRY, never to the run: an entry over it is reported
96/// [`UnverifiedCause::OverEntryCeiling`] and skipped, and the rest of the
97/// archive is recovered as usual. It used to abort the run outright, which
98/// meant one absurd declaration cost every entry around it.
99pub const MAX_SALVAGE_ENTRY: u64 = 4 * 1024 * 1024 * 1024;
100
101/// What a format can prove about a candidate it found.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum Verifier {
104    /// CRC-32, as zip, arj and gzip compute it.
105    Crc32(u32),
106    /// CRC-16/ARC, as lha, arc and zoo compute it.
107    Crc16(u16),
108}
109
110/// A record a scanner believes it found, before anything is decoded.
111///
112/// # Why this is `#[non_exhaustive]` AND has a constructor
113///
114/// Ruling S-I, deferred from Salvage Stage 2 Task 1 to Task 9 on purpose:
115/// this struct is the one shape [`SalvageScan::next_candidate`] *forces* an
116/// external implementor to build, so every field added to it used to break
117/// every out-of-crate scanner at once — and at `0.5.0` all four crates are
118/// published, so "out-of-crate" is not hypothetical. The attribute alone
119/// would have been the wrong fix (`CONTRIBUTING.md`'s `#[non_exhaustive]`
120/// section says why: it forbids `..` construction from another crate, which
121/// is exactly what an implementor must do), which is why it waited for
122/// [`Candidate::new`] — and for all five in-tree scanners to exist, so the
123/// constructor's shape could be read off real callers rather than guessed.
124///
125/// The three arguments [`Candidate::new`] takes are the three facts every
126/// scanner knows the instant it recognises a record. The four `with_*`
127/// setters cover the rest, each taking the field's own type unchanged so
128/// nothing is hidden behind a conversion.
129#[derive(Debug)]
130#[non_exhaustive]
131pub struct Candidate {
132    /// Byte offset of the record's header in the archive.
133    pub offset: u64,
134    /// Byte offset where this record's PAYLOAD begins — past whatever
135    /// fixed and variable-length header fields this format's record
136    /// carries (a zip local header's name/extra fields, ARC's fixed
137    /// 28-byte record, and so on).
138    ///
139    /// Task 3c added this field after `entries.rs`'s write path hardcoded
140    /// a ZIP local header's own 30-byte-plus-name-plus-extra layout as
141    /// the way to locate every format's payload — which crashed
142    /// (`Error::Io`, exit 1, the wildcard this project treats as a
143    /// defect) the moment a non-zip scanner (ARC) reported a candidate
144    /// near the end of the file, because reading 30 bytes from ITS offset
145    /// ran past EOF. Each scanner already computes this value once, at
146    /// discovery, to know where ITS OWN payload begins — this field
147    /// carries that computation forward instead of a consumer re-deriving
148    /// (and mis-deriving) it later. A future scanner (zoo, lha, arj) fills
149    /// this the same way; nothing outside the scanner that discovered a
150    /// candidate needs to know its record's own layout at all.
151    pub payload_start: u64,
152    pub meta: EntryMeta,
153    /// Declared payload length. `None` when the header does not carry one.
154    pub declared_len: Option<u64>,
155    /// The checksum the ORIGINAL writer computed, when the format has one.
156    /// `None` is what produces the intact/complete split honestly, rather
157    /// than by a per-format convention someone has to remember.
158    pub verifier: Option<Verifier>,
159    /// How many of `declared_len`'s bytes the SOURCE actually holds, when
160    /// it holds fewer — the truncated-tail case. `None` means the declared
161    /// payload is entirely present (the ordinary case) or nothing was
162    /// declared at all; `Some(n)` always means `n < declared_len`.
163    ///
164    /// # Why a scanner reports this instead of dropping the candidate
165    ///
166    /// A truncated archive — an interrupted download — is the single most
167    /// common damaged zip there is, and it was the one shape salvage said
168    /// nothing at all about. `zip_salvage.rs`'s validation gate refused any
169    /// candidate whose declared payload ran past the end of the file, which
170    /// is a correct refusal to INVENT the missing bytes (`zip -FF`
171    /// fabricates them and reports success) implemented as SILENCE: no row,
172    /// no note, no non-zero exit. Swept across six truncation points inside
173    /// a three-header archive's last entry, every one reported "2 scanned"
174    /// at exit 0 — the third header was not even counted.
175    ///
176    /// Reporting the candidate with this field set is what turns the
177    /// refusal into a statement. Nothing is invented: the declared length
178    /// stays exactly what the header said, so a reader can see both figures,
179    /// and the bytes that ARE present are still a genuine prefix. What a
180    /// scanner must NOT do is fold the shortfall into `declared_len` — that
181    /// would make a truncated entry indistinguishable from a whole one of
182    /// the smaller size, which is the same mistake in a different place.
183    pub available_len: Option<u64>,
184    /// The archive's own record marks this entry DELETED — the format's
185    /// owner removed it and the writer left the record, and its payload,
186    /// in place.
187    ///
188    /// `false` for every format that has no such flag (zip, tar, ar, cpio,
189    /// arc), which is why it is a plain `bool` rather than an `Option`:
190    /// "this format cannot express deletion" and "this record is not
191    /// deleted" lead to the identical, unannotated row, and a third state
192    /// would be a distinction no consumer could act on.
193    ///
194    /// # Why a scanner reports one rather than dropping it (Ruling S-R)
195    ///
196    /// Salvage Stage 2 Task 4, for ZOO — the first format here with the
197    /// flag. `zoo d` marks an entry `deleted = 1` and leaves it whole in the
198    /// file; `zoolist.c` neither lists nor extracts it, and `legacy::zoo`'s
199    /// reader follows that exactly. The flag is ONE BYTE, so in a damaged
200    /// archive a bit flip turns a live entry into one no ordinary verb will
201    /// ever hand back — which is precisely the archive this verb exists for.
202    /// So the scanner reports it.
203    ///
204    /// **And the report carries a marker, which is the part that took a
205    /// review to get right.** Everywhere else salvage accepts less than an
206    /// ordinary entry it says so on the artifact or in the row: a `Partial`
207    /// lands as `NAME.partial` and never under its real name, a second
208    /// record under a taken name lands as `NAME.salvaged-N`, a proven copy
209    /// prints `[shadowed]`. A deleted record reported `Intact` under its
210    /// real name at exit 0 was the one place that leniency was invisible —
211    /// four verbs call the archive empty and the fifth writes the file, with
212    /// nothing telling a user which it was.
213    ///
214    /// **The exit code deliberately does NOT move.** Recovering a deleted
215    /// record is this verb working as designed, not degraded fidelity — see
216    /// `stuffr::entries`'s own exit-code aggregation, which this field is
217    /// invisible to.
218    pub marked_deleted: bool,
219}
220
221impl Candidate {
222    /// A candidate at `offset`, whose payload begins at `payload_start`,
223    /// carrying `meta`.
224    ///
225    /// Nothing else is claimed. The four fields this does not take default
226    /// to the answers that assert the least:
227    ///
228    /// * `declared_len: None` — the header carries no length, so nothing
229    ///   bounds the payload and [`annotate_candidates`] reports
230    ///   [`UnverifiedCause::NoDeclaredLength`] rather than reading it.
231    /// * `verifier: None` — the format offers nothing to prove the content
232    ///   with, so no shadow is ever claimed for this record (see
233    ///   [`SalvagedEntry::shadows`], which needs a verifier to compare).
234    /// * `available_len: None` — every declared byte is present, which is
235    ///   the ordinary case; a scanner sets this only when it has MEASURED a
236    ///   shortfall.
237    /// * `marked_deleted: false` — the format has no deleted flag, or this
238    ///   record is not marked. Four of the five in-tree scanners leave it.
239    ///
240    /// One default is worth naming because it decides a STATUS, not just a
241    /// field: with no `verifier`, [`SalvageScan::verify`]'s own default
242    /// answers [`SalvageStatus::Unattested`] — the tier that asserts the
243    /// least, matching the discipline of the four above. It answered
244    /// [`SalvageStatus::Complete`] until Salvage Stage 3, which was the
245    /// lenient direction: `Complete` claims a header self-check the default
246    /// never performs, and routes to exit 0. A scanner carrying a checksum
247    /// has to write its own `verify` regardless (the default checks
248    /// nothing), and that `verify` reads this very field, so the omission
249    /// fails in the scanner's own first test rather than silently.
250    pub fn new(offset: u64, payload_start: u64, meta: EntryMeta) -> Self {
251        Self {
252            offset,
253            payload_start,
254            meta,
255            declared_len: None,
256            verifier: None,
257            available_len: None,
258            marked_deleted: false,
259        }
260    }
261
262    /// Sets [`Self::declared_len`].
263    #[must_use]
264    pub fn with_declared_len(mut self, declared_len: Option<u64>) -> Self {
265        self.declared_len = declared_len;
266        self
267    }
268
269    /// Sets [`Self::verifier`].
270    #[must_use]
271    pub fn with_verifier(mut self, verifier: Option<Verifier>) -> Self {
272        self.verifier = verifier;
273        self
274    }
275
276    /// Sets [`Self::available_len`] — the truncated-tail case, and only
277    /// when the shortfall was measured. See that field's doc for why a
278    /// scanner must never fold it into [`Self::declared_len`] instead.
279    #[must_use]
280    pub fn with_available_len(mut self, available_len: Option<u64>) -> Self {
281        self.available_len = available_len;
282        self
283    }
284
285    /// Sets [`Self::marked_deleted`] — Ruling S-R. See that field's doc.
286    #[must_use]
287    pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
288        self.marked_deleted = marked_deleted;
289        self
290    }
291}
292
293/// Why an entry is [`SalvageStatus::Unverified`].
294///
295/// Three causes, and the STATUS does not distinguish them because the
296/// decision they lead to is identical (listed, not written, exit 3) — the
297/// same split [`crate::salvage`]'s consumers apply to
298/// [`SalvageStatus::Partial`] via their own cause type (`entries.rs`'s
299/// `PartialCause`, one crate up): **a tier carries a decision, a message
300/// carries a cause.**
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum UnverifiedCause {
303    /// This build recognises the entry's compression method but cannot
304    /// decode it (zip method 99 is AES, 14 is LZMA — recognised, not
305    /// decoded).
306    UndecodableMethod,
307    /// No length was available to bound the payload, so it was never read —
308    /// a data-descriptor entry the raw scan found with no central-directory
309    /// record to reconcile against. A CD-reconciled data-descriptor entry
310    /// gets a real declared length and CRC from the central directory and
311    /// does NOT reach this cause; see `zip_salvage.rs`'s
312    /// `candidate_from_cd_record`.
313    NoDeclaredLength,
314    /// The bytes this entry would have required are past the ceiling this
315    /// run will read for ONE entry — `policy.max_entry` narrowed by the
316    /// scanner's own [`SalvageScan::max_whole_entry`] — so nothing was read
317    /// and nothing was decoded. See [`annotate_candidates`], which is the
318    /// one place that decides this, for the whole rule.
319    ///
320    /// # Why this is a cause and not an `Err`
321    ///
322    /// Salvage Stage 2 Task 3c's fix round 3. This refusal used to be an
323    /// [`crate::Error::ResourceLimit`] raised from two different places — the
324    /// engine's own [`collect_candidates`], and (for ARC) the scanner's own
325    /// `verify` — and an `Err` at either site **aborts the whole run**:
326    /// measured at the CLI on a two-entry archive whose second entry was
327    /// over the ceiling, `stuffr salvage --list` printed no rows at all and
328    /// exited 6, and `salvage -C out` left the destination empty, losing a
329    /// first entry that decodes perfectly. That is the one thing this verb
330    /// exists not to do. Three fix rounds chased the shape from site to
331    /// site; what actually closes it is that an over-ceiling entry has a
332    /// STATUS, so there is no `Err` left to propagate.
333    ///
334    /// Distinct from [`SalvageStatus::Partial`], which is what this used to
335    /// collapse into once the `Err` was folded away at the write layer: a
336    /// truncated entry's genuine prefix IS recovered (as `NAME.partial`),
337    /// while an over-ceiling entry has real bytes nobody read. Reporting it
338    /// as `Partial (truncated)` stated two false things — the entry is not
339    /// truncated, and the empty `NAME.partial` it produced said nothing
340    /// survived.
341    ///
342    /// # Why it carries both figures (fix round 4, NEW-F)
343    ///
344    /// The `Err` this replaced carried a sentence naming the declared size
345    /// and the ceiling; the first version of this variant was a unit, so a
346    /// user meeting a container's own fixed ceiling was told neither number
347    /// and could not tell whether `--max-entry` would help. The cause is
348    /// the only place that knows both, so it carries both and the CLI row
349    /// prints them — the project's own "a message carries a cause", with
350    /// the numbers that make the message actionable.
351    OverEntryCeiling {
352        /// Bytes reading this entry would have required: the BOUNDED
353        /// figure the comparison actually used — `available_len` when the
354        /// payload is truncated, the header's `declared_len` otherwise —
355        /// never the raw declaration when the file holds less than it.
356        needed: u64,
357        /// The ceiling in force for this run: [`SalvagePolicy::max_entry`]
358        /// narrowed by [`SalvageScan::max_whole_entry`]. Equal to
359        /// `max_entry` means `--max-entry` can raise it; below it means a
360        /// whole-decoding container's own fixed ceiling decided, and no
361        /// flag moves that.
362        ceiling: u64,
363    },
364}
365
366/// What was proven about an entry AFTER decoding it.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum SalvageStatus {
369    /// A checksum the original writer computed agrees.
370    Intact,
371    /// Every declared byte was present and the header self-verified, but the
372    /// format offers NO CHECKSUM AT ALL to prove the content — `tar`, in
373    /// this project's own formats. Never the right answer for a format that
374    /// DOES carry one; see [`SalvageStatus::Unverified`] for that case,
375    /// which this status is not permitted to stand in for.
376    ///
377    /// # `cpio` and `ar` are NOT this tier, and were named here until Stage 3
378    ///
379    /// This doc said "tar, cpio and ar" from Stage 1 until Salvage Stage 3,
380    /// which is where the sentence's own *"and the header self-verified"*
381    /// clause was measured against the three formats it named. `tar` has a
382    /// header checksum and really does self-verify. **`cpio` and `ar` have
383    /// neither a content checksum nor a self-verifying header**, so for
384    /// those two the clause is not merely unproven — there is nothing in the
385    /// format it could ever be true of, and a scanner answering `Complete`
386    /// for one would be asserting a header self-check that does not exist.
387    /// They are [`SalvageStatus::Unattested`].
388    ///
389    /// `stuffr_core::testing::check_salvage_claim` is what holds the line: this
390    /// tier requires a format whose only checksum covers the HEADER
391    /// (`Attestation::HeaderChecksumOnly`) AND that checksum to have been
392    /// checked — so the checksumless pair cannot reach it, and neither can a
393    /// format with a content checksum, whose honest tiers are `Intact`,
394    /// `Partial` and `Unverified`.
395    Complete,
396    /// Nothing attests that this candidate is an entry AT ALL: the format
397    /// carries **no checksum and no self-verifying header**, so a
398    /// coincidental header match and a real entry are indistinguishable in
399    /// the bytes — `cpio` and `ar`, in this project's own formats.
400    ///
401    /// The entry is still WRITTEN, under its real name: `salvage` is the
402    /// recovery-biased verb, and a tier that refused to write would make
403    /// these two formats unsalvageable rather than honestly salvaged. What
404    /// the tier buys is that the run says so — `salvage_exit_code` answers
405    /// **4** for it (degraded fidelity, never 3: nothing was refused and no
406    /// capability was missing), and the `--list` row carries the reason
407    /// beside the entry. There is no flag to opt into or out of this; the
408    /// formats' own shape decides it.
409    ///
410    /// # Not [`Self::Unverified`] — and the near-miss name is the hazard
411    ///
412    /// The variant deliberately is NOT called `Unverifiable`: that is two
413    /// letters from the [`Self::Unverified`] this enum already has, in an
414    /// enum whose variants select exit codes, and a reviewer skimming match
415    /// arms would not reliably see the difference. The distinction, stated
416    /// wherever both appear:
417    ///
418    /// * [`Self::Unverified`] — the format *has* a checksum; this build
419    ///   could not use it (an undecodable method), or the header declared
420    ///   nothing to check.
421    /// * `Unattested` — the format has **no checksum and no self-verifying
422    ///   header**, so nothing attests that this candidate is an entry at
423    ///   all.
424    ///
425    /// The consequences differ as much as the claims do: `Unverified` is
426    /// NOT written and exits 3 (this build could not do it — a rebuild may
427    /// fix it), `Unattested` IS written and exits 4 (nothing can fix it;
428    /// the format carries no evidence to find).
429    Unattested,
430    /// Nothing about this entry's content was verified.
431    ///
432    /// Two causes ([`UnverifiedCause`]), and the tier does not distinguish
433    /// them because the decision is identical — the entry is listed, not
434    /// written, and the run exits 3:
435    ///   * this build could not decode the method (zip 99 is AES, 14 is
436    ///     LZMA);
437    ///   * no length was available to bound the payload, so it was never
438    ///     read (a data-descriptor entry the raw scan found with no
439    ///     central-directory record to reconcile against).
440    ///
441    /// Distinct from [`Self::Complete`], which asserts every DECLARED byte
442    /// was present — a claim that requires something to have been declared
443    /// in the first place. Reporting `Complete` for either cause above was
444    /// this enum's third instance of "nothing to disprove" being read as
445    /// "proven" (after the Task 1 hardcoded placeholder and the Task 4
446    /// undecodable-method case this variant was originally added for) — the
447    /// exit-code table this status exists to keep honest reserves exit 3 for
448    /// both causes, never exit 0, which is what `Complete` would have routed
449    /// either one to.
450    Unverified(UnverifiedCause),
451    /// The payload ran out, the decoder failed mid-stream, or the content
452    /// decoded whole and disagreed with the checksum the original writer
453    /// computed. All three are the same fact from a caller's perspective —
454    /// this build could not confirm the content is what was written — and
455    /// none of them is `Complete` (something WAS checked) or `Intact` (it
456    /// did not agree, or never finished).
457    Partial,
458}
459
460/// Which class of evidence a FORMAT offers for one of its entries — a
461/// per-format constant, and the third argument to the honesty oracle
462/// `stuffr_core::testing::check_salvage_claim`.
463///
464/// **Always compiled, unlike the oracle**, since Salvage Stage 3 Task 5:
465/// `stuffr formats`' SALVAGE column renders it (`yes` only for
466/// [`Attestation::ContentChecksum`]), so it cannot live behind the
467/// `testing` feature with the oracle that consumes it. Which format has
468/// which class is stated once, beside the scanner dispatch
469/// (`stuffr::entries::salvage_attestation`); the fuzz target and the corpus
470/// generator read it from there rather than keeping a table of their own.
471///
472/// **Three classes, which is why this is an enum and not a `bool`.** Salvage
473/// Stage 3 Task 1 first shipped the argument as `format_offers_verifier:
474/// bool`, and its fix round's review showed with a truth table that no
475/// reading of a two-valued flag can hold the boundary: read as "a content
476/// checksum", it let `cpio` claim [`SalvageStatus::Complete`]; read as "a
477/// content checksum OR a self-verifying header" (the shipped reading), it
478/// let `tar` claim [`SalvageStatus::Intact`] and — live against the five
479/// scanners that shipped before this stage — let `zip`, `arc`, `zoo`, `lha`
480/// or `arj` answer `Complete` for an entry whose CRC was compared and
481/// DISAGREED, turning an exit-4 `Partial` into an exit-0 clean recovery.
482/// Both false claims sat in the one cell (`checked`, `offers`) that tar's
483/// honest `Complete` also occupies, so no function of those inputs could
484/// refuse them and permit it. Ruling 3-G.
485///
486/// Two bools (`offers_content_checksum`, `header_self_verifies`) were
487/// considered and refused: four states for three classes, one of them
488/// meaningless, and the split pushed back onto every caller. Here each
489/// scanner NAMES its class.
490///
491/// **Deliberately NOT `#[non_exhaustive]`**, for the reason Ruling 3-D kept
492/// it off [`SalvageStatus`]: a fourth class of evidence must fail to compile
493/// at every `match`, `check_salvage_claim`'s own included, rather than be
494/// absorbed by a wildcard arm that decides what it permits by accident.
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum Attestation {
497    /// A checksum over the entry's CONTENT, computed by the original writer —
498    /// `zip`, `arc`, `zoo`, `lha`, `arj`. The only class that can earn
499    /// [`SalvageStatus::Intact`].
500    ContentChecksum,
501    /// A checksum over the HEADER only; the content has none — `tar`. The
502    /// only class that can earn [`SalvageStatus::Complete`]: "every declared
503    /// byte was present and the header self-verified" is exactly what this
504    /// class can prove and all it can prove.
505    HeaderChecksumOnly,
506    /// Neither — `cpio`, `ar`. A coincidental header match and a real entry
507    /// are indistinguishable in the bytes, so the only honest tier this
508    /// class can reach on a whole entry is [`SalvageStatus::Unattested`].
509    Nothing,
510}
511
512/// One entry the scan recovered.
513///
514/// `#[non_exhaustive]` plus [`SalvagedEntry::new`], for the same reason
515/// [`Candidate`] carries both — see that type's own doc. This one is built
516/// by [`annotate_candidates`] in ordinary use, but every per-format scanner
517/// crate builds one directly to drive its `write_payload` seam, so the
518/// literal it used to need was an out-of-crate literal all the same.
519#[derive(Debug)]
520#[non_exhaustive]
521pub struct SalvagedEntry {
522    /// This entry's position in SCAN order — not an index any container's
523    /// own format assigns, and never renumbered by what `list` would show.
524    /// A later task's CLI surfaces this distinction explicitly (`--index`
525    /// on `salvage` names a scan position, not a list index).
526    pub scan_position: usize,
527    pub offset: u64,
528    /// Carried forward from [`Candidate::payload_start`] — see that
529    /// field's doc for why a consumer must use this rather than
530    /// re-deriving a payload's location from `offset` itself.
531    pub payload_start: u64,
532    pub meta: EntryMeta,
533    pub status: SalvageStatus,
534    /// Set when this record's `(name, declared_len, verifier)` all agree
535    /// with an EARLIER record's — a measurement, never inferred from a
536    /// repeated name alone, and never set for a degenerate zero-length
537    /// checksum (every empty file and every directory entry shares one).
538    /// See [`annotate_candidates`] for where this is computed.
539    ///
540    /// **This is the byte-identical duplicate, and ONLY that.** The other,
541    /// weaker fact — an earlier record under the same name whose content
542    /// this scan could not prove identical — is [`Self::collides_with`], and
543    /// the two are mutually exclusive by construction. They were one word
544    /// ("shadow") until the final whole-branch review measured what that
545    /// cost: `stuffr list`'s own fidelity warning uses "shadowed" for a
546    /// repeated NAME, so on an archive whose duplicates differ, `list` said
547    /// two records were shadowed and `salvage --list` marked none.
548    pub shadows: Option<usize>,
549    /// Set when an EARLIER record carries the same `meta.name` and this one
550    /// is not that record's [`Self::shadows`] — i.e. the name repeats but
551    /// the content could not be proven identical.
552    ///
553    /// This is the fact `stuffr list`'s `Fidelity::EntryCountMismatch`
554    /// warning is about: a later record repeating a name is what makes an
555    /// earlier one unreachable through a central directory's name index,
556    /// whatever the bytes under it. It is also the fact that decides
557    /// anything on the WRITE side, because two records under one name and
558    /// two different payloads cannot both land on one path.
559    ///
560    /// Deliberately NOT gated on a verifier, a declared length or the
561    /// degenerate zero-length case the way [`Self::shadows`] is: those
562    /// guards exist because claiming two records are COPIES of each other
563    /// needs proof, and this annotation claims no such thing. A repeated
564    /// name is directly observed, and `None` here means only that no
565    /// earlier record used this name — never that one did and this could
566    /// not tell.
567    pub collides_with: Option<usize>,
568    /// Carried forward unchanged from [`Candidate::marked_deleted`] — see
569    /// that field for the whole ruling. Unlike [`Self::shadows`] and
570    /// [`Self::collides_with`], this is not something this module MEASURES:
571    /// it is a fact the archive's own record states, which only the scanner
572    /// that read that record can know.
573    pub marked_deleted: bool,
574}
575
576impl SalvagedEntry {
577    /// An entry at scan position `scan_position`, found at `offset`, whose
578    /// payload begins at `payload_start`, carrying `meta` and proven to
579    /// `status`.
580    ///
581    /// The three fields this does not take default to "nothing was
582    /// observed": no shadow, no name collision, not marked deleted. All
583    /// three are annotations [`annotate_candidates`] adds (or, for
584    /// `marked_deleted`, carries forward from [`Candidate`]), so a caller
585    /// building one directly — every per-format `write_payload` test does —
586    /// wants exactly those defaults.
587    pub fn new(
588        scan_position: usize,
589        offset: u64,
590        payload_start: u64,
591        meta: EntryMeta,
592        status: SalvageStatus,
593    ) -> Self {
594        Self {
595            scan_position,
596            offset,
597            payload_start,
598            meta,
599            status,
600            shadows: None,
601            collides_with: None,
602            marked_deleted: false,
603        }
604    }
605
606    /// Sets [`Self::shadows`] — the byte-identical duplicate, and only
607    /// that. See the field's own doc for what separates it from
608    /// [`Self::collides_with`].
609    #[must_use]
610    pub fn with_shadows(mut self, shadows: Option<usize>) -> Self {
611        self.shadows = shadows;
612        self
613    }
614
615    /// Sets [`Self::collides_with`] — the weaker, name-only annotation.
616    #[must_use]
617    pub fn with_collides_with(mut self, collides_with: Option<usize>) -> Self {
618        self.collides_with = collides_with;
619        self
620    }
621
622    /// Sets [`Self::marked_deleted`], carried forward from
623    /// [`Candidate::marked_deleted`] rather than measured here.
624    #[must_use]
625    pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
626        self.marked_deleted = marked_deleted;
627        self
628    }
629}
630
631/// The result of a scan: every entry the scanner recovered, in scan order.
632///
633/// Later tasks may extend this struct; the `entries` field stays public,
634/// and `#[non_exhaustive]` is what makes "extend" honest. No constructor is
635/// paired with it, unlike [`Candidate`] and [`SalvagedEntry`]: this struct
636/// is built by [`salvage_all`] alone, in this crate, and no scanner — in
637/// this workspace or outside it — has any reason to build one.
638#[derive(Debug)]
639#[non_exhaustive]
640pub struct SalvageOutcome {
641    pub entries: Vec<SalvagedEntry>,
642    /// Header shapes the scanner recognised and could not gate, in scan
643    /// order — see [`Sighting`]. Always empty from [`salvage_all`]: the
644    /// engine cannot know what its scanner refused, so a format's own
645    /// `salvage_*` entry point fills it in after the scan. A sighting is
646    /// never an entry; the facade's `salvage_exit_code` counts a
647    /// non-selective run that has one as degraded (exit 4) since the
648    /// Salvage Stage 3 final review's I-1, which reversed Ruling S-X there.
649    pub sightings: Vec<Sighting>,
650    /// Where a SEQUENTIAL-ONLY scanner's walk stopped short of the end of
651    /// the source, if it did — see [`WalkStop`]. Always `None` from
652    /// [`salvage_all`], for the same reason [`Self::sightings`] is always
653    /// empty there: only the scanner knows its walk ended early, so its own
654    /// `salvage_*` entry point fills this in. Like a sighting it is never an
655    /// entry. Unlike a sighting it never touches the run's exit code.
656    pub walk_stop: Option<WalkStop>,
657}
658
659/// A header a scanner SAW and has no gate for (Rulings S-V and 3-J): its
660/// structure is a header's, and something about it — a field this build
661/// cannot check, a shape a shifted copy also takes — leaves the scan unable
662/// to tell it from a phantom. So it is counted here and never becomes a
663/// [`Candidate`], because the engine advances past a candidate by its
664/// declared length and a phantom's length is how a scan jumps a real header.
665///
666/// **Why it has to be reported at all.** A run that recovers nothing while
667/// seeing one refuses with [`Error::Unsupported`] (exit 3) — the scanner's own
668/// job, since only it can name the shape. A run that recovered OTHER entries
669/// reports them at its ordinary exit code (Ruling S-X), and until Salvage
670/// Stage 3's Task 2-N it said nothing else: an entry `stuffr list` shows was
671/// simply absent from `salvage --list`, at exit 0, with an empty stderr. The
672/// sightings recorded here are what the caller prints instead
673/// ([`describe_sightings`]), in a mixed run as in any other — and since the
674/// stage's final review (I-1, reversing S-X for this case) a non-selective
675/// mixed run also exits 4, not 0: a `salvage -C out && rm` script must not
676/// delete an archive holding an entry `stuffr list` reads.
677#[derive(Debug, Clone, PartialEq, Eq)]
678#[non_exhaustive]
679pub struct Sighting {
680    /// The scanner's format, for the sentence [`describe_sightings`] writes.
681    pub format: FormatId,
682    /// Where the header was seen.
683    pub offset: u64,
684    /// What was seen, as a plural noun phrase that follows a count —
685    /// `"header(s) whose magic field … is neither \`ustar\` nor blank"`. One
686    /// phrase per shape, shared with the scanner's own exit-3 refusal, so
687    /// the two can never describe one shape two ways.
688    pub shape: &'static str,
689    /// Why the header was not listed — which sentence
690    /// [`describe_sightings`] reports it in.
691    pub kind: SightingKind,
692    /// How many further sightings of this same (format, shape, kind) a
693    /// [`SightingLog`] saw but did not store, folded into this one. 0 from
694    /// every constructor. [`describe_sightings`] adds it to the count it
695    /// prints, so a capped log reports the same total as an uncapped one.
696    pub more: u64,
697}
698
699/// Why a [`Sighting`]'s header was not listed. The two are different claims,
700/// so [`describe_sightings`] words them apart.
701#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
702#[non_exhaustive]
703pub enum SightingKind {
704    /// A header with a header's structure, in a shape this build has no
705    /// gate for — every sighting before 0.10.3.
706    #[default]
707    Ungateable,
708    /// A header the scan could gate, but whose extension chains it did not
709    /// read because a bound on the work it spends on them was reached —
710    /// tar's per-header and scan-wide budgets (0.10.3). The header itself
711    /// may be genuine: the scan-wide budget can be spent by forged chains
712    /// elsewhere in the file. The scan carries on past it.
713    WorkBounded,
714    /// A candidate header whose verdict needed a long field — a name, a
715    /// variable part, an extension chain, the bytes a header CRC covers —
716    /// that the scan did not read, because its [`ScanBudget`] was spent
717    /// (0.10.3). Like [`Self::WorkBounded`] the header itself may be
718    /// genuine, starved by forged candidates elsewhere in the file, and the
719    /// scan carries on past it. Its own kind rather than
720    /// [`Self::WorkBounded`] because that one's sentence names extension
721    /// chains, which is false of a zip name or a CRC.
722    VerdictBounded,
723}
724
725impl Sighting {
726    /// An [`SightingKind::Ungateable`] sighting.
727    pub fn new(format: FormatId, offset: u64, shape: &'static str) -> Self {
728        Self {
729            format,
730            offset,
731            shape,
732            kind: SightingKind::Ungateable,
733            more: 0,
734        }
735    }
736
737    /// This sighting standing for `more` further ones that were counted but
738    /// not stored (see [`Sighting::more`]).
739    #[must_use]
740    pub fn with_more(self, more: u64) -> Self {
741        Self { more, ..self }
742    }
743
744    /// A [`SightingKind::WorkBounded`] sighting.
745    pub fn work_bounded(format: FormatId, offset: u64, shape: &'static str) -> Self {
746        Self {
747            kind: SightingKind::WorkBounded,
748            ..Self::new(format, offset, shape)
749        }
750    }
751
752    /// A [`SightingKind::VerdictBounded`] sighting: the candidate at
753    /// `offset` was refused unexamined because the scan's [`ScanBudget`]
754    /// was spent.
755    pub fn verdict_bounded(format: FormatId, offset: u64, shape: &'static str) -> Self {
756        Self {
757            kind: SightingKind::VerdictBounded,
758            ..Self::new(format, offset, shape)
759        }
760    }
761}
762
763/// A bounded store for [`Sighting`]s: at most `cap` per (format, shape,
764/// kind) are kept, so the memory one scan spends on them is at most
765/// groups x cap however hostile the input, while the count of each group
766/// stays exact. [`Self::count`] gives the exact total pushed, and
767/// [`Self::into_sightings`] folds the uncounted remainder into the last
768/// stored sighting's [`Sighting::more`], so [`describe_sightings`] prints
769/// the same sentence it would for every sighting stored.
770#[derive(Debug, Clone)]
771pub struct SightingLog {
772    cap: usize,
773    groups: Vec<LogGroup>,
774}
775
776#[derive(Debug, Clone)]
777struct LogGroup {
778    key: (FormatId, &'static str, SightingKind),
779    stored: Vec<Sighting>,
780    total: u64,
781}
782
783impl SightingLog {
784    /// The default per-group cap.
785    pub const DEFAULT_CAP: usize = 64;
786
787    /// An empty log with [`Self::DEFAULT_CAP`].
788    pub fn new() -> Self {
789        Self::with_cap(Self::DEFAULT_CAP)
790    }
791
792    /// An empty log keeping at most `cap` sightings per group. A `cap` of 0
793    /// is raised to 1, so a group always has a sighting to carry its count.
794    ///
795    /// A `cap` below 8 (`SIGHTING_OFFSETS_SHOWN`) CHANGES the sentence
796    /// [`describe_sightings`] prints: it lists only the stored offsets, so a
797    /// group holding more than `cap` would show fewer offsets and a larger
798    /// "and N more" than an uncapped list. The count stays exact either way.
799    pub fn with_cap(cap: usize) -> Self {
800        Self {
801            cap: cap.max(1),
802            groups: Vec::new(),
803        }
804    }
805
806    /// Record `sighting`: stored while its group holds fewer than the cap,
807    /// and always counted. A sighting's own [`Sighting::more`] is counted
808    /// too.
809    pub fn push(&mut self, sighting: Sighting) {
810        let key = (sighting.format, sighting.shape, sighting.kind);
811        let idx = match self.groups.iter().position(|g| g.key == key) {
812            Some(i) => i,
813            None => {
814                self.groups.push(LogGroup {
815                    key,
816                    stored: Vec::new(),
817                    total: 0,
818                });
819                self.groups.len() - 1
820            }
821        };
822        let g = &mut self.groups[idx];
823        g.total = g.total.saturating_add(1).saturating_add(sighting.more);
824        if g.stored.len() < self.cap {
825            g.stored.push(sighting);
826        }
827    }
828
829    /// The exact number of sightings of this group pushed, stored or not.
830    pub fn count(&self, format: FormatId, shape: &'static str, kind: SightingKind) -> u64 {
831        self.groups
832            .iter()
833            .find(|g| g.key == (format, shape, kind))
834            .map_or(0, |g| g.total)
835    }
836
837    /// Whether nothing has been pushed.
838    pub fn is_empty(&self) -> bool {
839        self.groups.is_empty()
840    }
841
842    /// The stored sightings, groups in first-seen order, each group's last
843    /// one carrying the count of those not stored in [`Sighting::more`].
844    ///
845    /// The offsets kept are the first `cap` of each group in PUSH order, so
846    /// a scanner that wants the smallest listed must push each group in
847    /// ascending offset order.
848    pub fn into_sightings(self) -> Vec<Sighting> {
849        let mut out = Vec::new();
850        for mut g in self.groups {
851            let stored_weight: u64 = g
852                .stored
853                .iter()
854                .fold(0u64, |a, s| a.saturating_add(1).saturating_add(s.more));
855            let rest = g.total.saturating_sub(stored_weight);
856            if let Some(last) = g.stored.last_mut() {
857                last.more = last.more.saturating_add(rest);
858            }
859            out.extend(g.stored);
860        }
861        out
862    }
863}
864
865impl Default for SightingLog {
866    fn default() -> Self {
867        Self::new()
868    }
869}
870
871/// The most offsets [`describe_sightings`] lists per shape before it says
872/// how many more there were. The count itself is always the complete figure.
873const SIGHTING_OFFSETS_SHOWN: usize = 8;
874
875/// The one sentence a caller prints for `sightings`, or `None` when there
876/// are none — how many headers of each shape were seen, where, and that the
877/// ordinary verbs may read them or name the variant they are in. It is the
878/// same wording family as each scanner's exit-3 refusal (its shape phrases
879/// are the scanner's own), and it is written here, once, so every scanner
880/// that records a [`Sighting`] gets the same report without a line of its
881/// own.
882///
883/// **"Or name the variant" is cpio's clause (Salvage Stage 3 Task 3).** The
884/// tail said only "may read these headers normally", which is true of a tar
885/// header the scan cannot gate and false of a cpio `odc` or `newc-crc`
886/// header: `stuffr list` refuses both variants at exit 3, naming them. A note
887/// telling a user to expect `list` to read them would be the false "list
888/// reads it" claim Task 2-N's fix round removed from tar.
889///
890/// **[`SightingKind::WorkBounded`] sightings get their own sentence**
891/// (0.10.3): "the structure of a header, but in a shape it has no gate for"
892/// is false of a header the scan gated and then stopped spending work on.
893/// When a run has both kinds, the two sentences are joined; the
894/// [`SightingKind::Ungateable`] one is worded exactly as before.
895///
896/// **So do [`SightingKind::VerdictBounded`] ones** (0.10.3 Task 4b): the
897/// work-bounded sentence names extension chains, which is false of a zip
898/// name, a ZOO variable part or the bytes an ARJ header CRC covers. Each
899/// sentence that applies is joined in the same way, and the two older ones
900/// are worded exactly as before.
901pub fn describe_sightings(sightings: &[Sighting]) -> Option<String> {
902    describe(sightings, true)
903}
904
905/// [`describe_sightings`], optionally without each sentence's closing
906/// "`stuffr list` and `stuffr unpack` may read these headers normally"
907/// clause — for a caller that has just said so itself.
908fn describe(sightings: &[Sighting], list_clause: bool) -> Option<String> {
909    let tail = if list_clause {
910        "; `stuffr list` and `stuffr unpack` may read these headers normally"
911    } else {
912        ""
913    };
914    let of_kind = |kind: SightingKind| -> Vec<&Sighting> {
915        sightings.iter().filter(|s| s.kind == kind).collect()
916    };
917    let ungateable = of_kind(SightingKind::Ungateable);
918    let bounded = of_kind(SightingKind::WorkBounded);
919    let verdicts = of_kind(SightingKind::VerdictBounded);
920    let mut sentences = Vec::new();
921    if !ungateable.is_empty() {
922        sentences.push(format!(
923            "this build's salvage scanner saw {} — each with the structure of a header, but in \
924             a shape it has no gate for, so none is listed or recovered; `stuffr list` and \
925             `stuffr unpack` may read these headers normally, or name the variant they are in, \
926             and it is the SCAN that stops there",
927            sighting_groups(&ungateable)
928        ));
929    }
930    if !bounded.is_empty() {
931        sentences.push(format!(
932            "this build's salvage scanner bounds the work it spends on extension chains, and \
933             did not read the chains in front of {} — so whatever those chains describe is not \
934             listed or recovered there, though the scan went on past them{tail}",
935            sighting_groups(&bounded)
936        ));
937    }
938    if !verdicts.is_empty() {
939        sentences.push(format!(
940            "this build's salvage scanner bounds the work it spends judging candidate headers \
941             across the whole scan, and did not judge {} — so whatever those headers \
942             describe is not listed or recovered there, though the scan went on past them{tail}",
943            sighting_groups(&verdicts)
944        ));
945    }
946    (!sentences.is_empty()).then(|| sentences.join("; and separately, "))
947}
948
949/// What a salvage refusal's own text already says about `stuffr list` and
950/// `stuffr unpack`, which decides whether [`refusal_with_bounded_notes`]'s
951/// note repeats the point.
952#[derive(Debug, Clone, Copy, PartialEq, Eq)]
953#[non_exhaustive]
954pub enum RefusalSays {
955    /// The refusal has just said that `stuffr list` and `stuffr unpack` read
956    /// these headers normally (tar, lha): the note leaves its closing clause
957    /// to that.
958    ListReadsThem,
959    /// The refusal says nothing of the kind (cpio's names the variant, not
960    /// the verbs): the note closes with "`stuffr list` and `stuffr unpack`
961    /// may read these headers normally".
962    NothingAboutList,
963}
964
965/// `refusal` for an `Err(Unsupported)` that also carries what the scan would
966/// otherwise have dropped: the work- and verdict-bounded `sightings`, in
967/// [`describe_sightings`]' words, after `"; and separately, "`. Ungateable
968/// sightings are left out — the refusal already describes those shapes — and
969/// with no bounded sighting the refusal comes back unchanged. One line.
970/// `says` is what `refusal` already says about the list/unpack verbs; see
971/// [`RefusalSays`].
972pub fn refusal_with_bounded_notes(
973    refusal: String,
974    sightings: Vec<Sighting>,
975    says: RefusalSays,
976) -> String {
977    let bounded: Vec<Sighting> = sightings
978        .into_iter()
979        .filter(|s| s.kind != SightingKind::Ungateable)
980        .collect();
981    match describe(&bounded, says == RefusalSays::NothingAboutList) {
982        Some(note) => format!("{refusal}; and separately, {note}"),
983        None => refusal,
984    }
985}
986
987/// `"{count} {format} {shape} at offset(s) {offsets}"` per (format, shape),
988/// joined by `"; and "` — the middle of each [`describe_sightings`] sentence.
989fn sighting_groups(sightings: &[&Sighting]) -> String {
990    // Grouped by (format, shape) in first-seen order: a handful of shapes
991    // at most, so a linear search beats a map and keeps the order stable.
992    // `extra` is the sightings a [`SightingLog`] counted but did not store.
993    let mut groups: Vec<((FormatId, &'static str), Vec<u64>, u64)> = Vec::new();
994    for s in sightings {
995        let key = (s.format, s.shape);
996        match groups.iter_mut().find(|(k, _, _)| *k == key) {
997            Some((_, offsets, extra)) => {
998                offsets.push(s.offset);
999                *extra = extra.saturating_add(s.more);
1000            }
1001            None => groups.push((key, vec![s.offset], s.more)),
1002        }
1003    }
1004    let parts: Vec<String> = groups
1005        .iter()
1006        .map(|((format, shape), offsets, extra)| {
1007            let count = offsets.len() as u64 + extra;
1008            let mut shown: Vec<String> = offsets
1009                .iter()
1010                .take(SIGHTING_OFFSETS_SHOWN)
1011                .map(u64::to_string)
1012                .collect();
1013            if count > SIGHTING_OFFSETS_SHOWN as u64 {
1014                shown.push(format!("and {} more", count - shown.len() as u64));
1015            }
1016            format!("{count} {format} {shape} at offset(s) {}", shown.join(", "))
1017        })
1018        .collect();
1019    parts.join("; and ")
1020}
1021
1022/// Where a scanner that can ONLY walk its format in order — record after
1023/// record from the start, the way the ordinary reader does — had to stop
1024/// before the end of the source (Salvage Stage 3 Task 4).
1025///
1026/// # Why a walk stop is not a [`Sighting`]
1027///
1028/// A sighting is a header the scan SAW, whose structure is a header's and
1029/// which it cannot gate. A walk stop is the opposite: a place where the
1030/// walk found nothing it could read as a record at all, and — because the
1031/// format carries nothing strong enough to search for (an `ar` member's
1032/// only marker is two bytes) — cannot look any further. Folding it into
1033/// [`Sighting`] would make "saw a real header" and "could see nothing" one
1034/// channel, and [`describe_sightings`]'s own sentence ("`stuffr list` may
1035/// read these headers normally") would then be false about it.
1036///
1037/// # What it must say, and why it has to be said at all
1038///
1039/// The entries a sequential walk cannot reach are **unreachable by
1040/// construction, not absent**: a run that recovers three entries from a
1041/// ten-entry archive with a hole after the third reports three rows, and
1042/// nothing in those rows says seven more may sit behind the hole. So the
1043/// caller prints [`describe_walk_stop`] of this on every run that has one,
1044/// and the offset it names is where a user with a hex editor would look.
1045#[derive(Debug, Clone, PartialEq, Eq)]
1046#[non_exhaustive]
1047pub struct WalkStop {
1048    /// The scanner's format, for the sentence [`describe_walk_stop`] writes.
1049    pub format: FormatId,
1050    /// Where the record the walk could not read begins — the first byte
1051    /// after the last record it did read.
1052    pub offset: u64,
1053    /// Bytes from [`Self::offset`] to the end of the source.
1054    pub remaining: u64,
1055    /// Which of the two things this stop is — see [`WalkStopKind`].
1056    pub kind: WalkStopKind,
1057    /// The ordinary reader's own words for why that record would not read,
1058    /// so the note and `stuffr list`'s refusal of the same bytes agree.
1059    pub cause: String,
1060}
1061
1062/// The three stops a sequential walk can make, each told apart by what the
1063/// scanner measured rather than guessed.
1064#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1065pub enum WalkStopKind {
1066    /// The file ends inside the record at the stop point — the reader ran
1067    /// out of bytes mid-record, or fewer bytes remain than the format's
1068    /// smallest record header. Everything from the stop point on belongs to
1069    /// that incomplete record, so no entry can be hiding after it — the
1070    /// common damage, and the one a sequential walk handles fully.
1071    CutShort,
1072    /// Enough bytes remain to hold further records, and the walk could not
1073    /// read the one at the stop point. Whatever those bytes hold is
1074    /// unreachable by construction.
1075    Unreadable,
1076    /// The record at the stop point is one the scanner RECOGNISES — its
1077    /// [`WalkStop::cause`] names it — in a shape this build cannot read (Task
1078    /// 4 fix round 1: an `ar` symbol table whose header the reader cannot
1079    /// parse; GNU `ar`'s Mach-O `__.SYMDEF` with a blank mode was the first,
1080    /// and is read since 0.8.1). Not evidence of damage; what follows is
1081    /// unreachable to this scanner.
1082    UnsupportedShape,
1083}
1084
1085impl WalkStop {
1086    pub fn new(
1087        format: FormatId,
1088        offset: u64,
1089        remaining: u64,
1090        kind: WalkStopKind,
1091        cause: impl Into<String>,
1092    ) -> Self {
1093        Self {
1094            format,
1095            offset,
1096            remaining,
1097            kind,
1098            cause: cause.into(),
1099        }
1100    }
1101}
1102
1103/// The one sentence a caller prints for a [`WalkStop`] — written here, once,
1104/// so every sequential-only scanner gets the same report without a line of
1105/// its own, and so its kinds can never be described two ways each.
1106///
1107/// An [`WalkStopKind::Unreadable`] stop says the entries after it are
1108/// *unreachable by construction, not absent*, with the offset and the byte
1109/// count; a [`WalkStopKind::CutShort`] one says that nothing after it is
1110/// missing from the report, which is exactly as true — the file ended inside
1111/// that record; an [`WalkStopKind::UnsupportedShape`] one names the shape and
1112/// says it is not evidence of damage.
1113///
1114/// Two of the sentences say the walk goes "as `stuffr list` does". That is a
1115/// contract on every scanner that reports a [`WalkStop`]: it walks with its
1116/// format's ORDINARY reader, over the file's own bytes, repairing none of
1117/// them. `ar`'s walk served its global header as `!<arch>\n` until Stage 3
1118/// Task 4's fix round 1, which made the phrase false (a bad magic `list`
1119/// refuses was walked past) — a scanner that needs a repair like that must
1120/// get its own sentence here rather than borrow this one.
1121pub fn describe_walk_stop(stop: &WalkStop) -> String {
1122    let WalkStop {
1123        format,
1124        offset,
1125        remaining,
1126        kind,
1127        cause,
1128    } = stop;
1129    let bytes = if *remaining == 1 { "byte" } else { "bytes" };
1130    match kind {
1131        WalkStopKind::Unreadable => format!(
1132            "this build's {format} salvage scanner walks the archive in order from its start, \
1133             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
1134             to go: {cause}. The {format} format marks its records with nothing strong enough \
1135             to search for, so the scanner cannot find its way past a record it cannot read; \
1136             any entries in those {remaining} {bytes} are unreachable by construction — not \
1137             absent, and not counted in this report"
1138        ),
1139        WalkStopKind::UnsupportedShape => format!(
1140            "this build's {format} salvage scanner walks the archive in order from its start, \
1141             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
1142             to go: {cause}. That is a record shape this build does not read, not evidence of \
1143             damage; any entries in those {remaining} {bytes} are unreachable to this scanner \
1144             and not counted in this report"
1145        ),
1146        WalkStopKind::CutShort => format!(
1147            "this build's {format} salvage scanner walks the archive in order from its start, \
1148             and the file ends {remaining} {bytes} into the record at offset {offset} ({cause}): \
1149             too few for any further entry, so the archive is cut short there and nothing \
1150             after that point is missing from this report"
1151        ),
1152    }
1153}
1154
1155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1156pub enum PartialPolicy {
1157    Keep,
1158    Skip,
1159    Ask,
1160}
1161
1162pub struct SalvagePolicy {
1163    /// Recovery-biased by default: salvage is the one permissive verb, and
1164    /// the strict path is the entire rest of the tool.
1165    pub partial: PartialPolicy,
1166    /// Structural ceiling on any one entry's length, refused BEFORE
1167    /// allocation — and refused as a per-entry
1168    /// [`UnverifiedCause::OverEntryCeiling`], never as an error that ends
1169    /// the run. [`SalvageScan::max_whole_entry`] narrows it further for a
1170    /// format that decodes an entry whole; the two compose as a minimum and
1171    /// are applied together, once, in [`annotate_candidates`].
1172    pub max_entry: u64,
1173    /// Demand proof: every [`SalvageStatus::Partial`] entry is skipped,
1174    /// whatever [`Self::partial`] says, and so is every
1175    /// [`SalvageStatus::Unattested`] one (Ruling 3-H, Salvage Stage 3).
1176    ///
1177    /// The second clause is this flag's doing, unlike the two refused
1178    /// below: an `Unattested` entry is WRITTEN by default, under its real
1179    /// name, and only `strict` declines it — the format carries nothing
1180    /// that could prove it, which is exactly what "demand proof" asks for.
1181    ///
1182    /// **Those two are the whole of it, and this doc claimed three things
1183    /// until the final whole-branch review's F5.** It read "partial skipped,
1184    /// ceiling fixed, nothing unverifiable", and only the first clause was
1185    /// this flag's doing. The ceiling is NOT fixed under `strict`:
1186    /// [`Self::max_entry`] applies identically with and without it, and
1187    /// `--max-entry` is honoured either way. And an
1188    /// [`SalvageStatus::Unverified`] entry is never written under any
1189    /// policy at all — that is the ops layer's unconditional rule, not
1190    /// something `strict` causes — so crediting it here made a flag look
1191    /// load-bearing for a guarantee that holds without it.
1192    ///
1193    /// The true clauses are enough to justify the flag: `salvage` is this
1194    /// tool's single recovery-biased verb, so `strict` is how a caller asks
1195    /// for the discipline every other verb applies by default.
1196    pub strict: bool,
1197}
1198
1199impl Default for SalvagePolicy {
1200    fn default() -> Self {
1201        Self {
1202            partial: PartialPolicy::Keep,
1203            max_entry: MAX_SALVAGE_ENTRY,
1204            strict: false,
1205        }
1206    }
1207}
1208
1209/// Reusable scratch for a scanner's forward search for its next signature
1210/// (a magic, a checksum-valid block). Reads start at [`Self::FIRST_CHUNK`],
1211/// or at `2 * sig_len - 1` when that is larger, so the first read always
1212/// tests `sig_len` offsets, and double up to [`Self::MAX_CHUNK`], into one
1213/// buffer kept across calls,
1214/// so a search whose answer is a few bytes away reads a few bytes: a scan
1215/// that resumes one byte past a refused candidate costs work proportional to
1216/// the distance it actually searched, never a fixed 64 KiB per call. That is
1217/// the property `testing::check_scan_is_linear` holds every scanner to.
1218/// O(1) memory however far the next signature is.
1219///
1220/// **The bytes a search read past its answer are kept for the next call
1221/// (0.10.3 Task 4).** A call whose `from` lies inside what the previous
1222/// call read tests those bytes again without reading them again, and reads
1223/// only past them. Without that, every call paid at least its first read
1224/// afresh, so candidates a few bytes apart — an accepted header every
1225/// eleven bytes, say — cost a first read EACH: 64 bytes, or `2 * sig_len -
1226/// 1` for a long signature, per candidate. A scan whose `from` only moves
1227/// forward therefore reads each byte about once in its searches, however
1228/// dense its candidates are.
1229///
1230/// **One `ForwardSearch` serves one source**, whose bytes do not change
1231/// while it is in use — a scanner owns one per scan. Pass a different
1232/// source and the kept bytes are that other source's. A call with a
1233/// smaller `file_len` than the last never tests a kept byte at or past it.
1234#[derive(Debug, Default)]
1235pub struct ForwardSearch {
1236    /// Bytes read from the source, starting at offset `window_start`.
1237    window: Vec<u8>,
1238    window_start: u64,
1239}
1240
1241impl ForwardSearch {
1242    /// Size of the first read of every search, unless the signature is long
1243    /// enough that `2 * sig_len - 1` is larger — see [`Self::find`].
1244    pub const FIRST_CHUNK: usize = 64;
1245    /// Ceiling the read size doubles up to.
1246    pub const MAX_CHUNK: usize = 64 * 1024;
1247
1248    /// An empty search; the buffer grows on first use and is then reused.
1249    pub fn new() -> Self {
1250        Self::default()
1251    }
1252
1253    /// The first offset `>= from` at which `matches` accepts the `sig_len`
1254    /// bytes starting there, or `None` when none does before `file_len`.
1255    /// `matches` sees exactly `sig_len` bytes. Every offset is tested once,
1256    /// carrying `sig_len - 1` bytes across a chunk boundary. Never reads at
1257    /// or past `file_len`, and stops at EOF if the source is shorter.
1258    ///
1259    /// The first read is `max(FIRST_CHUNK, 2 * sig_len - 1)` bytes, so it
1260    /// tests at least `sig_len` offsets. A first read of only `sig_len`
1261    /// bytes tests ONE offset: for tar's 512-byte block, a header one block
1262    /// on then cost 512 + 1,024 bytes, three times the distance searched.
1263    pub fn find(
1264        &mut self,
1265        src: &mut dyn SeekRead,
1266        from: u64,
1267        file_len: u64,
1268        sig_len: usize,
1269        matches: impl FnMut(&[u8]) -> bool,
1270    ) -> io::Result<Option<u64>> {
1271        self.find_to_eof(src, from, file_len, sig_len, sig_len, matches)
1272    }
1273
1274    /// [`Self::find`] for a record whose length is not fixed: offsets with
1275    /// at least `sig_len` bytes before `file_len` are offered exactly
1276    /// `sig_len` bytes, and the last offsets — those with fewer, down to
1277    /// `min_len` — are offered every byte left before `file_len`. A record
1278    /// whose longest form is `sig_len` bytes can then be judged whole
1279    /// inside the search, wherever it starts, without a second read near
1280    /// EOF. `min_len` is at least 1 and at most `sig_len`.
1281    pub fn find_to_eof(
1282        &mut self,
1283        src: &mut dyn SeekRead,
1284        from: u64,
1285        file_len: u64,
1286        sig_len: usize,
1287        min_len: usize,
1288        mut matches: impl FnMut(&[u8]) -> bool,
1289    ) -> io::Result<Option<u64>> {
1290        debug_assert!(1 <= min_len && min_len <= sig_len);
1291        if min_len == 0 || file_len.saturating_sub(from) < min_len as u64 {
1292            return Ok(None);
1293        }
1294        // Keep what the last call read from `from` on, if it read `from`;
1295        // otherwise start empty. Never keep a byte at or past `file_len`.
1296        let kept_end = self.window_start + self.window.len() as u64;
1297        if from >= self.window_start && from < kept_end {
1298            self.window.drain(..(from - self.window_start) as usize);
1299            let fits = (file_len - from).min(self.window.len() as u64);
1300            self.window.truncate(fits as usize);
1301        } else {
1302            self.window.clear();
1303        }
1304        self.window_start = from;
1305        let mut chunk = Self::FIRST_CHUNK.max(2 * sig_len - 1);
1306        let mut at_eof = false;
1307        loop {
1308            // Every offset with a full `sig_len` bytes in the window.
1309            if self.window.len() >= sig_len {
1310                let last = self.window.len() - sig_len;
1311                if let Some(at) = (0..=last).find(|&i| matches(&self.window[i..i + sig_len])) {
1312                    return Ok(Some(self.window_start + at as u64));
1313                }
1314                let tested = last + 1;
1315                self.window_start += tested as u64;
1316                self.window.drain(..tested);
1317            }
1318            let end = self.window_start + self.window.len() as u64;
1319            if at_eof || end >= file_len {
1320                // Nothing more to read: the offsets left have fewer than
1321                // `sig_len` bytes, and are offered what there is down to
1322                // `min_len`. They stay in the window, so a later call
1323                // re-tests them without a read.
1324                let short = self.window.len().saturating_sub(min_len - 1);
1325                let tail = (0..short).find(|&i| matches(&self.window[i..]));
1326                return Ok(tail.map(|at| self.window_start + at as u64));
1327            }
1328            let old = self.window.len();
1329            let want = (chunk as u64).min(file_len - end) as usize;
1330            src.seek(SeekFrom::Start(end))?;
1331            self.window.resize(old + want, 0);
1332            let n = src.read(&mut self.window[old..])?;
1333            self.window.truncate(old + n);
1334            if n == 0 {
1335                at_eof = true;
1336            } else if n == want {
1337                // Grow only on a full read: a source that trickles short
1338                // reads must not make every call zero a 64 KiB buffer for
1339                // 7 bytes.
1340                chunk = (chunk * 2).min(Self::MAX_CHUNK);
1341            }
1342        }
1343    }
1344}
1345
1346/// The work one salvage scan may spend judging candidates beyond their
1347/// fixed headers: bytes a verdict consumes (a long name, a variable part, an
1348/// extension chain, the bytes a header CRC covers), counted whether they come
1349/// from the source or from a search window. A legitimate archive consumes
1350/// each verdict's bytes once, at most `file_len` in total, so the limit of
1351/// `2 × file_len + 32 KiB` never trips on real input; forged candidates
1352/// that would spend past it are refused unexamined and reported as a
1353/// verdict-bounded sighting ([`SightingKind::VerdictBounded`]), and the
1354/// scan goes on.
1355///
1356/// **Why the slack is only 32 KiB (Rulings T4b-2 and T4b-5).** A
1357/// legitimate scan's verdict bytes all lie inside the file, so they total
1358/// at most `file_len` — and where a scanner pays for a second read too
1359/// (cpio pays for a name in the scan and again when the header before it
1360/// corroborates its size), at most `2 × file_len`. The per-byte term covers
1361/// real input on its own, and the slack only rounds. It was 1 MiB, which
1362/// let a fuzz-scale input of a few KiB spend about 1 MiB: an 8 KiB file of
1363/// overlapping zip names read 536,640 bytes, past
1364/// `stuffr_core::testing::check_scan_is_linear`'s `8 × file_len + 64 KiB`.
1365/// At 32 KiB the same file reads 65,536 bytes, half that bound. 16 KiB
1366/// was tried first and changed the outcome of 27 fuzz-corpus ZOO inputs,
1367/// whose overlapping type-2 records each read most of the file; at 32 KiB,
1368/// with ZOO charging each variable part once, all 27 keep their outcome.
1369///
1370/// **Why a scan needs one (0.10.3).** A scanner resumes one byte past every
1371/// candidate it refuses, and past every candidate whose payload runs off the
1372/// end of the file, so candidates can overlap. Where a candidate's verdict
1373/// needs a field the format lets run to 64 KiB — a zip name, a ZOO type-2
1374/// variable part, an LHA extension chain — packing such candidates every few
1375/// dozen bytes made the scan read that field once per candidate: measured at
1376/// 495x, 962x and 957x the input. No verdict can be reached by reading less,
1377/// so the WORK is bounded instead, across the whole scan: tar's extension-read
1378/// budget (Ruling T3-1), generalised.
1379///
1380/// One per scan, shared by every pass the scan makes. A refused candidate is
1381/// reported through [`Sighting::verdict_bounded`], so a genuine header
1382/// starved by forgeries elsewhere is named, never lost silently.
1383#[derive(Debug)]
1384pub struct ScanBudget {
1385    limit: u64,
1386    spent: u64,
1387}
1388
1389impl ScanBudget {
1390    /// Verdict bytes the scan may spend per byte of input.
1391    pub const PER_INPUT_BYTE: u64 = 2;
1392    /// Verdict bytes the scan may spend on top of [`Self::PER_INPUT_BYTE`]:
1393    /// rounding only, and small enough that it never dominates a small
1394    /// input's `check_scan_is_linear` bound — see the type's doc.
1395    pub const SLACK: u64 = 32 * 1024;
1396
1397    /// The budget for one scan of a `file_len`-byte input:
1398    /// `2 × file_len + 32 KiB`.
1399    pub fn for_input(file_len: u64) -> Self {
1400        Self {
1401            limit: file_len
1402                .saturating_mul(Self::PER_INPUT_BYTE)
1403                .saturating_add(Self::SLACK),
1404            spent: 0,
1405        }
1406    }
1407
1408    /// Charges `bytes` if they fit; `false` (charging nothing) if they would
1409    /// take the scan past its limit: the caller refuses the candidate
1410    /// without examining it.
1411    pub fn try_spend(&mut self, bytes: u64) -> bool {
1412        match self.spent.checked_add(bytes) {
1413            Some(total) if total <= self.limit => {
1414                self.spent = total;
1415                true
1416            }
1417            _ => false,
1418        }
1419    }
1420
1421    /// Bytes charged so far.
1422    pub fn spent(&self) -> u64 {
1423        self.spent
1424    }
1425}
1426
1427/// The per-format seam. A scanner recognises its own format's record shape
1428/// (a local header's magic, a member's checksum field) and reports what it
1429/// finds as a stream of [`Candidate`]s; [`salvage_all`] supplies everything
1430/// that does not depend on which format that is.
1431pub trait SalvageScan {
1432    /// Find the next candidate at or after `from`. `Ok(None)` ends the scan.
1433    fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;
1434
1435    /// The largest single entry this scanner is willing to have read for it
1436    /// — the ceiling [`annotate_candidates`] applies, narrowed further by
1437    /// `policy.max_entry`, before it calls [`Self::verify`] at all.
1438    ///
1439    /// `u64::MAX` (the default) means "this scanner imposes none of its
1440    /// own": zip streams every payload it verifies through a `take`, so the
1441    /// only figure that bounds it is the policy's. A format that **decodes
1442    /// an entry whole** — ARC, and the legacy containers still to come —
1443    /// overrides this with its own container-side ceiling
1444    /// (`arc.rs`'s `MAX_ARC_ENTRY_LEN`, 256 MiB), because for those the
1445    /// figure really does become a single allocation.
1446    ///
1447    /// **Declaring a ceiling here is the ONLY way a scanner refuses an
1448    /// entry for its size**, and that is the point: the refusal is then a
1449    /// [`SalvageStatus`] the engine assigns
1450    /// ([`UnverifiedCause::OverEntryCeiling`]), not a [`crate::Error`] the
1451    /// scanner raises, so it can never abort a run over one entry. A
1452    /// scanner that instead checks a size inside [`Self::verify`] and
1453    /// `?`-propagates the failure reintroduces the exact defect three fix
1454    /// rounds of Task 3c chased.
1455    ///
1456    /// **That is documented, not enforced, and saying so is more useful
1457    /// than claiming otherwise.** An earlier wording here called the defect
1458    /// "unrepresentable"; it is not. [`Self::verify`] still returns
1459    /// `Result` — it must, so a scanner CAN report a genuine run-level
1460    /// fault — so the wrong thing is still expressible. What changed is
1461    /// that the right thing now exists, costs one method, and is written
1462    /// into [`Self::verify`]'s contract below.
1463    fn max_whole_entry(&self) -> u64 {
1464        u64::MAX
1465    }
1466
1467    /// Decide what was actually proven about a candidate's payload, after
1468    /// discovery — called once per candidate, in file order, by
1469    /// [`annotate_candidates`].
1470    ///
1471    /// # The contract on `Err`
1472    ///
1473    /// **`Err` here aborts the WHOLE run**, discarding every entry already
1474    /// recovered, so it is reserved for a fault that really is about the
1475    /// run and not about this one entry (the destination is gone, the
1476    /// source handle itself failed). Everything a single candidate can be
1477    /// wrong about — a short read, a bad checksum, a method this build
1478    /// cannot decode, a device error partway through one payload — is a
1479    /// [`SalvageStatus`], never an `Err`. `zip_salvage.rs`'s own
1480    /// `verify_candidate` has stated this in prose since Stage 1 and folds
1481    /// every one of those; ARC's did not, for one case (its size ceiling),
1482    /// and that single `?` cost three fix rounds. The size ceiling now
1483    /// belongs to [`Self::max_whole_entry`], which the engine applies, so
1484    /// there is nothing left for an implementation to `?` on.
1485    ///
1486    /// # What an implementation may assume
1487    ///
1488    /// The candidate's bounded length (`available_len` if the payload was
1489    /// truncated, else `declared_len`) is already **at or below**
1490    /// [`Self::max_whole_entry`] — [`annotate_candidates`] answers
1491    /// [`UnverifiedCause::OverEntryCeiling`] itself, without calling this,
1492    /// for anything above it. So an implementation that decodes whole may
1493    /// size a buffer from that length without re-checking it.
1494    ///
1495    /// The default answers [`SalvageStatus::Unattested`] unconditionally,
1496    /// which is the least this method can claim: it reads nothing, decodes
1497    /// nothing and compares nothing, so "nothing attests that this candidate
1498    /// is an entry at all" is exactly what happened. A format that carries a
1499    /// checksum overrides this to decode the payload — reusing its own
1500    /// existing codec machinery, never inventing a new one here — and
1501    /// compare it against [`Candidate::verifier`]; a format that carries a
1502    /// self-verifying HEADER and no content checksum (tar) overrides it to
1503    /// check that header and answer [`SalvageStatus::Complete`].
1504    ///
1505    /// **It answered `Complete` until Salvage Stage 3**, and the change is
1506    /// not cosmetic. `Complete` asserts that every declared byte was present
1507    /// *and the header self-verified* — two claims this default has never
1508    /// been in a position to make — and it routes to **exit 0**, so a
1509    /// checksumless scanner that simply forgot to override was reporting a
1510    /// clean recovery over candidates nothing had examined. `Unattested`
1511    /// routes to exit 4 and says why on the row. The same argument
1512    /// [`Candidate::new`] makes for its own four defaults: the default is
1513    /// the answer that asserts the LEAST, so forgetting to override is
1514    /// visible rather than flattering.
1515    fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
1516        Ok(SalvageStatus::Unattested)
1517    }
1518
1519    /// Decode one salvaged entry's payload out of the archive at
1520    /// `archive_path` and into `out`, answering whether the entry's whole
1521    /// declared length was produced.
1522    ///
1523    /// # Why this is on the trait at all (final whole-branch review, F2)
1524    ///
1525    /// `0.6.0` spends a breaking release making this trait implementable
1526    /// from outside. It was implementable and not USABLE: the ops layer's
1527    /// payload dispatch was a private `match` on a [`crate::FormatId`]
1528    /// string, so a scanner written outside this workspace got
1529    /// [`salvage_all`] and none of the behaviour that makes `salvage` what
1530    /// it is. This method is where a scanner's write half now DECLARES
1531    /// itself, beside the scan half, in one place a reader can see both.
1532    ///
1533    /// **It does not by itself make an out-of-tree scanner reachable from
1534    /// `stuffr salvage`, and pretending otherwise would be the same
1535    /// over-claim F2 named.** The ops layer still resolves a format to a
1536    /// scanner by name, with no registration point for a third party to add
1537    /// one; closing that needs a salvage registry and is Stage 3's. What
1538    /// changed is that the seam is now declared rather than scattered — the
1539    /// five in-tree scanners implement this method and the ops layer calls
1540    /// it, so the next one has a signature to fit rather than a convention
1541    /// to guess.
1542    ///
1543    /// # The contract
1544    ///
1545    /// `Ok(true)` means every declared byte was produced; `Ok(false)` means
1546    /// the payload ran short or the decoder stopped early, which the caller
1547    /// renders as a `.partial` write. **`Err` is reserved for the same
1548    /// narrow set [`Self::verify`] reserves it for** — with one addition
1549    /// the ops layer relies on: [`crate::Error::Io`] here is treated as a
1550    /// fact about THIS entry (the archive could not be re-opened or read
1551    /// for it, the destination refused the bytes) and folded into that
1552    /// entry's own outcome, never the run's. A size refusal is never an
1553    /// `Err`: it belongs to [`Self::max_whole_entry`], which the engine
1554    /// applies before `verify` is ever called.
1555    ///
1556    /// The default refuses. A scanner that only ever reports
1557    /// [`SalvageStatus::Unverified`] has nothing to write, and inheriting a
1558    /// refusal is the honest answer for it; the alternative — a required
1559    /// method — would be a breaking change to every implementation for no
1560    /// gain to one that cannot write anything anyway.
1561    fn write_payload(
1562        &self,
1563        _archive_path: &Path,
1564        _entry: &SalvagedEntry,
1565        _compressed_len: u64,
1566        _out: &mut dyn Write,
1567    ) -> Result<bool> {
1568        Err(Error::Unsupported(
1569            "this salvage scanner has no payload writer".to_string(),
1570        ))
1571    }
1572}
1573
1574/// Walk `scan` over `src`, collecting every candidate it reports into a
1575/// [`SalvageOutcome`].
1576///
1577/// **Requires a seekable source.** Scanning searches back and forth over the
1578/// archive rather than reading it once in order, so this takes
1579/// `&mut dyn SeekRead` rather than the ladder's `Box<dyn Source>` (whose
1580/// `as_seek()` may be `None` for a pipe). A caller sitting on a
1581/// non-seekable source must spool it first — exactly as the `arj` and `zoo`
1582/// containers already do, reporting `Rung::Spilled` — which is a later
1583/// task's ops-layer concern, not this function's.
1584///
1585/// Every candidate's length is bounded against the run's entry ceiling
1586/// (`policy.max_entry`, narrowed by [`SalvageScan::max_whole_entry`])
1587/// **before** anything is sized from it — in [`annotate_candidates`], one
1588/// step before the only place that could size anything, and as a per-entry
1589/// [`UnverifiedCause::OverEntryCeiling`] rather than a [`crate::Error`] that ends
1590/// the run.
1591pub fn salvage_all(
1592    scan: &mut dyn SalvageScan,
1593    src: &mut dyn SeekRead,
1594    policy: &SalvagePolicy,
1595) -> Result<SalvageOutcome> {
1596    let candidates = collect_candidates(scan, src)?;
1597    annotate_candidates(&*scan, src, candidates, policy)
1598}
1599
1600/// The resync loop alone: find a candidate, advance strictly past it,
1601/// repeat. Never reads a payload, never sizes anything from a declared
1602/// length, and never calls [`SalvageScan::verify`] — see
1603/// [`annotate_candidates`] for both the entry ceiling and the verification.
1604///
1605/// Exposed separately from [`salvage_all`] so a format that reconciles more
1606/// than one candidate source (zip's central-directory fallback) can collect
1607/// from each source before merging, and annotate the merged list once.
1608///
1609/// # The entry ceiling used to be enforced here, and was an `Err`
1610///
1611/// Salvage Stage 2 Task 3c's fix round 3 moved it to
1612/// [`annotate_candidates`] and turned it into a status. Two things were
1613/// wrong with it here, and only the second is obvious:
1614///
1615/// 1. **It refused a whole archive over one entry.** Measured at the CLI on
1616///    a 78-byte two-entry ARC, `stuffr salvage --list --max-entry 4`
1617///    printed no rows and exited 6 — the healthy first entry, which the
1618///    same binary recovers byte-for-byte at the default ceiling, simply
1619///    lost. That is the same defect the ARC scanner's own ceiling had, in
1620///    the engine rather than in a format.
1621/// 2. **It was one of two ceilings, checked in two places**, so which
1622///    disposition an over-ceiling entry got depended on which check saw it
1623///    first. There is now one rule, applied once, to both figures.
1624///
1625/// Nothing is weakened by the move: this loop sizes nothing from a declared
1626/// length (it only advances `from` by it), so "bounded before anything is
1627/// sized from it" holds exactly as before — the bound simply sits one
1628/// function later, still strictly before the only allocation there is.
1629pub fn collect_candidates(
1630    scan: &mut dyn SalvageScan,
1631    src: &mut dyn SeekRead,
1632) -> Result<Vec<Candidate>> {
1633    let mut candidates = Vec::new();
1634    let mut from = 0u64;
1635
1636    while let Some(candidate) = scan.next_candidate(src, from)? {
1637        // Advance strictly past this candidate so the scan always makes
1638        // progress — but NEVER by a length this candidate merely CLAIMED.
1639        // `available_len.is_some()` means the header's own declared length
1640        // disagreed with the file (there were fewer bytes left than it
1641        // said), and a header that lied about its size once cannot be
1642        // trusted to say how much of the source its "payload" consumed
1643        // either. This used to advance by `available_len` — the bytes
1644        // actually present, not the declared figure — on the reasoning that
1645        // a truncated entry has nothing worth finding after it anyway. That
1646        // is true for a genuinely truncated REAL last entry, but the
1647        // engine cannot tell that case apart from a coincidental
1648        // false-positive header sitting on top of noise with a garbage
1649        // multi-hundred-megabyte declared length: `available_len` is the
1650        // same "everything left in the file" figure either way. Salvage
1651        // Stage 2 Task 3's fix round 1 found exactly this — one
1652        // coincidental marker+name match in an ARC noise corpus, with a
1653        // declared length far past the file, jumped the scan straight to
1654        // EOF in one step and silently dropped every real entry that
1655        // happened to sit between it and the end of the file: no error, no
1656        // `Partial`, no note, in the one verb whose entire job is not
1657        // losing entries silently. So a truncated candidate now advances
1658        // minimally — past its own marker byte only — so the very next
1659        // byte onward is examined for another header. That costs a few
1660        // bytes of re-scanning after a genuinely truncated real entry
1661        // (which has nothing left to find anyway, so the outcome is
1662        // unchanged); it is the entire fix for a phantom, whose lie no
1663        // longer costs the rest of the archive. An UNTRUNCATED candidate's
1664        // declared length was just checked against the file two lines
1665        // above — it fits, so it is not a lie, and advancing by it is
1666        // unchanged.
1667        let advance = if candidate.available_len.is_some() {
1668            1
1669        } else {
1670            candidate.declared_len.unwrap_or(1).max(1)
1671        };
1672        from = candidate.offset.saturating_add(advance);
1673
1674        candidates.push(candidate);
1675    }
1676
1677    Ok(candidates)
1678}
1679
1680/// Turns already-discovered candidates — in file order — into the bounded,
1681/// verified, shadow-annotated entries a caller receives. See this module's
1682/// doc comment for what the passes (bound, verify, shadow) mean and why
1683/// they live here rather than per-format.
1684///
1685/// # The entry ceiling, in one place (fix round 3, Task 3c)
1686///
1687/// `policy.max_entry` narrowed by [`SalvageScan::max_whole_entry`] is the
1688/// run's ceiling on ONE entry, and this is the only place it is applied. A
1689/// candidate whose bounded length (`available_len` if the payload is
1690/// truncated, else `declared_len`) is over it is reported
1691/// [`SalvageStatus::Unverified`]`(`[`UnverifiedCause::OverEntryCeiling`]`)`
1692/// and [`SalvageScan::verify`] is **not called for it at all** — so nothing
1693/// is read and nothing is allocated, which was the whole purpose of the two
1694/// `Err`-raising checks this replaced, without their cost: neither the
1695/// engine's own nor a scanner's could refuse one entry without ending the
1696/// run and discarding every entry already recovered.
1697///
1698/// The bounded length, never the declared one, is what is compared — the
1699/// rule [`Candidate::available_len`]'s own doc argues for and the reason a
1700/// truncated tail's garbage declaration cannot decide anything: a candidate
1701/// whose payload ran out can never cost more than the bytes that exist.
1702pub fn annotate_candidates(
1703    scan: &dyn SalvageScan,
1704    src: &mut dyn SeekRead,
1705    candidates: Vec<Candidate>,
1706    policy: &SalvagePolicy,
1707) -> Result<SalvageOutcome> {
1708    let ceiling = policy.max_entry.min(scan.max_whole_entry());
1709    let mut entries = Vec::with_capacity(candidates.len());
1710    // Earliest scan_position to report each distinct (name, declared_len,
1711    // verifier) triple seen so far — a candidate reporting one already in
1712    // here is shadowing that position. A degenerate zero-length checksum
1713    // (every empty file, every directory entry) is never inserted here and
1714    // never looked up here — see `is_degenerate` below.
1715    let mut seen: HashMap<(String, Option<u64>, Verifier), usize> = HashMap::new();
1716    // Earliest scan_position to report each distinct NAME, regardless of
1717    // anything else about the record — the weaker, directly-observed fact
1718    // `SalvagedEntry::collides_with` reports, and the one `stuffr list`'s
1719    // own fidelity warning is about. Kept separate from `seen` above rather
1720    // than folded into it: that map is deliberately full of guards against
1721    // over-claiming a COPY (a verifier must exist, the declared length must
1722    // agree, a zero-length checksum never counts), and every one of them
1723    // would be wrong here, where nothing about content is being claimed.
1724    let mut names_seen: HashMap<String, usize> = HashMap::new();
1725
1726    for (scan_position, candidate) in candidates.into_iter().enumerate() {
1727        // Bounded length, then the ceiling, then — only if it fits —
1728        // verification. `verify` is what reads and (for a whole-decoding
1729        // format) allocates, so refusing above it is refusing before the
1730        // allocation, which is the property the two `Err`s this replaced
1731        // existed for.
1732        let bounded_len = candidate.available_len.or(candidate.declared_len);
1733        let status = match bounded_len {
1734            Some(needed) if needed > ceiling => {
1735                SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { needed, ceiling })
1736            }
1737            _ => scan.verify(src, &candidate)?,
1738        };
1739
1740        let declared_len = candidate.declared_len;
1741        let verifier = candidate.verifier;
1742        // A checksum over zero declared bytes is the same value for every
1743        // empty entry that ever existed (CRC-32 and CRC-16/ARC of an empty
1744        // input are both fixed constants) and proves nothing about any two
1745        // of them being copies of each other — measured against a real
1746        // archive during this task's fix round: every `__init__.py` and
1747        // every directory entry collapsed onto one shadow chain under a
1748        // checksum-only match.
1749        let is_degenerate = declared_len == Some(0);
1750
1751        let shadows = if is_degenerate {
1752            None
1753        } else {
1754            verifier.and_then(|v| {
1755                seen.get(&(candidate.meta.name.clone(), declared_len, v))
1756                    .copied()
1757            })
1758        };
1759
1760        if !is_degenerate
1761            && shadows.is_none()
1762            && let Some(v) = verifier
1763        {
1764            seen.entry((candidate.meta.name.clone(), declared_len, v))
1765                .or_insert(scan_position);
1766        }
1767
1768        // The two annotations are mutually exclusive: a record proven to be
1769        // a copy is reported as a shadow and nothing else, because "this is
1770        // a duplicate of #2" is strictly more informative than "something
1771        // earlier used this name". Everything else that repeats a name is a
1772        // collision.
1773        let earliest_with_name = names_seen.get(&candidate.meta.name).copied();
1774        let collides_with = if shadows.is_some() {
1775            None
1776        } else {
1777            earliest_with_name
1778        };
1779        if earliest_with_name.is_none() {
1780            names_seen.insert(candidate.meta.name.clone(), scan_position);
1781        }
1782
1783        let marked_deleted = candidate.marked_deleted;
1784        entries.push(
1785            SalvagedEntry::new(
1786                scan_position,
1787                candidate.offset,
1788                candidate.payload_start,
1789                candidate.meta,
1790                status,
1791            )
1792            .with_shadows(shadows)
1793            .with_collides_with(collides_with)
1794            .with_marked_deleted(marked_deleted),
1795        );
1796    }
1797
1798    Ok(SalvageOutcome {
1799        entries,
1800        sightings: Vec::new(),
1801        walk_stop: None,
1802    })
1803}
1804
1805/// Streams `reader` into `out`, bounded to `expected_len` bytes — the
1806/// entry's own declared uncompressed size — so a small compressed input
1807/// cannot expand arbitrarily far past what its own header claims. Never
1808/// buffers a growable copy of the payload, only a fixed 64 KiB window, and
1809/// never trusts the decoder to stop on its own.
1810///
1811/// Returns `true` when exactly `expected_len` bytes were produced, `false`
1812/// when `reader` ran out or errored first — [`crate`]'s own convention
1813/// throughout this module: "did not fully recover" is a fact a caller
1814/// reports (as [`SalvageStatus::Partial`]'s own two causes already are),
1815/// never a [`crate::Error`] that aborts the whole run over one entry.
1816///
1817/// Lives here, rather than duplicated once per format module, because it
1818/// has no format-specific knowledge at all — only `Read`/`Write` and a byte
1819/// count. Task 3c moved it up from `entries.rs`, where it was zip-shaped
1820/// plumbing bolted onto a zip-only write path; every [`SalvageScan`]
1821/// implementation's own `write_payload` (zip's, ARC's, and any later
1822/// format's) calls this same function once it has a decoded reader in
1823/// hand, so a truncated or overrunning decode is reported identically
1824/// regardless of which format produced it.
1825pub fn stream_bounded_copy(
1826    mut reader: impl Read,
1827    expected_len: u64,
1828    out: &mut dyn Write,
1829) -> Result<bool> {
1830    let mut buf = [0u8; 64 * 1024];
1831    let mut produced = 0u64;
1832    loop {
1833        if produced >= expected_len {
1834            return Ok(true);
1835        }
1836        let want = ((expected_len - produced) as usize).min(buf.len());
1837        let n = match reader.read(&mut buf[..want]) {
1838            Ok(0) => return Ok(false),
1839            Ok(n) => n,
1840            Err(_) => return Ok(false),
1841        };
1842        out.write_all(&buf[..n])?;
1843        produced += n as u64;
1844    }
1845}
1846
1847#[cfg(test)]
1848mod tests {
1849    use std::io::{Cursor, Read, Seek, SeekFrom};
1850
1851    use super::*;
1852
1853    fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
1854        Cursor::new(data.to_vec())
1855    }
1856
1857    /// A reader that panics if asked to read more bytes in one call than its
1858    /// backing buffer holds. `salvage_all`'s own loop never reads a
1859    /// candidate's payload at all (see the module doc), so a correct
1860    /// implementation never trips this — it exists to catch a regression
1861    /// that reads before bounding, the exact shape this project has shipped
1862    /// six times across `cpio`, `ar` and `zip`.
1863    struct PanickingReader(Cursor<Vec<u8>>);
1864
1865    impl Read for PanickingReader {
1866        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1867            let backing_len = self.0.get_ref().len();
1868            assert!(
1869                buf.len() <= backing_len,
1870                "oversized read requested: {} bytes against a {backing_len}-byte source \
1871                 — an allocation or read was sized from a declared length before it was bounded",
1872                buf.len()
1873            );
1874            self.0.read(buf)
1875        }
1876    }
1877
1878    impl Seek for PanickingReader {
1879        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
1880            self.0.seek(pos)
1881        }
1882    }
1883
1884    fn panicking_reader(len: usize) -> PanickingReader {
1885        PanickingReader(Cursor::new(vec![0u8; len]))
1886    }
1887
1888    /// A scanner that never finds anything, however many times it is asked.
1889    struct NeverFinds;
1890
1891    impl SalvageScan for NeverFinds {
1892        fn next_candidate(
1893            &mut self,
1894            _src: &mut dyn SeekRead,
1895            _from: u64,
1896        ) -> Result<Option<Candidate>> {
1897            Ok(None)
1898        }
1899    }
1900
1901    /// A scanner that reports exactly one candidate, declaring the given
1902    /// length, then ends the scan. It never touches `src` itself — it
1903    /// exists purely to hand `salvage_all` a declared length to bound.
1904    struct DeclaresLength(u64);
1905
1906    impl SalvageScan for DeclaresLength {
1907        fn next_candidate(
1908            &mut self,
1909            _src: &mut dyn SeekRead,
1910            from: u64,
1911        ) -> Result<Option<Candidate>> {
1912            if from > 0 {
1913                return Ok(None);
1914            }
1915            Ok(Some(Candidate {
1916                offset: 0,
1917                payload_start: 0,
1918                meta: EntryMeta::file("absurd"),
1919                declared_len: Some(self.0),
1920                verifier: None,
1921                available_len: None,
1922                marked_deleted: false,
1923            }))
1924        }
1925    }
1926
1927    /// A scanner reporting one candidate whose header DECLARES `declared`
1928    /// bytes while the source holds only `available` of them — the
1929    /// truncated-tail shape, the one a real scanner reports by setting
1930    /// [`Candidate::available_len`].
1931    struct DeclaresMoreThanIsThere {
1932        declared: u64,
1933        available: u64,
1934    }
1935
1936    impl SalvageScan for DeclaresMoreThanIsThere {
1937        fn next_candidate(
1938            &mut self,
1939            _src: &mut dyn SeekRead,
1940            from: u64,
1941        ) -> Result<Option<Candidate>> {
1942            if from > 0 {
1943                return Ok(None);
1944            }
1945            Ok(Some(Candidate {
1946                offset: 0,
1947                payload_start: 0,
1948                meta: EntryMeta::file("cut-short"),
1949                declared_len: Some(self.declared),
1950                verifier: Some(Verifier::Crc32(1)),
1951                available_len: Some(self.available),
1952                marked_deleted: false,
1953            }))
1954        }
1955    }
1956
1957    #[test]
1958    fn a_scanner_that_finds_nothing_yields_no_entries() {
1959        let out = salvage_all(
1960            &mut NeverFinds,
1961            &mut bytes(&[0u8; 4096]),
1962            &SalvagePolicy::default(),
1963        );
1964        assert!(
1965            out.unwrap().entries.is_empty(),
1966            "a scanner finding nothing must yield nothing"
1967        );
1968    }
1969
1970    /// The ceiling test that matters most: a reader that PANICS on an
1971    /// oversized read, so this test fails if the read happens at all — not
1972    /// merely if the wrong status comes back. See this module's
1973    /// falsification note in the task report for what happens when the
1974    /// bound is moved below a read.
1975    ///
1976    /// Fix round 3 (Task 3c): the refusal is a per-entry STATUS now, not an
1977    /// `Err` — so this asserts both halves, that nothing was read AND that
1978    /// the run completed with the entry reported. The old assertion
1979    /// (`unwrap_err()`, exit 6) proved only the first and was satisfied by
1980    /// the behaviour that lost every other entry in the archive.
1981    #[test]
1982    fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
1983        let mut scan = DeclaresLength(u64::MAX);
1984        let out = salvage_all(
1985            &mut scan,
1986            &mut panicking_reader(4096),
1987            &SalvagePolicy::default(),
1988        )
1989        .expect("an entry over the ceiling must not abort the run");
1990        assert_eq!(out.entries.len(), 1, "the entry is still reported");
1991        assert_eq!(
1992            out.entries[0].status,
1993            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1994                needed: u64::MAX,
1995                ceiling: MAX_SALVAGE_ENTRY,
1996            })
1997        );
1998    }
1999
2000    /// The other half of the same rule, and the one the CLI reaches:
2001    /// everything AROUND an over-ceiling entry is still recovered. Measured
2002    /// before this fix as `stuffr salvage --list --max-entry 4` over a
2003    /// 78-byte ARC — no rows at all, exit 6.
2004    #[test]
2005    fn an_entry_over_the_ceiling_does_not_cost_the_entries_around_it() {
2006        struct TwoCandidates;
2007        impl SalvageScan for TwoCandidates {
2008            fn next_candidate(
2009                &mut self,
2010                _src: &mut dyn SeekRead,
2011                from: u64,
2012            ) -> Result<Option<Candidate>> {
2013                let (offset, len, name) = match from {
2014                    0 => (0u64, 16u64, "small-first"),
2015                    1..=16 => (17u64, 4096u64, "huge-second"),
2016                    _ => return Ok(None),
2017                };
2018                Ok(Some(Candidate {
2019                    offset,
2020                    payload_start: offset,
2021                    meta: EntryMeta::file(name),
2022                    declared_len: Some(len),
2023                    verifier: Some(Verifier::Crc32(len as u32)),
2024                    available_len: None,
2025                    marked_deleted: false,
2026                }))
2027            }
2028        }
2029
2030        let out = salvage_all(
2031            &mut TwoCandidates,
2032            &mut bytes(&[0u8; 8192]),
2033            &SalvagePolicy {
2034                max_entry: 64,
2035                ..SalvagePolicy::default()
2036            },
2037        )
2038        .expect("one over-ceiling entry must never abort the run");
2039        assert_eq!(out.entries.len(), 2);
2040        // `TwoCandidates` does not override `verify`, so the first entry
2041        // takes the trait's default — `Unattested` since Salvage Stage 3,
2042        // where it was `Complete`. Neither figure is what this test is
2043        // about: the point is that the SECOND entry's ceiling refusal did
2044        // not cost the first entry its row.
2045        assert_eq!(out.entries[0].status, SalvageStatus::Unattested);
2046        assert_eq!(
2047            out.entries[1].status,
2048            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
2049                needed: 4096,
2050                ceiling: 64,
2051            })
2052        );
2053    }
2054
2055    /// A scanner's own [`SalvageScan::max_whole_entry`] narrows the policy's
2056    /// ceiling — the composition ARC relies on, proven against a mock so it
2057    /// does not depend on ARC's own 256 MiB constant. The candidate here is
2058    /// well under `policy.max_entry` and over the scanner's own figure, so
2059    /// only the `min` can refuse it; the panicking reader proves it is
2060    /// refused without being read.
2061    #[test]
2062    fn a_scanners_own_whole_entry_ceiling_narrows_the_policys() {
2063        struct DecodesWhole;
2064        impl SalvageScan for DecodesWhole {
2065            fn next_candidate(
2066                &mut self,
2067                _src: &mut dyn SeekRead,
2068                from: u64,
2069            ) -> Result<Option<Candidate>> {
2070                if from > 0 {
2071                    return Ok(None);
2072                }
2073                Ok(Some(Candidate {
2074                    offset: 0,
2075                    payload_start: 0,
2076                    meta: EntryMeta::file("whole"),
2077                    declared_len: Some(2048),
2078                    verifier: Some(Verifier::Crc32(7)),
2079                    available_len: None,
2080                    marked_deleted: false,
2081                }))
2082            }
2083            fn max_whole_entry(&self) -> u64 {
2084                1024
2085            }
2086            fn verify(&self, _src: &mut dyn SeekRead, _c: &Candidate) -> Result<SalvageStatus> {
2087                panic!("verify must not run for a candidate over the ceiling");
2088            }
2089        }
2090
2091        let out = salvage_all(
2092            &mut DecodesWhole,
2093            &mut panicking_reader(4096),
2094            &SalvagePolicy::default(),
2095        )
2096        .expect("a scanner ceiling is a per-entry refusal, not a run failure");
2097        assert_eq!(
2098            out.entries[0].status,
2099            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
2100                needed: 2048,
2101                // The SCANNER's figure, not the policy's 4 GiB — the `min`
2102                // is what this test is really pinning.
2103                ceiling: 1024,
2104            })
2105        );
2106    }
2107
2108    #[test]
2109    fn a_declared_length_within_the_ceiling_is_collected_rather_than_refused() {
2110        let mut scan = DeclaresLength(4096);
2111        let out = salvage_all(
2112            &mut scan,
2113            &mut panicking_reader(4096),
2114            &SalvagePolicy::default(),
2115        )
2116        .unwrap();
2117        assert_eq!(out.entries.len(), 1);
2118        let entry = &out.entries[0];
2119        assert_eq!(entry.scan_position, 0);
2120        assert_eq!(entry.offset, 0);
2121        // `DeclaresLength` does not override `verify`, so this is the
2122        // trait's default: `Unattested` since Salvage Stage 3, where it was
2123        // `Complete`. The test is about the entry being COLLECTED — its
2124        // declared length sits under the ceiling — not about which tier a
2125        // mock that checks nothing lands in.
2126        assert_eq!(entry.status, SalvageStatus::Unattested);
2127        assert_eq!(entry.shadows, None);
2128        assert_eq!(entry.collides_with, None);
2129    }
2130
2131    /// A truncated candidate is COLLECTED, not dropped — the whole point of
2132    /// [`Candidate::available_len`]. Before it existed, `zip_salvage.rs`
2133    /// refused such a candidate outright and a truncated archive's last
2134    /// entry vanished with no row, no note and exit 0.
2135    #[test]
2136    fn a_candidate_whose_payload_ran_out_is_still_collected() {
2137        let mut scan = DeclaresMoreThanIsThere {
2138            declared: 4000,
2139            available: 1950,
2140        };
2141        let out = salvage_all(
2142            &mut scan,
2143            &mut panicking_reader(4096),
2144            &SalvagePolicy::default(),
2145        )
2146        .unwrap();
2147        assert_eq!(
2148            out.entries.len(),
2149            1,
2150            "a header found but not completable is a fact worth reporting"
2151        );
2152    }
2153
2154    /// The ceiling is checked against what could be READ, not against what
2155    /// a truncated header CLAIMED. Otherwise a garbage length in a tail the
2156    /// scan has already established is not there would abort the entire run
2157    /// at exit 6 — losing every earlier entry over an absent one, in the
2158    /// verb that exists to rescue exactly that archive.
2159    #[test]
2160    fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
2161        let mut scan = DeclaresMoreThanIsThere {
2162            declared: u64::MAX,
2163            available: 2048,
2164        };
2165        let out = salvage_all(
2166            &mut scan,
2167            &mut panicking_reader(4096),
2168            &SalvagePolicy::default(),
2169        )
2170        .expect("an absent tail must not abort a run that recovered real entries");
2171        assert_eq!(out.entries.len(), 1);
2172    }
2173
2174    /// The complement, so the rule above cannot be read as "truncated
2175    /// candidates are never bounded": what IS there is still bounded, and a
2176    /// truncated candidate holding more available bytes than the ceiling
2177    /// allows is refused exactly as an untruncated one would be — reported
2178    /// [`UnverifiedCause::OverEntryCeiling`], never read (the reader
2179    /// panics if it is), and never allowed to end the run.
2180    #[test]
2181    fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
2182        let mut scan = DeclaresMoreThanIsThere {
2183            declared: u64::MAX,
2184            available: 4096,
2185        };
2186        let out = salvage_all(
2187            &mut scan,
2188            &mut panicking_reader(4096),
2189            &SalvagePolicy {
2190                max_entry: 1024,
2191                ..SalvagePolicy::default()
2192            },
2193        )
2194        .expect("a bounded refusal is per-entry, never a run failure");
2195        assert_eq!(
2196            out.entries[0].status,
2197            // `needed` is 4096 — the bytes that ARE there — never the
2198            // `u64::MAX` this candidate's header declared. Fix round 4
2199            // (NEW-F): the figures a user is shown are the ones the
2200            // comparison used.
2201            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
2202                needed: 4096,
2203                ceiling: 1024,
2204            }),
2205            "an over-ceiling entry is unverified, not truncated: nobody read it"
2206        );
2207    }
2208
2209    #[test]
2210    fn the_default_policy_is_recovery_biased() {
2211        let policy = SalvagePolicy::default();
2212        assert_eq!(policy.partial, PartialPolicy::Keep);
2213        assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
2214        assert!(!policy.strict);
2215    }
2216
2217    // -----------------------------------------------------------------
2218    // Task 4: `verify` dispatch and shadow detection, proven against a
2219    // mock — same discipline as the rest of this module, since neither
2220    // mechanism needs to know which format it is annotating.
2221    // -----------------------------------------------------------------
2222
2223    /// Reports a fixed, caller-scripted sequence of candidates and answers
2224    /// `verify` from the SAME script rather than decoding anything — this
2225    /// module's engine is proven against mocks, and verification/shadow
2226    /// detection are no exception. Each entry names its own `name` and
2227    /// `declared_len` (rather than a fixed, offset-derived name) so a test
2228    /// can freely vary any one of the three fields shadow detection now
2229    /// requires to agree.
2230    struct ScriptedCandidates {
2231        plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
2232        next: usize,
2233    }
2234
2235    impl SalvageScan for ScriptedCandidates {
2236        fn next_candidate(
2237            &mut self,
2238            _src: &mut dyn SeekRead,
2239            _from: u64,
2240        ) -> Result<Option<Candidate>> {
2241            if self.next >= self.plan.len() {
2242                return Ok(None);
2243            }
2244            let offset = self.next as u64;
2245            let (name, declared_len, verifier, _) = self.plan[self.next];
2246            self.next += 1;
2247            Ok(Some(Candidate {
2248                offset,
2249                payload_start: offset,
2250                meta: EntryMeta::file(name),
2251                declared_len,
2252                verifier,
2253                available_len: None,
2254                marked_deleted: false,
2255            }))
2256        }
2257
2258        fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
2259            Ok(self.plan[candidate.offset as usize].3)
2260        }
2261    }
2262
2263    /// `verify` decides each entry's status — the whole point of Task 4:
2264    /// this used to be a hardcoded `Complete` for every candidate, which was
2265    /// wrong for any format carrying a real checksum.
2266    #[test]
2267    fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
2268        let mut scan = ScriptedCandidates {
2269            plan: vec![
2270                (
2271                    "a",
2272                    Some(1),
2273                    Some(Verifier::Crc32(1)),
2274                    SalvageStatus::Intact,
2275                ),
2276                ("b", Some(1), None, SalvageStatus::Complete),
2277                (
2278                    "c",
2279                    Some(1),
2280                    Some(Verifier::Crc32(2)),
2281                    SalvageStatus::Partial,
2282                ),
2283            ],
2284            next: 0,
2285        };
2286        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2287            .expect("scripted candidates must annotate cleanly");
2288        assert_eq!(out.entries[0].status, SalvageStatus::Intact);
2289        assert_eq!(out.entries[1].status, SalvageStatus::Complete);
2290        assert_eq!(out.entries[2].status, SalvageStatus::Partial);
2291    }
2292
2293    /// A later candidate reporting the SAME name, declared_len AND verifier
2294    /// as an earlier one is its shadow — pointing at the earliest position,
2295    /// not merely "some" earlier one.
2296    #[test]
2297    fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
2298        let mut scan = ScriptedCandidates {
2299            plan: vec![
2300                (
2301                    "dup",
2302                    Some(1),
2303                    Some(Verifier::Crc32(7)),
2304                    SalvageStatus::Intact,
2305                ),
2306                (
2307                    "other",
2308                    Some(1),
2309                    Some(Verifier::Crc32(9)),
2310                    SalvageStatus::Intact,
2311                ),
2312                (
2313                    "dup",
2314                    Some(1),
2315                    Some(Verifier::Crc32(7)),
2316                    SalvageStatus::Intact,
2317                ),
2318            ],
2319            next: 0,
2320        };
2321        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2322            .expect("scripted candidates must annotate cleanly");
2323        assert_eq!(out.entries[0].shadows, None);
2324        assert_eq!(out.entries[1].shadows, None);
2325        assert_eq!(
2326            out.entries[2].shadows,
2327            Some(0),
2328            "must point at the earliest matching position"
2329        );
2330    }
2331
2332    fn find_all(data: &[u8], sig: &[u8]) -> Vec<u64> {
2333        let mut search = ForwardSearch::new();
2334        let mut src = Cursor::new(data.to_vec());
2335        let len = data.len() as u64;
2336        let mut from = 0;
2337        let mut hits = Vec::new();
2338        while let Some(at) = search
2339            .find(&mut src, from, len, sig.len(), |w| w == sig)
2340            .unwrap()
2341        {
2342            hits.push(at);
2343            from = at + 1;
2344        }
2345        hits
2346    }
2347
2348    fn naive(data: &[u8], sig: &[u8]) -> Vec<u64> {
2349        (0..=data.len().saturating_sub(sig.len()))
2350            .filter(|&i| data.len() >= sig.len() && &data[i..i + sig.len()] == sig)
2351            .map(|i| i as u64)
2352            .collect()
2353    }
2354
2355    #[test]
2356    fn forward_search_finds_what_a_naive_scan_finds() {
2357        let sig = b"070701";
2358        // Hits at 0, near small-chunk boundaries (each `find` restarts at
2359        // FIRST_CHUNK, so large-chunk straddles are tested separately below),
2360        // and flush against EOF.
2361        let mut data = vec![b'x'; 200_000];
2362        for at in [0usize, 62, 63, 127, 255, 4093, 65_533, 131_070, 199_994] {
2363            data[at..at + sig.len()].copy_from_slice(sig);
2364        }
2365        assert_eq!(find_all(&data, sig), naive(&data, sig));
2366    }
2367
2368    #[test]
2369    fn forward_search_past_eof_and_on_short_input_is_none() {
2370        let mut search = ForwardSearch::new();
2371        let mut src = Cursor::new(b"0707".to_vec());
2372        assert_eq!(search.find(&mut src, 0, 4, 6, |_| true).unwrap(), None);
2373        assert_eq!(search.find(&mut src, 9, 4, 1, |_| true).unwrap(), None);
2374    }
2375
2376    /// A source that returns at most 7 bytes per read.
2377    struct Trickle(Cursor<Vec<u8>>);
2378    impl Read for Trickle {
2379        fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
2380            let n = out.len().min(7);
2381            self.0.read(&mut out[..n])
2382        }
2383    }
2384    impl Seek for Trickle {
2385        fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
2386            self.0.seek(to)
2387        }
2388    }
2389
2390    #[test]
2391    fn forward_search_survives_short_reads() {
2392        let mut data = vec![0u8; 5_000];
2393        data[4_321..4_327].copy_from_slice(b"070701");
2394        let mut search = ForwardSearch::new();
2395        let mut src = Trickle(Cursor::new(data));
2396        assert_eq!(
2397            search
2398                .find(&mut src, 0, 5_000, 6, |w| w == b"070701")
2399                .unwrap(),
2400            Some(4_321)
2401        );
2402    }
2403
2404    #[test]
2405    fn forward_search_no_match_reads_each_byte_once_and_ends_at_eof() {
2406        use crate::testing::CountingSource;
2407        let data = vec![b'x'; 200_000];
2408        let mut src = CountingSource::new(Cursor::new(data));
2409        let mut search = ForwardSearch::new();
2410        assert_eq!(
2411            search
2412                .find(&mut src, 0, 200_000, 6, |w| w == b"070701")
2413                .unwrap(),
2414            None
2415        );
2416        assert_eq!(src.bytes_read(), 200_000);
2417    }
2418
2419    #[test]
2420    fn forward_search_no_match_through_short_reads_ends_at_eof() {
2421        let mut search = ForwardSearch::new();
2422        let mut src = Trickle(Cursor::new(vec![b'x'; 200_000]));
2423        assert_eq!(
2424            search
2425                .find(&mut src, 0, 200_000, 6, |w| w == b"070701")
2426                .unwrap(),
2427            None
2428        );
2429    }
2430
2431    #[test]
2432    fn forward_search_on_a_truncated_source_ends_at_eof() {
2433        let mut search = ForwardSearch::new();
2434        let mut src = Cursor::new(vec![b'x'; 1_000]);
2435        assert_eq!(
2436            search
2437                .find(&mut src, 0, 50_000, 6, |w| w == b"070701")
2438                .unwrap(),
2439            None
2440        );
2441    }
2442
2443    #[test]
2444    fn forward_search_never_looks_past_an_understated_file_len() {
2445        let mut data = vec![b'x'; 10_000];
2446        data[5_000..5_006].copy_from_slice(b"070701");
2447        let mut search = ForwardSearch::new();
2448        let mut src = Cursor::new(data);
2449        assert_eq!(
2450            search
2451                .find(&mut src, 0, 4_000, 6, |w| w == b"070701")
2452                .unwrap(),
2453            None
2454        );
2455        // A hit straddling file_len is not "before file_len" either.
2456        assert_eq!(
2457            search
2458                .find(&mut src, 0, 5_003, 6, |w| w == b"070701")
2459                .unwrap(),
2460            None
2461        );
2462        assert_eq!(
2463            search
2464                .find(&mut src, 0, 5_006, 6, |w| w == b"070701")
2465                .unwrap(),
2466            Some(5_000)
2467        );
2468    }
2469
2470    #[test]
2471    fn forward_search_finds_a_hit_straddling_large_chunk_boundaries() {
2472        let sig = b"070701";
2473        // Cumulative end offset of each read in one `find` from 0.
2474        let mut ends = Vec::new();
2475        let (mut chunk, mut at) = (ForwardSearch::FIRST_CHUNK, 0usize);
2476        while ends.len() < 14 {
2477            at += chunk;
2478            ends.push(at);
2479            chunk = (chunk * 2).min(ForwardSearch::MAX_CHUNK);
2480        }
2481        // The read of MAX_CHUNK / 2 is the last doubling; the next read is
2482        // the first of MAX_CHUNK size.
2483        let (mut sizes, mut chunk) = (Vec::new(), ForwardSearch::FIRST_CHUNK);
2484        for _ in 0..ends.len() {
2485            sizes.push(chunk);
2486            chunk = (chunk * 2).min(ForwardSearch::MAX_CHUNK);
2487        }
2488        let doubling = ends[sizes
2489            .iter()
2490            .position(|&c| c == ForwardSearch::MAX_CHUNK / 2)
2491            .unwrap()];
2492        let first_max = ends[sizes
2493            .iter()
2494            .position(|&c| c == ForwardSearch::MAX_CHUNK)
2495            .unwrap()];
2496        for boundary in [doubling, first_max, first_max + ForwardSearch::MAX_CHUNK] {
2497            for start in [boundary - 5, boundary - 3, boundary - 1] {
2498                let mut data = vec![b'x'; boundary + 100];
2499                data[start..start + sig.len()].copy_from_slice(sig);
2500                let mut search = ForwardSearch::new();
2501                let mut src = Cursor::new(data.clone());
2502                let found = search
2503                    .find(&mut src, 0, data.len() as u64, sig.len(), |w| w == sig)
2504                    .unwrap();
2505                assert_eq!(found, Some(start as u64), "boundary {boundary}");
2506            }
2507        }
2508    }
2509
2510    #[test]
2511    fn a_nearby_hit_reads_only_a_few_bytes() {
2512        use crate::testing::CountingSource;
2513        let mut data = vec![0u8; 1 << 20];
2514        data[10..16].copy_from_slice(b"070701");
2515        let mut src = CountingSource::new(Cursor::new(data));
2516        let mut search = ForwardSearch::new();
2517        assert_eq!(
2518            search
2519                .find(&mut src, 0, 1 << 20, 6, |w| w == b"070701")
2520                .unwrap(),
2521            Some(10)
2522        );
2523        assert!(
2524            src.bytes_read() <= ForwardSearch::FIRST_CHUNK as u64,
2525            "{}",
2526            src.bytes_read()
2527        );
2528    }
2529
2530    /// A long signature's first read tests `sig_len` offsets, not one: a
2531    /// 512-byte signature 300 bytes on is found by ONE read of `2 * 512 - 1`
2532    /// bytes. With a first read of `sig_len` it took 512 + 1,024.
2533    #[test]
2534    fn a_long_signature_s_first_read_tests_sig_len_offsets() {
2535        use crate::testing::CountingSource;
2536        let sig = [0xA5u8; 512];
2537        let mut data = vec![0u8; 1 << 20];
2538        data[300..812].copy_from_slice(&sig);
2539        let mut src = CountingSource::new(Cursor::new(data));
2540        let mut search = ForwardSearch::new();
2541        assert_eq!(
2542            search
2543                .find(&mut src, 0, 1 << 20, 512, |w| w == sig)
2544                .unwrap(),
2545            Some(300)
2546        );
2547        assert_eq!(src.bytes_read(), 2 * 512 - 1);
2548    }
2549
2550    /// Dense hits cost no first read each: what one call read past its
2551    /// answer is tested by the next without reading it again. A hit every 7
2552    /// bytes through 1 MiB used to cost 64 bytes read per hit (9x); kept,
2553    /// the searches read each byte once.
2554    #[test]
2555    fn dense_hits_read_each_byte_about_once() {
2556        use crate::testing::CountingSource;
2557        let sig = b"070701";
2558        let mut data = vec![b'x'; 1 << 20];
2559        for at in (0..data.len() - sig.len()).step_by(7) {
2560            data[at..at + sig.len()].copy_from_slice(sig);
2561        }
2562        let len = data.len() as u64;
2563        let mut src = CountingSource::new(Cursor::new(data.clone()));
2564        let mut search = ForwardSearch::new();
2565        let (mut from, mut hits) = (0, Vec::new());
2566        while let Some(at) = search
2567            .find(&mut src, from, len, sig.len(), |w| w == sig)
2568            .unwrap()
2569        {
2570            hits.push(at);
2571            // A scanner reads its candidate between searches; the kept
2572            // bytes must survive the source's position moving.
2573            src.seek(SeekFrom::Start(at)).unwrap();
2574            let mut header = [0u8; 6];
2575            src.read_exact(&mut header).unwrap();
2576            from = at + 1;
2577        }
2578        assert_eq!(hits, naive(&data, sig));
2579        let header_reads = 6 * hits.len() as u64;
2580        assert!(
2581            src.bytes_read() - header_reads <= len + ForwardSearch::MAX_CHUNK as u64,
2582            "searches read {} bytes of a {len}-byte input",
2583            src.bytes_read() - header_reads
2584        );
2585    }
2586
2587    /// A call starting before what was kept, or past it, still answers
2588    /// exactly what a fresh search answers.
2589    #[test]
2590    fn kept_bytes_never_change_an_answer() {
2591        let sig = b"070701";
2592        let mut data = vec![b'x'; 300_000];
2593        for at in [5usize, 70, 4_000, 70_000, 250_000, 299_994] {
2594            data[at..at + sig.len()].copy_from_slice(sig);
2595        }
2596        let len = data.len() as u64;
2597        let mut kept = ForwardSearch::new();
2598        let mut src = Cursor::new(data.clone());
2599        for from in [
2600            0u64, 6, 3, 71, 60, 4_001, 200_000, 69_000, 299_995, 250_001, 0,
2601        ] {
2602            for file_len in [len, 250_003, 70_006, 4_005] {
2603                let fresh = ForwardSearch::new()
2604                    .find(&mut Cursor::new(data.clone()), from, file_len, 6, |w| {
2605                        w == sig
2606                    })
2607                    .unwrap();
2608                let got = kept
2609                    .find(&mut src, from, file_len, 6, |w| w == sig)
2610                    .unwrap();
2611                assert_eq!(got, fresh, "from {from}, file_len {file_len}");
2612            }
2613        }
2614    }
2615
2616    /// `find_to_eof` offers the last offsets every byte left, down to
2617    /// `min_len`, and offers the rest exactly `sig_len`.
2618    #[test]
2619    fn find_to_eof_offers_the_tail_what_is_left() {
2620        let data: Vec<u8> = (0..100u8).collect();
2621        let mut offered = Vec::new();
2622        let found = ForwardSearch::new()
2623            .find_to_eof(&mut Cursor::new(data.clone()), 10, 100, 20, 3, |w| {
2624                offered.push((w[0], w.len()));
2625                false
2626            })
2627            .unwrap();
2628        assert_eq!(found, None);
2629        let expected: Vec<(u8, usize)> = (10u8..=97)
2630            .map(|at| (at, 20.min(100 - at as usize)))
2631            .collect();
2632        assert_eq!(offered, expected);
2633        // A hit in the tail answers its offset.
2634        let at = ForwardSearch::new()
2635            .find_to_eof(&mut Cursor::new(data), 0, 100, 20, 1, |w| w[0] == 95)
2636            .unwrap();
2637        assert_eq!(at, Some(95));
2638    }
2639
2640    /// Pins `annotate_candidates`' semantics across the Vec -> HashMap change:
2641    /// a repeat's `collides_with` is the EARLIEST position with its name, and
2642    /// a non-degenerate same-name/length/verifier repeat shadows the earliest.
2643    #[test]
2644    fn annotate_candidates_reports_the_earliest_position_at_scale() {
2645        let names: Vec<&'static str> = (0..3_000)
2646            .map(|i| &*Box::leak(format!("f{i}").into_boxed_str()))
2647            .collect();
2648        let mut plan = Vec::new();
2649        for i in 0..3_000usize {
2650            // Every tenth candidate repeats the name of an original (never a repeat),
2651            // at position (i % 100) / 10 * 10.
2652            let name = if i % 10 == 9 {
2653                names[(i % 100) / 10 * 10]
2654            } else {
2655                names[i]
2656            };
2657            let declared = if i % 20 == 19 { Some(8) } else { Some(4) };
2658            // Some repeats carry a different checksum: same name and length
2659            // but another verifier is a collision, not a shadow.
2660            let crc = if i % 10 == 9 && (i / 10) % 2 == 0 {
2661                8
2662            } else {
2663                7
2664            };
2665            plan.push((
2666                name,
2667                declared,
2668                Some(Verifier::Crc32(crc)),
2669                SalvageStatus::Intact,
2670            ));
2671        }
2672        let keys: Vec<_> = plan.iter().map(|p| (p.0, p.1, p.2)).collect();
2673        let mut scan = ScriptedCandidates { plan, next: 0 };
2674        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2675            .expect("scripted candidates must annotate cleanly");
2676        assert_eq!(out.entries.len(), 3_000);
2677        let mut first_triple = HashMap::new();
2678        let mut first_name = HashMap::new();
2679        for (i, k) in keys.iter().enumerate() {
2680            first_triple.entry(*k).or_insert(i);
2681            first_name.entry(k.0).or_insert(i);
2682        }
2683        for (i, e) in out.entries.iter().enumerate() {
2684            let same_triple = first_triple[&keys[i]];
2685            let same_name = first_name[keys[i].0];
2686            let (want_shadow, want_collide) = if same_triple != i {
2687                (Some(same_triple), None)
2688            } else if same_name != i {
2689                (None, Some(same_name))
2690            } else {
2691                (None, None)
2692            };
2693            assert_eq!(e.shadows, want_shadow, "shadows at {i}");
2694            assert_eq!(e.collides_with, want_collide, "collides_with at {i}");
2695        }
2696        let shadowed = out.entries.iter().filter(|e| e.shadows.is_some()).count();
2697        let collided = out
2698            .entries
2699            .iter()
2700            .filter(|e| e.collides_with.is_some())
2701            .count();
2702        assert!(shadowed > 0 && collided > 0, "{shadowed} {collided}");
2703    }
2704
2705    /// A candidate with no verifier at all (a format with no checksum, or a
2706    /// zip data-descriptor entry) must never be reported as shadowing or
2707    /// shadowed — there is nothing to measure, so nothing is claimed.
2708    #[test]
2709    fn candidates_with_no_verifier_never_shadow_each_other() {
2710        let mut scan = ScriptedCandidates {
2711            plan: vec![
2712                ("a", Some(1), None, SalvageStatus::Complete),
2713                ("a", Some(1), None, SalvageStatus::Complete),
2714            ],
2715            next: 0,
2716        };
2717        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2718            .expect("scripted candidates must annotate cleanly");
2719        assert_eq!(out.entries[0].shadows, None);
2720        assert_eq!(out.entries[1].shadows, None);
2721    }
2722
2723    /// Two candidates whose checksum AND declared_len agree but whose NAME
2724    /// differs must never be linked — the fix-round requirement that a
2725    /// checksum match alone is not enough, only the full
2726    /// `(name, declared_len, verifier)` triple.
2727    #[test]
2728    fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
2729        let mut scan = ScriptedCandidates {
2730            plan: vec![
2731                (
2732                    "one.txt",
2733                    Some(4),
2734                    Some(Verifier::Crc32(42)),
2735                    SalvageStatus::Intact,
2736                ),
2737                (
2738                    "two.txt",
2739                    Some(4),
2740                    Some(Verifier::Crc32(42)),
2741                    SalvageStatus::Intact,
2742                ),
2743            ],
2744            next: 0,
2745        };
2746        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2747            .expect("scripted candidates must annotate cleanly");
2748        assert_eq!(
2749            out.entries[1].shadows, None,
2750            "same checksum, different name — not a shadow"
2751        );
2752    }
2753
2754    /// The degenerate case: every empty entry (and, in zip, every directory
2755    /// entry) checksums to the same fixed constant over zero declared
2756    /// bytes. Even under the SAME name, a zero-length checksum must never
2757    /// mark a shadow — the value proves nothing, unlike a real collision.
2758    #[test]
2759    fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
2760        let mut scan = ScriptedCandidates {
2761            plan: vec![
2762                (
2763                    "empty.txt",
2764                    Some(0),
2765                    Some(Verifier::Crc32(0)),
2766                    SalvageStatus::Complete,
2767                ),
2768                (
2769                    "empty.txt",
2770                    Some(0),
2771                    Some(Verifier::Crc32(0)),
2772                    SalvageStatus::Complete,
2773                ),
2774            ],
2775            next: 0,
2776        };
2777        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2778            .expect("scripted candidates must annotate cleanly");
2779        assert_eq!(out.entries[0].shadows, None);
2780        assert_eq!(
2781            out.entries[1].shadows, None,
2782            "a zero-byte checksum must never mark a shadow, even under an identical name"
2783        );
2784        assert_eq!(
2785            out.entries[1].collides_with,
2786            Some(0),
2787            "the NAME still repeats, and that is a directly observed fact no checksum \
2788             guard applies to"
2789        );
2790    }
2791
2792    // -----------------------------------------------------------------
2793    // Name collisions: the second annotation, separate from shadowing.
2794    // -----------------------------------------------------------------
2795
2796    /// The finding this annotation closes: two records under one name whose
2797    /// content does NOT agree are what `stuffr list` calls shadowed, and
2798    /// what salvage marked in no way at all — because its own `shadows`
2799    /// means something stricter.
2800    #[test]
2801    fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
2802        let mut scan = ScriptedCandidates {
2803            plan: vec![
2804                (
2805                    "dup.txt",
2806                    Some(21),
2807                    Some(Verifier::Crc32(7)),
2808                    SalvageStatus::Intact,
2809                ),
2810                (
2811                    "other.txt",
2812                    Some(21),
2813                    Some(Verifier::Crc32(9)),
2814                    SalvageStatus::Intact,
2815                ),
2816                (
2817                    "dup.txt",
2818                    Some(21),
2819                    Some(Verifier::Crc32(11)),
2820                    SalvageStatus::Intact,
2821                ),
2822            ],
2823            next: 0,
2824        };
2825        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2826            .expect("scripted candidates must annotate cleanly");
2827        assert_eq!(out.entries[2].shadows, None, "the content does not agree");
2828        assert_eq!(
2829            out.entries[2].collides_with,
2830            Some(0),
2831            "but the name does, and that is the fact `list` warns about"
2832        );
2833        assert_eq!(out.entries[0].collides_with, None);
2834        assert_eq!(out.entries[1].collides_with, None);
2835    }
2836
2837    /// The two annotations are mutually exclusive: a record PROVEN to be a
2838    /// copy says so, and does not also report the weaker fact. Reporting
2839    /// both would make every shadow print two markers for one relationship.
2840    #[test]
2841    fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
2842        let mut scan = ScriptedCandidates {
2843            plan: vec![
2844                (
2845                    "dup.txt",
2846                    Some(21),
2847                    Some(Verifier::Crc32(7)),
2848                    SalvageStatus::Intact,
2849                ),
2850                (
2851                    "dup.txt",
2852                    Some(21),
2853                    Some(Verifier::Crc32(7)),
2854                    SalvageStatus::Intact,
2855                ),
2856            ],
2857            next: 0,
2858        };
2859        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2860            .expect("scripted candidates must annotate cleanly");
2861        assert_eq!(out.entries[1].shadows, Some(0));
2862        assert_eq!(out.entries[1].collides_with, None);
2863    }
2864
2865    /// A third record under a name two earlier ones already used points at
2866    /// the EARLIEST of them, the same rule `shadows` follows — so a chain of
2867    /// collisions names one origin rather than each pointing at its
2868    /// predecessor.
2869    #[test]
2870    fn a_third_record_under_one_name_names_the_earliest_position() {
2871        let mut scan = ScriptedCandidates {
2872            plan: vec![
2873                (
2874                    "dup",
2875                    Some(4),
2876                    Some(Verifier::Crc32(1)),
2877                    SalvageStatus::Intact,
2878                ),
2879                (
2880                    "dup",
2881                    Some(4),
2882                    Some(Verifier::Crc32(2)),
2883                    SalvageStatus::Intact,
2884                ),
2885                (
2886                    "dup",
2887                    Some(4),
2888                    Some(Verifier::Crc32(3)),
2889                    SalvageStatus::Intact,
2890                ),
2891            ],
2892            next: 0,
2893        };
2894        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2895            .expect("scripted candidates must annotate cleanly");
2896        assert_eq!(out.entries[1].collides_with, Some(0));
2897        assert_eq!(out.entries[2].collides_with, Some(0));
2898    }
2899
2900    /// The negative double: distinct names must never be marked, however
2901    /// much else they share. Without this, an annotation that fired on
2902    /// everything would pass every test above.
2903    #[test]
2904    fn distinct_names_are_never_reported_as_colliding() {
2905        let mut scan = ScriptedCandidates {
2906            plan: vec![
2907                (
2908                    "one.txt",
2909                    Some(4),
2910                    Some(Verifier::Crc32(42)),
2911                    SalvageStatus::Intact,
2912                ),
2913                (
2914                    "two.txt",
2915                    Some(4),
2916                    Some(Verifier::Crc32(42)),
2917                    SalvageStatus::Intact,
2918                ),
2919            ],
2920            next: 0,
2921        };
2922        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2923            .expect("scripted candidates must annotate cleanly");
2924        assert_eq!(out.entries[0].collides_with, None);
2925        assert_eq!(out.entries[1].collides_with, None);
2926    }
2927
2928    /// A collision needs no verifier at all, unlike a shadow. A format with
2929    /// no checksum (tar, cpio, ar — and a zip data-descriptor entry) can
2930    /// still repeat a name, and that repetition is exactly as real there.
2931    #[test]
2932    fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
2933        let mut scan = ScriptedCandidates {
2934            plan: vec![
2935                ("same", Some(4), None, SalvageStatus::Complete),
2936                ("same", Some(4), None, SalvageStatus::Complete),
2937            ],
2938            next: 0,
2939        };
2940        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2941            .expect("scripted candidates must annotate cleanly");
2942        assert_eq!(
2943            out.entries[1].shadows, None,
2944            "no verifier, so nothing proves they are copies"
2945        );
2946        assert_eq!(
2947            out.entries[1].collides_with,
2948            Some(0),
2949            "but the name repeats, which needs no verifier to observe"
2950        );
2951    }
2952
2953    /// Ruling S-I. `Candidate` is `#[non_exhaustive]`, so an out-of-crate
2954    /// scanner reaches it only through `new` plus the `with_*` setters —
2955    /// which means the DEFAULTS are now public API, not an implementation
2956    /// detail of six struct literals. Each of the four must assert the
2957    /// least: nothing declared, nothing to verify with, nothing missing,
2958    /// nothing deleted.
2959    #[test]
2960    fn a_new_candidate_claims_nothing_it_was_not_told() {
2961        let c = Candidate::new(7, 42, EntryMeta::file("probe"));
2962        assert_eq!(c.offset, 7);
2963        assert_eq!(c.payload_start, 42);
2964        assert_eq!(c.declared_len, None, "no length was declared");
2965        assert_eq!(c.verifier, None, "nothing can prove the content");
2966        assert_eq!(
2967            c.available_len, None,
2968            "a shortfall is MEASURED, never assumed"
2969        );
2970        assert!(!c.marked_deleted, "no record is deleted until one says so");
2971    }
2972
2973    /// The four setters are the only route to those fields from another
2974    /// crate, so each one is pinned rather than trusted to the compiler.
2975    #[test]
2976    fn each_candidate_setter_reaches_its_own_field() {
2977        let c = Candidate::new(0, 0, EntryMeta::file("probe"))
2978            .with_declared_len(Some(9))
2979            .with_verifier(Some(Verifier::Crc16(0xbeef)))
2980            .with_available_len(Some(4))
2981            .with_marked_deleted(true);
2982        assert_eq!(c.declared_len, Some(9));
2983        assert_eq!(c.verifier, Some(Verifier::Crc16(0xbeef)));
2984        assert_eq!(c.available_len, Some(4));
2985        assert!(c.marked_deleted);
2986    }
2987
2988    /// Same ruling, same reason, for the type every per-format
2989    /// `write_payload` test builds directly. Its three defaults are the
2990    /// annotations `annotate_candidates` adds, so an entry built by hand
2991    /// must carry none of them.
2992    #[test]
2993    fn a_new_salvaged_entry_carries_no_annotations() {
2994        let e = SalvagedEntry::new(3, 7, 42, EntryMeta::file("probe"), SalvageStatus::Complete);
2995        assert_eq!(e.scan_position, 3);
2996        assert_eq!(e.offset, 7);
2997        assert_eq!(e.payload_start, 42);
2998        assert_eq!(e.status, SalvageStatus::Complete);
2999        assert_eq!(e.shadows, None);
3000        assert_eq!(e.collides_with, None);
3001        assert!(!e.marked_deleted);
3002
3003        let annotated = SalvagedEntry::new(0, 0, 0, EntryMeta::file("p"), SalvageStatus::Intact)
3004            .with_shadows(Some(1))
3005            .with_collides_with(Some(2))
3006            .with_marked_deleted(true);
3007        assert_eq!(annotated.shadows, Some(1));
3008        assert_eq!(annotated.collides_with, Some(2));
3009        assert!(annotated.marked_deleted);
3010    }
3011
3012    /// 0.10.4: a log stores at most its cap per (format, shape, kind), and
3013    /// the count it keeps is exact.
3014    #[test]
3015    fn a_log_stores_at_most_the_cap_per_group_and_counts_the_rest() {
3016        let mut log = SightingLog::with_cap(4);
3017        for off in 0..10u64 {
3018            log.push(Sighting::verdict_bounded(FormatId::new("zip"), off, "x"));
3019        }
3020        log.push(Sighting::new(FormatId::new("zip"), 99, "y"));
3021        assert_eq!(
3022            log.count(FormatId::new("zip"), "x", SightingKind::VerdictBounded),
3023            10
3024        );
3025        let out = log.into_sightings();
3026        let x: Vec<_> = out.iter().filter(|s| s.shape == "x").collect();
3027        assert_eq!(x.iter().map(|s| s.offset).collect::<Vec<_>>(), [0, 1, 2, 3]);
3028        assert_eq!(x.iter().map(|s| s.more).sum::<u64>(), 6);
3029        assert_eq!(x.last().unwrap().more, 6);
3030        assert_eq!(out.iter().filter(|s| s.shape == "y").count(), 1);
3031    }
3032
3033    /// 0.10.4: capping never changes the sentence a caller prints.
3034    #[test]
3035    fn capping_keeps_the_printed_sentence_identical() {
3036        let all: Vec<Sighting> = (0..500u64)
3037            .map(|o| Sighting::verdict_bounded(FormatId::new("zip"), o * 3, "x"))
3038            .chain((0..20u64).map(|o| Sighting::new(FormatId::new("tar"), o, "y")))
3039            .collect();
3040        let mut log = SightingLog::new();
3041        for s in all.clone() {
3042            log.push(s);
3043        }
3044        assert_eq!(
3045            describe_sightings(&log.into_sightings()),
3046            describe_sightings(&all)
3047        );
3048    }
3049
3050    /// 0.10.4: the printed sentence is identical capped and uncapped for
3051    /// every cap from [`SIGHTING_OFFSETS_SHOWN`] up, over 1000 deterministic
3052    /// inputs that interleave formats, shapes and kinds.
3053    #[test]
3054    fn capping_keeps_the_sentence_identical_across_random_inputs() {
3055        let mut state = 0x9E37_79B9_7F4A_7C15u64;
3056        let mut next = move || {
3057            state ^= state << 13;
3058            state ^= state >> 7;
3059            state ^= state << 17;
3060            state
3061        };
3062        let formats = [FormatId::new("zip"), FormatId::new("tar")];
3063        let shapes = ["x", "y", "z"];
3064        let kinds = [
3065            SightingKind::Ungateable,
3066            SightingKind::WorkBounded,
3067            SightingKind::VerdictBounded,
3068        ];
3069        for _ in 0..1000 {
3070            let len = (next() % 300) as usize;
3071            let all: Vec<Sighting> = (0..len)
3072                .map(|_| {
3073                    let r = next();
3074                    let s = Sighting::new(
3075                        formats[(r % 2) as usize],
3076                        (r >> 8) % 100_000,
3077                        shapes[((r >> 2) % 3) as usize],
3078                    );
3079                    Sighting {
3080                        kind: kinds[((r >> 4) % 3) as usize],
3081                        ..s
3082                    }
3083                })
3084                .collect();
3085            for cap in [8, 9, 64] {
3086                let mut log = SightingLog::with_cap(cap);
3087                for s in all.clone() {
3088                    log.push(s);
3089                }
3090                assert_eq!(
3091                    describe_sightings(&log.into_sightings()),
3092                    describe_sightings(&all),
3093                    "cap {cap}, {len} sightings"
3094                );
3095            }
3096        }
3097    }
3098
3099    /// Task 2-N: the note a caller prints for its sightings. None for none —
3100    /// a run with nothing refused must print nothing — and otherwise one
3101    /// group per (format, shape), each with its complete count and at most
3102    /// [`SIGHTING_OFFSETS_SHOWN`] offsets, in the order first seen.
3103    #[test]
3104    fn describe_sightings_groups_by_shape_and_bounds_what_it_lists() {
3105        assert_eq!(describe_sightings(&[]), None);
3106
3107        let f = FormatId::new("fmt");
3108        let mut seen: Vec<Sighting> = (0..10u64)
3109            .map(|i| Sighting::new(f, i * 512, "a(s)"))
3110            .collect();
3111        seen.insert(1, Sighting::new(f, 7, "b(s)"));
3112        let note = describe_sightings(&seen).unwrap();
3113        assert!(
3114            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"),
3115            "{note}"
3116        );
3117        assert!(note.contains("`stuffr list`"), "{note}");
3118    }
3119
3120    /// 0.10.3: a work-bounded sighting has its own sentence, and an
3121    /// ungateable one keeps its sentence word for word — alone or alongside.
3122    #[test]
3123    fn describe_sightings_words_a_work_bounded_sighting_apart() {
3124        let f = FormatId::new("fmt");
3125        let old = describe_sightings(&[Sighting::new(f, 0, "a(s)")]).unwrap();
3126        assert_eq!(
3127            old,
3128            "this build's salvage scanner saw 1 fmt a(s) at offset(s) 0 — each with the \
3129             structure of a header, but in a shape it has no gate for, so none is listed or \
3130             recovered; `stuffr list` and `stuffr unpack` may read these headers normally, or \
3131             name the variant they are in, and it is the SCAN that stops there"
3132        );
3133        let bounded = describe_sightings(&[Sighting::work_bounded(f, 9, "b(s)")]).unwrap();
3134        assert!(bounded.contains("1 fmt b(s) at offset(s) 9"), "{bounded}");
3135        assert!(!bounded.contains("no gate for"), "{bounded}");
3136        assert!(bounded.contains("bounds the work"), "{bounded}");
3137        // Fix round 1: true for a genuine header starved by forgeries
3138        // elsewhere, and for a scan that goes on past it.
3139        assert!(bounded.contains("the scan went on past them"), "{bounded}");
3140        assert!(!bounded.contains("forged"), "{bounded}");
3141        assert!(!bounded.contains("SCAN that stops"), "{bounded}");
3142        let both = describe_sightings(&[
3143            Sighting::work_bounded(f, 9, "b(s)"),
3144            Sighting::new(f, 0, "a(s)"),
3145        ])
3146        .unwrap();
3147        assert_eq!(both, format!("{old}; and separately, {bounded}"));
3148    }
3149
3150    /// 0.10.3 Task 4b: a verdict-bounded sighting has a third sentence —
3151    /// not the extension-chain one, which would be false of a zip name —
3152    /// and the other two keep their words beside it.
3153    #[test]
3154    fn describe_sightings_words_a_verdict_bounded_sighting_apart() {
3155        let f = FormatId::new("fmt");
3156        let old = describe_sightings(&[Sighting::new(f, 0, "a(s)")]).unwrap();
3157        let bounded = describe_sightings(&[Sighting::work_bounded(f, 9, "b(s)")]).unwrap();
3158        let verdict = describe_sightings(&[Sighting::verdict_bounded(f, 7, "c(s)")]).unwrap();
3159        assert_eq!(
3160            verdict,
3161            "this build's salvage scanner bounds the work it spends judging candidate headers \
3162             across the whole scan, and did not judge 1 fmt c(s) at offset(s) 7 — so \
3163             whatever those headers describe is not listed or recovered there, though the scan \
3164             went on past them; `stuffr list` and `stuffr unpack` may read these headers normally"
3165        );
3166        assert!(!verdict.contains("extension chains"), "{verdict}");
3167        let all = describe_sightings(&[
3168            Sighting::verdict_bounded(f, 7, "c(s)"),
3169            Sighting::work_bounded(f, 9, "b(s)"),
3170            Sighting::new(f, 0, "a(s)"),
3171        ])
3172        .unwrap();
3173        assert_eq!(
3174            all,
3175            format!("{old}; and separately, {bounded}; and separately, {verdict}")
3176        );
3177    }
3178
3179    /// Task 4b: the budget is exactly `2 × file_len + 32 KiB`, a charge
3180    /// that lands on the limit fits, and one past it is refused WHOLE —
3181    /// nothing is charged, so a later, smaller charge can still fit.
3182    #[test]
3183    fn a_scan_budget_spends_to_its_exact_limit_and_refuses_past_it() {
3184        let limit = 2 * 1000 + 32 * 1024;
3185        let mut budget = ScanBudget::for_input(1000);
3186        assert!(budget.try_spend(limit - 10));
3187        assert_eq!(budget.spent(), limit - 10);
3188        assert!(!budget.try_spend(11), "one byte past the limit");
3189        assert_eq!(budget.spent(), limit - 10, "a refusal charges nothing");
3190        assert!(budget.try_spend(10), "exactly to the limit");
3191        assert_eq!(budget.spent(), limit);
3192        assert!(!budget.try_spend(1));
3193        assert_eq!(budget.spent(), limit);
3194    }
3195
3196    /// Spending zero always fits, even on a spent budget, and a charge whose
3197    /// sum overflows `u64` is a refusal, never a wrap.
3198    #[test]
3199    fn a_scan_budget_takes_zero_and_refuses_an_overflowing_charge() {
3200        let mut budget = ScanBudget::for_input(0);
3201        assert!(budget.try_spend(0));
3202        assert_eq!(budget.spent(), 0);
3203        assert!(budget.try_spend(ScanBudget::SLACK));
3204        assert!(budget.try_spend(0), "zero fits a spent budget");
3205        assert!(!budget.try_spend(u64::MAX));
3206        assert_eq!(budget.spent(), ScanBudget::SLACK);
3207
3208        let mut huge = ScanBudget::for_input(u64::MAX);
3209        assert!(huge.try_spend(u64::MAX), "the limit saturates at u64::MAX");
3210        assert!(!huge.try_spend(1), "and a sum past it does not wrap");
3211        assert_eq!(huge.spent(), u64::MAX);
3212    }
3213
3214    /// Task 4: the walk-stop note. A hole says what the brief requires —
3215    /// the entries after it are unreachable by construction, NOT absent,
3216    /// with the offset and the byte count — and a cut-short tail says the
3217    /// opposite, because nothing can be hiding in too few bytes for a record.
3218    #[test]
3219    fn describe_walk_stop_says_unreachable_for_a_hole_and_complete_for_a_cut() {
3220        let f = FormatId::new("fmt");
3221        let hole = describe_walk_stop(&WalkStop::new(
3222            f,
3223            1234,
3224            5000,
3225            WalkStopKind::Unreadable,
3226            "bad field",
3227        ));
3228        for needle in [
3229            "offset 1234",
3230            "5000 bytes",
3231            "bad field",
3232            "unreachable by construction",
3233            "not absent",
3234            "fmt",
3235        ] {
3236            assert!(hole.contains(needle), "missing {needle:?}: {hole}");
3237        }
3238        let cut = describe_walk_stop(&WalkStop::new(f, 90, 1, WalkStopKind::CutShort, "eof"));
3239        assert!(cut.contains("1 byte into the record at offset 90"), "{cut}");
3240        assert!(cut.contains("cut short"), "{cut}");
3241        assert!(!cut.contains("unreachable"), "{cut}");
3242        let shape = describe_walk_stop(&WalkStop::new(
3243            f,
3244            8,
3245            600,
3246            WalkStopKind::UnsupportedShape,
3247            "the symbol table",
3248        ));
3249        assert!(shape.contains("the symbol table"), "{shape}");
3250        assert!(shape.contains("not evidence of damage"), "{shape}");
3251        assert!(!shape.contains("unreachable by construction"), "{shape}");
3252    }
3253}