Skip to main content

archive_core/
detect.rs

1//! Format determination: content magic decides the codec / container; the file
2//! name is a secondary hint for the tar-compression aliases (`.tgz`→gzip+tar,
3//! `.tbz2`→bzip2+tar) and the magic-absent cases.
4
5/// A recognized packing format.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum Format {
9    /// Bare gzip stream (a single compressed file, e.g. `disk.dd.gz`).
10    Gzip,
11    /// Bare bzip2 stream (a single compressed file, e.g. `disk.dd.bz2`).
12    Bzip2,
13    /// tar compressed with gzip (`.tgz` / `.tar.gz`) — a member list.
14    TarGz,
15    /// tar compressed with bzip2 (`.tbz2` / `.tar.bz2`) — a member list.
16    TarBz2,
17    /// Uncompressed tar (`ustar`/v7) — a member list. Detected by the `ustar`
18    /// magic at offset 257, so a bare-compressed tar peeled to its inner stream
19    /// is recognized regardless of its (now-stripped) name.
20    Tar,
21    /// ZIP archive — a member list.
22    Zip,
23    /// 7-Zip archive — a member list.
24    SevenZip,
25    /// Not a recognized archive layer.
26    Unknown,
27}
28
29impl Format {
30    /// A 1→1 bare compression wrapper that peels to a single inner byte stream.
31    #[must_use]
32    pub fn is_compression_wrapper(self) -> bool {
33        matches!(self, Format::Gzip | Format::Bzip2)
34    }
35
36    /// A multi-member archive (tar / tar.gz / tar.bz2 / zip / 7z).
37    #[must_use]
38    pub fn is_archive(self) -> bool {
39        matches!(
40            self,
41            Format::Tar | Format::TarGz | Format::TarBz2 | Format::Zip | Format::SevenZip
42        )
43    }
44}
45
46/// The name-suffix set for a bzip2-compressed tar. `.tbz2`, `.tbz`, and `.tb2`
47/// are all common short aliases for `.tar.bz2` (e7z / GNU tar). One place so the
48/// magic branch and the magic-silent fallback stay in sync.
49fn is_tar_bz2_name(ends: &impl Fn(&str) -> bool) -> bool {
50    ends(".tar.bz2") || ends(".tbz2") || ends(".tbz") || ends(".tb2")
51}
52
53/// Determine the packing format. Container identity (zip / 7z) is decided by
54/// **magic**; the tar-compression combos (`.tgz`/`.tbz2`/`.tbz`/`.tb2`) are
55/// distinguished from bare gzip/bzip2 by the file **name** (the outer magic
56/// alone can't tell a gzipped tar from a gzipped single file).
57#[must_use]
58pub fn sniff(name: Option<&str>, head: &[u8]) -> Format {
59    // Containers with a definitive magic.
60    if head.starts_with(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) {
61        return Format::SevenZip;
62    }
63    if head.starts_with(b"PK\x03\x04") {
64        return Format::Zip;
65    }
66    // Uncompressed tar: the `ustar` magic lives at offset 257 (POSIX writes
67    // "ustar\0", GNU "ustar  "; both begin "ustar"). Checked by magic so a
68    // bare-compressed tar peeled to its inner stream is recognized even after
69    // its `.tbz`/`.tgz` name was stripped.
70    if head.len() >= 262 && &head[257..262] == b"ustar" {
71        return Format::Tar;
72    }
73    // Compression codecs: the name decides tar-inside vs bare single file.
74    let lower = name.map(str::to_ascii_lowercase);
75    let ends = |suf: &str| lower.as_deref().is_some_and(|n| n.ends_with(suf));
76    if head.starts_with(&[0x1F, 0x8B]) {
77        return if ends(".tgz") || ends(".tar.gz") {
78            Format::TarGz
79        } else {
80            Format::Gzip
81        };
82    }
83    if head.starts_with(b"BZh") {
84        return if is_tar_bz2_name(&ends) {
85            Format::TarBz2
86        } else {
87            Format::Bzip2
88        };
89    }
90    // Magic silent → fall to the name (renamed / stripped-header cases).
91    if ends(".7z") {
92        return Format::SevenZip;
93    }
94    // `.clbx` is Cellebrite's extraction container — an ordinary ZIP (per the
95    // published cellebrite-labs/clbx spec: files + msgpack metadata inside a ZIP).
96    // Its CLBX-specific semantics are a higher layer; here it's read as a ZIP.
97    if ends(".zip") || ends(".clbx") {
98        return Format::Zip;
99    }
100    if ends(".tgz") || ends(".tar.gz") {
101        return Format::TarGz;
102    }
103    if is_tar_bz2_name(&ends) {
104        return Format::TarBz2;
105    }
106    if ends(".tar") {
107        return Format::Tar;
108    }
109    if ends(".gz") {
110        return Format::Gzip;
111    }
112    if ends(".bz2") {
113        return Format::Bzip2;
114    }
115    Format::Unknown
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    // `.tb2`/`.tbz` are the common short aliases for `.tar.bz2` (e7z, GNU tar):
123    // bzip2-compressed tar. With `BZh` magic they must sniff as TarBz2 (an
124    // archive), NOT bare Bzip2 — the magic alone can't tell a bzipped tar from a
125    // bzipped single file, so the name decides.
126    #[test]
127    fn tbz_and_tb2_aliases_are_tar_bzip2() {
128        for name in ["evidence.tb2", "evidence.tbz", "EVIDENCE.TB2"] {
129            assert_eq!(sniff(Some(name), b"BZh9"), Format::TarBz2, "{name}");
130        }
131        // A genuine bare bzip2 single file stays bare.
132        assert_eq!(sniff(Some("notes.txt.bz2"), b"BZh9"), Format::Bzip2);
133        assert_eq!(sniff(Some("disk.dd.bz2"), b"BZh9"), Format::Bzip2);
134    }
135
136    // The same aliases resolve when the magic is silent (renamed / stripped head).
137    #[test]
138    fn tbz_and_tb2_aliases_resolve_by_name_when_magic_silent() {
139        for name in ["evidence.tb2", "evidence.tbz"] {
140            assert_eq!(sniff(Some(name), b"\x00\x01\x02"), Format::TarBz2, "{name}");
141        }
142    }
143}