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 brotli;
12pub mod bzip2;
13pub mod bzip2recover;
14pub mod compress;
15pub mod gzip;
16mod host;
17pub mod links;
18pub mod lz4;
19pub mod unzip;
20pub mod xz;
21pub mod zip;
22pub mod zstd;
23
24#[cfg(test)]
25pub(crate) use host::ARM_TEST_LOCK;
26#[doc(hidden)]
27pub use host::InputReads;
28/// The whole-run signal hook `stuffr bench` registers (kill its memory
29/// child, clear its bar) before the shared cleanup handler re-raises.
30#[cfg(unix)]
31pub(crate) use host::hook_run_signals;
32/// The armed-temporary entry point `stuffr bench` shares with the families.
33pub(crate) use host::{CleanupGuard, create_armed, with_run_signals_held};
34#[cfg(all(test, unix))]
35pub(crate) use host::{armed_path, disarm_inherited};
36
37/// What [`run_in_counted`] returns: the exit code, standard output,
38/// standard error, and the inputs opened with what was read from each.
39#[doc(hidden)]
40pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
41
42/// A family of tools the binary can impersonate.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum Family {
46    /// `bzip2`, `bunzip2`, `bzcat`.
47    Bzip2,
48    /// `gzip`, `gunzip`, `zcat`, `gzcat`, in the flavour
49    /// [`gzip::default_flavour`] or `STUFFR_GZIP_FLAVOR` picks.
50    Gzip,
51    /// `xz`, `unxz`, `xzcat`, `lzma`, `unlzma`, `lzcat`.
52    Xz,
53    /// `zstd`, `unzstd`, `zstdcat`, `zstdmt`.
54    Zstd,
55    /// `lz4`, `unlz4`, `lz4cat`, `lz4c`.
56    Lz4,
57    /// `brotli`, `unbrotli`, `brcat`.
58    Brotli,
59    /// `unzip`, `zipinfo`, `funzip`, in the flavour
60    /// [`unzip::default_flavour`] or `STUFFR_UNZIP_FLAVOR` picks.
61    Unzip,
62    /// `compress`, `uncompress`, in the flavour
63    /// [`compress::default_flavour`] or `STUFFR_COMPRESS_FLAVOR` picks.
64    Compress,
65    /// `zip`, `zipcloak`, `zipnote`, `zipsplit`, in the flavour
66    /// [`zip::default_flavour`] or `STUFFR_ZIP_FLAVOR` picks.
67    Zip,
68}
69
70/// Invoked name to family. The name is matched exactly, after the directory
71/// is stripped.
72const NAMES: &[(&str, Family)] = &[
73    ("bzip2", Family::Bzip2),
74    ("bunzip2", Family::Bzip2),
75    ("bzcat", Family::Bzip2),
76    ("bzip2recover", Family::Bzip2),
77    ("gzip", Family::Gzip),
78    ("gunzip", Family::Gzip),
79    ("zcat", Family::Gzip),
80    ("gzcat", Family::Gzip),
81    ("xz", Family::Xz),
82    ("unxz", Family::Xz),
83    ("xzcat", Family::Xz),
84    ("lzma", Family::Xz),
85    ("unlzma", Family::Xz),
86    ("lzcat", Family::Xz),
87    ("zstd", Family::Zstd),
88    ("unzstd", Family::Zstd),
89    ("zstdcat", Family::Zstd),
90    ("zstdmt", Family::Zstd),
91    ("lz4", Family::Lz4),
92    ("unlz4", Family::Lz4),
93    ("lz4cat", Family::Lz4),
94    ("lz4c", Family::Lz4),
95    ("unzip", Family::Unzip),
96    ("zipinfo", Family::Unzip),
97    ("funzip", Family::Unzip),
98    ("compress", Family::Compress),
99    ("uncompress", Family::Compress),
100    ("zip", Family::Zip),
101    ("zipcloak", Family::Zip),
102    ("zipnote", Family::Zip),
103    ("zipsplit", Family::Zip),
104    ("brotli", Family::Brotli),
105    ("unbrotli", Family::Brotli),
106    ("brcat", Family::Brotli),
107];
108
109/// Every compatibility name, from the one table `family_for` also reads.
110pub fn names() -> &'static [&'static str] {
111    static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
112    LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
113}
114
115/// The family and canonical name for an `argv[0]`, or `None` when it is not a
116/// compatibility name. Only the final path component counts, and on Windows a
117/// trailing `.exe` is ignored.
118pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
119    let file = Path::new(argv0).file_name()?;
120    let file = file.to_str()?;
121    #[cfg(windows)]
122    let file = file.strip_suffix(".exe").unwrap_or(file);
123    NAMES
124        .iter()
125        .find(|(n, _)| *n == file)
126        .map(|&(n, fam)| (fam, n))
127}
128
129/// Run the compatibility tool `args[0]` names, or return `None` when it names
130/// none (the caller then behaves as `stuffr`). The tool gets the arguments
131/// after `args[0]`; `bzip2recover`, which prints its `argv[0]` verbatim,
132/// gets `args[0]` as well.
133pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
134    // The interrupt integration test's helper (`host::signal_test`), in
135    // debug builds only.
136    #[cfg(all(unix, debug_assertions))]
137    if Path::new(args.first()?).file_name() == Some(OsStr::new("__compat-signal-test")) {
138        return Some(host::signal_test(&args[1..]));
139    }
140    let (family, name) = family_for(args.first()?)?;
141    let mut args = args.into_iter();
142    let argv0 = args.next().unwrap_or_default();
143    let rest = args.collect();
144    Some(match family {
145        // bzip2recover prints its argv[0] in full, so it gets it.
146        Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
147        Family::Bzip2 => bzip2::run(name, rest),
148        Family::Gzip => gzip::run(name, rest),
149        Family::Xz => xz::run(name, &argv0, rest),
150        Family::Zstd => zstd::run(name, &argv0, rest),
151        Family::Lz4 => lz4::run(name, &argv0, rest),
152        Family::Brotli => brotli::run(name, &argv0, rest),
153        Family::Unzip => unzip::run(name, &argv0, rest),
154        Family::Compress => compress::run(name, rest),
155        Family::Zip => zip::run(name, &argv0, rest),
156    })
157}
158
159/// Run the compatibility tool `name` names in-process, without touching the
160/// process: operands resolve against `dir`, `stdin` stands in for standard
161/// input, and the exit code, standard output and standard error come back.
162/// Neither stream is a terminal, the environment is not read and `SIGPIPE`
163/// keeps its disposition. `None` when `name` is not a compatibility name.
164/// The gzip family runs as `gzip_flavour` (the environment's
165/// `STUFFR_GZIP_FLAVOR` is not read); the other families ignore it. The
166/// unzip, compress and zip families run as [`unzip::default_flavour`],
167/// [`compress::default_flavour`] and [`zip::default_flavour`] here; pick
168/// theirs with [`run_in_counted`].
169/// Same code as [`dispatch`] below the process boundary; for the fuzz
170/// target, not a stable interface.
171#[doc(hidden)]
172pub fn run_in(
173    dir: &Path,
174    name: &str,
175    args: Vec<OsString>,
176    stdin: &[u8],
177    gzip_flavour: gzip::Flavour,
178) -> Option<(i32, Vec<u8>, Vec<u8>)> {
179    run_in_counted(
180        dir,
181        name,
182        args,
183        stdin,
184        gzip_flavour,
185        unzip::default_flavour(),
186        compress::default_flavour(),
187        zip::default_flavour(),
188    )
189    .map(|(code, out, err, _)| (code, out, err))
190}
191
192/// [`run_in`], also returning the inputs the run opened and what it read
193/// from each, by path. Only the gzip family counts its inputs; the map is
194/// empty for the others. The xz family runs with a fixed machine and a
195/// capped decoder memory limit ([`xz::run_in_counted`]), the zstd family
196/// with a fixed machine and R9's bounds ([`zstd::run_in_counted`]), and
197/// the lz4 family with a fixed machine ([`lz4::run_in_counted`]). The unzip
198/// family runs as `unzip_flavour` (R7; `STUFFR_UNZIP_FLAVOR` is not read),
199/// as [`unzip::run_in_counted`] describes, and the compress family as
200/// `compress_flavour` (`STUFFR_COMPRESS_FLAVOR` is not read), counting its
201/// inputs ([`compress::run_in_counted`]); the zip family as `zip_flavour`
202/// ([`zip::run_in_counted`]; `STUFFR_ZIP_FLAVOR` is not read).
203#[doc(hidden)]
204#[allow(clippy::too_many_arguments)]
205pub fn run_in_counted(
206    dir: &Path,
207    name: &str,
208    args: Vec<OsString>,
209    stdin: &[u8],
210    gzip_flavour: gzip::Flavour,
211    unzip_flavour: unzip::Flavour,
212    compress_flavour: compress::Flavour,
213    zip_flavour: zip::Flavour,
214) -> Option<CountedRun> {
215    let (family, canonical) = family_for(OsStr::new(name))?;
216    Some(match family {
217        Family::Bzip2 if canonical == "bzip2recover" => {
218            let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
219            (code, Vec::new(), err, BTreeMap::new())
220        }
221        Family::Bzip2 => {
222            let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
223            (code, out, err, BTreeMap::new())
224        }
225        Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
226        Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
227        Family::Zstd => zstd::run_in_counted(dir, canonical, &args, stdin, &|_| None),
228        Family::Lz4 => lz4::run_in_counted(dir, canonical, &args, stdin, &|_| None),
229        Family::Brotli => brotli::run_in_counted(dir, canonical, &args, stdin),
230        Family::Unzip => {
231            unzip::run_in_counted(unzip_flavour, dir, canonical, &args, stdin, &|_| None)
232        }
233        Family::Compress => {
234            compress::run_in_counted(compress_flavour, dir, canonical, &args, stdin, &|_| None)
235        }
236        Family::Zip => zip::run_in_counted(zip_flavour, dir, canonical, &args, stdin),
237    })
238}
239
240/// A name as a sandboxed run resolves it, before the run's directory is
241/// joined on: leading `/`s dropped, a component that is exactly `..` made an
242/// ordinary name. For the fuzz target's read bounds, which must compare the
243/// names a payload supplies with the files a run opened; not a stable
244/// interface.
245#[doc(hidden)]
246pub fn sandbox_name(b: &[u8]) -> Vec<u8> {
247    host::confine_name(b)
248}
249
250/// Whether stdin is a terminal.
251pub(crate) fn stdin_is_tty() -> bool {
252    io::stdin().is_terminal()
253}
254
255/// Whether stdout is a terminal.
256pub(crate) fn stdout_is_tty() -> bool {
257    io::stdout().is_terminal()
258}
259
260/// Copy the access and modification times, permission bits and (where
261/// permitted) owner in `meta` onto `to`. A refused `chown` (`EPERM`) is
262/// ignored, as the reference tools do; every other failure is returned.
263///
264/// Takes metadata rather than a path because bzip2 saves its input's
265/// metadata before reading it, so the access time it copies is the one from
266/// before the read. `to` is opened for writing to set the times, so call
267/// this before restricting `to`'s permissions any further.
268pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
269    copy_metadata_with_mtime(meta, to, None)
270}
271
272/// [`copy_metadata_from`], with `mtime` as the modification time instead of
273/// `meta`'s when given (a gzip header's stored time, under `gunzip -N`).
274pub(crate) fn copy_metadata_with_mtime(
275    meta: &std::fs::Metadata,
276    to: &Path,
277    mtime: Option<std::time::SystemTime>,
278) -> io::Result<()> {
279    let mut times = std::fs::FileTimes::new();
280    if let Ok(t) = meta.accessed() {
281        times = times.set_accessed(t);
282    }
283    if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
284        times = times.set_modified(t);
285    }
286    std::fs::File::options()
287        .write(true)
288        .open(to)?
289        .set_times(times)?;
290    #[cfg(unix)]
291    {
292        use std::os::unix::fs::MetadataExt;
293        match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
294            Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
295            _ => {}
296        }
297    }
298    // After `chown`, which may clear set-id bits.
299    std::fs::set_permissions(to, meta.permissions())
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    /// The gzip flavour `run_in` gets; the bzip2 family ignores it.
307    const G: gzip::Flavour = gzip::Flavour::Gnu;
308
309    /// The unzip flavour `run_in_counted` gets; the other families ignore
310    /// it.
311    const U: unzip::Flavour = unzip::Flavour::Apple;
312
313    /// The compress flavour `run_in_counted` gets; the other families
314    /// ignore it.
315    const C: compress::Flavour = compress::Flavour::Apple;
316
317    /// The zip flavour `run_in_counted` gets; the other families ignore it.
318    const Z: zip::Flavour = zip::Flavour::Apple;
319
320    /// `STUFFR_UNZIP_FLAVOR` selects `apple` or `ubuntu`, unset or empty is
321    /// the default, and anything else (another case, a blank) is refused.
322    #[test]
323    fn flavour_var_rejects_other_values() {
324        let d = unzip::default_flavour();
325        assert_eq!(unzip::flavour_from(None), Ok(d));
326        assert_eq!(unzip::flavour_from(Some("".into())), Ok(d));
327        assert_eq!(
328            unzip::flavour_from(Some("apple".into())),
329            Ok(unzip::Flavour::Apple)
330        );
331        assert_eq!(
332            unzip::flavour_from(Some("ubuntu".into())),
333            Ok(unzip::Flavour::Ubuntu)
334        );
335        for bad in ["Apple", "UBUNTU", " apple", "debian", "bsd"] {
336            assert_eq!(unzip::flavour_from(Some(bad.into())), Err(bad.into()));
337        }
338    }
339
340    /// `run_in_counted` reaches the unzip family in the flavour it is
341    /// given, and `zipinfo` and `funzip` by name.
342    #[test]
343    fn run_in_counted_reaches_the_unzip_family() {
344        let dir = std::env::temp_dir();
345        let run = |name: &str, f: unzip::Flavour| {
346            let (code, out, err, reads) =
347                run_in_counted(&dir, name, vec!["-h".into()], b"", G, f, C, Z).unwrap();
348            assert!(reads.is_empty(), "{reads:?}");
349            (code, String::from_utf8(out).unwrap(), err)
350        };
351        let (code, out, _) = run("unzip", unzip::Flavour::Apple);
352        assert_eq!(code, 0);
353        assert!(out.contains("Please report bugs to Apple."), "{out}");
354        let (_, out, _) = run("unzip", unzip::Flavour::Ubuntu);
355        assert!(!out.contains("Apple"), "{out}");
356        let (code, out, _) = run("zipinfo", U);
357        assert_eq!(code, 0);
358        assert!(
359            out.starts_with("ZipInfo 3.00 of 20 April 2009, by stuffr "),
360            "{out}"
361        );
362        // `-h` is funzip's password; with no terminal it never shows usage,
363        // and what it answers then is Task 12's.
364        let (code, out, _) = run("funzip", U);
365        assert!(unzip::EXIT_CODES_APPLE.contains(&code), "exit {code}");
366        assert!(!out.contains("fUnZip"), "{out}");
367    }
368
369    /// `run_in_counted` reaches the compress family by both names, in the
370    /// flavour it is given, and counts the inputs it opens.
371    #[test]
372    fn run_in_counted_reaches_the_compress_family() {
373        /// Removes the directory when dropped, a panic's unwind included.
374        struct Guard(std::path::PathBuf);
375        impl Drop for Guard {
376            fn drop(&mut self) {
377                let _ = std::fs::remove_dir_all(&self.0);
378            }
379        }
380        let dir = std::env::temp_dir().join(format!("stuffr-run-in-z-{}", std::process::id()));
381        let _ = std::fs::remove_dir_all(&dir);
382        std::fs::create_dir_all(&dir).unwrap();
383        let _guard = Guard(dir.clone());
384        std::fs::write(dir.join("a"), b"hello hello hello hello\n").unwrap();
385        for (c, usage) in [
386            (
387                compress::Flavour::Apple,
388                "uncompress: illegal option -- x\nusage: uncompress [-cfv] [-b bits] [file ...]\n",
389            ),
390            (
391                compress::Flavour::Ncompress,
392                "Unknown flag: 'x'; Usage: uncompress [-dfhvcVr] [-b maxbits] [--] [path ...]\n",
393            ),
394        ] {
395            let (code, _, err, _) =
396                run_in_counted(&dir, "uncompress", vec!["-x".into()], b"", G, U, c, Z).unwrap();
397            assert_eq!(code, 1, "{c:?}");
398            assert!(
399                String::from_utf8_lossy(&err).starts_with(usage),
400                "{c:?}: {}",
401                String::from_utf8_lossy(&err)
402            );
403            let (code, out, err, reads) = run_in_counted(
404                &dir,
405                "compress",
406                vec!["-c".into(), "a".into()],
407                b"",
408                G,
409                U,
410                c,
411                Z,
412            )
413            .unwrap();
414            assert_eq!(code, 0, "{c:?}: {}", String::from_utf8_lossy(&err));
415            assert!(out.starts_with(&[0x1f, 0x9d, 0x90]), "{c:?}");
416            assert_eq!(reads[&dir.join("a")].opens, 1, "{c:?}");
417        }
418    }
419
420    /// `run_in_counted` reaches the zip family by all four names, in the
421    /// flavour it is given: the banner names the build.
422    #[test]
423    fn run_in_counted_reaches_the_zip_family() {
424        let dir = std::env::temp_dir();
425        for name in ["zip", "zipcloak", "zipnote", "zipsplit"] {
426            for (f, origin) in [
427                (zip::Flavour::Apple, "with modifications by Apple Inc."),
428                (zip::Flavour::Ubuntu, "Debian's build"),
429            ] {
430                let (code, out, err, reads) =
431                    run_in_counted(&dir, name, vec!["-h".into()], b"", G, U, C, f).unwrap();
432                let out = String::from_utf8(out).unwrap();
433                assert_eq!((code, err.as_slice()), (0, &b""[..]), "{name}");
434                assert!(reads.is_empty(), "{reads:?}");
435                assert!(
436                    out.contains(", by stuffr ") && out.contains(origin),
437                    "{name} {f:?}: {out}"
438                );
439            }
440        }
441        // the plain entry point runs the zip family in its default flavour
442        let (code, out, _) = run_in(&dir, "zipsplit", vec!["-L".into()], b"", G).unwrap();
443        assert_eq!(code, 0);
444        assert!(String::from_utf8(out).unwrap().contains("Info-ZIP"));
445    }
446
447    /// The `compat` fuzz target picks its tool from `COMPAT_NAMES`; a name
448    /// added here and not there would go unfuzzed.
449    #[test]
450    fn fuzz_names_match_the_dispatch_table() {
451        assert_eq!(stuffr::testing::COMPAT_NAMES, names());
452    }
453
454    #[test]
455    fn compat_names_are_recognised_by_file_name_only() {
456        use std::ffi::OsStr;
457        assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
458        assert_eq!(
459            family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
460            Some("bunzip2")
461        );
462        assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
463        assert_eq!(
464            family_for(OsStr::new("bzip2recover")).map(|f| f.1),
465            Some("bzip2recover")
466        );
467        assert!(family_for(OsStr::new("stuffr")).is_none());
468        assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
469        assert!(family_for(OsStr::new("bzip2x")).is_none());
470        #[cfg(windows)]
471        assert_eq!(
472            family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
473            Some("bzip2")
474        );
475    }
476
477    #[test]
478    fn stuffr_itself_does_not_dispatch() {
479        assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
480    }
481
482    #[test]
483    fn dispatch_ignores_an_empty_argv() {
484        assert!(dispatch(Vec::new()).is_none());
485    }
486
487    #[cfg(unix)]
488    #[test]
489    fn dispatch_ignores_a_non_utf8_argv0() {
490        use std::os::unix::ffi::OsStrExt;
491        let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
492        assert!(dispatch(vec![bad]).is_none());
493        let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
494        assert!(dispatch(vec![bad]).is_none());
495    }
496
497    /// `run_in` reaches both families, resolves operands against its
498    /// directory and captures standard output.
499    #[test]
500    fn run_in_works_inside_its_directory() {
501        let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
502        let _ = std::fs::remove_dir_all(&dir);
503        std::fs::create_dir_all(&dir).unwrap();
504        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
505        let (code, out, err) =
506            run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
507        assert_eq!(
508            (code, out.len()),
509            (0, 0),
510            "{}",
511            String::from_utf8_lossy(&err)
512        );
513        let packed = std::fs::read(dir.join("a.bz2")).unwrap();
514        assert!(packed.starts_with(b"BZh9"));
515        let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
516        assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
517        let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
518        assert_eq!(
519            (code, out.len()),
520            (0, 0),
521            "{}",
522            String::from_utf8_lossy(&err)
523        );
524        assert!(dir.join("rec00001a.bz2").exists());
525        // A damaged stream from stdin: exit 2, stdin is never a terminal.
526        let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
527        assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
528        assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
529        let _ = std::fs::remove_dir_all(&dir);
530    }
531
532    /// `run_in_counted` reports, for the gzip family in either flavour, each
533    /// input opened, how often, its length and the bytes read from it; a
534    /// file only listed (`-l`) is still an open; the bzip2 family counts
535    /// nothing.
536    #[test]
537    fn run_in_counted_counts_the_gzip_familys_inputs() {
538        let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
539        for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
540            let _ = std::fs::remove_dir_all(&dir);
541            std::fs::create_dir_all(&dir).unwrap();
542            std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
543            let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
544            let (code, _, err, reads) =
545                run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour, U, C, Z).unwrap();
546            assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
547            let a = reads[&dir.join("a")];
548            assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
549            assert_eq!(reads.len(), 1, "{reads:?}");
550
551            let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
552            for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
553                let (code, _, err, reads) =
554                    run_in_counted(&dir, "gzip", args(mode), b"", flavour, U, C, Z).unwrap();
555                assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
556                let gz = reads[&dir.join("a.gz")];
557                assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
558                assert_eq!(gz.len, len);
559                assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
560            }
561            let (_, _, _, reads) =
562                run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour, U, C, Z).unwrap();
563            assert!(reads.is_empty(), "{reads:?}");
564        }
565        let _ = std::fs::remove_dir_all(&dir);
566    }
567
568    /// The zstd family counts every file it reads: an input, a `-D`
569    /// dictionary and a `--filelist` list (the fuzz target's read bound
570    /// is blind to an open that bypasses `Host::track`).
571    #[test]
572    fn run_in_counted_counts_the_zstd_familys_inputs() {
573        let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
574        let _ = std::fs::remove_dir_all(&dir);
575        std::fs::create_dir_all(&dir).unwrap();
576        let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
577        let run = |a: &[&str]| {
578            let (code, _, err, reads) =
579                run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu, U, C, Z).unwrap();
580            assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
581            reads
582        };
583        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
584        std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
585        std::fs::write(dir.join("l"), b"a\n").unwrap();
586
587        let reads = run(&["-q", "-k", "a"]);
588        assert_eq!(
589            reads[&dir.join("a")],
590            InputReads {
591                opens: 1,
592                bytes: 18,
593                len: 18,
594                ..Default::default()
595            }
596        );
597        let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
598
599        let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
600        let dict = reads[&dir.join("d.dict")];
601        assert_eq!(
602            dict,
603            InputReads {
604                opens: 1,
605                bytes: 17,
606                len: 17,
607                ..Default::default()
608            }
609        );
610        assert_eq!(reads[&dir.join("a.zst")].len, zst);
611
612        let reads = run(&["-c", "--filelist=l"]);
613        assert_eq!(
614            reads[&dir.join("l")],
615            InputReads {
616                opens: 1,
617                bytes: 2,
618                len: 2,
619                ..Default::default()
620            }
621        );
622        assert_eq!(reads[&dir.join("a")].opens, 1);
623        let _ = std::fs::remove_dir_all(&dir);
624    }
625
626    /// Output past the sandbox's cap fails as a full disk does: exit 1.
627    #[test]
628    fn run_in_caps_what_a_run_writes() {
629        let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
630        let _ = std::fs::remove_dir_all(&dir);
631        std::fs::create_dir_all(&dir).unwrap();
632        let big = vec![0u8; 9 * 1024 * 1024];
633        let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
634        assert_eq!(code, 0);
635        let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
636        let err = String::from_utf8_lossy(&err);
637        assert_eq!(code, 1, "{err}");
638        // A write the budget cannot hold fails whole, so the output stops
639        // within one buffered write (64 KiB) of the cap, never past it.
640        let cap = 8 * 1024 * 1024;
641        assert!(
642            out.len() <= cap && out.len() > cap - 64 * 1024,
643            "{}",
644            out.len()
645        );
646        assert!(err.contains("I/O or other error"), "{err}");
647        // `-t` writes nothing, but its decoding is charged to the same cap.
648        let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
649        let err = String::from_utf8_lossy(&err);
650        assert_eq!((code, out.len()), (1, 0), "{err}");
651        assert!(err.contains("I/O or other error"), "{err}");
652        let _ = std::fs::remove_dir_all(&dir);
653    }
654
655    #[test]
656    fn names_has_thirty_four_entries() {
657        assert_eq!(names().len(), 34);
658    }
659
660    #[test]
661    fn names_lists_the_table() {
662        assert_eq!(
663            names(),
664            [
665                "bzip2",
666                "bunzip2",
667                "bzcat",
668                "bzip2recover",
669                "gzip",
670                "gunzip",
671                "zcat",
672                "gzcat",
673                "xz",
674                "unxz",
675                "xzcat",
676                "lzma",
677                "unlzma",
678                "lzcat",
679                "zstd",
680                "unzstd",
681                "zstdcat",
682                "zstdmt",
683                "lz4",
684                "unlz4",
685                "lz4cat",
686                "lz4c",
687                "unzip",
688                "zipinfo",
689                "funzip",
690                "compress",
691                "uncompress",
692                "zip",
693                "zipcloak",
694                "zipnote",
695                "zipsplit",
696                "brotli",
697                "unbrotli",
698                "brcat"
699            ]
700        );
701    }
702
703    #[test]
704    fn copy_metadata_carries_mtime_and_permissions() {
705        let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
706        std::fs::create_dir_all(&dir).unwrap();
707        let (a, b) = (dir.join("a"), dir.join("b"));
708        std::fs::write(&a, b"x").unwrap();
709        std::fs::write(&b, b"y").unwrap();
710        let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
711        std::fs::File::options()
712            .write(true)
713            .open(&a)
714            .unwrap()
715            .set_modified(old)
716            .unwrap();
717        #[cfg(unix)]
718        {
719            use std::os::unix::fs::PermissionsExt;
720            std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
721        }
722        copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
723        let (ma, mb) = (
724            std::fs::metadata(&a).unwrap(),
725            std::fs::metadata(&b).unwrap(),
726        );
727        assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
728        assert_eq!(ma.permissions(), mb.permissions());
729        let _ = std::fs::remove_dir_all(&dir);
730    }
731}