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