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