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, bound its
69//! declared length, advance) and [`annotate_candidates`] (verify + shadow,
70//! above) are both exposed separately from [`salvage_all`] — which is just
71//! the two of them run in sequence — so a format that reconciles more than
72//! one candidate source (zip's central-directory fallback, `zip_salvage.rs`)
73//! can collect from each source under the identical bound, merge, and
74//! annotate the merged list exactly once, rather than annotating twice and
75//! reconciling two already-decided outcomes.
76
77use crate::archive::EntryMeta;
78use crate::error::{Error, Result};
79use crate::source::SeekRead;
80
81/// 4 GiB. A scanned length is untrustworthy TWICE over — attacker-controlled
82/// AND possibly corrupt — so this is a structural ceiling, not a budget.
83pub const MAX_SALVAGE_ENTRY: u64 = 4 * 1024 * 1024 * 1024;
84
85/// What a format can prove about a candidate it found.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Verifier {
88    /// CRC-32, as zip, arj and gzip compute it.
89    Crc32(u32),
90    /// CRC-16/ARC, as lha, arc and zoo compute it.
91    Crc16(u16),
92}
93
94/// A record a scanner believes it found, before anything is decoded.
95#[derive(Debug)]
96pub struct Candidate {
97    /// Byte offset of the record's header in the archive.
98    pub offset: u64,
99    pub meta: EntryMeta,
100    /// Declared payload length. `None` when the header does not carry one.
101    pub declared_len: Option<u64>,
102    /// The checksum the ORIGINAL writer computed, when the format has one.
103    /// `None` is what produces the intact/complete split honestly, rather
104    /// than by a per-format convention someone has to remember.
105    pub verifier: Option<Verifier>,
106    /// How many of `declared_len`'s bytes the SOURCE actually holds, when
107    /// it holds fewer — the truncated-tail case. `None` means the declared
108    /// payload is entirely present (the ordinary case) or nothing was
109    /// declared at all; `Some(n)` always means `n < declared_len`.
110    ///
111    /// # Why a scanner reports this instead of dropping the candidate
112    ///
113    /// A truncated archive — an interrupted download — is the single most
114    /// common damaged zip there is, and it was the one shape salvage said
115    /// nothing at all about. `zip_salvage.rs`'s validation gate refused any
116    /// candidate whose declared payload ran past the end of the file, which
117    /// is a correct refusal to INVENT the missing bytes (`zip -FF`
118    /// fabricates them and reports success) implemented as SILENCE: no row,
119    /// no note, no non-zero exit. Swept across six truncation points inside
120    /// a three-header archive's last entry, every one reported "2 scanned"
121    /// at exit 0 — the third header was not even counted.
122    ///
123    /// Reporting the candidate with this field set is what turns the
124    /// refusal into a statement. Nothing is invented: the declared length
125    /// stays exactly what the header said, so a reader can see both figures,
126    /// and the bytes that ARE present are still a genuine prefix. What a
127    /// scanner must NOT do is fold the shortfall into `declared_len` — that
128    /// would make a truncated entry indistinguishable from a whole one of
129    /// the smaller size, which is the same mistake in a different place.
130    pub available_len: Option<u64>,
131}
132
133/// Why an entry is [`SalvageStatus::Unverified`].
134///
135/// Two causes, and the STATUS does not distinguish them because the decision
136/// they lead to is identical (listed, not written, exit 3) — the same split
137/// [`crate::salvage`]'s consumers apply to [`SalvageStatus::Partial`] via
138/// their own cause type (`entries.rs`'s `PartialCause`, one crate up): **a
139/// tier carries a decision, a message carries a cause.**
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum UnverifiedCause {
142    /// This build recognises the entry's compression method but cannot
143    /// decode it (zip method 99 is AES, 14 is LZMA — recognised, not
144    /// decoded).
145    UndecodableMethod,
146    /// No length was available to bound the payload, so it was never read —
147    /// a data-descriptor entry the raw scan found with no central-directory
148    /// record to reconcile against. A CD-reconciled data-descriptor entry
149    /// gets a real declared length and CRC from the central directory and
150    /// does NOT reach this cause; see `zip_salvage.rs`'s
151    /// `candidate_from_cd_record`.
152    NoDeclaredLength,
153}
154
155/// What was proven about an entry AFTER decoding it.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum SalvageStatus {
158    /// A checksum the original writer computed agrees.
159    Intact,
160    /// Every declared byte was present and the header self-verified, but the
161    /// format offers NO CHECKSUM AT ALL to prove the content — tar, cpio and
162    /// ar, in this project's own formats. Never the right answer for a
163    /// format that DOES carry one; see [`SalvageStatus::Unverified`] for
164    /// that case, which this status is not permitted to stand in for.
165    Complete,
166    /// Nothing about this entry's content was verified.
167    ///
168    /// Two causes ([`UnverifiedCause`]), and the tier does not distinguish
169    /// them because the decision is identical — the entry is listed, not
170    /// written, and the run exits 3:
171    ///   * this build could not decode the method (zip 99 is AES, 14 is
172    ///     LZMA);
173    ///   * no length was available to bound the payload, so it was never
174    ///     read (a data-descriptor entry the raw scan found with no
175    ///     central-directory record to reconcile against).
176    ///
177    /// Distinct from [`Self::Complete`], which asserts every DECLARED byte
178    /// was present — a claim that requires something to have been declared
179    /// in the first place. Reporting `Complete` for either cause above was
180    /// this enum's third instance of "nothing to disprove" being read as
181    /// "proven" (after the Task 1 hardcoded placeholder and the Task 4
182    /// undecodable-method case this variant was originally added for) — the
183    /// exit-code table this status exists to keep honest reserves exit 3 for
184    /// both causes, never exit 0, which is what `Complete` would have routed
185    /// either one to.
186    Unverified(UnverifiedCause),
187    /// The payload ran out, the decoder failed mid-stream, or the content
188    /// decoded whole and disagreed with the checksum the original writer
189    /// computed. All three are the same fact from a caller's perspective —
190    /// this build could not confirm the content is what was written — and
191    /// none of them is `Complete` (something WAS checked) or `Intact` (it
192    /// did not agree, or never finished).
193    Partial,
194}
195
196/// One entry the scan recovered.
197#[derive(Debug)]
198pub struct SalvagedEntry {
199    /// This entry's position in SCAN order — not an index any container's
200    /// own format assigns, and never renumbered by what `list` would show.
201    /// A later task's CLI surfaces this distinction explicitly (`--index`
202    /// on `salvage` names a scan position, not a list index).
203    pub scan_position: usize,
204    pub offset: u64,
205    pub meta: EntryMeta,
206    pub status: SalvageStatus,
207    /// Set when this record's `(name, declared_len, verifier)` all agree
208    /// with an EARLIER record's — a measurement, never inferred from a
209    /// repeated name alone, and never set for a degenerate zero-length
210    /// checksum (every empty file and every directory entry shares one).
211    /// See [`annotate_candidates`] for where this is computed.
212    ///
213    /// **This is the byte-identical duplicate, and ONLY that.** The other,
214    /// weaker fact — an earlier record under the same name whose content
215    /// this scan could not prove identical — is [`Self::collides_with`], and
216    /// the two are mutually exclusive by construction. They were one word
217    /// ("shadow") until the final whole-branch review measured what that
218    /// cost: `stuffr list`'s own fidelity warning uses "shadowed" for a
219    /// repeated NAME, so on an archive whose duplicates differ, `list` said
220    /// two records were shadowed and `salvage --list` marked none.
221    pub shadows: Option<usize>,
222    /// Set when an EARLIER record carries the same `meta.name` and this one
223    /// is not that record's [`Self::shadows`] — i.e. the name repeats but
224    /// the content could not be proven identical.
225    ///
226    /// This is the fact `stuffr list`'s `Fidelity::EntryCountMismatch`
227    /// warning is about: a later record repeating a name is what makes an
228    /// earlier one unreachable through a central directory's name index,
229    /// whatever the bytes under it. It is also the fact that decides
230    /// anything on the WRITE side, because two records under one name and
231    /// two different payloads cannot both land on one path.
232    ///
233    /// Deliberately NOT gated on a verifier, a declared length or the
234    /// degenerate zero-length case the way [`Self::shadows`] is: those
235    /// guards exist because claiming two records are COPIES of each other
236    /// needs proof, and this annotation claims no such thing. A repeated
237    /// name is directly observed, and `None` here means only that no
238    /// earlier record used this name — never that one did and this could
239    /// not tell.
240    pub collides_with: Option<usize>,
241}
242
243/// The result of a scan: every entry the scanner recovered, in scan order.
244///
245/// Later tasks may extend this struct; the `entries` field stays public.
246#[derive(Debug)]
247pub struct SalvageOutcome {
248    pub entries: Vec<SalvagedEntry>,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum PartialPolicy {
253    Keep,
254    Skip,
255    Ask,
256}
257
258pub struct SalvagePolicy {
259    /// Recovery-biased by default: salvage is the one permissive verb, and
260    /// the strict path is the entire rest of the tool.
261    pub partial: PartialPolicy,
262    /// Structural ceiling on any declared length, refused BEFORE allocation.
263    pub max_entry: u64,
264    /// Demand proof: partial skipped, ceiling fixed, nothing unverifiable.
265    pub strict: bool,
266}
267
268impl Default for SalvagePolicy {
269    fn default() -> Self {
270        Self {
271            partial: PartialPolicy::Keep,
272            max_entry: MAX_SALVAGE_ENTRY,
273            strict: false,
274        }
275    }
276}
277
278/// The per-format seam. A scanner recognises its own format's record shape
279/// (a local header's magic, a member's checksum field) and reports what it
280/// finds as a stream of [`Candidate`]s; [`salvage_all`] supplies everything
281/// that does not depend on which format that is.
282pub trait SalvageScan {
283    /// Find the next candidate at or after `from`. `Ok(None)` ends the scan.
284    fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;
285
286    /// Decide what was actually proven about a candidate's payload, after
287    /// discovery — called once per candidate, in file order, by
288    /// [`annotate_candidates`].
289    ///
290    /// The default answers [`SalvageStatus::Complete`] unconditionally: a
291    /// scanner with no real checksum (or, in this module's own tests, a mock
292    /// that never populates [`Candidate::verifier`]) has nothing to check,
293    /// and `Complete` is the honest claim for that case. A format that DOES
294    /// carry a checksum overrides this to decode the payload — reusing its
295    /// own existing codec machinery, never inventing a new one here — and
296    /// compare it against [`Candidate::verifier`].
297    fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
298        Ok(SalvageStatus::Complete)
299    }
300}
301
302/// Walk `scan` over `src`, collecting every candidate it reports into a
303/// [`SalvageOutcome`].
304///
305/// **Requires a seekable source.** Scanning searches back and forth over the
306/// archive rather than reading it once in order, so this takes
307/// `&mut dyn SeekRead` rather than the ladder's `Box<dyn Source>` (whose
308/// `as_seek()` may be `None` for a pipe). A caller sitting on a
309/// non-seekable source must spool it first — exactly as the `arj` and `zoo`
310/// containers already do, reporting `Rung::Spilled` — which is a later
311/// task's ops-layer concern, not this function's.
312///
313/// Every candidate's `declared_len` is bounded against `policy.max_entry`
314/// **before** anything is sized from it: an oversized declaration is refused
315/// with [`Error::ResourceLimit`] the moment it is seen, never after an
316/// allocation or a read has already acted on it.
317pub fn salvage_all(
318    scan: &mut dyn SalvageScan,
319    src: &mut dyn SeekRead,
320    policy: &SalvagePolicy,
321) -> Result<SalvageOutcome> {
322    let candidates = collect_candidates(scan, src, policy)?;
323    annotate_candidates(&*scan, src, candidates)
324}
325
326/// The resync loop alone: find a candidate, bound its declared length
327/// against `policy.max_entry` **before** anything is sized from it, advance
328/// strictly past it, repeat. Never reads a payload and never calls
329/// [`SalvageScan::verify`] — see [`annotate_candidates`] for that.
330///
331/// Exposed separately from [`salvage_all`] so a format that reconciles more
332/// than one candidate source (zip's central-directory fallback) can collect
333/// from each source under the identical bound before merging.
334pub fn collect_candidates(
335    scan: &mut dyn SalvageScan,
336    src: &mut dyn SeekRead,
337    policy: &SalvagePolicy,
338) -> Result<Vec<Candidate>> {
339    let mut candidates = Vec::new();
340    let mut from = 0u64;
341
342    while let Some(candidate) = scan.next_candidate(src, from)? {
343        // The ceiling is checked against what could actually be READ, not
344        // against what the header DECLARED, and the two differ only for a
345        // truncated candidate ([`Candidate::available_len`]). That is not a
346        // loosening: this ceiling exists to refuse before anything is sized
347        // from an attacker-controlled figure, and for a truncated candidate
348        // nothing can ever be sized past the bytes that exist — the source
349        // ends first. Checking the declared figure instead would abort the
350        // WHOLE run (exit 6) over a garbage length in a tail the scan has
351        // already established is not there, which is exactly the archive a
352        // caller reached for salvage to rescue.
353        let bounded_len = candidate.available_len.or(candidate.declared_len);
354        if let Some(len) = bounded_len
355            && len > policy.max_entry
356        {
357            return Err(Error::ResourceLimit(format!(
358                "declared entry length {len} bytes exceeds the {}-byte salvage ceiling \
359                 (see MAX_SALVAGE_ENTRY)",
360                policy.max_entry
361            )));
362        }
363
364        // Advance strictly past this candidate so the scan always makes
365        // progress, regardless of what the scanner reported `from` as or
366        // whether it declared a length at all. A truncated candidate
367        // advances past the bytes that EXIST, not past the ones it claimed:
368        // advancing by the declared figure would skip the rest of the file
369        // (there is none) either way, but the available one keeps the loop's
370        // arithmetic honest about where it actually is.
371        let advance = bounded_len.unwrap_or(1).max(1);
372        from = candidate.offset.saturating_add(advance);
373
374        candidates.push(candidate);
375    }
376
377    Ok(candidates)
378}
379
380/// Turns already-discovered, already-bounded candidates — in file order —
381/// into the verified, shadow-annotated entries a caller receives. See this
382/// module's doc comment for what the two passes (verify, shadow) mean and
383/// why they live here rather than per-format.
384pub fn annotate_candidates(
385    scan: &dyn SalvageScan,
386    src: &mut dyn SeekRead,
387    candidates: Vec<Candidate>,
388) -> Result<SalvageOutcome> {
389    let mut entries = Vec::with_capacity(candidates.len());
390    // Earliest scan_position to report each distinct (name, declared_len,
391    // verifier) triple seen so far — a candidate reporting one already in
392    // here is shadowing that position. A degenerate zero-length checksum
393    // (every empty file, every directory entry) is never pushed here and
394    // never looked up here — see `is_degenerate` below.
395    let mut seen: Vec<(String, Option<u64>, Verifier, usize)> = Vec::new();
396    // Earliest scan_position to report each distinct NAME, regardless of
397    // anything else about the record — the weaker, directly-observed fact
398    // `SalvagedEntry::collides_with` reports, and the one `stuffr list`'s
399    // own fidelity warning is about. Kept separate from `seen` above rather
400    // than folded into it: that list is deliberately full of guards against
401    // over-claiming a COPY (a verifier must exist, the declared length must
402    // agree, a zero-length checksum never counts), and every one of them
403    // would be wrong here, where nothing about content is being claimed.
404    let mut names_seen: Vec<(String, usize)> = Vec::new();
405
406    for (scan_position, candidate) in candidates.into_iter().enumerate() {
407        let status = scan.verify(src, &candidate)?;
408
409        let declared_len = candidate.declared_len;
410        let verifier = candidate.verifier;
411        // A checksum over zero declared bytes is the same value for every
412        // empty entry that ever existed (CRC-32 and CRC-16/ARC of an empty
413        // input are both fixed constants) and proves nothing about any two
414        // of them being copies of each other — measured against a real
415        // archive during this task's fix round: every `__init__.py` and
416        // every directory entry collapsed onto one shadow chain under a
417        // checksum-only match.
418        let is_degenerate = declared_len == Some(0);
419
420        let shadows = if is_degenerate {
421            None
422        } else {
423            verifier.and_then(|v| {
424                seen.iter()
425                    .find(|&&(ref seen_name, seen_len, seen_verifier, _)| {
426                        seen_verifier == v
427                            && seen_len == declared_len
428                            && *seen_name == candidate.meta.name
429                    })
430                    .map(|&(_, _, _, earlier_position)| earlier_position)
431            })
432        };
433
434        if !is_degenerate
435            && shadows.is_none()
436            && let Some(v) = verifier
437        {
438            seen.push((candidate.meta.name.clone(), declared_len, v, scan_position));
439        }
440
441        // The two annotations are mutually exclusive: a record proven to be
442        // a copy is reported as a shadow and nothing else, because "this is
443        // a duplicate of #2" is strictly more informative than "something
444        // earlier used this name". Everything else that repeats a name is a
445        // collision.
446        let earliest_with_name = names_seen
447            .iter()
448            .find(|(name, _)| *name == candidate.meta.name)
449            .map(|&(_, position)| position);
450        let collides_with = if shadows.is_some() {
451            None
452        } else {
453            earliest_with_name
454        };
455        if earliest_with_name.is_none() {
456            names_seen.push((candidate.meta.name.clone(), scan_position));
457        }
458
459        entries.push(SalvagedEntry {
460            scan_position,
461            offset: candidate.offset,
462            meta: candidate.meta,
463            status,
464            shadows,
465            collides_with,
466        });
467    }
468
469    Ok(SalvageOutcome { entries })
470}
471
472#[cfg(test)]
473mod tests {
474    use std::io::{Cursor, Read, Seek, SeekFrom};
475
476    use super::*;
477
478    fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
479        Cursor::new(data.to_vec())
480    }
481
482    /// A reader that panics if asked to read more bytes in one call than its
483    /// backing buffer holds. `salvage_all`'s own loop never reads a
484    /// candidate's payload at all (see the module doc), so a correct
485    /// implementation never trips this — it exists to catch a regression
486    /// that reads before bounding, the exact shape this project has shipped
487    /// six times across `cpio`, `ar` and `zip`.
488    struct PanickingReader(Cursor<Vec<u8>>);
489
490    impl Read for PanickingReader {
491        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
492            let backing_len = self.0.get_ref().len();
493            assert!(
494                buf.len() <= backing_len,
495                "oversized read requested: {} bytes against a {backing_len}-byte source \
496                 — an allocation or read was sized from a declared length before it was bounded",
497                buf.len()
498            );
499            self.0.read(buf)
500        }
501    }
502
503    impl Seek for PanickingReader {
504        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
505            self.0.seek(pos)
506        }
507    }
508
509    fn panicking_reader(len: usize) -> PanickingReader {
510        PanickingReader(Cursor::new(vec![0u8; len]))
511    }
512
513    /// A scanner that never finds anything, however many times it is asked.
514    struct NeverFinds;
515
516    impl SalvageScan for NeverFinds {
517        fn next_candidate(
518            &mut self,
519            _src: &mut dyn SeekRead,
520            _from: u64,
521        ) -> Result<Option<Candidate>> {
522            Ok(None)
523        }
524    }
525
526    /// A scanner that reports exactly one candidate, declaring the given
527    /// length, then ends the scan. It never touches `src` itself — it
528    /// exists purely to hand `salvage_all` a declared length to bound.
529    struct DeclaresLength(u64);
530
531    impl SalvageScan for DeclaresLength {
532        fn next_candidate(
533            &mut self,
534            _src: &mut dyn SeekRead,
535            from: u64,
536        ) -> Result<Option<Candidate>> {
537            if from > 0 {
538                return Ok(None);
539            }
540            Ok(Some(Candidate {
541                offset: 0,
542                meta: EntryMeta::file("absurd"),
543                declared_len: Some(self.0),
544                verifier: None,
545                available_len: None,
546            }))
547        }
548    }
549
550    /// A scanner reporting one candidate whose header DECLARES `declared`
551    /// bytes while the source holds only `available` of them — the
552    /// truncated-tail shape, the one a real scanner reports by setting
553    /// [`Candidate::available_len`].
554    struct DeclaresMoreThanIsThere {
555        declared: u64,
556        available: u64,
557    }
558
559    impl SalvageScan for DeclaresMoreThanIsThere {
560        fn next_candidate(
561            &mut self,
562            _src: &mut dyn SeekRead,
563            from: u64,
564        ) -> Result<Option<Candidate>> {
565            if from > 0 {
566                return Ok(None);
567            }
568            Ok(Some(Candidate {
569                offset: 0,
570                meta: EntryMeta::file("cut-short"),
571                declared_len: Some(self.declared),
572                verifier: Some(Verifier::Crc32(1)),
573                available_len: Some(self.available),
574            }))
575        }
576    }
577
578    #[test]
579    fn a_scanner_that_finds_nothing_yields_no_entries() {
580        let out = salvage_all(
581            &mut NeverFinds,
582            &mut bytes(&[0u8; 4096]),
583            &SalvagePolicy::default(),
584        );
585        assert!(
586            out.unwrap().entries.is_empty(),
587            "a scanner finding nothing must yield nothing"
588        );
589    }
590
591    /// The ceiling test that matters most: a reader that PANICS on an
592    /// oversized read, so this test fails if the allocation happens at all
593    /// — not merely if the wrong error code comes back. See this module's
594    /// falsification note in the task report for what happens when the
595    /// bound is moved below a read.
596    #[test]
597    fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
598        let mut scan = DeclaresLength(u64::MAX);
599        let err = salvage_all(
600            &mut scan,
601            &mut panicking_reader(4096),
602            &SalvagePolicy::default(),
603        )
604        .unwrap_err();
605        assert_eq!(err.exit_code(), 6);
606    }
607
608    #[test]
609    fn a_declared_length_within_the_ceiling_is_collected_as_complete() {
610        let mut scan = DeclaresLength(4096);
611        let out = salvage_all(
612            &mut scan,
613            &mut panicking_reader(4096),
614            &SalvagePolicy::default(),
615        )
616        .unwrap();
617        assert_eq!(out.entries.len(), 1);
618        let entry = &out.entries[0];
619        assert_eq!(entry.scan_position, 0);
620        assert_eq!(entry.offset, 0);
621        assert_eq!(entry.status, SalvageStatus::Complete);
622        assert_eq!(entry.shadows, None);
623        assert_eq!(entry.collides_with, None);
624    }
625
626    /// A truncated candidate is COLLECTED, not dropped — the whole point of
627    /// [`Candidate::available_len`]. Before it existed, `zip_salvage.rs`
628    /// refused such a candidate outright and a truncated archive's last
629    /// entry vanished with no row, no note and exit 0.
630    #[test]
631    fn a_candidate_whose_payload_ran_out_is_still_collected() {
632        let mut scan = DeclaresMoreThanIsThere {
633            declared: 4000,
634            available: 1950,
635        };
636        let out = salvage_all(
637            &mut scan,
638            &mut panicking_reader(4096),
639            &SalvagePolicy::default(),
640        )
641        .unwrap();
642        assert_eq!(
643            out.entries.len(),
644            1,
645            "a header found but not completable is a fact worth reporting"
646        );
647    }
648
649    /// The ceiling is checked against what could be READ, not against what
650    /// a truncated header CLAIMED. Otherwise a garbage length in a tail the
651    /// scan has already established is not there would abort the entire run
652    /// at exit 6 — losing every earlier entry over an absent one, in the
653    /// verb that exists to rescue exactly that archive.
654    #[test]
655    fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
656        let mut scan = DeclaresMoreThanIsThere {
657            declared: u64::MAX,
658            available: 2048,
659        };
660        let out = salvage_all(
661            &mut scan,
662            &mut panicking_reader(4096),
663            &SalvagePolicy::default(),
664        )
665        .expect("an absent tail must not abort a run that recovered real entries");
666        assert_eq!(out.entries.len(), 1);
667    }
668
669    /// The complement, so the rule above cannot be read as "truncated
670    /// candidates are never bounded": what IS there is still bounded, and a
671    /// truncated candidate holding more available bytes than the ceiling
672    /// allows is refused exactly as an untruncated one would be.
673    #[test]
674    fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
675        let mut scan = DeclaresMoreThanIsThere {
676            declared: u64::MAX,
677            available: 4096,
678        };
679        let err = salvage_all(
680            &mut scan,
681            &mut panicking_reader(4096),
682            &SalvagePolicy {
683                max_entry: 1024,
684                ..SalvagePolicy::default()
685            },
686        )
687        .unwrap_err();
688        assert_eq!(err.exit_code(), 6);
689    }
690
691    #[test]
692    fn the_default_policy_is_recovery_biased() {
693        let policy = SalvagePolicy::default();
694        assert_eq!(policy.partial, PartialPolicy::Keep);
695        assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
696        assert!(!policy.strict);
697    }
698
699    // -----------------------------------------------------------------
700    // Task 4: `verify` dispatch and shadow detection, proven against a
701    // mock — same discipline as the rest of this module, since neither
702    // mechanism needs to know which format it is annotating.
703    // -----------------------------------------------------------------
704
705    /// Reports a fixed, caller-scripted sequence of candidates and answers
706    /// `verify` from the SAME script rather than decoding anything — this
707    /// module's engine is proven against mocks, and verification/shadow
708    /// detection are no exception. Each entry names its own `name` and
709    /// `declared_len` (rather than a fixed, offset-derived name) so a test
710    /// can freely vary any one of the three fields shadow detection now
711    /// requires to agree.
712    struct ScriptedCandidates {
713        plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
714        next: usize,
715    }
716
717    impl SalvageScan for ScriptedCandidates {
718        fn next_candidate(
719            &mut self,
720            _src: &mut dyn SeekRead,
721            _from: u64,
722        ) -> Result<Option<Candidate>> {
723            if self.next >= self.plan.len() {
724                return Ok(None);
725            }
726            let offset = self.next as u64;
727            let (name, declared_len, verifier, _) = self.plan[self.next];
728            self.next += 1;
729            Ok(Some(Candidate {
730                offset,
731                meta: EntryMeta::file(name),
732                declared_len,
733                verifier,
734                available_len: None,
735            }))
736        }
737
738        fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
739            Ok(self.plan[candidate.offset as usize].3)
740        }
741    }
742
743    /// `verify` decides each entry's status — the whole point of Task 4:
744    /// this used to be a hardcoded `Complete` for every candidate, which was
745    /// wrong for any format carrying a real checksum.
746    #[test]
747    fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
748        let mut scan = ScriptedCandidates {
749            plan: vec![
750                (
751                    "a",
752                    Some(1),
753                    Some(Verifier::Crc32(1)),
754                    SalvageStatus::Intact,
755                ),
756                ("b", Some(1), None, SalvageStatus::Complete),
757                (
758                    "c",
759                    Some(1),
760                    Some(Verifier::Crc32(2)),
761                    SalvageStatus::Partial,
762                ),
763            ],
764            next: 0,
765        };
766        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
767            .expect("scripted candidates must annotate cleanly");
768        assert_eq!(out.entries[0].status, SalvageStatus::Intact);
769        assert_eq!(out.entries[1].status, SalvageStatus::Complete);
770        assert_eq!(out.entries[2].status, SalvageStatus::Partial);
771    }
772
773    /// A later candidate reporting the SAME name, declared_len AND verifier
774    /// as an earlier one is its shadow — pointing at the earliest position,
775    /// not merely "some" earlier one.
776    #[test]
777    fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
778        let mut scan = ScriptedCandidates {
779            plan: vec![
780                (
781                    "dup",
782                    Some(1),
783                    Some(Verifier::Crc32(7)),
784                    SalvageStatus::Intact,
785                ),
786                (
787                    "other",
788                    Some(1),
789                    Some(Verifier::Crc32(9)),
790                    SalvageStatus::Intact,
791                ),
792                (
793                    "dup",
794                    Some(1),
795                    Some(Verifier::Crc32(7)),
796                    SalvageStatus::Intact,
797                ),
798            ],
799            next: 0,
800        };
801        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
802            .expect("scripted candidates must annotate cleanly");
803        assert_eq!(out.entries[0].shadows, None);
804        assert_eq!(out.entries[1].shadows, None);
805        assert_eq!(
806            out.entries[2].shadows,
807            Some(0),
808            "must point at the earliest matching position"
809        );
810    }
811
812    /// A candidate with no verifier at all (a format with no checksum, or a
813    /// zip data-descriptor entry) must never be reported as shadowing or
814    /// shadowed — there is nothing to measure, so nothing is claimed.
815    #[test]
816    fn candidates_with_no_verifier_never_shadow_each_other() {
817        let mut scan = ScriptedCandidates {
818            plan: vec![
819                ("a", Some(1), None, SalvageStatus::Complete),
820                ("a", Some(1), None, SalvageStatus::Complete),
821            ],
822            next: 0,
823        };
824        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
825            .expect("scripted candidates must annotate cleanly");
826        assert_eq!(out.entries[0].shadows, None);
827        assert_eq!(out.entries[1].shadows, None);
828    }
829
830    /// Two candidates whose checksum AND declared_len agree but whose NAME
831    /// differs must never be linked — the fix-round requirement that a
832    /// checksum match alone is not enough, only the full
833    /// `(name, declared_len, verifier)` triple.
834    #[test]
835    fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
836        let mut scan = ScriptedCandidates {
837            plan: vec![
838                (
839                    "one.txt",
840                    Some(4),
841                    Some(Verifier::Crc32(42)),
842                    SalvageStatus::Intact,
843                ),
844                (
845                    "two.txt",
846                    Some(4),
847                    Some(Verifier::Crc32(42)),
848                    SalvageStatus::Intact,
849                ),
850            ],
851            next: 0,
852        };
853        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
854            .expect("scripted candidates must annotate cleanly");
855        assert_eq!(
856            out.entries[1].shadows, None,
857            "same checksum, different name — not a shadow"
858        );
859    }
860
861    /// The degenerate case: every empty entry (and, in zip, every directory
862    /// entry) checksums to the same fixed constant over zero declared
863    /// bytes. Even under the SAME name, a zero-length checksum must never
864    /// mark a shadow — the value proves nothing, unlike a real collision.
865    #[test]
866    fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
867        let mut scan = ScriptedCandidates {
868            plan: vec![
869                (
870                    "empty.txt",
871                    Some(0),
872                    Some(Verifier::Crc32(0)),
873                    SalvageStatus::Complete,
874                ),
875                (
876                    "empty.txt",
877                    Some(0),
878                    Some(Verifier::Crc32(0)),
879                    SalvageStatus::Complete,
880                ),
881            ],
882            next: 0,
883        };
884        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
885            .expect("scripted candidates must annotate cleanly");
886        assert_eq!(out.entries[0].shadows, None);
887        assert_eq!(
888            out.entries[1].shadows, None,
889            "a zero-byte checksum must never mark a shadow, even under an identical name"
890        );
891        assert_eq!(
892            out.entries[1].collides_with,
893            Some(0),
894            "the NAME still repeats, and that is a directly observed fact no checksum \
895             guard applies to"
896        );
897    }
898
899    // -----------------------------------------------------------------
900    // Name collisions: the second annotation, separate from shadowing.
901    // -----------------------------------------------------------------
902
903    /// The finding this annotation closes: two records under one name whose
904    /// content does NOT agree are what `stuffr list` calls shadowed, and
905    /// what salvage marked in no way at all — because its own `shadows`
906    /// means something stricter.
907    #[test]
908    fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
909        let mut scan = ScriptedCandidates {
910            plan: vec![
911                (
912                    "dup.txt",
913                    Some(21),
914                    Some(Verifier::Crc32(7)),
915                    SalvageStatus::Intact,
916                ),
917                (
918                    "other.txt",
919                    Some(21),
920                    Some(Verifier::Crc32(9)),
921                    SalvageStatus::Intact,
922                ),
923                (
924                    "dup.txt",
925                    Some(21),
926                    Some(Verifier::Crc32(11)),
927                    SalvageStatus::Intact,
928                ),
929            ],
930            next: 0,
931        };
932        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
933            .expect("scripted candidates must annotate cleanly");
934        assert_eq!(out.entries[2].shadows, None, "the content does not agree");
935        assert_eq!(
936            out.entries[2].collides_with,
937            Some(0),
938            "but the name does, and that is the fact `list` warns about"
939        );
940        assert_eq!(out.entries[0].collides_with, None);
941        assert_eq!(out.entries[1].collides_with, None);
942    }
943
944    /// The two annotations are mutually exclusive: a record PROVEN to be a
945    /// copy says so, and does not also report the weaker fact. Reporting
946    /// both would make every shadow print two markers for one relationship.
947    #[test]
948    fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
949        let mut scan = ScriptedCandidates {
950            plan: vec![
951                (
952                    "dup.txt",
953                    Some(21),
954                    Some(Verifier::Crc32(7)),
955                    SalvageStatus::Intact,
956                ),
957                (
958                    "dup.txt",
959                    Some(21),
960                    Some(Verifier::Crc32(7)),
961                    SalvageStatus::Intact,
962                ),
963            ],
964            next: 0,
965        };
966        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
967            .expect("scripted candidates must annotate cleanly");
968        assert_eq!(out.entries[1].shadows, Some(0));
969        assert_eq!(out.entries[1].collides_with, None);
970    }
971
972    /// A third record under a name two earlier ones already used points at
973    /// the EARLIEST of them, the same rule `shadows` follows — so a chain of
974    /// collisions names one origin rather than each pointing at its
975    /// predecessor.
976    #[test]
977    fn a_third_record_under_one_name_names_the_earliest_position() {
978        let mut scan = ScriptedCandidates {
979            plan: vec![
980                (
981                    "dup",
982                    Some(4),
983                    Some(Verifier::Crc32(1)),
984                    SalvageStatus::Intact,
985                ),
986                (
987                    "dup",
988                    Some(4),
989                    Some(Verifier::Crc32(2)),
990                    SalvageStatus::Intact,
991                ),
992                (
993                    "dup",
994                    Some(4),
995                    Some(Verifier::Crc32(3)),
996                    SalvageStatus::Intact,
997                ),
998            ],
999            next: 0,
1000        };
1001        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1002            .expect("scripted candidates must annotate cleanly");
1003        assert_eq!(out.entries[1].collides_with, Some(0));
1004        assert_eq!(out.entries[2].collides_with, Some(0));
1005    }
1006
1007    /// The negative double: distinct names must never be marked, however
1008    /// much else they share. Without this, an annotation that fired on
1009    /// everything would pass every test above.
1010    #[test]
1011    fn distinct_names_are_never_reported_as_colliding() {
1012        let mut scan = ScriptedCandidates {
1013            plan: vec![
1014                (
1015                    "one.txt",
1016                    Some(4),
1017                    Some(Verifier::Crc32(42)),
1018                    SalvageStatus::Intact,
1019                ),
1020                (
1021                    "two.txt",
1022                    Some(4),
1023                    Some(Verifier::Crc32(42)),
1024                    SalvageStatus::Intact,
1025                ),
1026            ],
1027            next: 0,
1028        };
1029        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1030            .expect("scripted candidates must annotate cleanly");
1031        assert_eq!(out.entries[0].collides_with, None);
1032        assert_eq!(out.entries[1].collides_with, None);
1033    }
1034
1035    /// A collision needs no verifier at all, unlike a shadow. A format with
1036    /// no checksum (tar, cpio, ar — and a zip data-descriptor entry) can
1037    /// still repeat a name, and that repetition is exactly as real there.
1038    #[test]
1039    fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
1040        let mut scan = ScriptedCandidates {
1041            plan: vec![
1042                ("same", Some(4), None, SalvageStatus::Complete),
1043                ("same", Some(4), None, SalvageStatus::Complete),
1044            ],
1045            next: 0,
1046        };
1047        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1048            .expect("scripted candidates must annotate cleanly");
1049        assert_eq!(
1050            out.entries[1].shadows, None,
1051            "no verifier, so nothing proves they are copies"
1052        );
1053        assert_eq!(
1054            out.entries[1].collides_with,
1055            Some(0),
1056            "but the name repeats, which needs no verifier to observe"
1057        );
1058    }
1059}