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