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}