Skip to main content

stuffr_core/
registry.rs

1//! Format registry: what this build actually contains.
2//!
3//! Dispatch stays internal, but discovery is explicit — `stuffr formats` renders
4//! [`Registry::matrix`], so a user can always tell a missing feature flag from
5//! a broken file.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::archive::{Codec, Container};
11use crate::error::{Error, Result};
12use crate::format::{FormatId, FormatKind, FormatMeta, MagicRule};
13
14/// One row of the capability matrix.
15#[derive(Clone, PartialEq, Eq, Debug)]
16pub struct FormatRow {
17    pub id: FormatId,
18    pub kind: FormatKind,
19    pub extensions: &'static [&'static str],
20    pub read: bool,
21    pub write: bool,
22    pub parallel: bool,
23}
24
25#[derive(Default)]
26pub struct Registry {
27    codecs: HashMap<FormatId, Arc<dyn Codec>>,
28    containers: HashMap<FormatId, Arc<dyn Container>>,
29    metas: HashMap<FormatId, FormatMeta>,
30    by_ext: HashMap<String, FormatId>,
31    magics: Vec<MagicRule>,
32}
33
34impl Registry {
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    fn index(&mut self, meta: FormatMeta) {
40        // Symmetric with the `magics` purge below: re-registering a format with
41        // a different extension list must not leave stale mappings behind.
42        self.by_ext.retain(|_, v| *v != meta.id);
43        for ext in meta.extensions {
44            self.by_ext.insert(ext.to_ascii_lowercase(), meta.id);
45        }
46        // Replace rather than duplicate on re-registration.
47        self.magics.retain(|m| m.format != meta.id);
48        self.magics.extend_from_slice(meta.magics);
49        self.metas.insert(meta.id, meta);
50    }
51
52    pub fn register_codec(&mut self, codec: Arc<dyn Codec>, meta: FormatMeta) {
53        self.codecs.insert(meta.id, codec);
54        self.index(meta);
55    }
56
57    pub fn register_container(&mut self, container: Arc<dyn Container>, meta: FormatMeta) {
58        self.containers.insert(meta.id, container);
59        self.index(meta);
60    }
61
62    pub fn codec(&self, id: FormatId) -> Option<&Arc<dyn Codec>> {
63        self.codecs.get(&id)
64    }
65
66    pub fn container(&self, id: FormatId) -> Option<&Arc<dyn Container>> {
67        self.containers.get(&id)
68    }
69
70    /// Like [`Self::codec`] but yields `FormatNotEnabled` (exit 3), which is how
71    /// a caller distinguishes "wrong build" from "broken file".
72    pub fn require_codec(&self, id: FormatId) -> Result<&Arc<dyn Codec>> {
73        self.codec(id).ok_or(Error::FormatNotEnabled(id))
74    }
75
76    /// The codec for `id`, if this build has it **and** it can encode.
77    ///
78    /// Separate from [`Self::require_codec`] because a codec that cannot encode
79    /// is a normal thing to have: the pure-Rust zstd and xz fallbacks let a
80    /// build with no C toolchain still read those formats. Without this check
81    /// the caller reaches `encoder()` on such a codec, and the natural body for
82    /// that method is a panic — reachable straight from a command line.
83    pub fn require_encoder(&self, id: FormatId) -> Result<&Arc<dyn Codec>> {
84        let codec = self.require_codec(id)?;
85        if !codec.caps().encode {
86            return Err(Error::CapabilityUnavailable {
87                format: id,
88                available: "read",
89                requested: "written",
90            });
91        }
92        Ok(codec)
93    }
94
95    /// The codec for `id`, if this build has it **and** it can decode.
96    pub fn require_decoder(&self, id: FormatId) -> Result<&Arc<dyn Codec>> {
97        let codec = self.require_codec(id)?;
98        if !codec.caps().decode {
99            return Err(Error::CapabilityUnavailable {
100                format: id,
101                available: "written",
102                requested: "read",
103            });
104        }
105        Ok(codec)
106    }
107
108    pub fn require_container(&self, id: FormatId) -> Result<&Arc<dyn Container>> {
109        self.container(id).ok_or(Error::FormatNotEnabled(id))
110    }
111
112    /// Extension lookup. Accepts `"gz"` or `".gz"`, any case.
113    pub fn by_extension(&self, ext: &str) -> Option<FormatId> {
114        let key = ext.trim_start_matches('.').to_ascii_lowercase();
115        self.by_ext.get(&key).copied()
116    }
117
118    /// Every format whose magic matches `prefix`. Empty rather than a guess.
119    pub fn match_magic(&self, prefix: &[u8]) -> Vec<FormatId> {
120        let mut hits: Vec<(i16, FormatId)> = self
121            .magics
122            .iter()
123            .filter(|m| {
124                let end = match m.offset.checked_add(m.bytes.len()) {
125                    Some(e) => e,
126                    None => return false,
127                };
128                prefix.len() >= end && &prefix[m.offset..end] == m.bytes
129            })
130            .map(|m| {
131                (
132                    self.metas.get(&m.format).map_or(0, |x| x.priority),
133                    m.format,
134                )
135            })
136            .collect();
137        // Priority descending, then id ascending so equal ranks stay stable.
138        hits.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.as_str().cmp(b.1.as_str())));
139        hits.dedup_by_key(|(_, id)| *id);
140        hits.into_iter().map(|(_, id)| id).collect()
141    }
142
143    /// The priority of a registered format, or 0 if unknown.
144    pub fn priority_of(&self, id: FormatId) -> i16 {
145        self.metas.get(&id).map_or(0, |m| m.priority)
146    }
147
148    /// The capability matrix, sorted by id. Rendered by `stuffr formats`.
149    pub fn matrix(&self) -> Vec<FormatRow> {
150        let mut rows: Vec<FormatRow> = self
151            .metas
152            .values()
153            .map(|meta| {
154                let (read, write, parallel) = match meta.kind {
155                    FormatKind::Codec => match self.codecs.get(&meta.id) {
156                        Some(c) => {
157                            let caps = c.caps();
158                            (
159                                caps.decode,
160                                caps.encode,
161                                caps.parallel_encode || caps.parallel_decode,
162                            )
163                        }
164                        None => (false, false, false),
165                    },
166                    FormatKind::Container => match self.containers.get(&meta.id) {
167                        Some(c) => {
168                            let caps = c.caps();
169                            (caps.read, caps.write, false)
170                        }
171                        None => (false, false, false),
172                    },
173                };
174                FormatRow {
175                    id: meta.id,
176                    kind: meta.kind,
177                    extensions: meta.extensions,
178                    read,
179                    write,
180                    parallel,
181                }
182            })
183            .collect();
184        rows.sort_by_key(|r| r.id.as_str());
185        rows
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::archive::{DecodeOpts, EncodeOpts, Sink};
193    use crate::format::CodecCaps;
194    use crate::source::Source;
195    use crate::testing::{MOCK_CODEC, MOCK_CONTAINER, MockCodec, MockContainer};
196    use std::io::Write;
197
198    const CODEC_MAGIC: &[MagicRule] = &[MagicRule {
199        offset: 0,
200        bytes: b"MOCK",
201        format: MOCK_CODEC,
202    }];
203    const CONTAINER_MAGIC: &[MagicRule] = &[MagicRule {
204        offset: 0,
205        bytes: b"ME",
206        format: MOCK_CONTAINER,
207    }];
208
209    fn codec_meta() -> FormatMeta {
210        FormatMeta::codec(MOCK_CODEC, &["mk", "mock"], CODEC_MAGIC)
211    }
212
213    fn container_meta() -> FormatMeta {
214        FormatMeta::container(MOCK_CONTAINER, &["mar"], CONTAINER_MAGIC)
215    }
216
217    fn populated() -> Registry {
218        let mut r = Registry::new();
219        r.register_codec(Arc::new(MockCodec), codec_meta());
220        r.register_container(Arc::new(MockContainer), container_meta());
221        r
222    }
223
224    #[test]
225    fn an_empty_registry_reports_no_formats() {
226        assert!(Registry::new().matrix().is_empty());
227    }
228
229    #[test]
230    fn registered_formats_are_retrievable_by_id() {
231        let r = populated();
232        assert!(r.codec(MOCK_CODEC).is_some());
233        assert!(r.container(MOCK_CONTAINER).is_some());
234        // A codec is not a container, even under the same registry.
235        assert!(r.container(MOCK_CODEC).is_none());
236    }
237
238    #[test]
239    fn a_missing_format_is_format_not_enabled_not_a_generic_error() {
240        // Exit code 3 is how a script distinguishes "wrong build" from "broken
241        // file", so this must not collapse into a generic failure.
242        //
243        // Destructured with `let ... else` rather than `.unwrap_err()`:
244        // `unwrap_err` would require `dyn Codec: Debug`, which would force a
245        // Debug impl on every codec in the project to serve one assertion.
246        let r = Registry::new();
247        let Err(err) = r.require_codec(MOCK_CODEC) else {
248            panic!("expected FormatNotEnabled for an unregistered codec");
249        };
250        assert!(matches!(err, crate::Error::FormatNotEnabled(id) if id == MOCK_CODEC));
251        assert_eq!(err.exit_code(), 3);
252    }
253
254    #[test]
255    fn extension_lookup_is_case_insensitive_and_dot_tolerant() {
256        let r = populated();
257        assert_eq!(r.by_extension("mk"), Some(MOCK_CODEC));
258        assert_eq!(r.by_extension("MOCK"), Some(MOCK_CODEC));
259        assert_eq!(r.by_extension(".mar"), Some(MOCK_CONTAINER));
260        assert_eq!(r.by_extension("nope"), None);
261    }
262
263    #[test]
264    fn magic_matching_finds_the_format_from_a_prefix() {
265        let r = populated();
266        assert_eq!(r.match_magic(b"MOCKpayload"), vec![MOCK_CODEC]);
267        assert_eq!(r.match_magic(b"ME\x05\x00hello"), vec![MOCK_CONTAINER]);
268    }
269
270    #[test]
271    fn magic_matching_returns_empty_rather_than_guessing() {
272        let r = populated();
273        assert!(r.match_magic(b"unrecognised bytes").is_empty());
274        assert!(
275            r.match_magic(b"").is_empty(),
276            "an empty prefix matches nothing"
277        );
278        assert!(
279            r.match_magic(b"M").is_empty(),
280            "a truncated prefix must not match"
281        );
282    }
283
284    #[test]
285    fn magic_respects_the_declared_offset() {
286        const OFFSET_MAGIC: &[MagicRule] = &[MagicRule {
287            offset: 257,
288            bytes: b"ustar",
289            format: FormatId::new("tar"),
290        }];
291        let mut r = Registry::new();
292        r.register_container(
293            Arc::new(MockContainer),
294            FormatMeta::container(FormatId::new("tar"), &["tar"], OFFSET_MAGIC),
295        );
296
297        let mut buf = vec![0u8; 512];
298        buf[257..262].copy_from_slice(b"ustar");
299        assert_eq!(r.match_magic(&buf), vec![FormatId::new("tar")]);
300
301        // Same bytes at the wrong offset must not match.
302        let mut wrong = vec![0u8; 512];
303        wrong[0..5].copy_from_slice(b"ustar");
304        assert!(r.match_magic(&wrong).is_empty());
305    }
306
307    #[test]
308    fn matrix_reports_capabilities_and_is_sorted() {
309        let r = populated();
310        let m = r.matrix();
311        assert_eq!(m.len(), 2);
312        assert!(m.windows(2).all(|w| w[0].id.as_str() <= w[1].id.as_str()));
313
314        let codec_row = m.iter().find(|row| row.id == MOCK_CODEC).unwrap();
315        assert_eq!(codec_row.kind, FormatKind::Codec);
316        assert!(codec_row.read && codec_row.write);
317        assert!(!codec_row.parallel, "the mock declares no parallelism");
318        assert_eq!(codec_row.extensions, &["mk", "mock"]);
319    }
320
321    #[test]
322    fn re_registering_an_id_replaces_it_rather_than_duplicating() {
323        let mut r = populated();
324        r.register_codec(Arc::new(MockCodec), codec_meta());
325        assert_eq!(r.matrix().len(), 2);
326    }
327
328    #[test]
329    fn re_registering_with_a_different_extension_list_drops_the_old_mapping() {
330        let mut r = populated();
331        r.register_codec(
332            Arc::new(MockCodec),
333            FormatMeta::codec(MOCK_CODEC, &["mk2"], CODEC_MAGIC),
334        );
335        assert_eq!(r.by_extension("mk2"), Some(MOCK_CODEC));
336        assert_eq!(
337            r.by_extension("mk"),
338            None,
339            "a stale extension must not survive re-registration"
340        );
341        assert_eq!(r.by_extension("mock"), None);
342    }
343
344    #[test]
345    fn match_magic_orders_by_priority_before_id() {
346        // Two formats sharing one magic — the ZIP family's exact situation.
347        const SHARED: &[MagicRule] = &[MagicRule {
348            offset: 0,
349            bytes: b"PK\x03\x04",
350            format: FormatId::new("zip"),
351        }];
352        const SHARED2: &[MagicRule] = &[MagicRule {
353            offset: 0,
354            bytes: b"PK\x03\x04",
355            format: FormatId::new("apk"),
356        }];
357
358        let mut r = Registry::new();
359        // "apk" sorts before "zip" alphabetically, so without priority it wins.
360        r.register_container(
361            Arc::new(MockContainer),
362            FormatMeta::container(FormatId::new("apk"), &["apk"], SHARED2).with_priority(-10),
363        );
364        r.register_container(
365            Arc::new(MockContainer),
366            FormatMeta::container(FormatId::new("zip"), &["zip"], SHARED),
367        );
368
369        let hits = r.match_magic(b"PK\x03\x04rest");
370        assert_eq!(
371            hits.first().copied(),
372            Some(FormatId::new("zip")),
373            "the base format must outrank its derivative"
374        );
375        assert_eq!(hits.len(), 2);
376    }
377
378    #[test]
379    fn equal_priority_still_orders_deterministically_by_id() {
380        const A: &[MagicRule] = &[MagicRule {
381            offset: 0,
382            bytes: b"XX",
383            format: FormatId::new("bbb"),
384        }];
385        const B: &[MagicRule] = &[MagicRule {
386            offset: 0,
387            bytes: b"XX",
388            format: FormatId::new("aaa"),
389        }];
390        let mut r = Registry::new();
391        r.register_codec(
392            Arc::new(MockCodec),
393            FormatMeta::codec(FormatId::new("bbb"), &["b"], A),
394        );
395        r.register_codec(
396            Arc::new(MockCodec),
397            FormatMeta::codec(FormatId::new("aaa"), &["a"], B),
398        );
399        assert_eq!(
400            r.match_magic(b"XXrest"),
401            vec![FormatId::new("aaa"), FormatId::new("bbb")]
402        );
403    }
404
405    #[test]
406    fn a_decode_only_codec_is_refused_an_encoder_rather_than_reaching_it() {
407        use crate::testing::{MOCK_CODEC, MockCodec};
408
409        /// Stands in for 1c's pure-Rust zstd and xz fallbacks: readable, not
410        /// writable. Delegates decode to MockCodec so only `caps` differs.
411        struct ReadOnlyCodec;
412        impl Codec for ReadOnlyCodec {
413            fn id(&self) -> FormatId {
414                MOCK_CODEC
415            }
416            fn caps(&self) -> CodecCaps {
417                CodecCaps::decode_only()
418            }
419            fn decoder(&self, src: Box<dyn Source>, o: &DecodeOpts) -> Result<Box<dyn Source>> {
420                MockCodec.decoder(src, o)
421            }
422            fn encoder(&self, _d: Box<dyn Write + Send>, _o: &EncodeOpts) -> Result<Box<dyn Sink>> {
423                // This panic is never exercised by this test — neither the
424                // test nor `require_encoder` ever calls `encoder()`. It
425                // documents the production failure mode a buggy
426                // implementation would hit via `ops::compress`: without the
427                // capability check below, a decode-only codec's natural
428                // `encoder()` body is exactly this panic, reachable straight
429                // from a command line. The real assertion in this test is
430                // the `Err`/exit-code check below.
431                panic!("a capability check must stop the caller before it reaches here");
432            }
433        }
434
435        let mut reg = Registry::new();
436        reg.register_codec(
437            std::sync::Arc::new(ReadOnlyCodec),
438            FormatMeta::codec(MOCK_CODEC, &["mock"], &[]),
439        );
440
441        // The decoder is available.
442        assert!(reg.require_decoder(MOCK_CODEC).is_ok());
443
444        // The encoder is refused with a typed error, NOT by panicking inside
445        // the codec — which is what the unreachable!/unimplemented! that a
446        // codec author would naturally write there would do.
447        //
448        // Destructured with `let ... else` rather than `.unwrap_err()`, same
449        // reason as `a_missing_format_is_format_not_enabled_not_a_generic_error`
450        // above: `unwrap_err` requires the `Ok` type to be `Debug`, and the
451        // `Ok` type here is `&Arc<dyn Codec>`.
452        let Err(err) = reg.require_encoder(MOCK_CODEC) else {
453            panic!("expected CapabilityUnavailable for a decode-only codec");
454        };
455        assert!(
456            matches!(err, Error::CapabilityUnavailable { .. }),
457            "got {err:?}"
458        );
459        assert!(
460            err.to_string().contains("mock-codec"),
461            "must name the format: {err}"
462        );
463        assert_eq!(
464            err.exit_code(),
465            3,
466            "a capability refusal must be distinguishable from other errors so a script can branch on it"
467        );
468    }
469}