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