Skip to main content

stuffr_cli/compat/gzip/
mod.rs

1//! The gzip family (`gzip`, `gunzip`, `zcat`, `gzcat`): stuffr behaving as
2//! the platform's own gzip.
3//!
4//! Two personalities share the member layer (`member`) and the format
5//! sniffer (`sniff`):
6//!
7//! - **GNU** (`gnu/`): GNU gzip 1.15, written from its manual, `--help` and
8//!   black-box observation of the binary only. GNU gzip is GPLv3 and its
9//!   source is never read. Its `gunzip` and `zcat` are shell scripts that
10//!   run `gzip -d` and `gzip -dc`, so every message says `gzip:`; `gzcat`
11//!   behaves as `zcat`. The module doc of `gnu/mod.rs` lists the behaviours
12//!   reproduced and the deviations.
13//! - **BSD** (`bsd/`): Apple's gzip 487, ported from `file_cmds`
14//!   (BSD-2-Clause, `LICENSE-gzip-bsd`); its compressed bytes are Apple's
15//!   on the C tier. The module doc of `bsd/mod.rs` lists the behaviours
16//!   reproduced and the deviations.
17//!
18//! The flavour is [`default_flavour`] unless `STUFFR_GZIP_FLAVOR` says `gnu`
19//! or `bsd`; [`run`] reads the variable once, before any argument.
20//! Anything else in it is refused, exit 1, in stuffr's own words (neither
21//! reference has the variable).
22
23use std::collections::BTreeMap;
24use std::ffi::OsString;
25use std::io::{self, Write};
26use std::path::Path;
27use std::process::ExitCode;
28use std::sync::atomic::AtomicU64;
29use std::sync::{Arc, Mutex};
30
31use super::CountedRun;
32use super::host::{Host, SANDBOX_OUTPUT_CAP, Sandbox, reset_sigpipe};
33
34mod bsd;
35mod gnu;
36// Some of the member layer and the sniffer is used by neither personality
37// outside tests: `read_header`, `after_member` and `MemberInfo`'s sizes
38// (GNU reads with `read_header_with`; Apple's `gz_uncompress` is a
39// byte-at-a-time state machine of its own, `bsd/run/decompress.rs`),
40// `ZipEntry::data` and `Detected::Lzh` (GNU's zip and LZH paths).
41#[allow(dead_code)]
42pub(crate) mod member;
43#[allow(dead_code)]
44pub(crate) mod sniff;
45
46pub use sniff::Flavour;
47
48/// The environment variable that overrides [`default_flavour`].
49const FLAVOR_VAR: &str = "STUFFR_GZIP_FLAVOR";
50
51/// The flavour a build uses when `STUFFR_GZIP_FLAVOR` is unset or empty:
52/// BSD on Apple platforms and the BSDs, whose gzip is the FreeBSD/NetBSD
53/// one; GNU everywhere else, Windows included.
54pub fn default_flavour() -> Flavour {
55    if cfg!(any(
56        target_vendor = "apple",
57        target_os = "freebsd",
58        target_os = "netbsd",
59        target_os = "openbsd",
60        target_os = "dragonfly"
61    )) {
62        Flavour::Bsd
63    } else {
64        Flavour::Gnu
65    }
66}
67
68/// The flavour `STUFFR_GZIP_FLAVOR`'s value selects: `gnu` or `bsd`, the
69/// default when unset or empty, and `Err(value)` for anything else.
70fn flavour_from(value: Option<OsString>) -> Result<Flavour, OsString> {
71    match value {
72        None => Ok(default_flavour()),
73        Some(v) if v.is_empty() => Ok(default_flavour()),
74        Some(v) if v == "gnu" => Ok(Flavour::Gnu),
75        Some(v) if v == "bsd" => Ok(Flavour::Bsd),
76        Some(v) => Err(v),
77    }
78}
79
80/// Entry point for `gzip`, `gunzip`, `zcat` and `gzcat`; `name` is the
81/// canonical invoked name and `args` the arguments after `argv[0]`.
82pub fn run(name: &'static str, args: Vec<OsString>) -> ExitCode {
83    // gzip dies of SIGPIPE writing to a closed pipe.
84    reset_sigpipe();
85    let env = |k: &str| std::env::var_os(k);
86    let mut err = io::stderr();
87    let flavour = match flavour_from(env(FLAVOR_VAR)) {
88        Ok(f) => f,
89        Err(v) => {
90            let _ = writeln!(
91                err,
92                "{name}: {FLAVOR_VAR} must be gnu or bsd, not '{}'",
93                v.to_string_lossy()
94            );
95            return ExitCode::from(1);
96        }
97    };
98    // GNU's getopt names the program by its full `argv[0]` (only `gzip`
99    // itself is ever exec'd under its own name; the scripts exec `gzip`).
100    let argv0 = std::env::args_os()
101        .next()
102        .map(|a| a.as_encoded_bytes().to_vec())
103        .unwrap_or_else(|| name.as_bytes().to_vec());
104    let code = main(
105        flavour,
106        name,
107        &argv0,
108        &args,
109        &env,
110        &mut Host::Real,
111        &mut err,
112    );
113    ExitCode::from(code)
114}
115
116/// The flavour's `main`, below the process boundary.
117fn main(
118    flavour: Flavour,
119    name: &'static str,
120    argv0: &[u8],
121    args: &[OsString],
122    env: &dyn Fn(&str) -> Option<OsString>,
123    host: &mut Host,
124    err: &mut dyn Write,
125) -> u8 {
126    match flavour {
127        Flavour::Gnu => gnu::main(name, argv0, args, env, host, err),
128        Flavour::Bsd => bsd::main(name, args, env, host, err),
129    }
130}
131
132/// [`run`] without the process, as `flavour`: operands resolve against
133/// `dir`, standard input is `stdin` (read as a pipe), standard output and
134/// error are captured and returned with the exit code, neither is a
135/// terminal, `env` stands in for the environment (`GZIP`,
136/// `POSIXLY_CORRECT`; `STUFFR_GZIP_FLAVOR` is not consulted) and `SIGPIPE`
137/// is left alone. Standard output and output files share the
138/// [`SANDBOX_OUTPUT_CAP`] budget. For the fuzz target; not a stable
139/// interface.
140#[doc(hidden)]
141pub fn run_in(
142    flavour: Flavour,
143    dir: &Path,
144    name: &'static str,
145    args: &[OsString],
146    stdin: &[u8],
147    env: &dyn Fn(&str) -> Option<OsString>,
148) -> (i32, Vec<u8>, Vec<u8>) {
149    let (code, out, err, _) = run_in_counted(flavour, dir, name, args, stdin, env);
150    (code, out, err)
151}
152
153/// [`run_in`], also returning what the run opened and read, by path.
154#[doc(hidden)]
155pub fn run_in_counted(
156    flavour: Flavour,
157    dir: &Path,
158    name: &'static str,
159    args: &[OsString],
160    stdin: &[u8],
161    env: &dyn Fn(&str) -> Option<OsString>,
162) -> CountedRun {
163    let stdout = Arc::new(Mutex::new(Vec::new()));
164    let reads = Arc::new(Mutex::new(BTreeMap::new()));
165    let mut host = Host::Sandbox(Sandbox {
166        dir: dir.to_path_buf(),
167        stdin: Some(stdin.to_vec()),
168        stdout: stdout.clone(),
169        budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
170        reads: reads.clone(),
171    });
172    let mut err = Vec::new();
173    let code = main(
174        flavour,
175        name,
176        name.as_bytes(),
177        args,
178        env,
179        &mut host,
180        &mut err,
181    );
182    drop(host);
183    let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
184    let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
185    (i32::from(code), out, err, reads)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn the_flavour_variable_selects_or_is_refused() {
194        assert_eq!(flavour_from(None), Ok(default_flavour()));
195        assert_eq!(flavour_from(Some("".into())), Ok(default_flavour()));
196        assert_eq!(flavour_from(Some("gnu".into())), Ok(Flavour::Gnu));
197        assert_eq!(flavour_from(Some("bsd".into())), Ok(Flavour::Bsd));
198        assert_eq!(flavour_from(Some("GNU".into())), Err("GNU".into()));
199        assert_eq!(flavour_from(Some(" gnu".into())), Err(" gnu".into()));
200    }
201
202    /// The thread stack `-r` must fit in, whatever the depth. Compressing
203    /// is left out: a debug build of miniz builds its compressor state on
204    /// the stack before boxing it, which alone needs more than this.
205    const SMALL_STACK: usize = 256 * 1024;
206
207    /// Final review, item 4: `-r` walks a directory tree without native
208    /// recursion, so its depth costs heap, not stack. Both flavours walk a
209    /// chain of nested directories as deep as the platform's `PATH_MAX`
210    /// allows (capped at 1000 levels) on a thread with a 256 KiB stack,
211    /// which a frame per level overflowed: `gzip -r` (the leaf already has
212    /// its suffix, so it is visited and skipped), `gzip -tr` and
213    /// `gunzip -r`, which decodes the leaf. Unix only: the depth is sized
214    /// to unix's `PATH_MAX`.
215    #[cfg(unix)]
216    #[test]
217    fn recursion_depth_costs_no_stack() {
218        let root = std::env::temp_dir().join(format!("stuffr-deep-{}", std::process::id()));
219        let _ = std::fs::remove_dir_all(&root);
220        std::fs::create_dir_all(&root).unwrap();
221        // macOS's `/var` is a symbolic link, and its expansion counts.
222        let root = root.canonicalize().unwrap();
223        // `PATH_MAX` counts the NUL; leave room for the leaf and for the
224        // temporary name an output is written under first
225        // (`.stuffr-gzip-<pid>-<n>.tmp`).
226        let path_max = if cfg!(target_os = "macos") {
227            1024
228        } else {
229            4096
230        };
231        let room = path_max - 1 - root.as_os_str().len() - 64;
232        let levels = (room / 2).min(1000);
233        assert!(levels >= 400, "only {levels} levels fit under {root:?}");
234        let deep = root.join(vec!["d"; levels].join("/"));
235        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
236        gz.write_all(b"deep\n").unwrap();
237        let gz = gz.finish().unwrap();
238        for flavour in [Flavour::Gnu, Flavour::Bsd] {
239            std::fs::create_dir_all(&deep).unwrap();
240            std::fs::write(deep.join("leaf.gz"), &gz).unwrap();
241            // Apple notes the suffix as a warning, exit 1; GNU under `-r`
242            // says nothing.
243            let skipped = if flavour == Flavour::Bsd { 1 } else { 0 };
244            for (name, args, want) in [
245                ("gzip", &["-r", "d"][..], skipped),
246                ("gzip", &["-tr", "d"], 0),
247                ("gunzip", &["-r", "d"], 0),
248            ] {
249                let dir = root.clone();
250                let argv: Vec<OsString> = args.iter().map(OsString::from).collect();
251                // Named, so a stack overflow's abort names the test.
252                let (code, _, err) = std::thread::Builder::new()
253                    .name("recursion_depth_costs_no_stack".into())
254                    .stack_size(SMALL_STACK)
255                    .spawn(move || run_in(flavour, &dir, name, &argv, b"", &|_| None))
256                    .unwrap()
257                    .join()
258                    .expect("the walk panicked");
259                let err = String::from_utf8_lossy(&err);
260                assert_eq!(code, want, "{flavour:?} {name} {args:?}: {err}");
261            }
262            assert_eq!(std::fs::read(deep.join("leaf")).unwrap(), b"deep\n");
263            assert!(!deep.join("leaf.gz").exists());
264            std::fs::remove_dir_all(root.join("d")).unwrap();
265        }
266        let _ = std::fs::remove_dir_all(&root);
267    }
268
269    #[test]
270    fn the_default_flavour_follows_the_platform() {
271        let bsd = cfg!(any(
272            target_os = "macos",
273            target_os = "ios",
274            target_os = "freebsd",
275            target_os = "netbsd",
276            target_os = "openbsd",
277            target_os = "dragonfly"
278        ));
279        assert_eq!(
280            default_flavour(),
281            if bsd { Flavour::Bsd } else { Flavour::Gnu }
282        );
283    }
284}