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}