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 (`O_NOFOLLOW`) and every
267/// attribute set through that descriptor, so call this before restricting
268/// `to`'s permissions any further.
269pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
270    copy_metadata_with_mtime(meta, to, None)
271}
272
273/// [`copy_metadata_from`], with `mtime` as the modification time instead of
274/// `meta`'s when given (a gzip header's stored time, under `gunzip -N`).
275pub(crate) fn copy_metadata_with_mtime(
276    meta: &std::fs::Metadata,
277    to: &Path,
278    mtime: Option<std::time::SystemTime>,
279) -> io::Result<()> {
280    let mut times = std::fs::FileTimes::new();
281    if let Ok(t) = meta.accessed() {
282        times = times.set_accessed(t);
283    }
284    if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
285        times = times.set_modified(t);
286    }
287    // HOST-8: one descriptor, opened without following a symlink at `to`,
288    // carries the times, owner and mode, so a name swapped meanwhile is
289    // never followed.
290    let f = host::open_nofollow_for_write(to)?;
291    f.set_times(times)?;
292    #[cfg(unix)]
293    {
294        use std::os::unix::fs::MetadataExt;
295        match std::os::unix::fs::fchown(&f, Some(meta.uid()), Some(meta.gid())) {
296            Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
297            _ => {}
298        }
299    }
300    // After `chown`, which may clear set-id bits.
301    f.set_permissions(meta.permissions())
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// The gzip flavour `run_in` gets; the bzip2 family ignores it.
309    const G: gzip::Flavour = gzip::Flavour::Gnu;
310
311    /// The unzip flavour `run_in_counted` gets; the other families ignore
312    /// it.
313    const U: unzip::Flavour = unzip::Flavour::Apple;
314
315    /// The compress flavour `run_in_counted` gets; the other families
316    /// ignore it.
317    const C: compress::Flavour = compress::Flavour::Apple;
318
319    /// The zip flavour `run_in_counted` gets; the other families ignore it.
320    const Z: zip::Flavour = zip::Flavour::Apple;
321
322    /// `STUFFR_UNZIP_FLAVOR` selects `apple` or `ubuntu`, unset or empty is
323    /// the default, and anything else (another case, a blank) is refused.
324    #[test]
325    fn flavour_var_rejects_other_values() {
326        let d = unzip::default_flavour();
327        assert_eq!(unzip::flavour_from(None), Ok(d));
328        assert_eq!(unzip::flavour_from(Some("".into())), Ok(d));
329        assert_eq!(
330            unzip::flavour_from(Some("apple".into())),
331            Ok(unzip::Flavour::Apple)
332        );
333        assert_eq!(
334            unzip::flavour_from(Some("ubuntu".into())),
335            Ok(unzip::Flavour::Ubuntu)
336        );
337        for bad in ["Apple", "UBUNTU", " apple", "debian", "bsd"] {
338            assert_eq!(unzip::flavour_from(Some(bad.into())), Err(bad.into()));
339        }
340    }
341
342    /// `run_in_counted` reaches the unzip family in the flavour it is
343    /// given, and `zipinfo` and `funzip` by name.
344    #[test]
345    fn run_in_counted_reaches_the_unzip_family() {
346        let dir = std::env::temp_dir();
347        let run = |name: &str, f: unzip::Flavour| {
348            let (code, out, err, reads) =
349                run_in_counted(&dir, name, vec!["-h".into()], b"", G, f, C, Z).unwrap();
350            assert!(reads.is_empty(), "{reads:?}");
351            (code, String::from_utf8(out).unwrap(), err)
352        };
353        let (code, out, _) = run("unzip", unzip::Flavour::Apple);
354        assert_eq!(code, 0);
355        assert!(out.contains("Please report bugs to Apple."), "{out}");
356        let (_, out, _) = run("unzip", unzip::Flavour::Ubuntu);
357        assert!(!out.contains("Apple"), "{out}");
358        let (code, out, _) = run("zipinfo", U);
359        assert_eq!(code, 0);
360        assert!(
361            out.starts_with("ZipInfo 3.00 of 20 April 2009, by stuffr "),
362            "{out}"
363        );
364        // `-h` is funzip's password; with no terminal it never shows usage,
365        // and what it answers then is Task 12's.
366        let (code, out, _) = run("funzip", U);
367        assert!(unzip::EXIT_CODES_APPLE.contains(&code), "exit {code}");
368        assert!(!out.contains("fUnZip"), "{out}");
369    }
370
371    /// `run_in_counted` reaches the compress family by both names, in the
372    /// flavour it is given, and counts the inputs it opens.
373    #[test]
374    fn run_in_counted_reaches_the_compress_family() {
375        /// Removes the directory when dropped, a panic's unwind included.
376        struct Guard(std::path::PathBuf);
377        impl Drop for Guard {
378            fn drop(&mut self) {
379                let _ = std::fs::remove_dir_all(&self.0);
380            }
381        }
382        let dir = std::env::temp_dir().join(format!("stuffr-run-in-z-{}", std::process::id()));
383        let _ = std::fs::remove_dir_all(&dir);
384        std::fs::create_dir_all(&dir).unwrap();
385        let _guard = Guard(dir.clone());
386        std::fs::write(dir.join("a"), b"hello hello hello hello\n").unwrap();
387        for (c, usage) in [
388            (
389                compress::Flavour::Apple,
390                "uncompress: illegal option -- x\nusage: uncompress [-cfv] [-b bits] [file ...]\n",
391            ),
392            (
393                compress::Flavour::Ncompress,
394                "Unknown flag: 'x'; Usage: uncompress [-dfhvcVr] [-b maxbits] [--] [path ...]\n",
395            ),
396        ] {
397            let (code, _, err, _) =
398                run_in_counted(&dir, "uncompress", vec!["-x".into()], b"", G, U, c, Z).unwrap();
399            assert_eq!(code, 1, "{c:?}");
400            assert!(
401                String::from_utf8_lossy(&err).starts_with(usage),
402                "{c:?}: {}",
403                String::from_utf8_lossy(&err)
404            );
405            let (code, out, err, reads) = run_in_counted(
406                &dir,
407                "compress",
408                vec!["-c".into(), "a".into()],
409                b"",
410                G,
411                U,
412                c,
413                Z,
414            )
415            .unwrap();
416            assert_eq!(code, 0, "{c:?}: {}", String::from_utf8_lossy(&err));
417            assert!(out.starts_with(&[0x1f, 0x9d, 0x90]), "{c:?}");
418            assert_eq!(reads[&dir.join("a")].opens, 1, "{c:?}");
419        }
420    }
421
422    /// `run_in_counted` reaches the zip family by all four names, in the
423    /// flavour it is given: the banner names the build.
424    #[test]
425    fn run_in_counted_reaches_the_zip_family() {
426        let dir = std::env::temp_dir();
427        for name in ["zip", "zipcloak", "zipnote", "zipsplit"] {
428            for (f, origin) in [
429                (zip::Flavour::Apple, "with modifications by Apple Inc."),
430                (zip::Flavour::Ubuntu, "Debian's build"),
431            ] {
432                let (code, out, err, reads) =
433                    run_in_counted(&dir, name, vec!["-h".into()], b"", G, U, C, f).unwrap();
434                let out = String::from_utf8(out).unwrap();
435                assert_eq!((code, err.as_slice()), (0, &b""[..]), "{name}");
436                assert!(reads.is_empty(), "{reads:?}");
437                assert!(
438                    out.contains(", by stuffr ") && out.contains(origin),
439                    "{name} {f:?}: {out}"
440                );
441            }
442        }
443        // the plain entry point runs the zip family in its default flavour
444        let (code, out, _) = run_in(&dir, "zipsplit", vec!["-L".into()], b"", G).unwrap();
445        assert_eq!(code, 0);
446        assert!(String::from_utf8(out).unwrap().contains("Info-ZIP"));
447    }
448
449    /// The `compat` fuzz target picks its tool from `COMPAT_NAMES`; a name
450    /// added here and not there would go unfuzzed.
451    #[test]
452    fn fuzz_names_match_the_dispatch_table() {
453        assert_eq!(stuffr::testing::COMPAT_NAMES, names());
454    }
455
456    #[test]
457    fn compat_names_are_recognised_by_file_name_only() {
458        use std::ffi::OsStr;
459        assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
460        assert_eq!(
461            family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
462            Some("bunzip2")
463        );
464        assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
465        assert_eq!(
466            family_for(OsStr::new("bzip2recover")).map(|f| f.1),
467            Some("bzip2recover")
468        );
469        assert!(family_for(OsStr::new("stuffr")).is_none());
470        assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
471        assert!(family_for(OsStr::new("bzip2x")).is_none());
472        #[cfg(windows)]
473        assert_eq!(
474            family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
475            Some("bzip2")
476        );
477    }
478
479    #[test]
480    fn stuffr_itself_does_not_dispatch() {
481        assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
482    }
483
484    #[test]
485    fn dispatch_ignores_an_empty_argv() {
486        assert!(dispatch(Vec::new()).is_none());
487    }
488
489    #[cfg(unix)]
490    #[test]
491    fn dispatch_ignores_a_non_utf8_argv0() {
492        use std::os::unix::ffi::OsStrExt;
493        let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
494        assert!(dispatch(vec![bad]).is_none());
495        let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
496        assert!(dispatch(vec![bad]).is_none());
497    }
498
499    /// `run_in` reaches both families, resolves operands against its
500    /// directory and captures standard output.
501    #[test]
502    fn run_in_works_inside_its_directory() {
503        let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
504        let _ = std::fs::remove_dir_all(&dir);
505        std::fs::create_dir_all(&dir).unwrap();
506        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
507        let (code, out, err) =
508            run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
509        assert_eq!(
510            (code, out.len()),
511            (0, 0),
512            "{}",
513            String::from_utf8_lossy(&err)
514        );
515        let packed = std::fs::read(dir.join("a.bz2")).unwrap();
516        assert!(packed.starts_with(b"BZh9"));
517        let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
518        assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
519        let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
520        assert_eq!(
521            (code, out.len()),
522            (0, 0),
523            "{}",
524            String::from_utf8_lossy(&err)
525        );
526        assert!(dir.join("rec00001a.bz2").exists());
527        // A damaged stream from stdin: exit 2, stdin is never a terminal.
528        let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
529        assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
530        assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
531        let _ = std::fs::remove_dir_all(&dir);
532    }
533
534    /// `run_in_counted` reports, for the gzip family in either flavour, each
535    /// input opened, how often, its length and the bytes read from it; a
536    /// file only listed (`-l`) is still an open; the bzip2 family counts
537    /// nothing.
538    #[test]
539    fn run_in_counted_counts_the_gzip_familys_inputs() {
540        let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
541        for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
542            let _ = std::fs::remove_dir_all(&dir);
543            std::fs::create_dir_all(&dir).unwrap();
544            std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
545            let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
546            let (code, _, err, reads) =
547                run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour, U, C, Z).unwrap();
548            assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
549            let a = reads[&dir.join("a")];
550            assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
551            assert_eq!(reads.len(), 1, "{reads:?}");
552
553            let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
554            for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
555                let (code, _, err, reads) =
556                    run_in_counted(&dir, "gzip", args(mode), b"", flavour, U, C, Z).unwrap();
557                assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
558                let gz = reads[&dir.join("a.gz")];
559                assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
560                assert_eq!(gz.len, len);
561                assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
562            }
563            let (_, _, _, reads) =
564                run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour, U, C, Z).unwrap();
565            assert!(reads.is_empty(), "{reads:?}");
566        }
567        let _ = std::fs::remove_dir_all(&dir);
568    }
569
570    /// The zstd family counts every file it reads: an input, a `-D`
571    /// dictionary and a `--filelist` list (the fuzz target's read bound
572    /// is blind to an open that bypasses `Host::track`).
573    #[test]
574    fn run_in_counted_counts_the_zstd_familys_inputs() {
575        let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
576        let _ = std::fs::remove_dir_all(&dir);
577        std::fs::create_dir_all(&dir).unwrap();
578        let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
579        let run = |a: &[&str]| {
580            let (code, _, err, reads) =
581                run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu, U, C, Z).unwrap();
582            assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
583            reads
584        };
585        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
586        std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
587        std::fs::write(dir.join("l"), b"a\n").unwrap();
588
589        let reads = run(&["-q", "-k", "a"]);
590        assert_eq!(
591            reads[&dir.join("a")],
592            InputReads {
593                opens: 1,
594                bytes: 18,
595                len: 18,
596                ..Default::default()
597            }
598        );
599        let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
600
601        let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
602        let dict = reads[&dir.join("d.dict")];
603        assert_eq!(
604            dict,
605            InputReads {
606                opens: 1,
607                bytes: 17,
608                len: 17,
609                ..Default::default()
610            }
611        );
612        assert_eq!(reads[&dir.join("a.zst")].len, zst);
613
614        let reads = run(&["-c", "--filelist=l"]);
615        assert_eq!(
616            reads[&dir.join("l")],
617            InputReads {
618                opens: 1,
619                bytes: 2,
620                len: 2,
621                ..Default::default()
622            }
623        );
624        assert_eq!(reads[&dir.join("a")].opens, 1);
625        let _ = std::fs::remove_dir_all(&dir);
626    }
627
628    /// Output past the sandbox's cap fails as a full disk does: exit 1.
629    #[test]
630    fn run_in_caps_what_a_run_writes() {
631        let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
632        let _ = std::fs::remove_dir_all(&dir);
633        std::fs::create_dir_all(&dir).unwrap();
634        let big = vec![0u8; 9 * 1024 * 1024];
635        let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
636        assert_eq!(code, 0);
637        let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
638        let err = String::from_utf8_lossy(&err);
639        assert_eq!(code, 1, "{err}");
640        // A write the budget cannot hold fails whole, so the output stops
641        // within one buffered write (64 KiB) of the cap, never past it.
642        let cap = 8 * 1024 * 1024;
643        assert!(
644            out.len() <= cap && out.len() > cap - 64 * 1024,
645            "{}",
646            out.len()
647        );
648        assert!(err.contains("I/O or other error"), "{err}");
649        // `-t` writes nothing, but its decoding is charged to the same cap.
650        let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
651        let err = String::from_utf8_lossy(&err);
652        assert_eq!((code, out.len()), (1, 0), "{err}");
653        assert!(err.contains("I/O or other error"), "{err}");
654        let _ = std::fs::remove_dir_all(&dir);
655    }
656
657    #[test]
658    fn names_has_thirty_four_entries() {
659        assert_eq!(names().len(), 34);
660    }
661
662    #[test]
663    fn names_lists_the_table() {
664        assert_eq!(
665            names(),
666            [
667                "bzip2",
668                "bunzip2",
669                "bzcat",
670                "bzip2recover",
671                "gzip",
672                "gunzip",
673                "zcat",
674                "gzcat",
675                "xz",
676                "unxz",
677                "xzcat",
678                "lzma",
679                "unlzma",
680                "lzcat",
681                "zstd",
682                "unzstd",
683                "zstdcat",
684                "zstdmt",
685                "lz4",
686                "unlz4",
687                "lz4cat",
688                "lz4c",
689                "unzip",
690                "zipinfo",
691                "funzip",
692                "compress",
693                "uncompress",
694                "zip",
695                "zipcloak",
696                "zipnote",
697                "zipsplit",
698                "brotli",
699                "unbrotli",
700                "brcat"
701            ]
702        );
703    }
704
705    #[test]
706    fn copy_metadata_carries_mtime_and_permissions() {
707        let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
708        std::fs::create_dir_all(&dir).unwrap();
709        let (a, b) = (dir.join("a"), dir.join("b"));
710        std::fs::write(&a, b"x").unwrap();
711        std::fs::write(&b, b"y").unwrap();
712        let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
713        std::fs::File::options()
714            .write(true)
715            .open(&a)
716            .unwrap()
717            .set_modified(old)
718            .unwrap();
719        #[cfg(unix)]
720        {
721            use std::os::unix::fs::PermissionsExt;
722            std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
723        }
724        copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
725        let (ma, mb) = (
726            std::fs::metadata(&a).unwrap(),
727            std::fs::metadata(&b).unwrap(),
728        );
729        assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
730        assert_eq!(ma.permissions(), mb.permissions());
731        let _ = std::fs::remove_dir_all(&dir);
732    }
733}