use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
pub mod bzip2;
pub mod bzip2recover;
pub mod compress;
pub mod gzip;
mod host;
pub mod links;
pub mod lz4;
pub mod unzip;
pub mod xz;
pub mod zstd;
#[cfg(test)]
pub(crate) use host::ARM_TEST_LOCK;
#[doc(hidden)]
pub use host::InputReads;
#[cfg(unix)]
pub(crate) use host::hook_run_signals;
pub(crate) use host::{CleanupGuard, create_armed, with_run_signals_held};
#[cfg(all(test, unix))]
pub(crate) use host::{armed_path, disarm_inherited};
#[doc(hidden)]
pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Family {
Bzip2,
Gzip,
Xz,
Zstd,
Lz4,
Unzip,
Compress,
}
const NAMES: &[(&str, Family)] = &[
("bzip2", Family::Bzip2),
("bunzip2", Family::Bzip2),
("bzcat", Family::Bzip2),
("bzip2recover", Family::Bzip2),
("gzip", Family::Gzip),
("gunzip", Family::Gzip),
("zcat", Family::Gzip),
("gzcat", Family::Gzip),
("xz", Family::Xz),
("unxz", Family::Xz),
("xzcat", Family::Xz),
("lzma", Family::Xz),
("unlzma", Family::Xz),
("lzcat", Family::Xz),
("zstd", Family::Zstd),
("unzstd", Family::Zstd),
("zstdcat", Family::Zstd),
("zstdmt", Family::Zstd),
("lz4", Family::Lz4),
("unlz4", Family::Lz4),
("lz4cat", Family::Lz4),
("lz4c", Family::Lz4),
("unzip", Family::Unzip),
("zipinfo", Family::Unzip),
("funzip", Family::Unzip),
("compress", Family::Compress),
("uncompress", Family::Compress),
];
pub fn names() -> &'static [&'static str] {
static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
}
pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
let file = Path::new(argv0).file_name()?;
let file = file.to_str()?;
#[cfg(windows)]
let file = file.strip_suffix(".exe").unwrap_or(file);
NAMES
.iter()
.find(|(n, _)| *n == file)
.map(|&(n, fam)| (fam, n))
}
pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
#[cfg(all(unix, debug_assertions))]
if Path::new(args.first()?).file_name() == Some(OsStr::new("__compat-signal-test")) {
return Some(host::signal_test(&args[1..]));
}
let (family, name) = family_for(args.first()?)?;
let mut args = args.into_iter();
let argv0 = args.next().unwrap_or_default();
let rest = args.collect();
Some(match family {
Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
Family::Bzip2 => bzip2::run(name, rest),
Family::Gzip => gzip::run(name, rest),
Family::Xz => xz::run(name, &argv0, rest),
Family::Zstd => zstd::run(name, &argv0, rest),
Family::Lz4 => lz4::run(name, &argv0, rest),
Family::Unzip => unzip::run(name, &argv0, rest),
Family::Compress => compress::run(name, rest),
})
}
#[doc(hidden)]
pub fn run_in(
dir: &Path,
name: &str,
args: Vec<OsString>,
stdin: &[u8],
gzip_flavour: gzip::Flavour,
) -> Option<(i32, Vec<u8>, Vec<u8>)> {
run_in_counted(
dir,
name,
args,
stdin,
gzip_flavour,
unzip::default_flavour(),
compress::default_flavour(),
)
.map(|(code, out, err, _)| (code, out, err))
}
#[doc(hidden)]
pub fn run_in_counted(
dir: &Path,
name: &str,
args: Vec<OsString>,
stdin: &[u8],
gzip_flavour: gzip::Flavour,
unzip_flavour: unzip::Flavour,
compress_flavour: compress::Flavour,
) -> Option<CountedRun> {
let (family, canonical) = family_for(OsStr::new(name))?;
Some(match family {
Family::Bzip2 if canonical == "bzip2recover" => {
let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
(code, Vec::new(), err, BTreeMap::new())
}
Family::Bzip2 => {
let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
(code, out, err, BTreeMap::new())
}
Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
Family::Zstd => zstd::run_in_counted(dir, canonical, &args, stdin, &|_| None),
Family::Lz4 => lz4::run_in_counted(dir, canonical, &args, stdin, &|_| None),
Family::Unzip => {
unzip::run_in_counted(unzip_flavour, dir, canonical, &args, stdin, &|_| None)
}
Family::Compress => {
compress::run_in_counted(compress_flavour, dir, canonical, &args, stdin, &|_| None)
}
})
}
pub(crate) fn stdin_is_tty() -> bool {
io::stdin().is_terminal()
}
pub(crate) fn stdout_is_tty() -> bool {
io::stdout().is_terminal()
}
pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
copy_metadata_with_mtime(meta, to, None)
}
pub(crate) fn copy_metadata_with_mtime(
meta: &std::fs::Metadata,
to: &Path,
mtime: Option<std::time::SystemTime>,
) -> io::Result<()> {
let mut times = std::fs::FileTimes::new();
if let Ok(t) = meta.accessed() {
times = times.set_accessed(t);
}
if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
times = times.set_modified(t);
}
std::fs::File::options()
.write(true)
.open(to)?
.set_times(times)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
_ => {}
}
}
std::fs::set_permissions(to, meta.permissions())
}
#[cfg(test)]
mod tests {
use super::*;
const G: gzip::Flavour = gzip::Flavour::Gnu;
const U: unzip::Flavour = unzip::Flavour::Apple;
const C: compress::Flavour = compress::Flavour::Apple;
#[test]
fn flavour_var_rejects_other_values() {
let d = unzip::default_flavour();
assert_eq!(unzip::flavour_from(None), Ok(d));
assert_eq!(unzip::flavour_from(Some("".into())), Ok(d));
assert_eq!(
unzip::flavour_from(Some("apple".into())),
Ok(unzip::Flavour::Apple)
);
assert_eq!(
unzip::flavour_from(Some("ubuntu".into())),
Ok(unzip::Flavour::Ubuntu)
);
for bad in ["Apple", "UBUNTU", " apple", "debian", "bsd"] {
assert_eq!(unzip::flavour_from(Some(bad.into())), Err(bad.into()));
}
}
#[test]
fn run_in_counted_reaches_the_unzip_family() {
let dir = std::env::temp_dir();
let run = |name: &str, f: unzip::Flavour| {
let (code, out, err, reads) =
run_in_counted(&dir, name, vec!["-h".into()], b"", G, f, C).unwrap();
assert!(reads.is_empty(), "{reads:?}");
(code, String::from_utf8(out).unwrap(), err)
};
let (code, out, _) = run("unzip", unzip::Flavour::Apple);
assert_eq!(code, 0);
assert!(out.contains("Please report bugs to Apple."), "{out}");
let (_, out, _) = run("unzip", unzip::Flavour::Ubuntu);
assert!(!out.contains("Apple"), "{out}");
let (code, out, _) = run("zipinfo", U);
assert_eq!(code, 0);
assert!(
out.starts_with("ZipInfo 3.00 of 20 April 2009, by stuffr "),
"{out}"
);
let (code, out, _) = run("funzip", U);
assert!(unzip::EXIT_CODES_APPLE.contains(&code), "exit {code}");
assert!(!out.contains("fUnZip"), "{out}");
}
#[test]
fn run_in_counted_reaches_the_compress_family() {
struct Guard(std::path::PathBuf);
impl Drop for Guard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let dir = std::env::temp_dir().join(format!("stuffr-run-in-z-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let _guard = Guard(dir.clone());
std::fs::write(dir.join("a"), b"hello hello hello hello\n").unwrap();
for (c, usage) in [
(
compress::Flavour::Apple,
"uncompress: illegal option -- x\nusage: uncompress [-cfv] [-b bits] [file ...]\n",
),
(
compress::Flavour::Ncompress,
"Unknown flag: 'x'; Usage: uncompress [-dfhvcVr] [-b maxbits] [--] [path ...]\n",
),
] {
let (code, _, err, _) =
run_in_counted(&dir, "uncompress", vec!["-x".into()], b"", G, U, c).unwrap();
assert_eq!(code, 1, "{c:?}");
assert!(
String::from_utf8_lossy(&err).starts_with(usage),
"{c:?}: {}",
String::from_utf8_lossy(&err)
);
let (code, out, err, reads) = run_in_counted(
&dir,
"compress",
vec!["-c".into(), "a".into()],
b"",
G,
U,
c,
)
.unwrap();
assert_eq!(code, 0, "{c:?}: {}", String::from_utf8_lossy(&err));
assert!(out.starts_with(&[0x1f, 0x9d, 0x90]), "{c:?}");
assert_eq!(reads[&dir.join("a")].opens, 1, "{c:?}");
}
}
#[test]
fn fuzz_names_match_the_dispatch_table() {
assert_eq!(stuffr::testing::COMPAT_NAMES, names());
}
#[test]
fn compat_names_are_recognised_by_file_name_only() {
use std::ffi::OsStr;
assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
assert_eq!(
family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
Some("bunzip2")
);
assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
assert_eq!(
family_for(OsStr::new("bzip2recover")).map(|f| f.1),
Some("bzip2recover")
);
assert!(family_for(OsStr::new("stuffr")).is_none());
assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
assert!(family_for(OsStr::new("bzip2x")).is_none());
#[cfg(windows)]
assert_eq!(
family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
Some("bzip2")
);
}
#[test]
fn stuffr_itself_does_not_dispatch() {
assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
}
#[test]
fn dispatch_ignores_an_empty_argv() {
assert!(dispatch(Vec::new()).is_none());
}
#[cfg(unix)]
#[test]
fn dispatch_ignores_a_non_utf8_argv0() {
use std::os::unix::ffi::OsStrExt;
let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
assert!(dispatch(vec![bad]).is_none());
let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
assert!(dispatch(vec![bad]).is_none());
}
#[test]
fn run_in_works_inside_its_directory() {
let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
let (code, out, err) =
run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
assert_eq!(
(code, out.len()),
(0, 0),
"{}",
String::from_utf8_lossy(&err)
);
let packed = std::fs::read(dir.join("a.bz2")).unwrap();
assert!(packed.starts_with(b"BZh9"));
let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
assert_eq!(
(code, out.len()),
(0, 0),
"{}",
String::from_utf8_lossy(&err)
);
assert!(dir.join("rec00001a.bz2").exists());
let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_in_counted_counts_the_gzip_familys_inputs() {
let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
let (code, _, err, reads) =
run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour, U, C).unwrap();
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
let a = reads[&dir.join("a")];
assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
assert_eq!(reads.len(), 1, "{reads:?}");
let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
let (code, _, err, reads) =
run_in_counted(&dir, "gzip", args(mode), b"", flavour, U, C).unwrap();
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
let gz = reads[&dir.join("a.gz")];
assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
assert_eq!(gz.len, len);
assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
}
let (_, _, _, reads) =
run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour, U, C).unwrap();
assert!(reads.is_empty(), "{reads:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_in_counted_counts_the_zstd_familys_inputs() {
let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
let run = |a: &[&str]| {
let (code, _, err, reads) =
run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu, U, C).unwrap();
assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
reads
};
std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
std::fs::write(dir.join("l"), b"a\n").unwrap();
let reads = run(&["-q", "-k", "a"]);
assert_eq!(
reads[&dir.join("a")],
InputReads {
opens: 1,
bytes: 18,
len: 18,
..Default::default()
}
);
let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
let dict = reads[&dir.join("d.dict")];
assert_eq!(
dict,
InputReads {
opens: 1,
bytes: 17,
len: 17,
..Default::default()
}
);
assert_eq!(reads[&dir.join("a.zst")].len, zst);
let reads = run(&["-c", "--filelist=l"]);
assert_eq!(
reads[&dir.join("l")],
InputReads {
opens: 1,
bytes: 2,
len: 2,
..Default::default()
}
);
assert_eq!(reads[&dir.join("a")].opens, 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_in_caps_what_a_run_writes() {
let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let big = vec![0u8; 9 * 1024 * 1024];
let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
assert_eq!(code, 0);
let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
let err = String::from_utf8_lossy(&err);
assert_eq!(code, 1, "{err}");
let cap = 8 * 1024 * 1024;
assert!(
out.len() <= cap && out.len() > cap - 64 * 1024,
"{}",
out.len()
);
assert!(err.contains("I/O or other error"), "{err}");
let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
let err = String::from_utf8_lossy(&err);
assert_eq!((code, out.len()), (1, 0), "{err}");
assert!(err.contains("I/O or other error"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn names_lists_the_table() {
assert_eq!(
names(),
[
"bzip2",
"bunzip2",
"bzcat",
"bzip2recover",
"gzip",
"gunzip",
"zcat",
"gzcat",
"xz",
"unxz",
"xzcat",
"lzma",
"unlzma",
"lzcat",
"zstd",
"unzstd",
"zstdcat",
"zstdmt",
"lz4",
"unlz4",
"lz4cat",
"lz4c",
"unzip",
"zipinfo",
"funzip",
"compress",
"uncompress"
]
);
}
#[test]
fn copy_metadata_carries_mtime_and_permissions() {
let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a"), dir.join("b"));
std::fs::write(&a, b"x").unwrap();
std::fs::write(&b, b"y").unwrap();
let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
std::fs::File::options()
.write(true)
.open(&a)
.unwrap()
.set_modified(old)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
}
copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
let (ma, mb) = (
std::fs::metadata(&a).unwrap(),
std::fs::metadata(&b).unwrap(),
);
assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
assert_eq!(ma.permissions(), mb.permissions());
let _ = std::fs::remove_dir_all(&dir);
}
}