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