stuffr_core/fidelity.rs
1use std::fmt;
2
3use crate::format::FormatId;
4
5/// Which rung of the adaptive stream ladder a read landed on.
6#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize))]
8#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
9pub enum Rung {
10 /// Input was seekable. Authoritative index, full metadata.
11 Exact,
12 /// Not seekable, parsed forward. Real data, approximate metadata.
13 ForwardOnly,
14 /// Spooled to memory/temp then read exactly. Costs disk, not accuracy.
15 Spilled,
16 /// Best-effort salvage. Partial results by construction.
17 Degraded,
18}
19
20impl Rung {
21 /// Whether the read used the format's authoritative structures.
22 ///
23 /// Note `Spilled` is authoritative: spilling trades disk for accuracy, and
24 /// treating it as lossy would make `--strict-fidelity` reject exact reads.
25 pub fn is_authoritative(&self) -> bool {
26 matches!(self, Rung::Exact | Rung::Spilled)
27 }
28
29 /// Ordering by *quality loss*, worst = highest. Used by `merge`.
30 fn severity(&self) -> u8 {
31 match self {
32 Rung::Exact => 0,
33 Rung::Spilled => 1,
34 Rung::ForwardOnly => 2,
35 Rung::Degraded => 3,
36 }
37 }
38}
39
40impl fmt::Display for Rung {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 let s = match self {
43 Rung::Exact => "exact",
44 Rung::ForwardOnly => "forward-only",
45 Rung::Spilled => "spilled",
46 Rung::Degraded => "degraded",
47 };
48 f.write_str(s)
49 }
50}
51
52/// Which metadata fields are **missing**. `true` means absent, not present.
53#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize))]
55pub struct MetaFields {
56 pub mtime: bool,
57 pub mode: bool,
58 pub uid_gid: bool,
59 pub comment: bool,
60 pub extra: bool,
61 pub crc: bool,
62}
63
64impl MetaFields {
65 pub fn missing(&self) -> Vec<&'static str> {
66 let mut v = Vec::new();
67 if self.mtime {
68 v.push("mtime")
69 }
70 if self.mode {
71 v.push("mode")
72 }
73 if self.uid_gid {
74 v.push("uid_gid")
75 }
76 if self.comment {
77 v.push("comment")
78 }
79 if self.extra {
80 v.push("extra")
81 }
82 if self.crc {
83 v.push("crc")
84 }
85 v
86 }
87}
88
89/// One specific, structured thing that was lost or approximated.
90#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize))]
92#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "kebab-case"))]
93#[non_exhaustive]
94pub enum Fidelity {
95 #[error(
96 "`{format}` central/trailing index was never read; entry metadata came from inline headers"
97 )]
98 TrailingIndexUnread { format: FormatId },
99
100 #[error("size of entry `{entry}` was only known after its data (data descriptor)")]
101 SizeFromDataDescriptor { entry: String },
102
103 #[error("total entry count is unknown without the trailing index")]
104 EntryCountUnknown,
105
106 /// The archive's own index declares a different number of records than
107 /// the reader enumerated. Usually FEWER were enumerated, so entries exist
108 /// in the file that no caller can reach; zip can also enumerate MORE, when
109 /// its end-of-central-directory record's two counts contradict each other
110 /// (the v0.7.0 deep-fuzz finding), so the message names both figures
111 /// without saying which way round they are.
112 ///
113 /// Distinct from [`Self::EntryCountUnknown`], which is the forward reader
114 /// admitting it never saw a count at all. Here the count WAS read, from
115 /// the format's authoritative structure, and it disagrees with what came
116 /// back — the strongest evidence available that a read is incomplete, and
117 /// the one a seekable `Rung::Exact` read would otherwise have no way to
118 /// express. Without it a partial enumeration is indistinguishable from a
119 /// complete one, which is the failure this variant exists to prevent:
120 /// listing six of eight records and calling it exact fidelity.
121 ///
122 /// Raised, not recovered. Reaching the shadowed records would mean
123 /// re-parsing the index by hand; naming the shortfall is what the caller
124 /// needs in order to know not to trust the result, and
125 /// `--strict-fidelity` turns it into exit 4.
126 ///
127 /// `reason` is an owned `String` for the same reason
128 /// [`Self::EntrySkipped`]'s is: the cause is specific to the container
129 /// and the reader, and a shortfall that cannot say WHY is one the user
130 /// cannot act on.
131 #[error(
132 "`{format}` index declares {declared} entries but {enumerated} were \
133 enumerated: {reason}"
134 )]
135 EntryCountMismatch {
136 format: FormatId,
137 /// What the archive's own index says it holds.
138 declared: u64,
139 /// What the reader was actually able to enumerate.
140 enumerated: u64,
141 /// Why the two differ, in terms the caller can act on.
142 reason: String,
143 },
144
145 #[error("entry `{entry}` is missing metadata: {}", fields.missing().join(", "))]
146 MetadataIncomplete { entry: String, fields: MetaFields },
147
148 #[error("decoded a solid block, wasting {wasted_bytes} bytes, to reach the requested entry")]
149 SolidBlockFullyDecoded { wasted_bytes: u64 },
150
151 #[error("skipped encrypted entry `{entry}`")]
152 EncryptedEntrySkipped { entry: String },
153
154 /// An entry this build cannot materialise, named along with why.
155 ///
156 /// Distinct from [`Self::MetadataIncomplete`], which is about an entry
157 /// that WAS written and lost some of its metadata: here the entry itself
158 /// is absent from the result. Extraction raises it for a device node,
159 /// fifo, socket or hardlink — [`crate::EntryKind::Other`], the honest
160 /// answer a container gives for an entry shape `EntryKind` has no variant
161 /// for yet. Writing one out as a regular file carrying its "contents"
162 /// would be a silent lie about what the archive held. The write side
163 /// raises it too, for something met on disk that no container here can
164 /// store — a socket, an undecodable name, a directory being packed into
165 /// `ar`.
166 ///
167 /// `reason` is an owned `String` rather than a `&'static str` because the
168 /// write side's reasons carry runtime detail — the failing errno, the
169 /// container's own name, the undecodable bytes — and a reason that cannot
170 /// name the particular thing that went wrong is a reason the user cannot
171 /// act on.
172 #[error("skipped entry `{entry}`: {reason}")]
173 EntrySkipped { entry: String, reason: String },
174
175 /// A file that changed length between the moment its size was recorded
176 /// and the moment its bytes were read, stored at the length its header
177 /// already declares.
178 ///
179 /// Distinct from [`Self::EntrySkipped`]: the entry IS in the archive, and
180 /// the archive is structurally valid. What was lost is the tail of a file
181 /// that grew, or the tail of a file that shrank — replaced by zeros,
182 /// because the entry header was written before the payload was read and a
183 /// container cannot un-write it. Refusing instead would throw away the
184 /// whole archive over one file that a live system happened to touch,
185 /// which is what `pack ~ -o backup.tar` over a running home directory does
186 /// routinely; GNU tar prints `file changed as we read it` and continues,
187 /// and this is the same bargain, with the loss named rather than printed
188 /// and forgotten.
189 ///
190 /// `--strict-fidelity` still refuses on it, which is the half of the
191 /// bargain that keeps it honest.
192 #[error(
193 "entry `{entry}` changed size while it was being read: its header declares \
194 {declared} bytes, {fixup}"
195 )]
196 EntrySizeChanged {
197 entry: String,
198 /// The length written into the entry header, from the walk's `stat`.
199 declared: u64,
200 /// What the file actually supplied and what was done about it.
201 ///
202 /// Prose rather than a second `u64` because the two directions know
203 /// different things: a file that shrank supplies an exact count, while
204 /// a file that grew is only known to have had *more* — measuring how
205 /// much more would mean reading a tail that is deliberately not being
206 /// stored. A field that could name the figure in one case and not the
207 /// other would have to lie in one of them.
208 fixup: String,
209 },
210
211 #[error("stream truncated at offset {at}")]
212 TruncatedStream { at: u64 },
213
214 /// A directory record carries a checksum over ITSELF, and it does not
215 /// match the bytes the record is made of.
216 ///
217 /// Distinct from every other integrity signal in this enum: those are
218 /// about an entry's CONTENT, and a content checksum that fails is
219 /// [`crate::Error::Corrupt`], not a warning, because there is nothing
220 /// honest to hand back. This is about the METADATA record, and the
221 /// verdict is deliberately softer for one measured reason — the format
222 /// that carries such a field may itself treat it as advisory. ZOO is the
223 /// first: `zoo` 2.10's own lister prints a `*` beside an entry whose
224 /// `dir_crc` fails and carries on listing it, so refusing would be
225 /// stricter than the reference implementation, which is how a guard comes
226 /// to fire on input a real tool accepts.
227 ///
228 /// What it must NOT do is stay silent. A record whose own checksum fails
229 /// has been altered since it was written, and every field a caller is
230 /// about to trust — the entry's name, size, offset, and the link to the
231 /// next record — comes out of it. `--strict-fidelity` turns this into
232 /// exit 4, which is the whole point: a verb that says "exact fidelity"
233 /// over an altered record is making a claim the archive itself disputes.
234 ///
235 /// `offset` is the record's own position in the file, because a record
236 /// that fails its checksum may have no usable name to identify it by —
237 /// the reproducer that motivated this variant is a record whose name
238 /// field is intact and whose link field is not.
239 #[error(
240 "`{format}` directory record at offset {offset} fails its own checksum \
241 ({computed:#06x} computed against {recorded:#06x} recorded); the record has been \
242 altered since it was written"
243 )]
244 DirectoryRecordChecksum {
245 format: FormatId,
246 offset: u64,
247 computed: u16,
248 recorded: u16,
249 },
250}
251
252/// What a read cost in fidelity. Returned alongside data, never logged and
253/// discarded — the caller must be able to discover what was approximated.
254#[derive(Clone, PartialEq, Eq, Debug)]
255#[cfg_attr(feature = "serde", derive(serde::Serialize))]
256pub struct FidelityReport {
257 pub rung: Rung,
258 pub warnings: Vec<Fidelity>,
259}
260
261impl FidelityReport {
262 pub fn exact() -> Self {
263 Self::new(Rung::Exact)
264 }
265
266 pub fn new(rung: Rung) -> Self {
267 Self {
268 rung,
269 warnings: Vec::new(),
270 }
271 }
272
273 pub fn warn(&mut self, w: Fidelity) {
274 self.warnings.push(w);
275 }
276
277 /// Whether anything was approximated. **This is the `--strict-fidelity`
278 /// gate** (exit code 4).
279 ///
280 /// Gating on warnings rather than on the rung is deliberate: a tar read
281 /// from a pipe lands on `ForwardOnly`, but tar has no trailing index, so
282 /// nothing was lost. Failing it would hand a script an error it cannot act
283 /// on. The rung stays diagnostic — reported by `stuffr info`.
284 pub fn has_warnings(&self) -> bool {
285 !self.warnings.is_empty()
286 }
287
288 /// True only if the rung was authoritative *and* nothing was approximated.
289 ///
290 /// Deliberately **not** the `--strict-fidelity` gate — see
291 /// [`Self::has_warnings`]. This is the stronger "the format's own index was
292 /// read and nothing was lost" claim, useful for archival verification.
293 pub fn is_lossless(&self) -> bool {
294 self.rung.is_authoritative() && self.warnings.is_empty()
295 }
296
297 /// Fold another report in: worst rung wins, warnings are unioned.
298 ///
299 /// For combining reports from independently-resolved layers — e.g. a
300 /// codec's own report merged with the inner container's, once a chain has
301 /// more than one — rather than for a single container's own findings,
302 /// which it now records by calling [`Self::warn`] directly on the report
303 /// it inherited from the ladder (see [`crate::ladder::seed_report`]). A
304 /// warning already present is not repeated, so folding the same loss in
305 /// from two sources still tells the caller about it once.
306 pub fn merge(&mut self, other: &FidelityReport) {
307 if other.rung.severity() > self.rung.severity() {
308 self.rung = other.rung;
309 }
310 for w in &other.warnings {
311 if !self.warnings.contains(w) {
312 self.warnings.push(w.clone());
313 }
314 }
315 }
316}
317
318impl Default for FidelityReport {
319 fn default() -> Self {
320 Self::exact()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::format::FormatId;
328
329 #[test]
330 fn spilled_is_authoritative_even_though_it_is_a_lower_rung() {
331 // Spilling costs disk, not accuracy. Treating it as lossy would make
332 // `--strict-fidelity` reject perfectly exact reads.
333 assert!(Rung::Exact.is_authoritative());
334 assert!(Rung::Spilled.is_authoritative());
335 assert!(!Rung::ForwardOnly.is_authoritative());
336 assert!(!Rung::Degraded.is_authoritative());
337 }
338
339 #[test]
340 fn exact_report_is_lossless() {
341 let r = FidelityReport::exact();
342 assert_eq!(r.rung, Rung::Exact);
343 assert!(r.is_lossless());
344 assert!(r.warnings.is_empty());
345 }
346
347 #[test]
348 fn a_warning_makes_an_otherwise_exact_report_lossy() {
349 let mut r = FidelityReport::exact();
350 r.warn(Fidelity::EntryCountUnknown);
351 assert!(!r.is_lossless());
352 assert_eq!(r.warnings.len(), 1);
353 }
354
355 #[test]
356 fn forward_only_is_lossy_even_with_no_warnings() {
357 assert!(!FidelityReport::new(Rung::ForwardOnly).is_lossless());
358 }
359
360 #[test]
361 fn merge_keeps_the_worst_rung_and_concatenates_warnings() {
362 let mut a = FidelityReport::exact();
363 a.warn(Fidelity::EntryCountUnknown);
364
365 let mut b = FidelityReport::new(Rung::Degraded);
366 b.warn(Fidelity::TruncatedStream { at: 99 });
367
368 a.merge(&b);
369 assert_eq!(a.rung, Rung::Degraded);
370 assert_eq!(a.warnings.len(), 2);
371 }
372
373 #[test]
374 fn merge_does_not_upgrade_a_worse_rung() {
375 let mut a = FidelityReport::new(Rung::Degraded);
376 a.merge(&FidelityReport::exact());
377 assert_eq!(a.rung, Rung::Degraded);
378 }
379
380 #[test]
381 fn merge_does_not_repeat_a_warning_already_present() {
382 // The ladder and the container can each independently observe the same
383 // loss; the caller should be told once.
384 let mut a = FidelityReport::exact();
385 a.warn(Fidelity::EntryCountUnknown);
386
387 let mut b = FidelityReport::new(Rung::ForwardOnly);
388 b.warn(Fidelity::EntryCountUnknown);
389
390 a.merge(&b);
391 assert_eq!(a.warnings.len(), 1);
392 assert_eq!(a.rung, Rung::ForwardOnly);
393 }
394
395 #[test]
396 fn warnings_carry_actionable_detail_not_just_a_kind() {
397 // The whole premise is that the caller can find out WHAT was lost.
398 let f = Fidelity::SizeFromDataDescriptor {
399 entry: "logs/a.txt".into(),
400 };
401 assert!(format!("{f}").contains("logs/a.txt"));
402
403 let f = Fidelity::TrailingIndexUnread {
404 format: FormatId::new("zip"),
405 };
406 assert!(format!("{f}").contains("zip"));
407
408 let f = Fidelity::SolidBlockFullyDecoded {
409 wasted_bytes: 41_943_040,
410 };
411 assert!(format!("{f}").contains("41943040"));
412
413 let f = Fidelity::EntrySkipped {
414 entry: "dev/null".into(),
415 reason: "device nodes, fifos and hardlinks are not created".into(),
416 };
417 let s = format!("{f}");
418 assert!(
419 s.contains("dev/null") && s.contains("hardlinks"),
420 "a skip must say both which entry and why: {s}"
421 );
422
423 let f = Fidelity::EntryCountMismatch {
424 format: FormatId::new("zip"),
425 declared: 8,
426 enumerated: 6,
427 reason: "records sharing a name collapse into one".into(),
428 };
429 let s = format!("{f}");
430 assert!(
431 s.contains('8') && s.contains('6') && s.contains("sharing a name"),
432 "a shortfall must name BOTH counts and the cause, or the caller \
433 cannot tell how much is missing or why: {s}"
434 );
435
436 let f = Fidelity::MetadataIncomplete {
437 entry: "b.bin".into(),
438 fields: MetaFields {
439 mode: true,
440 ..Default::default()
441 },
442 };
443 let s = format!("{f}");
444 assert!(s.contains("b.bin") && s.contains("mode"));
445 }
446
447 #[test]
448 fn meta_fields_lists_only_the_missing_ones() {
449 let m = MetaFields {
450 mtime: true,
451 crc: true,
452 ..Default::default()
453 };
454 assert_eq!(m.missing(), vec!["mtime", "crc"]);
455 assert!(MetaFields::default().missing().is_empty());
456 }
457
458 #[test]
459 fn a_forward_only_read_that_lost_nothing_passes_strict_mode() {
460 // tar on a pipe: rung is ForwardOnly, but tar has no trailing index, so
461 // nothing was approximated. Strict mode must not fail this.
462 let r = FidelityReport::new(Rung::ForwardOnly);
463 assert!(!r.has_warnings(), "nothing was approximated");
464 assert!(!r.is_lossless(), "but the rung was not authoritative");
465 }
466
467 #[test]
468 fn strict_mode_fires_on_any_warning_regardless_of_rung() {
469 let mut r = FidelityReport::exact();
470 r.warn(Fidelity::EntryCountUnknown);
471 assert!(r.has_warnings());
472 }
473
474 #[test]
475 fn a_clean_exact_read_passes_both_predicates() {
476 let r = FidelityReport::exact();
477 assert!(!r.has_warnings());
478 assert!(r.is_lossless());
479 }
480}