Skip to main content

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    /// The decoder may record [`crate::Fidelity::TrailingDataIgnored`] in
212    /// [`crate::DecodeOpts::fidelity`]: the format is a concatenation of
213    /// members, and bytes after the last verified one that are neither a
214    /// member nor padding are read through and ignored with a warning (gzip,
215    /// bzip2).
216    ///
217    /// Such a stream's fidelity is only known once it has been read to the
218    /// end, so `stuffr info`, which decodes nothing beyond its probe prefix,
219    /// must not say "nothing approximated" for it — nor for a container
220    /// reached through it (`.tar.gz`). That is what reads this field: a
221    /// fact the codec declares, rather than a list of format names kept
222    /// elsewhere.
223    pub reports_trailing_data: bool,
224}
225
226/// What a container can do, and what it needs from its input.
227#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
228pub struct ContainerCaps {
229    pub read: bool,
230    pub write: bool,
231    /// Entries can be enumerated from a forward-only source at full data
232    /// fidelity (metadata may be approximate). True for zip, tar, cpio.
233    pub forward_parse: bool,
234    /// Only a best-effort salvage scan is possible forward-only. Distinct from
235    /// `forward_parse`: this rung yields partial results by construction.
236    pub degraded_parse: bool,
237    /// The authoritative index lives at the end of the stream (zip, 7z, rar).
238    pub trailing_index: bool,
239    /// Entries share codec state, so reaching entry N decodes 1..N (7z, rar).
240    pub solid: bool,
241    /// Each entry carries its own codec (zip, 7z).
242    pub per_entry_codec: bool,
243    /// Random access is required even to enumerate (squashfs, iso).
244    pub needs_seek: bool,
245    /// A directory can be recorded AS a directory, rather than as an empty
246    /// regular file that happens to share its name.
247    ///
248    /// `ar` is the counter-example and the reason this field exists: its
249    /// format has no directory concept at all, so writing one produces a
250    /// zero-byte *file* named `proj/sub` — after which an entry named
251    /// `proj/sub/a.txt` cannot be extracted, because its parent is a file.
252    /// A writer that cannot store the kind is expected to be told so by its
253    /// caller and warn, rather than emit the lie.
254    pub stores_dirs: bool,
255    /// A symlink can be recorded as a link, target and all, rather than as a
256    /// regular file carrying the target text as its contents.
257    pub stores_symlinks: bool,
258    /// Whether reading an archive of this format will NOTICE a corrupted
259    /// byte rather than silently returning wrong data — and, where it does,
260    /// what kind of guarantee that is. The container-side twin of
261    /// [`CodecCaps::detects_corruption`], reusing the same enum and the same
262    /// three meanings.
263    ///
264    /// Read by the read-only conformance harness's corruption property, and
265    /// it is the reason that property is honest rather than merely strict:
266    /// the harness flips the fixture's middle byte and demands the read-back
267    /// differ from the expectation, which a container with no integrity
268    /// check at all cannot promise. A fixture whose midpoint lands in a
269    /// reserved, comment or padding field no reader consults would fail a
270    /// CORRECT container, and the module's own rule is that properties skip
271    /// on evidence, never on trust — `ContainerCaps` being one of the two
272    /// admissible kinds of evidence.
273    ///
274    /// Defaults to [`CorruptionDetection::Never`] via [`Self::read_only`],
275    /// [`Self::read_write`] and `Default`, so a container claims this the
276    /// same way it claims every other capability here: explicitly. `lha`
277    /// (CRC-16 per entry) and `arj` (CRC-32 per entry) are the first two to
278    /// declare it.
279    pub detects_corruption: CorruptionDetection,
280    /// `stuffr salvage` has a scanner for this container: it can scan a
281    /// damaged archive for entries, report what it proved about each, and
282    /// write what it recovered.
283    ///
284    /// The one answer to "is salvage supported", read by `stuffr formats`'
285    /// SALVAGE column and by `stuffr::entries::salvage`, which refuses a
286    /// container saying `false` at exit 3 before scanning a byte. It is NOT
287    /// the dispatch: the facade still maps a format to its scanner by name,
288    /// and a test there pins the two to each other for every container a
289    /// build registers, in both directions.
290    ///
291    /// It says nothing about how much a recovered entry can be trusted —
292    /// that is a per-format class of evidence
293    /// ([`crate::salvage::Attestation`]), owned beside the scanner dispatch.
294    ///
295    /// Defaults to `false` via [`Self::read_only`], [`Self::read_write`] and
296    /// `Default`, like every other capability here: a container claims it
297    /// explicitly, in the same commit that wires its scanner.
298    pub salvage: bool,
299    /// The writer refuses a second entry under a name it has already
300    /// written, so an archive of this format holds one entry per name.
301    ///
302    /// `zip` is the case: `zip` 8.6.0's `ZipWriter` answers `Duplicate
303    /// filename`, while tar, cpio and ar append a repeat like any other
304    /// entry. Read by `stuffr convert`, whose source can legitimately repeat
305    /// a name (`pack`'s walk never does), to keep the first entry of each
306    /// name and warn about the rest rather than fail on the writer's refusal.
307    ///
308    /// `false` via [`Self::read_only`], [`Self::read_write`] and `Default`;
309    /// every registered container states it explicitly.
310    pub unique_names: bool,
311    /// An entry name containing a NUL byte is stored whole by the writer.
312    ///
313    /// `false` for a format whose name field is NUL-terminated or
314    /// NUL-padded — tar's ustar field and GNU `L` payload, cpio newc's
315    /// `c_namesize` name, ar's BSD `#1/N` name (its trailing NULs are
316    /// stripped on read), ARJ's header string, ARC's and ZOO's fixed fields
317    /// — where the name would otherwise come back shorter than it went in.
318    /// Every such writer refuses the name itself (a backstop); `stuffr
319    /// convert`, whose source can legitimately carry one (an ar or zip
320    /// member name), reads this to skip the entry with a warning instead
321    /// (`plan_entry_write`). `pack` never meets one: an OS path cannot hold
322    /// a NUL. `true` for zip and LHA, whose names are length-prefixed, so
323    /// the writer stores a NUL exactly as given. zip reads it back whole;
324    /// stuffr's LHA reader renders it `%00`, as it does every byte outside
325    /// printable ASCII (`lha_name_from_parts`): the byte is in the archive,
326    /// and only stuffr's rendering of it differs.
327    ///
328    /// `false` via [`Self::read_only`], [`Self::read_write`] and `Default`,
329    /// the safe answer for a container that has not been audited; every
330    /// registered container states it explicitly.
331    pub nul_in_names: bool,
332    /// A symlink TARGET containing a NUL byte is stored, and read back,
333    /// whole. The sibling of [`Self::nul_in_names`], and deliberately a
334    /// separate flag: tar and cpio differ on exactly this.
335    ///
336    /// `false` for tar, whose link target lives in the NUL-terminated
337    /// linkname field (or a GNU `K` payload, also NUL-terminated) — its
338    /// writer refuses one as a backstop, and `stuffr convert` skips such a
339    /// symlink with a warning instead (`plan_entry_write`). `true` for cpio
340    /// newc and zip, which store the target as the entry's BODY, by length
341    /// (measured round trip). A container with no symlink entries
342    /// (`!stores_symlinks`) states what its format would imply: `false` for
343    /// ar, arj, arc and zoo, which have no link representation at all;
344    /// `true` for LHA, whose `name|target` convention lives in its
345    /// length-prefixed name field.
346    ///
347    /// `false` via [`Self::read_only`], [`Self::read_write`] and `Default`;
348    /// every registered container states it explicitly.
349    pub nul_in_link_targets: bool,
350    /// The writer records an entry's owner — its uid and gid — so a missing
351    /// one is a real loss: tar, cpio and ar write `meta.uid.unwrap_or(0)`,
352    /// so an entry with no ids would assert `root:root`.
353    ///
354    /// `false` for a format with no owner field the writer fills: zip (the
355    /// Info-ZIP `ux` extra field is not written), LHA (no UNIX extension
356    /// header is written), ARJ, ARC and ZOO. Read by `plan_entry_write`, the
357    /// one owner of the ownership warning: an entry with no ids costs nothing
358    /// in a target that would not have stored them anyway, so `stuffr convert`
359    /// from lha to zip reports no `uid_gid` loss.
360    ///
361    /// `false` via [`Self::read_only`], [`Self::read_write`] and `Default`;
362    /// every registered container states it explicitly.
363    pub stores_ownership: bool,
364    /// The container's WRITER can store an [`crate::EntryKind::Hardlink`] as
365    /// a link, rather than only as a second copy of the bytes.
366    ///
367    /// `true` for tar alone (typeflag `1`). `false` for cpio, whose writer
368    /// writes `ino=0, nlink=1` for every entry, so a real cpio link would
369    /// first need a unique inode per file; and for zip, ar, LHA, ARJ, ARC and
370    /// ZOO, which have no link concept at all. Read by `plan_entry_write`,
371    /// the one owner of what a target cannot hold.
372    ///
373    /// `false` via [`Self::read_only`], [`Self::read_write`] and `Default`;
374    /// every registered container states it explicitly.
375    pub stores_hardlinks: bool,
376}
377
378/// A magic-byte rule. Detection matches these against a bounded prefix.
379#[derive(Clone, Copy, PartialEq, Eq, Debug)]
380pub struct MagicRule {
381    pub offset: usize,
382    pub bytes: &'static [u8],
383    pub format: FormatId,
384}
385
386/// A format's second look at a prefix its magic already matched: `true` when
387/// the bytes after the magic are what the format's own header says they must
388/// be, `false` when they are not.
389///
390/// A magic number is two to six bytes, and two bytes is a coin toss over a
391/// folder of ordinary files: an Apple disk image begins with zlib's `78 9c`,
392/// so `stuffr ls` listed one as `zlib`, and other application files that
393/// merely share a magic's few bytes were listed the same way. The function
394/// sees only the probe prefix (at most [`crate::probe::PROBE_LEN`] bytes,
395/// possibly fewer) and must be pure and cheap — it runs on every magic hit
396/// of every file a folder listing reads.
397/// It must answer `true` for every stream the format's writers produce,
398/// including one cut short inside the prefix, because a damaged archive the
399/// user names must stay reachable; it may only refuse what no writer emits.
400///
401/// `whole` says the prefix is the entire stream, not a window onto a longer
402/// one: a file shorter than [`crate::probe::PROBE_LEN`], or a decoded layer
403/// that ended cleanly inside it. Then a field the check reads that is cut
404/// short is a refusal, not a pass — a cut window may end anywhere, but a
405/// whole stream that stops inside its own first header is not that format
406/// (`!<arch>\n!` is no ar archive, `1f 8b` alone no gzip). A complete
407/// stream that is validly empty (`!<arch>\n`) still passes.
408///
409/// Core supplies only the hook. Every real check lives beside its format in
410/// `stuffr-formats`, which is what keeps this crate free of format knowledge.
411pub type ConfirmFn = fn(prefix: &[u8], whole: bool) -> bool;
412
413/// Registration metadata for one format.
414#[derive(Clone, Copy, Debug)]
415pub struct FormatMeta {
416    pub id: FormatId,
417    pub kind: FormatKind,
418    /// Extensions without the leading dot, lowercase. e.g. `["gz", "tgz"]`.
419    pub extensions: &'static [&'static str],
420    pub magics: &'static [MagicRule],
421    /// Breaks ties when several formats share a magic. Higher wins.
422    ///
423    /// The ZIP family is why this exists: jar, apk, docx, epub, odt and zip all
424    /// begin `PK\x03\x04`, so longest-magic-wins cannot separate them. Rank the
425    /// base format above its derivatives, and a bare stream resolves to the
426    /// base while a derivative is reached by extension.
427    pub priority: i16,
428    /// The format's confirmation of its own magic hits — see [`ConfirmFn`].
429    ///
430    /// `None` from [`Self::codec`] and [`Self::container`]: a format that
431    /// registers no function is treated as confirming every hit, so adding
432    /// the hook changes nothing for a format until it opts in with
433    /// [`Self::with_confirm`].
434    pub confirm: Option<ConfirmFn>,
435    /// The format's writers can emit a stream its magic does not match, so
436    /// a missing magic is not by itself a refusal: a v7 tar has no `ustar`
437    /// at 257, and an empty tar is zero blocks. Set by
438    /// [`Self::with_magic_optional`]; tar is the one format that needs it.
439    ///
440    /// Without it, [`crate::registry::Registry::confirm`] refuses any prefix
441    /// the format's own magic does not match before its [`ConfirmFn`] runs,
442    /// because that function judges only "a prefix its magic already
443    /// matched" — a `.pak` file named for arc but beginning `05 00` read as
444    /// arc's method 0, an empty archive, and passed.
445    pub magic_optional: bool,
446}
447
448impl CodecCaps {
449    /// A codec that both compresses and decompresses, with no parallelism and
450    /// no frame index. The common shape.
451    pub const fn round_trip() -> Self {
452        Self {
453            encode: true,
454            decode: true,
455            parallel_encode: false,
456            parallel_decode: false,
457            frame_index: false,
458            detects_corruption: CorruptionDetection::Never,
459            memory_per_worker: None,
460            weak_encoder: false,
461            truncation_undetectable: false,
462            reports_trailing_data: false,
463        }
464    }
465
466    /// A decode-only codec — the pure-Rust fallbacks that let a build with no C
467    /// toolchain still *open* a `.zst` or `.xz`.
468    pub const fn decode_only() -> Self {
469        Self {
470            encode: false,
471            decode: true,
472            parallel_encode: false,
473            parallel_decode: false,
474            frame_index: false,
475            detects_corruption: CorruptionDetection::Never,
476            memory_per_worker: None,
477            weak_encoder: false,
478            truncation_undetectable: false,
479            reports_trailing_data: false,
480        }
481    }
482}
483
484impl ContainerCaps {
485    /// A container that can be read but not written — RAR, whose compressor is
486    /// proprietary.
487    pub const fn read_only() -> Self {
488        Self {
489            read: true,
490            write: false,
491            forward_parse: false,
492            degraded_parse: false,
493            trailing_index: false,
494            solid: false,
495            per_entry_codec: false,
496            needs_seek: false,
497            stores_dirs: false,
498            stores_symlinks: false,
499            detects_corruption: CorruptionDetection::Never,
500            salvage: false,
501            unique_names: false,
502            nul_in_names: false,
503            nul_in_link_targets: false,
504            stores_ownership: false,
505            stores_hardlinks: false,
506        }
507    }
508
509    /// A container supporting both directions. Every other capability stays
510    /// false — a constructor must not claim what a format has not.
511    pub const fn read_write() -> Self {
512        let mut c = Self::read_only();
513        c.write = true;
514        c
515    }
516}
517
518impl FormatMeta {
519    pub const fn codec(
520        id: FormatId,
521        extensions: &'static [&'static str],
522        magics: &'static [MagicRule],
523    ) -> Self {
524        Self {
525            id,
526            kind: FormatKind::Codec,
527            extensions,
528            magics,
529            priority: 0,
530            confirm: None,
531            magic_optional: false,
532        }
533    }
534
535    pub const fn container(
536        id: FormatId,
537        extensions: &'static [&'static str],
538        magics: &'static [MagicRule],
539    ) -> Self {
540        Self {
541            id,
542            kind: FormatKind::Container,
543            extensions,
544            magics,
545            priority: 0,
546            confirm: None,
547            magic_optional: false,
548        }
549    }
550
551    pub const fn with_priority(mut self, priority: i16) -> Self {
552        self.priority = priority;
553        self
554    }
555
556    /// Registers `f` as this format's confirmation — see [`ConfirmFn`].
557    pub const fn with_confirm(mut self, f: ConfirmFn) -> Self {
558        self.confirm = Some(f);
559        self
560    }
561
562    /// Marks the format as one whose writers may omit its magic — see
563    /// [`Self::magic_optional`].
564    pub const fn with_magic_optional(mut self) -> Self {
565        self.magic_optional = true;
566        self
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn format_id_round_trips_and_displays() {
576        let id = FormatId::new("gzip");
577        assert_eq!(id.as_str(), "gzip");
578        assert_eq!(id.to_string(), "gzip");
579    }
580
581    #[test]
582    fn caps_default_to_no_capability() {
583        // A format must opt in to every capability. Defaulting to `true`
584        // anywhere would let an unimplemented backend silently claim support.
585        let c = CodecCaps::default();
586        assert!(!c.encode && !c.decode && !c.parallel_encode);
587        assert_eq!(c.detects_corruption, CorruptionDetection::Never);
588        let k = ContainerCaps::default();
589        assert!(!k.read && !k.write && !k.forward_parse && !k.needs_seek);
590    }
591
592    #[test]
593    fn codec_caps_constructors_express_the_two_common_shapes() {
594        let rt = CodecCaps::round_trip();
595        assert!(rt.encode && rt.decode);
596        assert!(!rt.parallel_encode && !rt.parallel_decode && !rt.frame_index);
597
598        // The pure ruzstd / lzma-rs fallbacks: openable, not writable.
599        let d = CodecCaps::decode_only();
600        assert!(d.decode && !d.encode);
601    }
602
603    #[test]
604    fn container_caps_constructors_express_the_two_common_shapes() {
605        let ro = ContainerCaps::read_only();
606        assert!(ro.read && !ro.write);
607        let rw = ContainerCaps::read_write();
608        assert!(rw.read && rw.write);
609        // Everything else stays opt-in — a constructor must not smuggle in
610        // capabilities a format has not claimed.
611        assert!(!rw.forward_parse && !rw.trailing_index && !rw.needs_seek);
612    }
613
614    #[test]
615    fn format_meta_constructors_set_the_kind_for_you() {
616        const M: &[MagicRule] = &[];
617        let c = FormatMeta::codec(FormatId::new("gzip"), &["gz"], M);
618        assert_eq!(c.kind, FormatKind::Codec);
619        let k = FormatMeta::container(FormatId::new("tar"), &["tar"], M);
620        assert_eq!(k.kind, FormatKind::Container);
621    }
622}