Skip to main content

stuffr_cli/compat/
mod.rs

1//! Compatibility links: when the binary is invoked under another tool's name
2//! (through a symlink), it behaves as that tool, with that tool's flags,
3//! messages and exit codes, not stuffr's.
4
5use std::collections::BTreeMap;
6use std::ffi::{OsStr, OsString};
7use std::io::{self, IsTerminal};
8use std::path::{Path, PathBuf};
9use std::process::ExitCode;
10
11pub mod bzip2;
12pub mod bzip2recover;
13pub mod gzip;
14mod host;
15pub mod links;
16pub mod lz4;
17pub mod xz;
18pub mod zstd;
19
20#[doc(hidden)]
21pub use host::InputReads;
22
23/// What [`run_in_counted`] returns: the exit code, standard output,
24/// standard error, and the inputs opened with what was read from each.
25#[doc(hidden)]
26pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
27
28/// A family of tools the binary can impersonate.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum Family {
32    /// `bzip2`, `bunzip2`, `bzcat`.
33    Bzip2,
34    /// `gzip`, `gunzip`, `zcat`, `gzcat`, in the flavour
35    /// [`gzip::default_flavour`] or `STUFFR_GZIP_FLAVOR` picks.
36    Gzip,
37    /// `xz`, `unxz`, `xzcat`, `lzma`, `unlzma`, `lzcat`.
38    Xz,
39    /// `zstd`, `unzstd`, `zstdcat`, `zstdmt`.
40    Zstd,
41    /// `lz4`, `unlz4`, `lz4cat`, `lz4c`.
42    Lz4,
43}
44
45/// Invoked name to family. The name is matched exactly, after the directory
46/// is stripped.
47const NAMES: &[(&str, Family)] = &[
48    ("bzip2", Family::Bzip2),
49    ("bunzip2", Family::Bzip2),
50    ("bzcat", Family::Bzip2),
51    ("bzip2recover", Family::Bzip2),
52    ("gzip", Family::Gzip),
53    ("gunzip", Family::Gzip),
54    ("zcat", Family::Gzip),
55    ("gzcat", Family::Gzip),
56    ("xz", Family::Xz),
57    ("unxz", Family::Xz),
58    ("xzcat", Family::Xz),
59    ("lzma", Family::Xz),
60    ("unlzma", Family::Xz),
61    ("lzcat", Family::Xz),
62    ("zstd", Family::Zstd),
63    ("unzstd", Family::Zstd),
64    ("zstdcat", Family::Zstd),
65    ("zstdmt", Family::Zstd),
66    ("lz4", Family::Lz4),
67    ("unlz4", Family::Lz4),
68    ("lz4cat", Family::Lz4),
69    ("lz4c", Family::Lz4),
70];
71
72/// Every compatibility name, from the one table `family_for` also reads.
73pub fn names() -> &'static [&'static str] {
74    static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
75    LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
76}
77
78/// The family and canonical name for an `argv[0]`, or `None` when it is not a
79/// compatibility name. Only the final path component counts, and on Windows a
80/// trailing `.exe` is ignored.
81pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
82    let file = Path::new(argv0).file_name()?;
83    let file = file.to_str()?;
84    #[cfg(windows)]
85    let file = file.strip_suffix(".exe").unwrap_or(file);
86    NAMES
87        .iter()
88        .find(|(n, _)| *n == file)
89        .map(|&(n, fam)| (fam, n))
90}
91
92/// Run the compatibility tool `args[0]` names, or return `None` when it names
93/// none (the caller then behaves as `stuffr`). The tool gets the arguments
94/// after `args[0]`; `bzip2recover`, which prints its `argv[0]` verbatim,
95/// gets `args[0]` as well.
96pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
97    // The interrupt integration test's helper (`host::signal_test`), in
98    // debug builds only.
99    #[cfg(all(unix, debug_assertions))]
100    if Path::new(args.first()?).file_name() == Some(OsStr::new("__compat-signal-test")) {
101        return Some(host::signal_test(&args[1..]));
102    }
103    let (family, name) = family_for(args.first()?)?;
104    let mut args = args.into_iter();
105    let argv0 = args.next().unwrap_or_default();
106    let rest = args.collect();
107    Some(match family {
108        // bzip2recover prints its argv[0] in full, so it gets it.
109        Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
110        Family::Bzip2 => bzip2::run(name, rest),
111        Family::Gzip => gzip::run(name, rest),
112        Family::Xz => xz::run(name, &argv0, rest),
113        Family::Zstd => zstd::run(name, &argv0, rest),
114        Family::Lz4 => lz4::run(name, &argv0, rest),
115    })
116}
117
118/// Run the compatibility tool `name` names in-process, without touching the
119/// process: operands resolve against `dir`, `stdin` stands in for standard
120/// input, and the exit code, standard output and standard error come back.
121/// Neither stream is a terminal, the environment is not read and `SIGPIPE`
122/// keeps its disposition. `None` when `name` is not a compatibility name.
123/// The gzip family runs as `gzip_flavour` (the environment's
124/// `STUFFR_GZIP_FLAVOR` is not read); the other families ignore it.
125/// Same code as [`dispatch`] below the process boundary; for the fuzz
126/// target, not a stable interface.
127#[doc(hidden)]
128pub fn run_in(
129    dir: &Path,
130    name: &str,
131    args: Vec<OsString>,
132    stdin: &[u8],
133    gzip_flavour: gzip::Flavour,
134) -> Option<(i32, Vec<u8>, Vec<u8>)> {
135    run_in_counted(dir, name, args, stdin, gzip_flavour).map(|(code, out, err, _)| (code, out, err))
136}
137
138/// [`run_in`], also returning the inputs the run opened and what it read
139/// from each, by path. Only the gzip family counts its inputs; the map is
140/// empty for the others. The xz family runs with a fixed machine and a
141/// capped decoder memory limit ([`xz::run_in_counted`]), the zstd family
142/// with a fixed machine and R9's bounds ([`zstd::run_in_counted`]), and
143/// the lz4 family with a fixed machine ([`lz4::run_in_counted`]).
144#[doc(hidden)]
145pub fn run_in_counted(
146    dir: &Path,
147    name: &str,
148    args: Vec<OsString>,
149    stdin: &[u8],
150    gzip_flavour: gzip::Flavour,
151) -> Option<CountedRun> {
152    let (family, canonical) = family_for(OsStr::new(name))?;
153    Some(match family {
154        Family::Bzip2 if canonical == "bzip2recover" => {
155            let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
156            (code, Vec::new(), err, BTreeMap::new())
157        }
158        Family::Bzip2 => {
159            let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
160            (code, out, err, BTreeMap::new())
161        }
162        Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
163        Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
164        Family::Zstd => zstd::run_in_counted(dir, canonical, &args, stdin, &|_| None),
165        Family::Lz4 => lz4::run_in_counted(dir, canonical, &args, stdin, &|_| None),
166    })
167}
168
169/// Whether stdin is a terminal.
170pub(crate) fn stdin_is_tty() -> bool {
171    io::stdin().is_terminal()
172}
173
174/// Whether stdout is a terminal.
175pub(crate) fn stdout_is_tty() -> bool {
176    io::stdout().is_terminal()
177}
178
179/// Copy the access and modification times, permission bits and (where
180/// permitted) owner in `meta` onto `to`. A refused `chown` (`EPERM`) is
181/// ignored, as the reference tools do; every other failure is returned.
182///
183/// Takes metadata rather than a path because bzip2 saves its input's
184/// metadata before reading it, so the access time it copies is the one from
185/// before the read. `to` is opened for writing to set the times, so call
186/// this before restricting `to`'s permissions any further.
187pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
188    copy_metadata_with_mtime(meta, to, None)
189}
190
191/// [`copy_metadata_from`], with `mtime` as the modification time instead of
192/// `meta`'s when given (a gzip header's stored time, under `gunzip -N`).
193pub(crate) fn copy_metadata_with_mtime(
194    meta: &std::fs::Metadata,
195    to: &Path,
196    mtime: Option<std::time::SystemTime>,
197) -> io::Result<()> {
198    let mut times = std::fs::FileTimes::new();
199    if let Ok(t) = meta.accessed() {
200        times = times.set_accessed(t);
201    }
202    if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
203        times = times.set_modified(t);
204    }
205    std::fs::File::options()
206        .write(true)
207        .open(to)?
208        .set_times(times)?;
209    #[cfg(unix)]
210    {
211        use std::os::unix::fs::MetadataExt;
212        match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
213            Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
214            _ => {}
215        }
216    }
217    // After `chown`, which may clear set-id bits.
218    std::fs::set_permissions(to, meta.permissions())
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    /// The gzip flavour `run_in` gets; the bzip2 family ignores it.
226    const G: gzip::Flavour = gzip::Flavour::Gnu;
227
228    /// The `compat` fuzz target picks its tool from `COMPAT_NAMES`; a name
229    /// added here and not there would go unfuzzed.
230    #[test]
231    fn fuzz_names_match_the_dispatch_table() {
232        assert_eq!(stuffr::testing::COMPAT_NAMES, names());
233    }
234
235    #[test]
236    fn compat_names_are_recognised_by_file_name_only() {
237        use std::ffi::OsStr;
238        assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
239        assert_eq!(
240            family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
241            Some("bunzip2")
242        );
243        assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
244        assert_eq!(
245            family_for(OsStr::new("bzip2recover")).map(|f| f.1),
246            Some("bzip2recover")
247        );
248        assert!(family_for(OsStr::new("stuffr")).is_none());
249        assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
250        assert!(family_for(OsStr::new("bzip2x")).is_none());
251        #[cfg(windows)]
252        assert_eq!(
253            family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
254            Some("bzip2")
255        );
256    }
257
258    #[test]
259    fn stuffr_itself_does_not_dispatch() {
260        assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
261    }
262
263    #[test]
264    fn dispatch_ignores_an_empty_argv() {
265        assert!(dispatch(Vec::new()).is_none());
266    }
267
268    #[cfg(unix)]
269    #[test]
270    fn dispatch_ignores_a_non_utf8_argv0() {
271        use std::os::unix::ffi::OsStrExt;
272        let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
273        assert!(dispatch(vec![bad]).is_none());
274        let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
275        assert!(dispatch(vec![bad]).is_none());
276    }
277
278    /// `run_in` reaches both families, resolves operands against its
279    /// directory and captures standard output.
280    #[test]
281    fn run_in_works_inside_its_directory() {
282        let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
283        let _ = std::fs::remove_dir_all(&dir);
284        std::fs::create_dir_all(&dir).unwrap();
285        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
286        let (code, out, err) =
287            run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
288        assert_eq!(
289            (code, out.len()),
290            (0, 0),
291            "{}",
292            String::from_utf8_lossy(&err)
293        );
294        let packed = std::fs::read(dir.join("a.bz2")).unwrap();
295        assert!(packed.starts_with(b"BZh9"));
296        let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
297        assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
298        let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
299        assert_eq!(
300            (code, out.len()),
301            (0, 0),
302            "{}",
303            String::from_utf8_lossy(&err)
304        );
305        assert!(dir.join("rec00001a.bz2").exists());
306        // A damaged stream from stdin: exit 2, stdin is never a terminal.
307        let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
308        assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
309        assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
310        let _ = std::fs::remove_dir_all(&dir);
311    }
312
313    /// `run_in_counted` reports, for the gzip family in either flavour, each
314    /// input opened, how often, its length and the bytes read from it; a
315    /// file only listed (`-l`) is still an open; the bzip2 family counts
316    /// nothing.
317    #[test]
318    fn run_in_counted_counts_the_gzip_familys_inputs() {
319        let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
320        for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
321            let _ = std::fs::remove_dir_all(&dir);
322            std::fs::create_dir_all(&dir).unwrap();
323            std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
324            let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
325            let (code, _, err, reads) =
326                run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour).unwrap();
327            assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
328            let a = reads[&dir.join("a")];
329            assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
330            assert_eq!(reads.len(), 1, "{reads:?}");
331
332            let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
333            for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
334                let (code, _, err, reads) =
335                    run_in_counted(&dir, "gzip", args(mode), b"", flavour).unwrap();
336                assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
337                let gz = reads[&dir.join("a.gz")];
338                assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
339                assert_eq!(gz.len, len);
340                assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
341            }
342            let (_, _, _, reads) =
343                run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour).unwrap();
344            assert!(reads.is_empty(), "{reads:?}");
345        }
346        let _ = std::fs::remove_dir_all(&dir);
347    }
348
349    /// The zstd family counts every file it reads: an input, a `-D`
350    /// dictionary and a `--filelist` list (the fuzz target's read bound
351    /// is blind to an open that bypasses `Host::track`).
352    #[test]
353    fn run_in_counted_counts_the_zstd_familys_inputs() {
354        let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
355        let _ = std::fs::remove_dir_all(&dir);
356        std::fs::create_dir_all(&dir).unwrap();
357        let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
358        let run = |a: &[&str]| {
359            let (code, _, err, reads) =
360                run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu).unwrap();
361            assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
362            reads
363        };
364        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
365        std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
366        std::fs::write(dir.join("l"), b"a\n").unwrap();
367
368        let reads = run(&["-q", "-k", "a"]);
369        assert_eq!(
370            reads[&dir.join("a")],
371            InputReads {
372                opens: 1,
373                bytes: 18,
374                len: 18
375            }
376        );
377        let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
378
379        let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
380        let dict = reads[&dir.join("d.dict")];
381        assert_eq!(
382            dict,
383            InputReads {
384                opens: 1,
385                bytes: 17,
386                len: 17
387            }
388        );
389        assert_eq!(reads[&dir.join("a.zst")].len, zst);
390
391        let reads = run(&["-c", "--filelist=l"]);
392        assert_eq!(
393            reads[&dir.join("l")],
394            InputReads {
395                opens: 1,
396                bytes: 2,
397                len: 2
398            }
399        );
400        assert_eq!(reads[&dir.join("a")].opens, 1);
401        let _ = std::fs::remove_dir_all(&dir);
402    }
403
404    /// Output past the sandbox's cap fails as a full disk does: exit 1.
405    #[test]
406    fn run_in_caps_what_a_run_writes() {
407        let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
408        let _ = std::fs::remove_dir_all(&dir);
409        std::fs::create_dir_all(&dir).unwrap();
410        let big = vec![0u8; 9 * 1024 * 1024];
411        let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
412        assert_eq!(code, 0);
413        let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
414        let err = String::from_utf8_lossy(&err);
415        assert_eq!(code, 1, "{err}");
416        // A write the budget cannot hold fails whole, so the output stops
417        // within one buffered write (64 KiB) of the cap, never past it.
418        let cap = 8 * 1024 * 1024;
419        assert!(
420            out.len() <= cap && out.len() > cap - 64 * 1024,
421            "{}",
422            out.len()
423        );
424        assert!(err.contains("I/O or other error"), "{err}");
425        // `-t` writes nothing, but its decoding is charged to the same cap.
426        let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
427        let err = String::from_utf8_lossy(&err);
428        assert_eq!((code, out.len()), (1, 0), "{err}");
429        assert!(err.contains("I/O or other error"), "{err}");
430        let _ = std::fs::remove_dir_all(&dir);
431    }
432
433    #[test]
434    fn names_lists_the_table() {
435        assert_eq!(
436            names(),
437            [
438                "bzip2",
439                "bunzip2",
440                "bzcat",
441                "bzip2recover",
442                "gzip",
443                "gunzip",
444                "zcat",
445                "gzcat",
446                "xz",
447                "unxz",
448                "xzcat",
449                "lzma",
450                "unlzma",
451                "lzcat",
452                "zstd",
453                "unzstd",
454                "zstdcat",
455                "zstdmt",
456                "lz4",
457                "unlz4",
458                "lz4cat",
459                "lz4c"
460            ]
461        );
462    }
463
464    #[test]
465    fn copy_metadata_carries_mtime_and_permissions() {
466        let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
467        std::fs::create_dir_all(&dir).unwrap();
468        let (a, b) = (dir.join("a"), dir.join("b"));
469        std::fs::write(&a, b"x").unwrap();
470        std::fs::write(&b, b"y").unwrap();
471        let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
472        std::fs::File::options()
473            .write(true)
474            .open(&a)
475            .unwrap()
476            .set_modified(old)
477            .unwrap();
478        #[cfg(unix)]
479        {
480            use std::os::unix::fs::PermissionsExt;
481            std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
482        }
483        copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
484        let (ma, mb) = (
485            std::fs::metadata(&a).unwrap(),
486            std::fs::metadata(&b).unwrap(),
487        );
488        assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
489        assert_eq!(ma.permissions(), mb.permissions());
490        let _ = std::fs::remove_dir_all(&dir);
491    }
492}