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