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 zip;
21pub mod zstd;
22
23#[cfg(test)]
24pub(crate) use host::ARM_TEST_LOCK;
25#[doc(hidden)]
26pub use host::InputReads;
27#[cfg(unix)]
30pub(crate) use host::hook_run_signals;
31pub(crate) use host::{CleanupGuard, create_armed, with_run_signals_held};
33#[cfg(all(test, unix))]
34pub(crate) use host::{armed_path, disarm_inherited};
35
36#[doc(hidden)]
39pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum Family {
45 Bzip2,
47 Gzip,
50 Xz,
52 Zstd,
54 Lz4,
56 Unzip,
59 Compress,
62 Zip,
65}
66
67const NAMES: &[(&str, Family)] = &[
70 ("bzip2", Family::Bzip2),
71 ("bunzip2", Family::Bzip2),
72 ("bzcat", Family::Bzip2),
73 ("bzip2recover", Family::Bzip2),
74 ("gzip", Family::Gzip),
75 ("gunzip", Family::Gzip),
76 ("zcat", Family::Gzip),
77 ("gzcat", Family::Gzip),
78 ("xz", Family::Xz),
79 ("unxz", Family::Xz),
80 ("xzcat", Family::Xz),
81 ("lzma", Family::Xz),
82 ("unlzma", Family::Xz),
83 ("lzcat", Family::Xz),
84 ("zstd", Family::Zstd),
85 ("unzstd", Family::Zstd),
86 ("zstdcat", Family::Zstd),
87 ("zstdmt", Family::Zstd),
88 ("lz4", Family::Lz4),
89 ("unlz4", Family::Lz4),
90 ("lz4cat", Family::Lz4),
91 ("lz4c", Family::Lz4),
92 ("unzip", Family::Unzip),
93 ("zipinfo", Family::Unzip),
94 ("funzip", Family::Unzip),
95 ("compress", Family::Compress),
96 ("uncompress", Family::Compress),
97 ("zip", Family::Zip),
98 ("zipcloak", Family::Zip),
99 ("zipnote", Family::Zip),
100 ("zipsplit", Family::Zip),
101];
102
103pub fn names() -> &'static [&'static str] {
105 static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
106 LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
107}
108
109pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
113 let file = Path::new(argv0).file_name()?;
114 let file = file.to_str()?;
115 #[cfg(windows)]
116 let file = file.strip_suffix(".exe").unwrap_or(file);
117 NAMES
118 .iter()
119 .find(|(n, _)| *n == file)
120 .map(|&(n, fam)| (fam, n))
121}
122
123pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
128 #[cfg(all(unix, debug_assertions))]
131 if Path::new(args.first()?).file_name() == Some(OsStr::new("__compat-signal-test")) {
132 return Some(host::signal_test(&args[1..]));
133 }
134 let (family, name) = family_for(args.first()?)?;
135 let mut args = args.into_iter();
136 let argv0 = args.next().unwrap_or_default();
137 let rest = args.collect();
138 Some(match family {
139 Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
141 Family::Bzip2 => bzip2::run(name, rest),
142 Family::Gzip => gzip::run(name, rest),
143 Family::Xz => xz::run(name, &argv0, rest),
144 Family::Zstd => zstd::run(name, &argv0, rest),
145 Family::Lz4 => lz4::run(name, &argv0, rest),
146 Family::Unzip => unzip::run(name, &argv0, rest),
147 Family::Compress => compress::run(name, rest),
148 Family::Zip => zip::run(name, &argv0, rest),
149 })
150}
151
152#[doc(hidden)]
165pub fn run_in(
166 dir: &Path,
167 name: &str,
168 args: Vec<OsString>,
169 stdin: &[u8],
170 gzip_flavour: gzip::Flavour,
171) -> Option<(i32, Vec<u8>, Vec<u8>)> {
172 run_in_counted(
173 dir,
174 name,
175 args,
176 stdin,
177 gzip_flavour,
178 unzip::default_flavour(),
179 compress::default_flavour(),
180 zip::default_flavour(),
181 )
182 .map(|(code, out, err, _)| (code, out, err))
183}
184
185#[doc(hidden)]
197#[allow(clippy::too_many_arguments)]
198pub fn run_in_counted(
199 dir: &Path,
200 name: &str,
201 args: Vec<OsString>,
202 stdin: &[u8],
203 gzip_flavour: gzip::Flavour,
204 unzip_flavour: unzip::Flavour,
205 compress_flavour: compress::Flavour,
206 zip_flavour: zip::Flavour,
207) -> Option<CountedRun> {
208 let (family, canonical) = family_for(OsStr::new(name))?;
209 Some(match family {
210 Family::Bzip2 if canonical == "bzip2recover" => {
211 let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
212 (code, Vec::new(), err, BTreeMap::new())
213 }
214 Family::Bzip2 => {
215 let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
216 (code, out, err, BTreeMap::new())
217 }
218 Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
219 Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
220 Family::Zstd => zstd::run_in_counted(dir, canonical, &args, stdin, &|_| None),
221 Family::Lz4 => lz4::run_in_counted(dir, canonical, &args, stdin, &|_| None),
222 Family::Unzip => {
223 unzip::run_in_counted(unzip_flavour, dir, canonical, &args, stdin, &|_| None)
224 }
225 Family::Compress => {
226 compress::run_in_counted(compress_flavour, dir, canonical, &args, stdin, &|_| None)
227 }
228 Family::Zip => zip::run_in_counted(zip_flavour, dir, canonical, &args, stdin),
229 })
230}
231
232#[doc(hidden)]
238pub fn sandbox_name(b: &[u8]) -> Vec<u8> {
239 host::confine_name(b)
240}
241
242pub(crate) fn stdin_is_tty() -> bool {
244 io::stdin().is_terminal()
245}
246
247pub(crate) fn stdout_is_tty() -> bool {
249 io::stdout().is_terminal()
250}
251
252pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
261 copy_metadata_with_mtime(meta, to, None)
262}
263
264pub(crate) fn copy_metadata_with_mtime(
267 meta: &std::fs::Metadata,
268 to: &Path,
269 mtime: Option<std::time::SystemTime>,
270) -> io::Result<()> {
271 let mut times = std::fs::FileTimes::new();
272 if let Ok(t) = meta.accessed() {
273 times = times.set_accessed(t);
274 }
275 if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
276 times = times.set_modified(t);
277 }
278 std::fs::File::options()
279 .write(true)
280 .open(to)?
281 .set_times(times)?;
282 #[cfg(unix)]
283 {
284 use std::os::unix::fs::MetadataExt;
285 match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
286 Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
287 _ => {}
288 }
289 }
290 std::fs::set_permissions(to, meta.permissions())
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 const G: gzip::Flavour = gzip::Flavour::Gnu;
300
301 const U: unzip::Flavour = unzip::Flavour::Apple;
304
305 const C: compress::Flavour = compress::Flavour::Apple;
308
309 const Z: zip::Flavour = zip::Flavour::Apple;
311
312 #[test]
315 fn flavour_var_rejects_other_values() {
316 let d = unzip::default_flavour();
317 assert_eq!(unzip::flavour_from(None), Ok(d));
318 assert_eq!(unzip::flavour_from(Some("".into())), Ok(d));
319 assert_eq!(
320 unzip::flavour_from(Some("apple".into())),
321 Ok(unzip::Flavour::Apple)
322 );
323 assert_eq!(
324 unzip::flavour_from(Some("ubuntu".into())),
325 Ok(unzip::Flavour::Ubuntu)
326 );
327 for bad in ["Apple", "UBUNTU", " apple", "debian", "bsd"] {
328 assert_eq!(unzip::flavour_from(Some(bad.into())), Err(bad.into()));
329 }
330 }
331
332 #[test]
335 fn run_in_counted_reaches_the_unzip_family() {
336 let dir = std::env::temp_dir();
337 let run = |name: &str, f: unzip::Flavour| {
338 let (code, out, err, reads) =
339 run_in_counted(&dir, name, vec!["-h".into()], b"", G, f, C, Z).unwrap();
340 assert!(reads.is_empty(), "{reads:?}");
341 (code, String::from_utf8(out).unwrap(), err)
342 };
343 let (code, out, _) = run("unzip", unzip::Flavour::Apple);
344 assert_eq!(code, 0);
345 assert!(out.contains("Please report bugs to Apple."), "{out}");
346 let (_, out, _) = run("unzip", unzip::Flavour::Ubuntu);
347 assert!(!out.contains("Apple"), "{out}");
348 let (code, out, _) = run("zipinfo", U);
349 assert_eq!(code, 0);
350 assert!(
351 out.starts_with("ZipInfo 3.00 of 20 April 2009, by stuffr "),
352 "{out}"
353 );
354 let (code, out, _) = run("funzip", U);
357 assert!(unzip::EXIT_CODES_APPLE.contains(&code), "exit {code}");
358 assert!(!out.contains("fUnZip"), "{out}");
359 }
360
361 #[test]
364 fn run_in_counted_reaches_the_compress_family() {
365 struct Guard(std::path::PathBuf);
367 impl Drop for Guard {
368 fn drop(&mut self) {
369 let _ = std::fs::remove_dir_all(&self.0);
370 }
371 }
372 let dir = std::env::temp_dir().join(format!("stuffr-run-in-z-{}", std::process::id()));
373 let _ = std::fs::remove_dir_all(&dir);
374 std::fs::create_dir_all(&dir).unwrap();
375 let _guard = Guard(dir.clone());
376 std::fs::write(dir.join("a"), b"hello hello hello hello\n").unwrap();
377 for (c, usage) in [
378 (
379 compress::Flavour::Apple,
380 "uncompress: illegal option -- x\nusage: uncompress [-cfv] [-b bits] [file ...]\n",
381 ),
382 (
383 compress::Flavour::Ncompress,
384 "Unknown flag: 'x'; Usage: uncompress [-dfhvcVr] [-b maxbits] [--] [path ...]\n",
385 ),
386 ] {
387 let (code, _, err, _) =
388 run_in_counted(&dir, "uncompress", vec!["-x".into()], b"", G, U, c, Z).unwrap();
389 assert_eq!(code, 1, "{c:?}");
390 assert!(
391 String::from_utf8_lossy(&err).starts_with(usage),
392 "{c:?}: {}",
393 String::from_utf8_lossy(&err)
394 );
395 let (code, out, err, reads) = run_in_counted(
396 &dir,
397 "compress",
398 vec!["-c".into(), "a".into()],
399 b"",
400 G,
401 U,
402 c,
403 Z,
404 )
405 .unwrap();
406 assert_eq!(code, 0, "{c:?}: {}", String::from_utf8_lossy(&err));
407 assert!(out.starts_with(&[0x1f, 0x9d, 0x90]), "{c:?}");
408 assert_eq!(reads[&dir.join("a")].opens, 1, "{c:?}");
409 }
410 }
411
412 #[test]
415 fn run_in_counted_reaches_the_zip_family() {
416 let dir = std::env::temp_dir();
417 for name in ["zip", "zipcloak", "zipnote", "zipsplit"] {
418 for (f, origin) in [
419 (zip::Flavour::Apple, "with modifications by Apple Inc."),
420 (zip::Flavour::Ubuntu, "Debian's build"),
421 ] {
422 let (code, out, err, reads) =
423 run_in_counted(&dir, name, vec!["-h".into()], b"", G, U, C, f).unwrap();
424 let out = String::from_utf8(out).unwrap();
425 assert_eq!((code, err.as_slice()), (0, &b""[..]), "{name}");
426 assert!(reads.is_empty(), "{reads:?}");
427 assert!(
428 out.contains(", by stuffr ") && out.contains(origin),
429 "{name} {f:?}: {out}"
430 );
431 }
432 }
433 let (code, out, _) = run_in(&dir, "zipsplit", vec!["-L".into()], b"", G).unwrap();
435 assert_eq!(code, 0);
436 assert!(String::from_utf8(out).unwrap().contains("Info-ZIP"));
437 }
438
439 #[test]
442 fn fuzz_names_match_the_dispatch_table() {
443 assert_eq!(stuffr::testing::COMPAT_NAMES, names());
444 }
445
446 #[test]
447 fn compat_names_are_recognised_by_file_name_only() {
448 use std::ffi::OsStr;
449 assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
450 assert_eq!(
451 family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
452 Some("bunzip2")
453 );
454 assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
455 assert_eq!(
456 family_for(OsStr::new("bzip2recover")).map(|f| f.1),
457 Some("bzip2recover")
458 );
459 assert!(family_for(OsStr::new("stuffr")).is_none());
460 assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
461 assert!(family_for(OsStr::new("bzip2x")).is_none());
462 #[cfg(windows)]
463 assert_eq!(
464 family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
465 Some("bzip2")
466 );
467 }
468
469 #[test]
470 fn stuffr_itself_does_not_dispatch() {
471 assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
472 }
473
474 #[test]
475 fn dispatch_ignores_an_empty_argv() {
476 assert!(dispatch(Vec::new()).is_none());
477 }
478
479 #[cfg(unix)]
480 #[test]
481 fn dispatch_ignores_a_non_utf8_argv0() {
482 use std::os::unix::ffi::OsStrExt;
483 let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
484 assert!(dispatch(vec![bad]).is_none());
485 let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
486 assert!(dispatch(vec![bad]).is_none());
487 }
488
489 #[test]
492 fn run_in_works_inside_its_directory() {
493 let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
494 let _ = std::fs::remove_dir_all(&dir);
495 std::fs::create_dir_all(&dir).unwrap();
496 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
497 let (code, out, err) =
498 run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
499 assert_eq!(
500 (code, out.len()),
501 (0, 0),
502 "{}",
503 String::from_utf8_lossy(&err)
504 );
505 let packed = std::fs::read(dir.join("a.bz2")).unwrap();
506 assert!(packed.starts_with(b"BZh9"));
507 let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
508 assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
509 let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
510 assert_eq!(
511 (code, out.len()),
512 (0, 0),
513 "{}",
514 String::from_utf8_lossy(&err)
515 );
516 assert!(dir.join("rec00001a.bz2").exists());
517 let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
519 assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
520 assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
521 let _ = std::fs::remove_dir_all(&dir);
522 }
523
524 #[test]
529 fn run_in_counted_counts_the_gzip_familys_inputs() {
530 let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
531 for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
532 let _ = std::fs::remove_dir_all(&dir);
533 std::fs::create_dir_all(&dir).unwrap();
534 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
535 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
536 let (code, _, err, reads) =
537 run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour, U, C, Z).unwrap();
538 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
539 let a = reads[&dir.join("a")];
540 assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
541 assert_eq!(reads.len(), 1, "{reads:?}");
542
543 let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
544 for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
545 let (code, _, err, reads) =
546 run_in_counted(&dir, "gzip", args(mode), b"", flavour, U, C, Z).unwrap();
547 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
548 let gz = reads[&dir.join("a.gz")];
549 assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
550 assert_eq!(gz.len, len);
551 assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
552 }
553 let (_, _, _, reads) =
554 run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour, U, C, Z).unwrap();
555 assert!(reads.is_empty(), "{reads:?}");
556 }
557 let _ = std::fs::remove_dir_all(&dir);
558 }
559
560 #[test]
564 fn run_in_counted_counts_the_zstd_familys_inputs() {
565 let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
566 let _ = std::fs::remove_dir_all(&dir);
567 std::fs::create_dir_all(&dir).unwrap();
568 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
569 let run = |a: &[&str]| {
570 let (code, _, err, reads) =
571 run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu, U, C, Z).unwrap();
572 assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
573 reads
574 };
575 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
576 std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
577 std::fs::write(dir.join("l"), b"a\n").unwrap();
578
579 let reads = run(&["-q", "-k", "a"]);
580 assert_eq!(
581 reads[&dir.join("a")],
582 InputReads {
583 opens: 1,
584 bytes: 18,
585 len: 18,
586 ..Default::default()
587 }
588 );
589 let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
590
591 let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
592 let dict = reads[&dir.join("d.dict")];
593 assert_eq!(
594 dict,
595 InputReads {
596 opens: 1,
597 bytes: 17,
598 len: 17,
599 ..Default::default()
600 }
601 );
602 assert_eq!(reads[&dir.join("a.zst")].len, zst);
603
604 let reads = run(&["-c", "--filelist=l"]);
605 assert_eq!(
606 reads[&dir.join("l")],
607 InputReads {
608 opens: 1,
609 bytes: 2,
610 len: 2,
611 ..Default::default()
612 }
613 );
614 assert_eq!(reads[&dir.join("a")].opens, 1);
615 let _ = std::fs::remove_dir_all(&dir);
616 }
617
618 #[test]
620 fn run_in_caps_what_a_run_writes() {
621 let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
622 let _ = std::fs::remove_dir_all(&dir);
623 std::fs::create_dir_all(&dir).unwrap();
624 let big = vec![0u8; 9 * 1024 * 1024];
625 let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
626 assert_eq!(code, 0);
627 let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
628 let err = String::from_utf8_lossy(&err);
629 assert_eq!(code, 1, "{err}");
630 let cap = 8 * 1024 * 1024;
633 assert!(
634 out.len() <= cap && out.len() > cap - 64 * 1024,
635 "{}",
636 out.len()
637 );
638 assert!(err.contains("I/O or other error"), "{err}");
639 let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
641 let err = String::from_utf8_lossy(&err);
642 assert_eq!((code, out.len()), (1, 0), "{err}");
643 assert!(err.contains("I/O or other error"), "{err}");
644 let _ = std::fs::remove_dir_all(&dir);
645 }
646
647 #[test]
648 fn names_lists_the_table() {
649 assert_eq!(
650 names(),
651 [
652 "bzip2",
653 "bunzip2",
654 "bzcat",
655 "bzip2recover",
656 "gzip",
657 "gunzip",
658 "zcat",
659 "gzcat",
660 "xz",
661 "unxz",
662 "xzcat",
663 "lzma",
664 "unlzma",
665 "lzcat",
666 "zstd",
667 "unzstd",
668 "zstdcat",
669 "zstdmt",
670 "lz4",
671 "unlz4",
672 "lz4cat",
673 "lz4c",
674 "unzip",
675 "zipinfo",
676 "funzip",
677 "compress",
678 "uncompress",
679 "zip",
680 "zipcloak",
681 "zipnote",
682 "zipsplit"
683 ]
684 );
685 }
686
687 #[test]
688 fn copy_metadata_carries_mtime_and_permissions() {
689 let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
690 std::fs::create_dir_all(&dir).unwrap();
691 let (a, b) = (dir.join("a"), dir.join("b"));
692 std::fs::write(&a, b"x").unwrap();
693 std::fs::write(&b, b"y").unwrap();
694 let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
695 std::fs::File::options()
696 .write(true)
697 .open(&a)
698 .unwrap()
699 .set_modified(old)
700 .unwrap();
701 #[cfg(unix)]
702 {
703 use std::os::unix::fs::PermissionsExt;
704 std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
705 }
706 copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
707 let (ma, mb) = (
708 std::fs::metadata(&a).unwrap(),
709 std::fs::metadata(&b).unwrap(),
710 );
711 assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
712 assert_eq!(ma.permissions(), mb.permissions());
713 let _ = std::fs::remove_dir_all(&dir);
714 }
715}