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