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
146/// What a container can do, and what it needs from its input.
147#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
148pub struct ContainerCaps {
149    pub read: bool,
150    pub write: bool,
151    /// Entries can be enumerated from a forward-only source at full data
152    /// fidelity (metadata may be approximate). True for zip, tar, cpio.
153    pub forward_parse: bool,
154    /// Only a best-effort salvage scan is possible forward-only. Distinct from
155    /// `forward_parse`: this rung yields partial results by construction.
156    pub degraded_parse: bool,
157    /// The authoritative index lives at the end of the stream (zip, 7z, rar).
158    pub trailing_index: bool,
159    /// Entries share codec state, so reaching entry N decodes 1..N (7z, rar).
160    pub solid: bool,
161    /// Each entry carries its own codec (zip, 7z).
162    pub per_entry_codec: bool,
163    /// Random access is required even to enumerate (squashfs, iso).
164    pub needs_seek: bool,
165    /// A directory can be recorded AS a directory, rather than as an empty
166    /// regular file that happens to share its name.
167    ///
168    /// `ar` is the counter-example and the reason this field exists: its
169    /// format has no directory concept at all, so writing one produces a
170    /// zero-byte *file* named `proj/sub` — after which an entry named
171    /// `proj/sub/a.txt` cannot be extracted, because its parent is a file.
172    /// A writer that cannot store the kind is expected to be told so by its
173    /// caller and warn, rather than emit the lie.
174    pub stores_dirs: bool,
175    /// A symlink can be recorded as a link, target and all, rather than as a
176    /// regular file carrying the target text as its contents.
177    pub stores_symlinks: bool,
178}
179
180/// A magic-byte rule. Detection matches these against a bounded prefix.
181#[derive(Clone, Copy, PartialEq, Eq, Debug)]
182pub struct MagicRule {
183    pub offset: usize,
184    pub bytes: &'static [u8],
185    pub format: FormatId,
186}
187
188/// Registration metadata for one format.
189#[derive(Clone, Copy, Debug)]
190pub struct FormatMeta {
191    pub id: FormatId,
192    pub kind: FormatKind,
193    /// Extensions without the leading dot, lowercase. e.g. `["gz", "tgz"]`.
194    pub extensions: &'static [&'static str],
195    pub magics: &'static [MagicRule],
196    /// Breaks ties when several formats share a magic. Higher wins.
197    ///
198    /// The ZIP family is why this exists: jar, apk, docx, epub, odt and zip all
199    /// begin `PK\x03\x04`, so longest-magic-wins cannot separate them. Rank the
200    /// base format above its derivatives, and a bare stream resolves to the
201    /// base while a derivative is reached by extension.
202    pub priority: i16,
203}
204
205impl CodecCaps {
206    /// A codec that both compresses and decompresses, with no parallelism and
207    /// no frame index. The common shape.
208    pub const fn round_trip() -> Self {
209        Self {
210            encode: true,
211            decode: true,
212            parallel_encode: false,
213            parallel_decode: false,
214            frame_index: false,
215            detects_corruption: CorruptionDetection::Never,
216            memory_per_worker: None,
217            weak_encoder: false,
218        }
219    }
220
221    /// A decode-only codec — the pure-Rust fallbacks that let a build with no C
222    /// toolchain still *open* a `.zst` or `.xz`.
223    pub const fn decode_only() -> Self {
224        Self {
225            encode: false,
226            decode: true,
227            parallel_encode: false,
228            parallel_decode: false,
229            frame_index: false,
230            detects_corruption: CorruptionDetection::Never,
231            memory_per_worker: None,
232            weak_encoder: false,
233        }
234    }
235}
236
237impl ContainerCaps {
238    /// A container that can be read but not written — RAR, whose compressor is
239    /// proprietary.
240    pub const fn read_only() -> Self {
241        Self {
242            read: true,
243            write: false,
244            forward_parse: false,
245            degraded_parse: false,
246            trailing_index: false,
247            solid: false,
248            per_entry_codec: false,
249            needs_seek: false,
250            stores_dirs: false,
251            stores_symlinks: false,
252        }
253    }
254
255    /// A container supporting both directions. Every other capability stays
256    /// false — a constructor must not claim what a format has not.
257    pub const fn read_write() -> Self {
258        let mut c = Self::read_only();
259        c.write = true;
260        c
261    }
262}
263
264impl FormatMeta {
265    pub const fn codec(
266        id: FormatId,
267        extensions: &'static [&'static str],
268        magics: &'static [MagicRule],
269    ) -> Self {
270        Self {
271            id,
272            kind: FormatKind::Codec,
273            extensions,
274            magics,
275            priority: 0,
276        }
277    }
278
279    pub const fn container(
280        id: FormatId,
281        extensions: &'static [&'static str],
282        magics: &'static [MagicRule],
283    ) -> Self {
284        Self {
285            id,
286            kind: FormatKind::Container,
287            extensions,
288            magics,
289            priority: 0,
290        }
291    }
292
293    pub const fn with_priority(mut self, priority: i16) -> Self {
294        self.priority = priority;
295        self
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn format_id_round_trips_and_displays() {
305        let id = FormatId::new("gzip");
306        assert_eq!(id.as_str(), "gzip");
307        assert_eq!(id.to_string(), "gzip");
308    }
309
310    #[test]
311    fn caps_default_to_no_capability() {
312        // A format must opt in to every capability. Defaulting to `true`
313        // anywhere would let an unimplemented backend silently claim support.
314        let c = CodecCaps::default();
315        assert!(!c.encode && !c.decode && !c.parallel_encode);
316        assert_eq!(c.detects_corruption, CorruptionDetection::Never);
317        let k = ContainerCaps::default();
318        assert!(!k.read && !k.write && !k.forward_parse && !k.needs_seek);
319    }
320
321    #[test]
322    fn codec_caps_constructors_express_the_two_common_shapes() {
323        let rt = CodecCaps::round_trip();
324        assert!(rt.encode && rt.decode);
325        assert!(!rt.parallel_encode && !rt.parallel_decode && !rt.frame_index);
326
327        // The pure ruzstd / lzma-rs fallbacks: openable, not writable.
328        let d = CodecCaps::decode_only();
329        assert!(d.decode && !d.encode);
330    }
331
332    #[test]
333    fn container_caps_constructors_express_the_two_common_shapes() {
334        let ro = ContainerCaps::read_only();
335        assert!(ro.read && !ro.write);
336        let rw = ContainerCaps::read_write();
337        assert!(rw.read && rw.write);
338        // Everything else stays opt-in — a constructor must not smuggle in
339        // capabilities a format has not claimed.
340        assert!(!rw.forward_parse && !rw.trailing_index && !rw.needs_seek);
341    }
342
343    #[test]
344    fn format_meta_constructors_set_the_kind_for_you() {
345        const M: &[MagicRule] = &[];
346        let c = FormatMeta::codec(FormatId::new("gzip"), &["gz"], M);
347        assert_eq!(c.kind, FormatKind::Codec);
348        let k = FormatMeta::container(FormatId::new("tar"), &["tar"], M);
349        assert_eq!(k.kind, FormatKind::Container);
350    }
351}