stuffr_core/format.rs
1use std::fmt;
2
3/// A stable, human-readable format identifier. `&'static str` rather than an
4/// enum so `stuffr-formats` can add formats without editing this crate.
5#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize))]
7pub struct FormatId(&'static str);
8
9impl FormatId {
10 pub const fn new(name: &'static str) -> Self {
11 Self(name)
12 }
13
14 pub const fn as_str(&self) -> &'static str {
15 self.0
16 }
17}
18
19impl fmt::Display for FormatId {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 f.write_str(self.0)
22 }
23}
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub enum FormatKind {
27 /// A byte-stream transform. Knows nothing about files or entries.
28 Codec,
29 /// A structure of entries. May invoke codecs internally.
30 Container,
31}
32
33/// Whether decoding a stream *this codec itself wrote* will notice corruption
34/// rather than silently returning wrong bytes, and — where it does — what
35/// kind of guarantee that is. A single `bool` used to carry this and meant two
36/// different things depending on the codec: a format-wide guarantee for gzip,
37/// but only "this build's encoder turns on an optional checksum" for zstd. A
38/// caller reading one undifferentiated bool had no way to tell which promise
39/// it was getting, so the two review findings that added careful wording to
40/// the old field's doc comment are migrated here, one paragraph per variant.
41#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
42pub enum CorruptionDetection {
43 /// The format mandates a check in every valid stream: gzip's trailer
44 /// CRC32, zlib's Adler-32, bzip2's per-block and whole-stream CRCs,
45 /// snappy's per-chunk CRC32C. This is a format-wide guarantee — it holds
46 /// for any conforming stream, not just one this codec produced itself.
47 Always,
48 /// The format permits a check but leaves it to the writer, so what a
49 /// caller can trust depends on which writer actually produced the stream
50 /// in front of them — not on which writer this codec happens to be. In
51 /// principle that cuts both ways: a codec's own encoder could turn the
52 /// check on while some foreign writer leaves it off, or the reverse.
53 /// Every codec in this tree currently sits on the first side —
54 /// **this build's encoder turns the check on; a foreign writer might
55 /// not.** zstd's content checksum, xz's check-type field, and lz4's
56 /// frame content checksum are all per-writer choices the format permits
57 /// omitting, and this project's own encoders choose to enable all
58 /// three: zstd's crate-level encoder omits it *by default* (its CLI
59 /// does not, but `zstd_c.rs` and `zstd_pure.rs` both turn it on
60 /// regardless), every xz encoder measured here — liblzma's and
61 /// lzma-rust2's — selects CRC64 without being asked, and `lz4.rs`'s
62 /// encoder calls `content_checksum(true)`, matching what the reference
63 /// `lz4` CLI writes by default. lz4 did not always sit here: earlier in
64 /// this project's history its own encoder left the checksum off, making
65 /// it the mirror case — detection *better* on a typical foreign stream
66 /// than on this codec's own output — until that was measured to leave
67 /// most corrupted positions in a self-written stream silently wrong
68 /// (see `lz4.rs`'s `caps()` and `decoder` docs for the before/after
69 /// figures). For all three formats, detection is *weaker* on a foreign
70 /// stream than on this codec's own output only when that foreign writer
71 /// skipped the checksum — see `zstd_c.rs`'s `decoder` doc for a measured
72 /// figure.
73 ///
74 /// Either way, a codec built over one of these optional-check formats
75 /// must document, at the point a caller would meet it, which direction
76 /// it sits and what that is worth on a stream this build did not write;
77 /// the doc comment on the codec's own `caps()` is not enough by itself —
78 /// the decoder needs one too.
79 WhenPresent,
80 /// No checksum exists, yet malformed input is still detected because the
81 /// format's own decoding constraints make corruption produce an invalid
82 /// decoder state. LZMA1 is the example: it has no checksum, so it looked
83 /// like `Never` — but it was never measured. Measured: sweeping four
84 /// payload shapes — compressible text, incompressible random, a source
85 /// corpus and all zeros — every corrupted stream either errored or
86 /// decoded identically, with **zero** silent-wrong decodes in any of
87 /// them. LZMA1's range coder plus its end marker are constrained enough
88 /// that a flipped bit almost always drives the decoder into an invalid
89 /// state. This is **detection by structural invalidity, not
90 /// verification**, and the difference from `Always` matters: a CRC gives
91 /// a guarantee against arbitrary corruption, whereas structure gives a
92 /// high empirical rate against *random* corruption and no promise at all
93 /// against a deliberately crafted edit.
94 Structural,
95 /// The format carries no check at all — not optional, structurally
96 /// absent: a bare deflate or brotli stream fed corrupt bytes produces
97 /// different output rather than an error, for any writer, always. Such a
98 /// format can never produce [`crate::Error::Corrupt`], and a caller is
99 /// entitled to know that rather than assume a guarantee that is not
100 /// there. The conservative default: a codec must opt in to claiming any
101 /// detection, matching `CodecCaps`'s "every field defaults to false"
102 /// contract.
103 #[default]
104 Never,
105}
106
107/// What a codec can do. Every field defaults to `false` (or, for
108/// `detects_corruption`, its equivalent conservative variant): a backend must
109/// opt in.
110#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
111pub struct CodecCaps {
112 pub encode: bool,
113 pub decode: bool,
114 pub parallel_encode: bool,
115 pub parallel_decode: bool,
116 /// Stream carries a frame/block index enabling random access.
117 pub frame_index: bool,
118 /// See [`CorruptionDetection`] for what each state promises.
119 pub detects_corruption: CorruptionDetection,
120
121 /// Rough working-set cost of one encode worker, in bytes, when the codec
122 /// knows it. `None` means unknown — assume modest.
123 ///
124 /// A static, conservative HIGH figure for callers who introspect
125 /// `CodecCaps` without encoding anything (property 12's own governor
126 /// sizing, and each codec's clamp test) — it is not what the governor
127 /// actually divides by during a real encode. The four codecs Phase 1f
128 /// gave a multi-threaded encoder (`zstd-c`, `xz-c`, `xz-pure`, `lzip`)
129 /// each call their own level-aware `per_worker_bytes(level)` for that,
130 /// because the true cost varies by preset (roughly 128 MiB below preset
131 /// 7, ~896 MiB at 7 and above, for xz and lzip) and this field's
132 /// contract is to round up to a single figure that covers every level a
133 /// caller might ask for.
134 pub memory_per_worker: Option<u64>,
135
136 /// This build's encoder for the format is markedly worse than the format's
137 /// usual one — a fallback that produces valid output nobody would choose.
138 ///
139 /// `ops` refuses to use it unless the caller opts in, because the output is
140 /// indistinguishable afterwards: a user who asked for `.xz` expects xz
141 /// ratios, and two builds of stuffr would otherwise produce very different
142 /// files from an identical command with no way to tell which they got.
143 pub weak_encoder: bool,
144
145 /// True only when the format itself carries no signal whatsoever — no
146 /// checksum, no length, no structural decode constraint, no end-of-stream
147 /// marker — that could ever distinguish a truncated stream from a complete
148 /// one. This does NOT exempt a codec from conformance property 10
149 /// (truncation, `conformance.rs`) — property 10 has no bare skip for any
150 /// declaration, deliberately: `conformance.rs`'s own `mod broken_codecs`
151 /// keeps `testing::MockCodec`, a bare unframed XOR pass-through,
152 /// specifically UNABLE to clear property 10
153 /// (`mock_codec_clears_every_property_up_to_truncation`), and a codec
154 /// that answered nothing at all about truncation would be
155 /// indistinguishable from one that never checked.
156 ///
157 /// What this field switches property 10 to instead is a WEAKER, but
158 /// still falsifiable, property: on truncated input, the decoder must
159 /// either error, or produce a byte sequence that is a genuine prefix of
160 /// what the same decoder produces from the untruncated input — one that
161 /// never shrinks as a less severe truncation is tried (monotonicity
162 /// across the several cut lengths property 10 already sweeps), and that
163 /// is not trivially empty when the untruncated decode is not (closing
164 /// the residual gap a bare prefix check alone leaves: the empty string
165 /// is a prefix of everything). That still catches real defect classes —
166 /// a decoder that mishandles a partial final unit and emits wrong
167 /// trailing bytes, reordered bytes, or fabricated padding fails this
168 /// exactly as it would fail the strict "must always error" property, and
169 /// one that always answers empty on truncation fails it too — while not
170 /// demanding a guarantee the format cannot give. `conformance.rs`'s
171 /// `a_decoder_that_returns_a_non_prefix_on_truncation_is_caught` and
172 /// `a_decoder_that_always_answers_empty_on_truncation_is_caught` prove
173 /// the weaker check still fires, by mutation, the same way
174 /// `mock_codec_clears_every_property_up_to_truncation` proves the strict
175 /// one does.
176 ///
177 /// Every real codec so far closes the gap the strict property demands: a
178 /// mandatory checksum (gzip, zlib, bzip2, snappy), a per-writer optional
179 /// one this build's own encoder always turns on (zstd, xz, lz4),
180 /// structural invalidity from a range coder (LZMA1), or, for a codec with
181 /// neither, an explicit end-of-stream signal (raw deflate's `BFINAL` bit
182 /// — see `deflate.rs`).
183 ///
184 /// `legacy::compress_z` (Phase 3b) is the first exception, and this field
185 /// exists because of it, not in anticipation of it: Unix compress's LZW
186 /// code stream has none of the above. A prefix of a valid stream decodes
187 /// via the identical state machine as the full stream and simply runs out
188 /// of bits, producing a shorter but otherwise byte-correct PREFIX of the
189 /// real output with no error — measured directly, at the bit level, on
190 /// this project's own `hello.Z` fixture: cutting 1, 25 or 50 of its 51
191 /// bytes left between 5 and 7 leftover, unconsumed bits in every case,
192 /// including the GENUINE, untruncated end (6 leftover bits) — no leftover
193 /// count or value distinguishes a real ending from a truncated one, because
194 /// both stop for the identical reason (too few bits left for the next
195 /// code). Confirmed independently against two production reference
196 /// tools, not just this crate: `/usr/bin/uncompress` and `/usr/bin/gzip
197 /// -dc` on macOS both exit 0 with silently-partial output on the same cut
198 /// files, and in every case what they emit is a strict prefix of the
199 /// untruncated decode — never garbage, never a differently-ordered or
200 /// padded result. This is a real, external, cross-validated fact about
201 /// the format, not a gap in `compress_z`'s own decoder — see that
202 /// module's doc for the full measurement.
203 ///
204 /// Defaults to `false` (the strict property applies) via
205 /// [`Self::round_trip`] and [`Self::decode_only`], so every existing
206 /// codec's behavior — including `testing::MockCodec`'s own deliberate
207 /// failure above — is unchanged; only `compress_z::CompressZ` sets this
208 /// explicitly.
209 pub truncation_undetectable: bool,
210}
211
212/// What a container can do, and what it needs from its input.
213#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
214pub struct ContainerCaps {
215 pub read: bool,
216 pub write: bool,
217 /// Entries can be enumerated from a forward-only source at full data
218 /// fidelity (metadata may be approximate). True for zip, tar, cpio.
219 pub forward_parse: bool,
220 /// Only a best-effort salvage scan is possible forward-only. Distinct from
221 /// `forward_parse`: this rung yields partial results by construction.
222 pub degraded_parse: bool,
223 /// The authoritative index lives at the end of the stream (zip, 7z, rar).
224 pub trailing_index: bool,
225 /// Entries share codec state, so reaching entry N decodes 1..N (7z, rar).
226 pub solid: bool,
227 /// Each entry carries its own codec (zip, 7z).
228 pub per_entry_codec: bool,
229 /// Random access is required even to enumerate (squashfs, iso).
230 pub needs_seek: bool,
231 /// A directory can be recorded AS a directory, rather than as an empty
232 /// regular file that happens to share its name.
233 ///
234 /// `ar` is the counter-example and the reason this field exists: its
235 /// format has no directory concept at all, so writing one produces a
236 /// zero-byte *file* named `proj/sub` — after which an entry named
237 /// `proj/sub/a.txt` cannot be extracted, because its parent is a file.
238 /// A writer that cannot store the kind is expected to be told so by its
239 /// caller and warn, rather than emit the lie.
240 pub stores_dirs: bool,
241 /// A symlink can be recorded as a link, target and all, rather than as a
242 /// regular file carrying the target text as its contents.
243 pub stores_symlinks: bool,
244 /// Whether reading an archive of this format will NOTICE a corrupted
245 /// byte rather than silently returning wrong data — and, where it does,
246 /// what kind of guarantee that is. The container-side twin of
247 /// [`CodecCaps::detects_corruption`], reusing the same enum and the same
248 /// three meanings.
249 ///
250 /// Read by the read-only conformance harness's corruption property, and
251 /// it is the reason that property is honest rather than merely strict:
252 /// the harness flips the fixture's middle byte and demands the read-back
253 /// differ from the expectation, which a container with no integrity
254 /// check at all cannot promise. A fixture whose midpoint lands in a
255 /// reserved, comment or padding field no reader consults would fail a
256 /// CORRECT container, and the module's own rule is that properties skip
257 /// on evidence, never on trust — `ContainerCaps` being one of the two
258 /// admissible kinds of evidence.
259 ///
260 /// Defaults to [`CorruptionDetection::Never`] via [`Self::read_only`],
261 /// [`Self::read_write`] and `Default`, so a container claims this the
262 /// same way it claims every other capability here: explicitly. `lha`
263 /// (CRC-16 per entry) and `arj` (CRC-32 per entry) are the first two to
264 /// declare it.
265 pub detects_corruption: CorruptionDetection,
266 /// `stuffr salvage` has a scanner for this container: it can scan a
267 /// damaged archive for entries, report what it proved about each, and
268 /// write what it recovered.
269 ///
270 /// The one answer to "is salvage supported", read by `stuffr formats`'
271 /// SALVAGE column and by `stuffr::entries::salvage`, which refuses a
272 /// container saying `false` at exit 3 before scanning a byte. It is NOT
273 /// the dispatch: the facade still maps a format to its scanner by name,
274 /// and a test there pins the two to each other for every container a
275 /// build registers, in both directions.
276 ///
277 /// It says nothing about how much a recovered entry can be trusted —
278 /// that is a per-format class of evidence
279 /// ([`crate::salvage::Attestation`]), owned beside the scanner dispatch.
280 ///
281 /// Defaults to `false` via [`Self::read_only`], [`Self::read_write`] and
282 /// `Default`, like every other capability here: a container claims it
283 /// explicitly, in the same commit that wires its scanner.
284 pub salvage: bool,
285}
286
287/// A magic-byte rule. Detection matches these against a bounded prefix.
288#[derive(Clone, Copy, PartialEq, Eq, Debug)]
289pub struct MagicRule {
290 pub offset: usize,
291 pub bytes: &'static [u8],
292 pub format: FormatId,
293}
294
295/// Registration metadata for one format.
296#[derive(Clone, Copy, Debug)]
297pub struct FormatMeta {
298 pub id: FormatId,
299 pub kind: FormatKind,
300 /// Extensions without the leading dot, lowercase. e.g. `["gz", "tgz"]`.
301 pub extensions: &'static [&'static str],
302 pub magics: &'static [MagicRule],
303 /// Breaks ties when several formats share a magic. Higher wins.
304 ///
305 /// The ZIP family is why this exists: jar, apk, docx, epub, odt and zip all
306 /// begin `PK\x03\x04`, so longest-magic-wins cannot separate them. Rank the
307 /// base format above its derivatives, and a bare stream resolves to the
308 /// base while a derivative is reached by extension.
309 pub priority: i16,
310}
311
312impl CodecCaps {
313 /// A codec that both compresses and decompresses, with no parallelism and
314 /// no frame index. The common shape.
315 pub const fn round_trip() -> Self {
316 Self {
317 encode: true,
318 decode: true,
319 parallel_encode: false,
320 parallel_decode: false,
321 frame_index: false,
322 detects_corruption: CorruptionDetection::Never,
323 memory_per_worker: None,
324 weak_encoder: false,
325 truncation_undetectable: false,
326 }
327 }
328
329 /// A decode-only codec — the pure-Rust fallbacks that let a build with no C
330 /// toolchain still *open* a `.zst` or `.xz`.
331 pub const fn decode_only() -> Self {
332 Self {
333 encode: false,
334 decode: true,
335 parallel_encode: false,
336 parallel_decode: false,
337 frame_index: false,
338 detects_corruption: CorruptionDetection::Never,
339 memory_per_worker: None,
340 weak_encoder: false,
341 truncation_undetectable: false,
342 }
343 }
344}
345
346impl ContainerCaps {
347 /// A container that can be read but not written — RAR, whose compressor is
348 /// proprietary.
349 pub const fn read_only() -> Self {
350 Self {
351 read: true,
352 write: false,
353 forward_parse: false,
354 degraded_parse: false,
355 trailing_index: false,
356 solid: false,
357 per_entry_codec: false,
358 needs_seek: false,
359 stores_dirs: false,
360 stores_symlinks: false,
361 detects_corruption: CorruptionDetection::Never,
362 salvage: false,
363 }
364 }
365
366 /// A container supporting both directions. Every other capability stays
367 /// false — a constructor must not claim what a format has not.
368 pub const fn read_write() -> Self {
369 let mut c = Self::read_only();
370 c.write = true;
371 c
372 }
373}
374
375impl FormatMeta {
376 pub const fn codec(
377 id: FormatId,
378 extensions: &'static [&'static str],
379 magics: &'static [MagicRule],
380 ) -> Self {
381 Self {
382 id,
383 kind: FormatKind::Codec,
384 extensions,
385 magics,
386 priority: 0,
387 }
388 }
389
390 pub const fn container(
391 id: FormatId,
392 extensions: &'static [&'static str],
393 magics: &'static [MagicRule],
394 ) -> Self {
395 Self {
396 id,
397 kind: FormatKind::Container,
398 extensions,
399 magics,
400 priority: 0,
401 }
402 }
403
404 pub const fn with_priority(mut self, priority: i16) -> Self {
405 self.priority = priority;
406 self
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn format_id_round_trips_and_displays() {
416 let id = FormatId::new("gzip");
417 assert_eq!(id.as_str(), "gzip");
418 assert_eq!(id.to_string(), "gzip");
419 }
420
421 #[test]
422 fn caps_default_to_no_capability() {
423 // A format must opt in to every capability. Defaulting to `true`
424 // anywhere would let an unimplemented backend silently claim support.
425 let c = CodecCaps::default();
426 assert!(!c.encode && !c.decode && !c.parallel_encode);
427 assert_eq!(c.detects_corruption, CorruptionDetection::Never);
428 let k = ContainerCaps::default();
429 assert!(!k.read && !k.write && !k.forward_parse && !k.needs_seek);
430 }
431
432 #[test]
433 fn codec_caps_constructors_express_the_two_common_shapes() {
434 let rt = CodecCaps::round_trip();
435 assert!(rt.encode && rt.decode);
436 assert!(!rt.parallel_encode && !rt.parallel_decode && !rt.frame_index);
437
438 // The pure ruzstd / lzma-rs fallbacks: openable, not writable.
439 let d = CodecCaps::decode_only();
440 assert!(d.decode && !d.encode);
441 }
442
443 #[test]
444 fn container_caps_constructors_express_the_two_common_shapes() {
445 let ro = ContainerCaps::read_only();
446 assert!(ro.read && !ro.write);
447 let rw = ContainerCaps::read_write();
448 assert!(rw.read && rw.write);
449 // Everything else stays opt-in — a constructor must not smuggle in
450 // capabilities a format has not claimed.
451 assert!(!rw.forward_parse && !rw.trailing_index && !rw.needs_seek);
452 }
453
454 #[test]
455 fn format_meta_constructors_set_the_kind_for_you() {
456 const M: &[MagicRule] = &[];
457 let c = FormatMeta::codec(FormatId::new("gzip"), &["gz"], M);
458 assert_eq!(c.kind, FormatKind::Codec);
459 let k = FormatMeta::container(FormatId::new("tar"), &["tar"], M);
460 assert_eq!(k.kind, FormatKind::Container);
461 }
462}