Skip to main content

stuffr_cli/compat/gzip/
sniff.rs

1//! Format sniffing for the gzip family: which formats each flavour's
2//! `gunzip` accepts, recognised by magic, and the decoders for the ones that
3//! are not gzip.
4//!
5//! Each table is the reference's. Apple's comes from `gzip.c`'s
6//! `file_gettype` (file_cmds-479, BSD-2-Clause): gzip (`1f 8b`, or the old
7//! `1f 9e`), bzip2 (`BZh` and a digit), `.Z` (`1f 9d`), pack (`1f 1e`), xz
8//! (`fd 37 7a 58`, four bytes only: the source says "we only have 4 bytes")
9//! and lzip (`LZIP`). GNU's is its manual's list (gzip, zip, compress, pack,
10//! LZH) checked by running `gzip -dc` on samples, plus the old gzip magic
11//! `1f 9e`, which GNU 1.15 also decodes; see the ruling on LZH on
12//! [`Detected::Lzh`].
13//!
14//! Every magic is at most four bytes, so [`detect`] takes the [`Lookahead`]
15//! the callers already hold, whose `fill_buf` guarantees [`member::PEEK`]
16//! bytes unless at EOF: a short peek is then a short input, never a short
17//! read.
18
19use std::io::{self, BufRead, Read};
20
21use stuffr::FormatId;
22use stuffr::pack_stream::{PackError, PackReader};
23
24use super::member::{Lookahead, PEEK};
25
26/// Which reference the gzip family follows.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Flavour {
29    /// GNU gzip 1.15, the default outside the BSDs and macOS.
30    Gnu,
31    /// Apple's gzip (FreeBSD/NetBSD lineage), the default on macOS and the
32    /// BSDs.
33    Bsd,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub(crate) enum Detected {
38    Gzip,
39    /// `compress` (`.Z`, `1f 9d`).
40    Compress,
41    Bzip2,
42    Xz,
43    Lzip,
44    /// The old Unix `pack` (`1f 1e`).
45    Pack,
46    /// A zip local header (`PK\x03\x04`), GNU only.
47    Zip,
48    /// GNU's LZH (`1f a0`, `compress -H`/SCO). Never returned: stuffr has no
49    /// decoder for it, so it is a documented gap and a `1f a0` input is
50    /// `Unknown` (see `foreign_reader`'s callers for the wording).
51    Lzh,
52    Unknown,
53}
54
55/// Classify by magic, peeking without consuming. A format the flavour does
56/// not decode (or this build lacks) is `Unknown`.
57pub(crate) fn detect<R: Read>(r: &mut Lookahead<R>, flavour: Flavour) -> io::Result<Detected> {
58    let buf = r.fill_buf()?;
59    let m = &buf[..buf.len().min(PEEK)];
60    let d = match flavour {
61        Flavour::Gnu => detect_gnu(m),
62        Flavour::Bsd => detect_bsd(m),
63    };
64    Ok(if available(d) { d } else { Detected::Unknown })
65}
66
67/// GNU's table. `1f 9e` (the old gzip magic) is gzip: GNU 1.15 decodes a
68/// member carrying it (observed: `gzip -dc` on one, exit 0; Ruling T5-1).
69fn detect_gnu(m: &[u8]) -> Detected {
70    match m {
71        [0x1f, 0x8b | 0x9e, ..] => Detected::Gzip,
72        [b'P', b'K', 3, 4] => Detected::Zip,
73        [0x1f, 0x9d, ..] => Detected::Compress,
74        [0x1f, 0x1e, ..] => Detected::Pack,
75        _ => Detected::Unknown,
76    }
77}
78
79/// `file_gettype`, in its order.
80fn detect_bsd(m: &[u8]) -> Detected {
81    match m {
82        [0x1f, 0x8b | 0x9e, ..] => Detected::Gzip,
83        [b'B', b'Z', b'h', d, ..] if d.is_ascii_digit() => Detected::Bzip2,
84        [0x1f, 0x9d, ..] => Detected::Compress,
85        [0x1f, 0x1e, ..] => Detected::Pack,
86        [0xfd, b'7', b'z', b'X'] => Detected::Xz,
87        [b'L', b'Z', b'I', b'P'] => Detected::Lzip,
88        _ => Detected::Unknown,
89    }
90}
91
92/// Whether this build can decode `d`. A codec absent from the registry (the
93/// `legacy` tier carries `compress`) makes its format unknown.
94fn available(d: Detected) -> bool {
95    match codec_id(d) {
96        Some(id) => stuffr::registry().codec(FormatId::new(id)).is_some(),
97        None => !matches!(d, Detected::Lzh),
98    }
99}
100
101fn codec_id(d: Detected) -> Option<&'static str> {
102    match d {
103        Detected::Compress => Some("compress"),
104        Detected::Bzip2 => Some("bzip2"),
105        Detected::Xz => Some("xz"),
106        Detected::Lzip => Some("lzip"),
107        _ => None,
108    }
109}
110
111/// A decoder for a non-gzip format, reading `r` to its end. Errors are
112/// `io::Error`s carrying the codec's own error, for the flavour to word.
113///
114/// A pack header's failure keeps its [`PackError`] inside an error of the
115/// kind every other codec gives the same failure: `UnexpectedEof` for an
116/// input that ends in the header or the tables, `InvalidData` for one that
117/// contradicts itself, the reader's own error for a failed read. (The
118/// bitstream's failures come from the reader, `PackError` inside `Other`.)
119///
120/// `r` is `Send + 'static` because stuffr's codecs own their source.
121pub(crate) fn foreign_reader(
122    d: Detected,
123    r: Box<dyn BufRead + Send>,
124) -> io::Result<Box<dyn Read + Send>> {
125    if d == Detected::Pack {
126        return match PackReader::new(r) {
127            Ok(p) => Ok(Box::new(p)),
128            Err(PackError::Io(e)) => Err(e),
129            Err(e @ (PackError::Truncated | PackError::BadHeader(_))) => {
130                Err(io::Error::new(io::ErrorKind::UnexpectedEof, e))
131            }
132            Err(PackError::Corrupt("Symbol table truncated")) => Err(io::Error::new(
133                io::ErrorKind::UnexpectedEof,
134                PackError::Corrupt("Symbol table truncated"),
135            )),
136            Err(e) => Err(io::Error::new(io::ErrorKind::InvalidData, e)),
137        };
138    }
139    let Some(id) = codec_id(d) else {
140        return Err(io::Error::new(
141            io::ErrorKind::InvalidInput,
142            "not a foreign format",
143        ));
144    };
145    let Some(codec) = stuffr::registry().codec(FormatId::new(id)) else {
146        return Err(io::Error::new(
147            io::ErrorKind::Unsupported,
148            "format not in this build",
149        ));
150    };
151    let src = Box::new(stuffr::StreamOnly::new(r));
152    match codec.decoder(src, &stuffr::DecodeOpts::default()) {
153        Ok(s) => Ok(Box::new(s)),
154        Err(stuffr::Error::Io(e)) => Err(e),
155        Err(e) => Err(io::Error::other(e)),
156    }
157}
158
159/// The first local entry of a zip: what GNU's `gunzip` reads. Method, flags
160/// and sizes are exposed raw; which entries GNU accepts or refuses, and how
161/// it words that, is decided by the personality.
162pub(crate) struct ZipEntry<R> {
163    pub method: u16,
164    pub flags: u16,
165    pub crc: u32,
166    pub compressed_size: u32,
167    pub uncompressed_size: u32,
168    pub name: Vec<u8>,
169    reader: R,
170}
171
172/// Parse the first local header (signature included) and the name/extra
173/// fields, leaving `r` at the start of the entry's data.
174pub(crate) fn zip_first_entry<R: BufRead>(mut r: R) -> io::Result<ZipEntry<R>> {
175    let mut h = [0u8; 30];
176    r.read_exact(&mut h)?;
177    if &h[..4] != b"PK\x03\x04" {
178        return Err(io::Error::new(
179            io::ErrorKind::InvalidData,
180            "not a zip local header",
181        ));
182    }
183    let u16_at = |i: usize| u16::from_le_bytes([h[i], h[i + 1]]);
184    let u32_at = |i: usize| u32::from_le_bytes([h[i], h[i + 1], h[i + 2], h[i + 3]]);
185    let (flags, method) = (u16_at(6), u16_at(8));
186    let (crc, compressed_size, uncompressed_size) = (u32_at(14), u32_at(18), u32_at(22));
187    let (nlen, xlen) = (usize::from(u16_at(26)), u64::from(u16_at(28)));
188    let mut name = vec![0u8; nlen];
189    r.read_exact(&mut name)?;
190    let skipped = io::copy(&mut (&mut r).take(xlen), &mut io::sink())?;
191    if skipped < xlen {
192        return Err(io::ErrorKind::UnexpectedEof.into());
193    }
194    Ok(ZipEntry {
195        method,
196        flags,
197        crc,
198        compressed_size,
199        uncompressed_size,
200        name,
201        reader: r,
202    })
203}
204
205impl<R: BufRead> ZipEntry<R> {
206    /// The entry's data: stored (needs the header's size, so not with a data
207    /// descriptor, flag bit 3) or deflated, which stops where the deflate
208    /// stream does. Any other method is `Unsupported`.
209    pub(crate) fn data(self) -> io::Result<Box<dyn Read>>
210    where
211        R: 'static,
212    {
213        match self.method {
214            0 if self.flags & 8 == 0 => Ok(Box::new(self.reader.take(self.compressed_size.into()))),
215            0 => Err(io::Error::new(
216                io::ErrorKind::Unsupported,
217                "stored entry with a data descriptor",
218            )),
219            8 => Ok(Box::new(flate2::bufread::DeflateDecoder::new(self.reader))),
220            _ => Err(io::Error::new(
221                io::ErrorKind::Unsupported,
222                "zip method not stored or deflated",
223            )),
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::io::Write;
232
233    fn la(b: &[u8]) -> Lookahead<&[u8]> {
234        Lookahead::new(b)
235    }
236
237    fn has(id: &'static str) -> bool {
238        stuffr::registry().codec(FormatId::new(id)).is_some()
239    }
240
241    #[test]
242    fn detect_every_magic_per_flavour() {
243        use Detected::*;
244        let compress = if has("compress") { Compress } else { Unknown };
245        let bzip2 = if has("bzip2") { Bzip2 } else { Unknown };
246        let xz = if has("xz") { Xz } else { Unknown };
247        let lzip = if has("lzip") { Lzip } else { Unknown };
248        let table: &[(&[u8], Flavour, Detected)] = &[
249            (b"\x1f\x8b\x08\x00", Flavour::Gnu, Gzip),
250            (b"\x1f\x9e\x08\x00", Flavour::Gnu, Gzip),
251            (b"PK\x03\x04", Flavour::Gnu, Zip),
252            (b"\x1f\x9d\x90\x00", Flavour::Gnu, compress),
253            (b"\x1f\x1e\x00\x00", Flavour::Gnu, Pack),
254            (b"\x1f\xa0\x00\x00", Flavour::Gnu, Unknown), // LZH gap
255            (b"\x1f\x8b\x08\x00", Flavour::Bsd, Gzip),
256            (b"\x1f\x9e\x08\x00", Flavour::Bsd, Gzip),
257            (b"\x1f\x9d\x90\x00", Flavour::Bsd, compress),
258            (b"BZh9", Flavour::Bsd, bzip2),
259            (b"\xfd7zXZ\x00", Flavour::Bsd, xz),
260            (b"\xfd7zX", Flavour::Bsd, xz),
261            (b"\x1f\x1e\x00\x00", Flavour::Bsd, Pack),
262            (b"LZIP\x01", Flavour::Bsd, lzip),
263        ];
264        for (bytes, fl, want) in table {
265            assert_eq!(
266                detect(&mut la(bytes), *fl).unwrap(),
267                *want,
268                "{bytes:02x?} {fl:?}"
269            );
270        }
271    }
272
273    #[test]
274    fn a_magic_the_flavour_lacks_is_unknown() {
275        let cases: &[(&[u8], Flavour)] = &[
276            (b"BZh9", Flavour::Gnu),
277            (b"\xfd7zXZ\x00", Flavour::Gnu),
278            (b"LZIP\x01", Flavour::Gnu),
279            (b"PK\x03\x04", Flavour::Bsd),
280            (b"\x1f\xa0\x00\x00", Flavour::Bsd),
281            (b"BZhx", Flavour::Bsd), // Apple wants a digit after BZh
282        ];
283        for (bytes, fl) in cases {
284            assert_eq!(
285                detect(&mut la(bytes), *fl).unwrap(),
286                Detected::Unknown,
287                "{bytes:02x?} {fl:?}"
288            );
289        }
290    }
291
292    #[test]
293    fn detect_does_not_consume() {
294        let mut r = la(b"\x1f\x8b\x08\x00rest");
295        detect(&mut r, Flavour::Gnu).unwrap();
296        let mut all = Vec::new();
297        r.read_to_end(&mut all).unwrap();
298        assert_eq!(all, b"\x1f\x8b\x08\x00rest");
299    }
300
301    #[test]
302    fn detect_sees_a_whole_magic_through_tiny_reads() {
303        struct OneByte<'a>(&'a [u8]);
304        impl Read for OneByte<'_> {
305            fn read(&mut self, o: &mut [u8]) -> io::Result<usize> {
306                let n = self.0.len().min(1).min(o.len());
307                o[..n].copy_from_slice(&self.0[..n]);
308                self.0 = &self.0[n..];
309                Ok(n)
310            }
311        }
312        let mut r = Lookahead::new(OneByte(b"PK\x03\x04xxxx"));
313        assert_eq!(detect(&mut r, Flavour::Gnu).unwrap(), Detected::Zip);
314        let mut r = Lookahead::new(OneByte(b"LZIP\x01"));
315        let want = if has("lzip") {
316            Detected::Lzip
317        } else {
318            Detected::Unknown
319        };
320        assert_eq!(detect(&mut r, Flavour::Bsd).unwrap(), want);
321    }
322
323    #[test]
324    fn two_byte_and_empty_inputs() {
325        for fl in [Flavour::Gnu, Flavour::Bsd] {
326            assert_eq!(detect(&mut la(b""), fl).unwrap(), Detected::Unknown);
327            assert_eq!(detect(&mut la(b"\x1f"), fl).unwrap(), Detected::Unknown);
328            assert_eq!(detect(&mut la(b"\x1f\x8b"), fl).unwrap(), Detected::Gzip);
329            assert_eq!(detect(&mut la(b"\x1f\x1e"), fl).unwrap(), Detected::Pack);
330            assert_eq!(detect(&mut la(b"PK"), fl).unwrap(), Detected::Unknown);
331        }
332    }
333
334    fn encode(id: &'static str, data: &[u8]) -> Vec<u8> {
335        use std::sync::{Arc, Mutex};
336        struct Shared(Arc<Mutex<Vec<u8>>>);
337        impl Write for Shared {
338            fn write(&mut self, b: &[u8]) -> io::Result<usize> {
339                self.0.lock().unwrap().extend_from_slice(b);
340                Ok(b.len())
341            }
342            fn flush(&mut self) -> io::Result<()> {
343                Ok(())
344            }
345        }
346        let out = Arc::new(Mutex::new(Vec::new()));
347        let codec = stuffr::registry().codec(FormatId::new(id)).unwrap();
348        let mut enc = codec
349            .encoder(
350                Box::new(Shared(out.clone())),
351                &stuffr::EncodeOpts::default(),
352            )
353            .unwrap();
354        enc.write_all(data).unwrap();
355        enc.finish().unwrap();
356        out.lock().unwrap().clone()
357    }
358
359    fn sample() -> Vec<u8> {
360        (0..5000u32)
361            .flat_map(|i| format!("line {i} of the sample\n").into_bytes())
362            .collect()
363    }
364
365    #[test]
366    fn foreign_reader_round_trips() {
367        let data = sample();
368        let mut cases: Vec<(Detected, Flavour, &str)> = vec![
369            (Detected::Bzip2, Flavour::Bsd, "bzip2"),
370            (Detected::Xz, Flavour::Bsd, "xz"),
371            (Detected::Lzip, Flavour::Bsd, "lzip"),
372        ];
373        // `.Z` is decode-only in some builds; encode it when we can.
374        if has("compress")
375            && stuffr::registry()
376                .codec(FormatId::new("compress"))
377                .unwrap()
378                .caps()
379                .encode
380        {
381            cases.push((Detected::Compress, Flavour::Gnu, "compress"));
382        }
383        for (d, fl, id) in cases {
384            if !has(id) {
385                continue;
386            }
387            let packed = encode(id, &data);
388            let mut r = Lookahead::new(io::Cursor::new(packed));
389            assert_eq!(detect(&mut r, fl).unwrap(), d, "{id}");
390            let mut out = Vec::new();
391            foreign_reader(d, Box::new(r))
392                .unwrap()
393                .read_to_end(&mut out)
394                .unwrap();
395            assert_eq!(out, data, "{id}");
396        }
397    }
398
399    #[test]
400    fn foreign_reader_decodes_pack() {
401        // `/usr/bin/gzip -dc` prints BAAAAAAA for these exact bytes.
402        let p = b"\x1f\x1e\x00\x00\x00\x08\x03\x01\x00\x00AB\x80".to_vec();
403        let mut r = Lookahead::new(io::Cursor::new(p));
404        assert_eq!(detect(&mut r, Flavour::Gnu).unwrap(), Detected::Pack);
405        let mut out = Vec::new();
406        foreign_reader(Detected::Pack, Box::new(r))
407            .unwrap()
408            .read_to_end(&mut out)
409            .unwrap();
410        assert_eq!(out, b"BAAAAAAA");
411    }
412
413    /// Every foreign format's failures through `foreign_reader`: a header
414    /// cut short, a stream cut short, and bytes that contradict
415    /// themselves are each an error (from `foreign_reader` or from the
416    /// reader), never a panic or a quiet short read.
417    #[test]
418    fn foreign_reader_fails_on_bad_input() {
419        let data = sample();
420        let mut formats: Vec<(Detected, Vec<u8>)> = Vec::new();
421        for (d, id) in [
422            (Detected::Bzip2, "bzip2"),
423            (Detected::Xz, "xz"),
424            (Detected::Lzip, "lzip"),
425            (Detected::Compress, "compress"),
426        ] {
427            if has(id)
428                && stuffr::registry()
429                    .codec(FormatId::new(id))
430                    .is_some_and(|c| c.caps().encode)
431            {
432                formats.push((d, encode(id, &data)));
433            }
434        }
435        let decode = |d: Detected, b: Vec<u8>| -> io::Result<Vec<u8>> {
436            let mut out = Vec::new();
437            foreign_reader(d, Box::new(io::Cursor::new(b)))?.read_to_end(&mut out)?;
438            Ok(out)
439        };
440        for (d, packed) in formats {
441            // The header alone, and half the stream. (`.Z` has no end
442            // marker: cut short, it is a shorter stream.)
443            if d != Detected::Compress {
444                for cut in [3, packed.len() / 2] {
445                    assert!(
446                        decode(d, packed[..cut].to_vec()).is_err(),
447                        "{d:?} cut at {cut}"
448                    );
449                }
450            }
451            // A run of bytes overwritten in the middle.
452            let mut bad = packed.clone();
453            let at = packed.len() / 2;
454            for b in &mut bad[at..at + 16.min(packed.len() - at)] {
455                *b ^= 0x5a;
456            }
457            assert!(
458                decode(d, bad).map_or(true, |o| o != data),
459                "{d:?}: corruption went unnoticed"
460            );
461        }
462        // pack: each header failure has its kind and keeps its words.
463        let kind_and_words = |b: &[u8]| {
464            let e = foreign_reader(Detected::Pack, Box::new(io::Cursor::new(b.to_vec())))
465                .err()
466                .unwrap();
467            let words = e.get_ref().map(ToString::to_string).unwrap_or_default();
468            (e.kind(), words)
469        };
470        assert_eq!(
471            kind_and_words(b"\x1f\x1e\x00\x00"),
472            (
473                io::ErrorKind::UnexpectedEof,
474                "header shorter than 7 bytes".into()
475            )
476        );
477        assert_eq!(
478            kind_and_words(b"\x1f\x1e\x00\x00\x00\x08\x03\x01"),
479            (
480                io::ErrorKind::UnexpectedEof,
481                "File appears to be truncated".into()
482            )
483        );
484        assert_eq!(
485            kind_and_words(b"\x1f\x1e\x00\x00\x00\x08\x03\x01\x00\x00A"),
486            (
487                io::ErrorKind::UnexpectedEof,
488                "Symbol table truncated".into()
489            )
490        );
491        assert_eq!(
492            kind_and_words(b"\x1f\x1e\x00\x00\x00\x08\x1a"),
493            (
494                io::ErrorKind::InvalidData,
495                "Huffman tree has insane levels".into()
496            )
497        );
498        // And the bitstream's, from the reader, after what it decoded.
499        let mut r = foreign_reader(
500            Detected::Pack,
501            Box::new(io::Cursor::new(
502                b"\x1f\x1e\x00\x00\x00\x0a\x03\x01\x00\x00AB\x80".to_vec(),
503            )),
504        )
505        .unwrap();
506        let mut out = Vec::new();
507        let e = r.read_to_end(&mut out).unwrap_err();
508        assert_eq!(out, b"BAAAAAAA");
509        assert_eq!(
510            e.get_ref().map(ToString::to_string).unwrap(),
511            "Premature EOF"
512        );
513    }
514
515    #[test]
516    fn foreign_reader_refuses_what_is_not_foreign() {
517        for d in [
518            Detected::Gzip,
519            Detected::Zip,
520            Detected::Lzh,
521            Detected::Unknown,
522        ] {
523            assert!(foreign_reader(d, Box::new(io::Cursor::new(Vec::new()))).is_err());
524        }
525    }
526
527    fn zip_entry(method: u16, flags: u16, data: &[u8], csize: u32, name: &[u8]) -> Vec<u8> {
528        let mut z = b"PK\x03\x04".to_vec();
529        z.extend(20u16.to_le_bytes());
530        z.extend(flags.to_le_bytes());
531        z.extend(method.to_le_bytes());
532        z.extend([0u8; 4]); // time, date
533        z.extend(0xdeadbeefu32.to_le_bytes());
534        z.extend(csize.to_le_bytes());
535        z.extend(7u32.to_le_bytes());
536        z.extend((name.len() as u16).to_le_bytes());
537        z.extend(2u16.to_le_bytes());
538        z.extend(name);
539        z.extend([0xaa, 0xbb]); // extra
540        z.extend(data);
541        z.extend(b"PK\x01\x02trailing central directory");
542        z
543    }
544
545    #[test]
546    fn zip_first_entry_stored_and_deflated() {
547        let z = zip_entry(0, 0, b"payload", 7, b"a.txt");
548        let e = zip_first_entry(io::Cursor::new(z)).unwrap();
549        assert_eq!(
550            (e.method, e.flags, e.crc, e.compressed_size),
551            (0, 0, 0xdeadbeef, 7)
552        );
553        assert_eq!(e.name, b"a.txt");
554        let mut out = Vec::new();
555        e.data().unwrap().read_to_end(&mut out).unwrap();
556        assert_eq!(out, b"payload");
557
558        let mut enc =
559            flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
560        enc.write_all(b"deflated payload").unwrap();
561        let d = enc.finish().unwrap();
562        let z = zip_entry(8, 0, &d, d.len() as u32, b"b");
563        let e = zip_first_entry(io::Cursor::new(z)).unwrap();
564        assert_eq!(e.method, 8);
565        let mut out = Vec::new();
566        e.data().unwrap().read_to_end(&mut out).unwrap();
567        assert_eq!(out, b"deflated payload");
568    }
569
570    #[test]
571    fn zip_first_entry_reports_other_methods_and_truncation() {
572        let z = zip_entry(12, 1, b"x", 1, b"c");
573        let e = zip_first_entry(io::Cursor::new(z)).unwrap();
574        assert_eq!((e.method, e.flags), (12, 1));
575        assert_eq!(e.data().err().unwrap().kind(), io::ErrorKind::Unsupported);
576        let z = zip_entry(0, 0, b"", 0, b"name");
577        let err = zip_first_entry(io::Cursor::new(&z[..20])).err().unwrap();
578        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
579    }
580}