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