1use 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 compress;
14pub mod gzip;
15mod host;
16pub mod links;
17pub mod lz4;
18pub mod unzip;
19pub mod xz;
20pub mod zstd;
21
22#[cfg(test)]
23pub(crate) use host::ARM_TEST_LOCK;
24#[doc(hidden)]
25pub use host::InputReads;
26#[cfg(unix)]
29pub(crate) use host::hook_run_signals;
30pub(crate) use host::{CleanupGuard, create_armed, with_run_signals_held};
32#[cfg(all(test, unix))]
33pub(crate) use host::{armed_path, disarm_inherited};
34
35#[doc(hidden)]
38pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum Family {
44 Bzip2,
46 Gzip,
49 Xz,
51 Zstd,
53 Lz4,
55 Unzip,
58 Compress,
61}
62
63const NAMES: &[(&str, Family)] = &[
66 ("bzip2", Family::Bzip2),
67 ("bunzip2", Family::Bzip2),
68 ("bzcat", Family::Bzip2),
69 ("bzip2recover", Family::Bzip2),
70 ("gzip", Family::Gzip),
71 ("gunzip", Family::Gzip),
72 ("zcat", Family::Gzip),
73 ("gzcat", Family::Gzip),
74 ("xz", Family::Xz),
75 ("unxz", Family::Xz),
76 ("xzcat", Family::Xz),
77 ("lzma", Family::Xz),
78 ("unlzma", Family::Xz),
79 ("lzcat", Family::Xz),
80 ("zstd", Family::Zstd),
81 ("unzstd", Family::Zstd),
82 ("zstdcat", Family::Zstd),
83 ("zstdmt", Family::Zstd),
84 ("lz4", Family::Lz4),
85 ("unlz4", Family::Lz4),
86 ("lz4cat", Family::Lz4),
87 ("lz4c", Family::Lz4),
88 ("unzip", Family::Unzip),
89 ("zipinfo", Family::Unzip),
90 ("funzip", Family::Unzip),
91 ("compress", Family::Compress),
92 ("uncompress", Family::Compress),
93];
94
95pub fn names() -> &'static [&'static str] {
97 static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
98 LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
99}
100
101pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
105 let file = Path::new(argv0).file_name()?;
106 let file = file.to_str()?;
107 #[cfg(windows)]
108 let file = file.strip_suffix(".exe").unwrap_or(file);
109 NAMES
110 .iter()
111 .find(|(n, _)| *n == file)
112 .map(|&(n, fam)| (fam, n))
113}
114
115pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
120 #[cfg(all(unix, debug_assertions))]
123 if Path::new(args.first()?).file_name() == Some(OsStr::new("__compat-signal-test")) {
124 return Some(host::signal_test(&args[1..]));
125 }
126 let (family, name) = family_for(args.first()?)?;
127 let mut args = args.into_iter();
128 let argv0 = args.next().unwrap_or_default();
129 let rest = args.collect();
130 Some(match family {
131 Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
133 Family::Bzip2 => bzip2::run(name, rest),
134 Family::Gzip => gzip::run(name, rest),
135 Family::Xz => xz::run(name, &argv0, rest),
136 Family::Zstd => zstd::run(name, &argv0, rest),
137 Family::Lz4 => lz4::run(name, &argv0, rest),
138 Family::Unzip => unzip::run(name, &argv0, rest),
139 Family::Compress => compress::run(name, rest),
140 })
141}
142
143#[doc(hidden)]
155pub fn run_in(
156 dir: &Path,
157 name: &str,
158 args: Vec<OsString>,
159 stdin: &[u8],
160 gzip_flavour: gzip::Flavour,
161) -> Option<(i32, Vec<u8>, Vec<u8>)> {
162 run_in_counted(
163 dir,
164 name,
165 args,
166 stdin,
167 gzip_flavour,
168 unzip::default_flavour(),
169 compress::default_flavour(),
170 )
171 .map(|(code, out, err, _)| (code, out, err))
172}
173
174#[doc(hidden)]
185pub fn run_in_counted(
186 dir: &Path,
187 name: &str,
188 args: Vec<OsString>,
189 stdin: &[u8],
190 gzip_flavour: gzip::Flavour,
191 unzip_flavour: unzip::Flavour,
192 compress_flavour: compress::Flavour,
193) -> Option<CountedRun> {
194 let (family, canonical) = family_for(OsStr::new(name))?;
195 Some(match family {
196 Family::Bzip2 if canonical == "bzip2recover" => {
197 let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
198 (code, Vec::new(), err, BTreeMap::new())
199 }
200 Family::Bzip2 => {
201 let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
202 (code, out, err, BTreeMap::new())
203 }
204 Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
205 Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
206 Family::Zstd => zstd::run_in_counted(dir, canonical, &args, stdin, &|_| None),
207 Family::Lz4 => lz4::run_in_counted(dir, canonical, &args, stdin, &|_| None),
208 Family::Unzip => {
209 unzip::run_in_counted(unzip_flavour, dir, canonical, &args, stdin, &|_| None)
210 }
211 Family::Compress => {
212 compress::run_in_counted(compress_flavour, dir, canonical, &args, stdin, &|_| None)
213 }
214 })
215}
216
217pub(crate) fn stdin_is_tty() -> bool {
219 io::stdin().is_terminal()
220}
221
222pub(crate) fn stdout_is_tty() -> bool {
224 io::stdout().is_terminal()
225}
226
227pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
236 copy_metadata_with_mtime(meta, to, None)
237}
238
239pub(crate) fn copy_metadata_with_mtime(
242 meta: &std::fs::Metadata,
243 to: &Path,
244 mtime: Option<std::time::SystemTime>,
245) -> io::Result<()> {
246 let mut times = std::fs::FileTimes::new();
247 if let Ok(t) = meta.accessed() {
248 times = times.set_accessed(t);
249 }
250 if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
251 times = times.set_modified(t);
252 }
253 std::fs::File::options()
254 .write(true)
255 .open(to)?
256 .set_times(times)?;
257 #[cfg(unix)]
258 {
259 use std::os::unix::fs::MetadataExt;
260 match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
261 Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
262 _ => {}
263 }
264 }
265 std::fs::set_permissions(to, meta.permissions())
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 const G: gzip::Flavour = gzip::Flavour::Gnu;
275
276 const U: unzip::Flavour = unzip::Flavour::Apple;
279
280 const C: compress::Flavour = compress::Flavour::Apple;
283
284 #[test]
287 fn flavour_var_rejects_other_values() {
288 let d = unzip::default_flavour();
289 assert_eq!(unzip::flavour_from(None), Ok(d));
290 assert_eq!(unzip::flavour_from(Some("".into())), Ok(d));
291 assert_eq!(
292 unzip::flavour_from(Some("apple".into())),
293 Ok(unzip::Flavour::Apple)
294 );
295 assert_eq!(
296 unzip::flavour_from(Some("ubuntu".into())),
297 Ok(unzip::Flavour::Ubuntu)
298 );
299 for bad in ["Apple", "UBUNTU", " apple", "debian", "bsd"] {
300 assert_eq!(unzip::flavour_from(Some(bad.into())), Err(bad.into()));
301 }
302 }
303
304 #[test]
307 fn run_in_counted_reaches_the_unzip_family() {
308 let dir = std::env::temp_dir();
309 let run = |name: &str, f: unzip::Flavour| {
310 let (code, out, err, reads) =
311 run_in_counted(&dir, name, vec!["-h".into()], b"", G, f, C).unwrap();
312 assert!(reads.is_empty(), "{reads:?}");
313 (code, String::from_utf8(out).unwrap(), err)
314 };
315 let (code, out, _) = run("unzip", unzip::Flavour::Apple);
316 assert_eq!(code, 0);
317 assert!(out.contains("Please report bugs to Apple."), "{out}");
318 let (_, out, _) = run("unzip", unzip::Flavour::Ubuntu);
319 assert!(!out.contains("Apple"), "{out}");
320 let (code, out, _) = run("zipinfo", U);
321 assert_eq!(code, 0);
322 assert!(
323 out.starts_with("ZipInfo 3.00 of 20 April 2009, by stuffr "),
324 "{out}"
325 );
326 let (code, out, _) = run("funzip", U);
329 assert!(unzip::EXIT_CODES_APPLE.contains(&code), "exit {code}");
330 assert!(!out.contains("fUnZip"), "{out}");
331 }
332
333 #[test]
336 fn run_in_counted_reaches_the_compress_family() {
337 struct Guard(std::path::PathBuf);
339 impl Drop for Guard {
340 fn drop(&mut self) {
341 let _ = std::fs::remove_dir_all(&self.0);
342 }
343 }
344 let dir = std::env::temp_dir().join(format!("stuffr-run-in-z-{}", std::process::id()));
345 let _ = std::fs::remove_dir_all(&dir);
346 std::fs::create_dir_all(&dir).unwrap();
347 let _guard = Guard(dir.clone());
348 std::fs::write(dir.join("a"), b"hello hello hello hello\n").unwrap();
349 for (c, usage) in [
350 (
351 compress::Flavour::Apple,
352 "uncompress: illegal option -- x\nusage: uncompress [-cfv] [-b bits] [file ...]\n",
353 ),
354 (
355 compress::Flavour::Ncompress,
356 "Unknown flag: 'x'; Usage: uncompress [-dfhvcVr] [-b maxbits] [--] [path ...]\n",
357 ),
358 ] {
359 let (code, _, err, _) =
360 run_in_counted(&dir, "uncompress", vec!["-x".into()], b"", G, U, c).unwrap();
361 assert_eq!(code, 1, "{c:?}");
362 assert!(
363 String::from_utf8_lossy(&err).starts_with(usage),
364 "{c:?}: {}",
365 String::from_utf8_lossy(&err)
366 );
367 let (code, out, err, reads) = run_in_counted(
368 &dir,
369 "compress",
370 vec!["-c".into(), "a".into()],
371 b"",
372 G,
373 U,
374 c,
375 )
376 .unwrap();
377 assert_eq!(code, 0, "{c:?}: {}", String::from_utf8_lossy(&err));
378 assert!(out.starts_with(&[0x1f, 0x9d, 0x90]), "{c:?}");
379 assert_eq!(reads[&dir.join("a")].opens, 1, "{c:?}");
380 }
381 }
382
383 #[test]
386 fn fuzz_names_match_the_dispatch_table() {
387 assert_eq!(stuffr::testing::COMPAT_NAMES, names());
388 }
389
390 #[test]
391 fn compat_names_are_recognised_by_file_name_only() {
392 use std::ffi::OsStr;
393 assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
394 assert_eq!(
395 family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
396 Some("bunzip2")
397 );
398 assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
399 assert_eq!(
400 family_for(OsStr::new("bzip2recover")).map(|f| f.1),
401 Some("bzip2recover")
402 );
403 assert!(family_for(OsStr::new("stuffr")).is_none());
404 assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
405 assert!(family_for(OsStr::new("bzip2x")).is_none());
406 #[cfg(windows)]
407 assert_eq!(
408 family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
409 Some("bzip2")
410 );
411 }
412
413 #[test]
414 fn stuffr_itself_does_not_dispatch() {
415 assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
416 }
417
418 #[test]
419 fn dispatch_ignores_an_empty_argv() {
420 assert!(dispatch(Vec::new()).is_none());
421 }
422
423 #[cfg(unix)]
424 #[test]
425 fn dispatch_ignores_a_non_utf8_argv0() {
426 use std::os::unix::ffi::OsStrExt;
427 let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
428 assert!(dispatch(vec![bad]).is_none());
429 let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
430 assert!(dispatch(vec![bad]).is_none());
431 }
432
433 #[test]
436 fn run_in_works_inside_its_directory() {
437 let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
438 let _ = std::fs::remove_dir_all(&dir);
439 std::fs::create_dir_all(&dir).unwrap();
440 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
441 let (code, out, err) =
442 run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
443 assert_eq!(
444 (code, out.len()),
445 (0, 0),
446 "{}",
447 String::from_utf8_lossy(&err)
448 );
449 let packed = std::fs::read(dir.join("a.bz2")).unwrap();
450 assert!(packed.starts_with(b"BZh9"));
451 let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
452 assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
453 let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
454 assert_eq!(
455 (code, out.len()),
456 (0, 0),
457 "{}",
458 String::from_utf8_lossy(&err)
459 );
460 assert!(dir.join("rec00001a.bz2").exists());
461 let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
463 assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
464 assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
465 let _ = std::fs::remove_dir_all(&dir);
466 }
467
468 #[test]
473 fn run_in_counted_counts_the_gzip_familys_inputs() {
474 let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
475 for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
476 let _ = std::fs::remove_dir_all(&dir);
477 std::fs::create_dir_all(&dir).unwrap();
478 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
479 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
480 let (code, _, err, reads) =
481 run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour, U, C).unwrap();
482 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
483 let a = reads[&dir.join("a")];
484 assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
485 assert_eq!(reads.len(), 1, "{reads:?}");
486
487 let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
488 for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
489 let (code, _, err, reads) =
490 run_in_counted(&dir, "gzip", args(mode), b"", flavour, U, C).unwrap();
491 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
492 let gz = reads[&dir.join("a.gz")];
493 assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
494 assert_eq!(gz.len, len);
495 assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
496 }
497 let (_, _, _, reads) =
498 run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour, U, C).unwrap();
499 assert!(reads.is_empty(), "{reads:?}");
500 }
501 let _ = std::fs::remove_dir_all(&dir);
502 }
503
504 #[test]
508 fn run_in_counted_counts_the_zstd_familys_inputs() {
509 let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
510 let _ = std::fs::remove_dir_all(&dir);
511 std::fs::create_dir_all(&dir).unwrap();
512 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
513 let run = |a: &[&str]| {
514 let (code, _, err, reads) =
515 run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu, U, C).unwrap();
516 assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
517 reads
518 };
519 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
520 std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
521 std::fs::write(dir.join("l"), b"a\n").unwrap();
522
523 let reads = run(&["-q", "-k", "a"]);
524 assert_eq!(
525 reads[&dir.join("a")],
526 InputReads {
527 opens: 1,
528 bytes: 18,
529 len: 18
530 }
531 );
532 let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
533
534 let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
535 let dict = reads[&dir.join("d.dict")];
536 assert_eq!(
537 dict,
538 InputReads {
539 opens: 1,
540 bytes: 17,
541 len: 17
542 }
543 );
544 assert_eq!(reads[&dir.join("a.zst")].len, zst);
545
546 let reads = run(&["-c", "--filelist=l"]);
547 assert_eq!(
548 reads[&dir.join("l")],
549 InputReads {
550 opens: 1,
551 bytes: 2,
552 len: 2
553 }
554 );
555 assert_eq!(reads[&dir.join("a")].opens, 1);
556 let _ = std::fs::remove_dir_all(&dir);
557 }
558
559 #[test]
561 fn run_in_caps_what_a_run_writes() {
562 let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
563 let _ = std::fs::remove_dir_all(&dir);
564 std::fs::create_dir_all(&dir).unwrap();
565 let big = vec![0u8; 9 * 1024 * 1024];
566 let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
567 assert_eq!(code, 0);
568 let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
569 let err = String::from_utf8_lossy(&err);
570 assert_eq!(code, 1, "{err}");
571 let cap = 8 * 1024 * 1024;
574 assert!(
575 out.len() <= cap && out.len() > cap - 64 * 1024,
576 "{}",
577 out.len()
578 );
579 assert!(err.contains("I/O or other error"), "{err}");
580 let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
582 let err = String::from_utf8_lossy(&err);
583 assert_eq!((code, out.len()), (1, 0), "{err}");
584 assert!(err.contains("I/O or other error"), "{err}");
585 let _ = std::fs::remove_dir_all(&dir);
586 }
587
588 #[test]
589 fn names_lists_the_table() {
590 assert_eq!(
591 names(),
592 [
593 "bzip2",
594 "bunzip2",
595 "bzcat",
596 "bzip2recover",
597 "gzip",
598 "gunzip",
599 "zcat",
600 "gzcat",
601 "xz",
602 "unxz",
603 "xzcat",
604 "lzma",
605 "unlzma",
606 "lzcat",
607 "zstd",
608 "unzstd",
609 "zstdcat",
610 "zstdmt",
611 "lz4",
612 "unlz4",
613 "lz4cat",
614 "lz4c",
615 "unzip",
616 "zipinfo",
617 "funzip",
618 "compress",
619 "uncompress"
620 ]
621 );
622 }
623
624 #[test]
625 fn copy_metadata_carries_mtime_and_permissions() {
626 let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
627 std::fs::create_dir_all(&dir).unwrap();
628 let (a, b) = (dir.join("a"), dir.join("b"));
629 std::fs::write(&a, b"x").unwrap();
630 std::fs::write(&b, b"y").unwrap();
631 let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
632 std::fs::File::options()
633 .write(true)
634 .open(&a)
635 .unwrap()
636 .set_modified(old)
637 .unwrap();
638 #[cfg(unix)]
639 {
640 use std::os::unix::fs::PermissionsExt;
641 std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
642 }
643 copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
644 let (ma, mb) = (
645 std::fs::metadata(&a).unwrap(),
646 std::fs::metadata(&b).unwrap(),
647 );
648 assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
649 assert_eq!(ma.permissions(), mb.permissions());
650 let _ = std::fs::remove_dir_all(&dir);
651 }
652}