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