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