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 xz;
17
18#[doc(hidden)]
19pub use host::InputReads;
20
21/// What [`run_in_counted`] returns: the exit code, standard output,
22/// standard error, and the inputs opened with what was read from each.
23#[doc(hidden)]
24pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
25
26/// A family of tools the binary can impersonate.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum Family {
30    /// `bzip2`, `bunzip2`, `bzcat`.
31    Bzip2,
32    /// `gzip`, `gunzip`, `zcat`, `gzcat`, in the flavour
33    /// [`gzip::default_flavour`] or `STUFFR_GZIP_FLAVOR` picks.
34    Gzip,
35    /// `xz`, `unxz`, `xzcat`, `lzma`, `unlzma`, `lzcat`.
36    Xz,
37}
38
39/// Invoked name to family. The name is matched exactly, after the directory
40/// is stripped.
41const NAMES: &[(&str, Family)] = &[
42    ("bzip2", Family::Bzip2),
43    ("bunzip2", Family::Bzip2),
44    ("bzcat", Family::Bzip2),
45    ("bzip2recover", Family::Bzip2),
46    ("gzip", Family::Gzip),
47    ("gunzip", Family::Gzip),
48    ("zcat", Family::Gzip),
49    ("gzcat", Family::Gzip),
50    ("xz", Family::Xz),
51    ("unxz", Family::Xz),
52    ("xzcat", Family::Xz),
53    ("lzma", Family::Xz),
54    ("unlzma", Family::Xz),
55    ("lzcat", Family::Xz),
56];
57
58/// Every compatibility name, from the one table `family_for` also reads.
59pub fn names() -> &'static [&'static str] {
60    static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
61    LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
62}
63
64/// The family and canonical name for an `argv[0]`, or `None` when it is not a
65/// compatibility name. Only the final path component counts, and on Windows a
66/// trailing `.exe` is ignored.
67pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
68    let file = Path::new(argv0).file_name()?;
69    let file = file.to_str()?;
70    #[cfg(windows)]
71    let file = file.strip_suffix(".exe").unwrap_or(file);
72    NAMES
73        .iter()
74        .find(|(n, _)| *n == file)
75        .map(|&(n, fam)| (fam, n))
76}
77
78/// Run the compatibility tool `args[0]` names, or return `None` when it names
79/// none (the caller then behaves as `stuffr`). The tool gets the arguments
80/// after `args[0]`; `bzip2recover`, which prints its `argv[0]` verbatim,
81/// gets `args[0]` as well.
82pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
83    let (family, name) = family_for(args.first()?)?;
84    let mut args = args.into_iter();
85    let argv0 = args.next().unwrap_or_default();
86    let rest = args.collect();
87    Some(match family {
88        // bzip2recover prints its argv[0] in full, so it gets it.
89        Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
90        Family::Bzip2 => bzip2::run(name, rest),
91        Family::Gzip => gzip::run(name, rest),
92        Family::Xz => xz::run(name, &argv0, rest),
93    })
94}
95
96/// Run the compatibility tool `name` names in-process, without touching the
97/// process: operands resolve against `dir`, `stdin` stands in for standard
98/// input, and the exit code, standard output and standard error come back.
99/// Neither stream is a terminal, the environment is not read and `SIGPIPE`
100/// keeps its disposition. `None` when `name` is not a compatibility name.
101/// The gzip family runs as `gzip_flavour` (the environment's
102/// `STUFFR_GZIP_FLAVOR` is not read); the other families ignore it.
103/// Same code as [`dispatch`] below the process boundary; for the fuzz
104/// target, not a stable interface.
105#[doc(hidden)]
106pub fn run_in(
107    dir: &Path,
108    name: &str,
109    args: Vec<OsString>,
110    stdin: &[u8],
111    gzip_flavour: gzip::Flavour,
112) -> Option<(i32, Vec<u8>, Vec<u8>)> {
113    run_in_counted(dir, name, args, stdin, gzip_flavour).map(|(code, out, err, _)| (code, out, err))
114}
115
116/// [`run_in`], also returning the inputs the run opened and what it read
117/// from each, by path. Only the gzip family counts its inputs; the map is
118/// empty for the others. The xz family runs with a fixed machine and a
119/// capped decoder memory limit ([`xz::run_in_counted`]).
120#[doc(hidden)]
121pub fn run_in_counted(
122    dir: &Path,
123    name: &str,
124    args: Vec<OsString>,
125    stdin: &[u8],
126    gzip_flavour: gzip::Flavour,
127) -> Option<CountedRun> {
128    let (family, canonical) = family_for(OsStr::new(name))?;
129    Some(match family {
130        Family::Bzip2 if canonical == "bzip2recover" => {
131            let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
132            (code, Vec::new(), err, BTreeMap::new())
133        }
134        Family::Bzip2 => {
135            let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
136            (code, out, err, BTreeMap::new())
137        }
138        Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
139        Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
140    })
141}
142
143/// Whether stdin is a terminal.
144pub(crate) fn stdin_is_tty() -> bool {
145    io::stdin().is_terminal()
146}
147
148/// Whether stdout is a terminal.
149pub(crate) fn stdout_is_tty() -> bool {
150    io::stdout().is_terminal()
151}
152
153/// Copy the access and modification times, permission bits and (where
154/// permitted) owner in `meta` onto `to`. A refused `chown` (`EPERM`) is
155/// ignored, as the reference tools do; every other failure is returned.
156///
157/// Takes metadata rather than a path because bzip2 saves its input's
158/// metadata before reading it, so the access time it copies is the one from
159/// before the read. `to` is opened for writing to set the times, so call
160/// this before restricting `to`'s permissions any further.
161pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
162    copy_metadata_with_mtime(meta, to, None)
163}
164
165/// [`copy_metadata_from`], with `mtime` as the modification time instead of
166/// `meta`'s when given (a gzip header's stored time, under `gunzip -N`).
167pub(crate) fn copy_metadata_with_mtime(
168    meta: &std::fs::Metadata,
169    to: &Path,
170    mtime: Option<std::time::SystemTime>,
171) -> io::Result<()> {
172    let mut times = std::fs::FileTimes::new();
173    if let Ok(t) = meta.accessed() {
174        times = times.set_accessed(t);
175    }
176    if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
177        times = times.set_modified(t);
178    }
179    std::fs::File::options()
180        .write(true)
181        .open(to)?
182        .set_times(times)?;
183    #[cfg(unix)]
184    {
185        use std::os::unix::fs::MetadataExt;
186        match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
187            Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
188            _ => {}
189        }
190    }
191    // After `chown`, which may clear set-id bits.
192    std::fs::set_permissions(to, meta.permissions())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    /// The gzip flavour `run_in` gets; the bzip2 family ignores it.
200    const G: gzip::Flavour = gzip::Flavour::Gnu;
201
202    /// The `compat` fuzz target picks its tool from `COMPAT_NAMES`; a name
203    /// added here and not there would go unfuzzed.
204    #[test]
205    fn fuzz_names_match_the_dispatch_table() {
206        assert_eq!(stuffr::testing::COMPAT_NAMES, names());
207    }
208
209    #[test]
210    fn compat_names_are_recognised_by_file_name_only() {
211        use std::ffi::OsStr;
212        assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
213        assert_eq!(
214            family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
215            Some("bunzip2")
216        );
217        assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
218        assert_eq!(
219            family_for(OsStr::new("bzip2recover")).map(|f| f.1),
220            Some("bzip2recover")
221        );
222        assert!(family_for(OsStr::new("stuffr")).is_none());
223        assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
224        assert!(family_for(OsStr::new("bzip2x")).is_none());
225        #[cfg(windows)]
226        assert_eq!(
227            family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
228            Some("bzip2")
229        );
230    }
231
232    #[test]
233    fn stuffr_itself_does_not_dispatch() {
234        assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
235    }
236
237    #[test]
238    fn dispatch_ignores_an_empty_argv() {
239        assert!(dispatch(Vec::new()).is_none());
240    }
241
242    #[cfg(unix)]
243    #[test]
244    fn dispatch_ignores_a_non_utf8_argv0() {
245        use std::os::unix::ffi::OsStrExt;
246        let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
247        assert!(dispatch(vec![bad]).is_none());
248        let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
249        assert!(dispatch(vec![bad]).is_none());
250    }
251
252    /// `run_in` reaches both families, resolves operands against its
253    /// directory and captures standard output.
254    #[test]
255    fn run_in_works_inside_its_directory() {
256        let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
257        let _ = std::fs::remove_dir_all(&dir);
258        std::fs::create_dir_all(&dir).unwrap();
259        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
260        let (code, out, err) =
261            run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
262        assert_eq!(
263            (code, out.len()),
264            (0, 0),
265            "{}",
266            String::from_utf8_lossy(&err)
267        );
268        let packed = std::fs::read(dir.join("a.bz2")).unwrap();
269        assert!(packed.starts_with(b"BZh9"));
270        let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
271        assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
272        let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
273        assert_eq!(
274            (code, out.len()),
275            (0, 0),
276            "{}",
277            String::from_utf8_lossy(&err)
278        );
279        assert!(dir.join("rec00001a.bz2").exists());
280        // A damaged stream from stdin: exit 2, stdin is never a terminal.
281        let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
282        assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
283        assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
284        let _ = std::fs::remove_dir_all(&dir);
285    }
286
287    /// `run_in_counted` reports, for the gzip family in either flavour, each
288    /// input opened, how often, its length and the bytes read from it; a
289    /// file only listed (`-l`) is still an open; the bzip2 family counts
290    /// nothing.
291    #[test]
292    fn run_in_counted_counts_the_gzip_familys_inputs() {
293        let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
294        for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
295            let _ = std::fs::remove_dir_all(&dir);
296            std::fs::create_dir_all(&dir).unwrap();
297            std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
298            let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
299            let (code, _, err, reads) =
300                run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour).unwrap();
301            assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
302            let a = reads[&dir.join("a")];
303            assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
304            assert_eq!(reads.len(), 1, "{reads:?}");
305
306            let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
307            for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
308                let (code, _, err, reads) =
309                    run_in_counted(&dir, "gzip", args(mode), b"", flavour).unwrap();
310                assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
311                let gz = reads[&dir.join("a.gz")];
312                assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
313                assert_eq!(gz.len, len);
314                assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
315            }
316            let (_, _, _, reads) =
317                run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour).unwrap();
318            assert!(reads.is_empty(), "{reads:?}");
319        }
320        let _ = std::fs::remove_dir_all(&dir);
321    }
322
323    /// Output past the sandbox's cap fails as a full disk does: exit 1.
324    #[test]
325    fn run_in_caps_what_a_run_writes() {
326        let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
327        let _ = std::fs::remove_dir_all(&dir);
328        std::fs::create_dir_all(&dir).unwrap();
329        let big = vec![0u8; 9 * 1024 * 1024];
330        let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
331        assert_eq!(code, 0);
332        let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
333        let err = String::from_utf8_lossy(&err);
334        assert_eq!(code, 1, "{err}");
335        // A write the budget cannot hold fails whole, so the output stops
336        // within one buffered write (64 KiB) of the cap, never past it.
337        let cap = 8 * 1024 * 1024;
338        assert!(
339            out.len() <= cap && out.len() > cap - 64 * 1024,
340            "{}",
341            out.len()
342        );
343        assert!(err.contains("I/O or other error"), "{err}");
344        // `-t` writes nothing, but its decoding is charged to the same cap.
345        let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
346        let err = String::from_utf8_lossy(&err);
347        assert_eq!((code, out.len()), (1, 0), "{err}");
348        assert!(err.contains("I/O or other error"), "{err}");
349        let _ = std::fs::remove_dir_all(&dir);
350    }
351
352    #[test]
353    fn names_lists_the_table() {
354        assert_eq!(
355            names(),
356            [
357                "bzip2",
358                "bunzip2",
359                "bzcat",
360                "bzip2recover",
361                "gzip",
362                "gunzip",
363                "zcat",
364                "gzcat",
365                "xz",
366                "unxz",
367                "xzcat",
368                "lzma",
369                "unlzma",
370                "lzcat"
371            ]
372        );
373    }
374
375    #[test]
376    fn copy_metadata_carries_mtime_and_permissions() {
377        let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
378        std::fs::create_dir_all(&dir).unwrap();
379        let (a, b) = (dir.join("a"), dir.join("b"));
380        std::fs::write(&a, b"x").unwrap();
381        std::fs::write(&b, b"y").unwrap();
382        let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
383        std::fs::File::options()
384            .write(true)
385            .open(&a)
386            .unwrap()
387            .set_modified(old)
388            .unwrap();
389        #[cfg(unix)]
390        {
391            use std::os::unix::fs::PermissionsExt;
392            std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
393        }
394        copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
395        let (ma, mb) = (
396            std::fs::metadata(&a).unwrap(),
397            std::fs::metadata(&b).unwrap(),
398        );
399        assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
400        assert_eq!(ma.permissions(), mb.permissions());
401        let _ = std::fs::remove_dir_all(&dir);
402    }
403}