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