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::ffi::{OsStr, OsString};
6use std::io::{self, IsTerminal};
7use std::path::Path;
8use std::process::ExitCode;
9
10pub mod bzip2;
11pub mod bzip2recover;
12pub mod links;
13
14/// A family of tools the binary can impersonate.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum Family {
18    /// `bzip2`, `bunzip2`, `bzcat`.
19    Bzip2,
20}
21
22/// Invoked name to family. The name is matched exactly, after the directory
23/// is stripped.
24const NAMES: &[(&str, Family)] = &[
25    ("bzip2", Family::Bzip2),
26    ("bunzip2", Family::Bzip2),
27    ("bzcat", Family::Bzip2),
28    ("bzip2recover", Family::Bzip2),
29];
30
31/// Every compatibility name, from the one table `family_for` also reads.
32pub fn names() -> &'static [&'static str] {
33    static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
34    LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
35}
36
37/// The family and canonical name for an `argv[0]`, or `None` when it is not a
38/// compatibility name. Only the final path component counts, and on Windows a
39/// trailing `.exe` is ignored.
40pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
41    let file = Path::new(argv0).file_name()?;
42    let file = file.to_str()?;
43    #[cfg(windows)]
44    let file = file.strip_suffix(".exe").unwrap_or(file);
45    NAMES
46        .iter()
47        .find(|(n, _)| *n == file)
48        .map(|&(n, fam)| (fam, n))
49}
50
51/// Run the compatibility tool `args[0]` names, or return `None` when it names
52/// none (the caller then behaves as `stuffr`). The tool gets the arguments
53/// after `args[0]`; `bzip2recover`, which prints its `argv[0]` verbatim,
54/// gets `args[0]` as well.
55pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
56    let (family, name) = family_for(args.first()?)?;
57    let mut args = args.into_iter();
58    let argv0 = args.next().unwrap_or_default();
59    let rest = args.collect();
60    Some(match family {
61        // bzip2recover prints its argv[0] in full, so it gets it.
62        Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
63        Family::Bzip2 => bzip2::run(name, rest),
64    })
65}
66
67/// Run the compatibility tool `name` names in-process, without touching the
68/// process: operands resolve against `dir`, `stdin` stands in for standard
69/// input, and the exit code, standard output and standard error come back.
70/// Neither stream is a terminal, the environment is not read and `SIGPIPE`
71/// keeps its disposition. `None` when `name` is not a compatibility name.
72/// Same code as [`dispatch`] below the process boundary; for the fuzz
73/// target, not a stable interface.
74#[doc(hidden)]
75pub fn run_in(
76    dir: &Path,
77    name: &str,
78    args: Vec<OsString>,
79    stdin: &[u8],
80) -> Option<(i32, Vec<u8>, Vec<u8>)> {
81    let (family, canonical) = family_for(OsStr::new(name))?;
82    Some(match family {
83        Family::Bzip2 if canonical == "bzip2recover" => {
84            let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
85            (code, Vec::new(), err)
86        }
87        Family::Bzip2 => bzip2::run_in(dir, canonical, &args, stdin),
88    })
89}
90
91/// Whether stdin is a terminal.
92pub(crate) fn stdin_is_tty() -> bool {
93    io::stdin().is_terminal()
94}
95
96/// Whether stdout is a terminal.
97pub(crate) fn stdout_is_tty() -> bool {
98    io::stdout().is_terminal()
99}
100
101/// Copy the access and modification times, permission bits and (where
102/// permitted) owner in `meta` onto `to`. A refused `chown` (`EPERM`) is
103/// ignored, as the reference tools do; every other failure is returned.
104///
105/// Takes metadata rather than a path because bzip2 saves its input's
106/// metadata before reading it, so the access time it copies is the one from
107/// before the read. `to` is opened for writing to set the times, so call
108/// this before restricting `to`'s permissions any further.
109pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
110    let mut times = std::fs::FileTimes::new();
111    if let Ok(t) = meta.accessed() {
112        times = times.set_accessed(t);
113    }
114    if let Ok(t) = meta.modified() {
115        times = times.set_modified(t);
116    }
117    std::fs::File::options()
118        .write(true)
119        .open(to)?
120        .set_times(times)?;
121    #[cfg(unix)]
122    {
123        use std::os::unix::fs::MetadataExt;
124        match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
125            Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
126            _ => {}
127        }
128    }
129    // After `chown`, which may clear set-id bits.
130    std::fs::set_permissions(to, meta.permissions())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// The `compat` fuzz target picks its tool from `COMPAT_NAMES`; a name
138    /// added here and not there would go unfuzzed.
139    #[test]
140    fn fuzz_names_match_the_dispatch_table() {
141        assert_eq!(stuffr::testing::COMPAT_NAMES, names());
142    }
143
144    #[test]
145    fn compat_names_are_recognised_by_file_name_only() {
146        use std::ffi::OsStr;
147        assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
148        assert_eq!(
149            family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
150            Some("bunzip2")
151        );
152        assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
153        assert_eq!(
154            family_for(OsStr::new("bzip2recover")).map(|f| f.1),
155            Some("bzip2recover")
156        );
157        assert!(family_for(OsStr::new("stuffr")).is_none());
158        assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
159        assert!(family_for(OsStr::new("bzip2x")).is_none());
160        #[cfg(windows)]
161        assert_eq!(
162            family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
163            Some("bzip2")
164        );
165    }
166
167    #[test]
168    fn stuffr_itself_does_not_dispatch() {
169        assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
170    }
171
172    #[test]
173    fn dispatch_ignores_an_empty_argv() {
174        assert!(dispatch(Vec::new()).is_none());
175    }
176
177    #[cfg(unix)]
178    #[test]
179    fn dispatch_ignores_a_non_utf8_argv0() {
180        use std::os::unix::ffi::OsStrExt;
181        let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
182        assert!(dispatch(vec![bad]).is_none());
183        let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
184        assert!(dispatch(vec![bad]).is_none());
185    }
186
187    /// `run_in` reaches both families, resolves operands against its
188    /// directory and captures standard output.
189    #[test]
190    fn run_in_works_inside_its_directory() {
191        let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
192        let _ = std::fs::remove_dir_all(&dir);
193        std::fs::create_dir_all(&dir).unwrap();
194        std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
195        let (code, out, err) = run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"").unwrap();
196        assert_eq!(
197            (code, out.len()),
198            (0, 0),
199            "{}",
200            String::from_utf8_lossy(&err)
201        );
202        let packed = std::fs::read(dir.join("a.bz2")).unwrap();
203        assert!(packed.starts_with(b"BZh9"));
204        let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed).unwrap();
205        assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
206        let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"").unwrap();
207        assert_eq!(
208            (code, out.len()),
209            (0, 0),
210            "{}",
211            String::from_utf8_lossy(&err)
212        );
213        assert!(dir.join("rec00001a.bz2").exists());
214        // A damaged stream from stdin: exit 2, stdin is never a terminal.
215        let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage").unwrap();
216        assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
217        assert!(run_in(&dir, "stuffr", vec![], b"").is_none());
218        let _ = std::fs::remove_dir_all(&dir);
219    }
220
221    /// Output past the sandbox's cap fails as a full disk does: exit 1.
222    #[test]
223    fn run_in_caps_what_a_run_writes() {
224        let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
225        let _ = std::fs::remove_dir_all(&dir);
226        std::fs::create_dir_all(&dir).unwrap();
227        let big = vec![0u8; 9 * 1024 * 1024];
228        let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big).unwrap();
229        assert_eq!(code, 0);
230        let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed).unwrap();
231        let err = String::from_utf8_lossy(&err);
232        assert_eq!(code, 1, "{err}");
233        // A write the budget cannot hold fails whole, so the output stops
234        // within one buffered write (64 KiB) of the cap, never past it.
235        let cap = 8 * 1024 * 1024;
236        assert!(
237            out.len() <= cap && out.len() > cap - 64 * 1024,
238            "{}",
239            out.len()
240        );
241        assert!(err.contains("I/O or other error"), "{err}");
242        // `-t` writes nothing, but its decoding is charged to the same cap.
243        let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed).unwrap();
244        let err = String::from_utf8_lossy(&err);
245        assert_eq!((code, out.len()), (1, 0), "{err}");
246        assert!(err.contains("I/O or other error"), "{err}");
247        let _ = std::fs::remove_dir_all(&dir);
248    }
249
250    #[test]
251    fn names_lists_the_table() {
252        assert_eq!(names(), ["bzip2", "bunzip2", "bzcat", "bzip2recover"]);
253    }
254
255    #[test]
256    fn copy_metadata_carries_mtime_and_permissions() {
257        let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
258        std::fs::create_dir_all(&dir).unwrap();
259        let (a, b) = (dir.join("a"), dir.join("b"));
260        std::fs::write(&a, b"x").unwrap();
261        std::fs::write(&b, b"y").unwrap();
262        let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
263        std::fs::File::options()
264            .write(true)
265            .open(&a)
266            .unwrap()
267            .set_modified(old)
268            .unwrap();
269        #[cfg(unix)]
270        {
271            use std::os::unix::fs::PermissionsExt;
272            std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
273        }
274        copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
275        let (ma, mb) = (
276            std::fs::metadata(&a).unwrap(),
277            std::fs::metadata(&b).unwrap(),
278        );
279        assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
280        assert_eq!(ma.permissions(), mb.permissions());
281        let _ = std::fs::remove_dir_all(&dir);
282    }
283}