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 gzip;
14mod host;
15pub mod links;
16pub mod lz4;
17pub mod unzip;
18pub mod xz;
19pub mod zstd;
20
21#[cfg(test)]
22pub(crate) use host::ARM_TEST_LOCK;
23#[doc(hidden)]
24pub use host::InputReads;
25#[cfg(all(test, unix))]
26pub(crate) use host::armed_path;
27pub(crate) use host::{CleanupGuard, create_armed};
29
30#[doc(hidden)]
33pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum Family {
39 Bzip2,
41 Gzip,
44 Xz,
46 Zstd,
48 Lz4,
50 Unzip,
53}
54
55const NAMES: &[(&str, Family)] = &[
58 ("bzip2", Family::Bzip2),
59 ("bunzip2", Family::Bzip2),
60 ("bzcat", Family::Bzip2),
61 ("bzip2recover", Family::Bzip2),
62 ("gzip", Family::Gzip),
63 ("gunzip", Family::Gzip),
64 ("zcat", Family::Gzip),
65 ("gzcat", Family::Gzip),
66 ("xz", Family::Xz),
67 ("unxz", Family::Xz),
68 ("xzcat", Family::Xz),
69 ("lzma", Family::Xz),
70 ("unlzma", Family::Xz),
71 ("lzcat", Family::Xz),
72 ("zstd", Family::Zstd),
73 ("unzstd", Family::Zstd),
74 ("zstdcat", Family::Zstd),
75 ("zstdmt", Family::Zstd),
76 ("lz4", Family::Lz4),
77 ("unlz4", Family::Lz4),
78 ("lz4cat", Family::Lz4),
79 ("lz4c", Family::Lz4),
80 ("unzip", Family::Unzip),
81 ("zipinfo", Family::Unzip),
82 ("funzip", Family::Unzip),
83];
84
85pub fn names() -> &'static [&'static str] {
87 static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
88 LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
89}
90
91pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
95 let file = Path::new(argv0).file_name()?;
96 let file = file.to_str()?;
97 #[cfg(windows)]
98 let file = file.strip_suffix(".exe").unwrap_or(file);
99 NAMES
100 .iter()
101 .find(|(n, _)| *n == file)
102 .map(|&(n, fam)| (fam, n))
103}
104
105pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
110 #[cfg(all(unix, debug_assertions))]
113 if Path::new(args.first()?).file_name() == Some(OsStr::new("__compat-signal-test")) {
114 return Some(host::signal_test(&args[1..]));
115 }
116 let (family, name) = family_for(args.first()?)?;
117 let mut args = args.into_iter();
118 let argv0 = args.next().unwrap_or_default();
119 let rest = args.collect();
120 Some(match family {
121 Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
123 Family::Bzip2 => bzip2::run(name, rest),
124 Family::Gzip => gzip::run(name, rest),
125 Family::Xz => xz::run(name, &argv0, rest),
126 Family::Zstd => zstd::run(name, &argv0, rest),
127 Family::Lz4 => lz4::run(name, &argv0, rest),
128 Family::Unzip => unzip::run(name, &argv0, rest),
129 })
130}
131
132#[doc(hidden)]
144pub fn run_in(
145 dir: &Path,
146 name: &str,
147 args: Vec<OsString>,
148 stdin: &[u8],
149 gzip_flavour: gzip::Flavour,
150) -> Option<(i32, Vec<u8>, Vec<u8>)> {
151 run_in_counted(
152 dir,
153 name,
154 args,
155 stdin,
156 gzip_flavour,
157 unzip::default_flavour(),
158 )
159 .map(|(code, out, err, _)| (code, out, err))
160}
161
162#[doc(hidden)]
171pub fn run_in_counted(
172 dir: &Path,
173 name: &str,
174 args: Vec<OsString>,
175 stdin: &[u8],
176 gzip_flavour: gzip::Flavour,
177 unzip_flavour: unzip::Flavour,
178) -> Option<CountedRun> {
179 let (family, canonical) = family_for(OsStr::new(name))?;
180 Some(match family {
181 Family::Bzip2 if canonical == "bzip2recover" => {
182 let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
183 (code, Vec::new(), err, BTreeMap::new())
184 }
185 Family::Bzip2 => {
186 let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
187 (code, out, err, BTreeMap::new())
188 }
189 Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
190 Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
191 Family::Zstd => zstd::run_in_counted(dir, canonical, &args, stdin, &|_| None),
192 Family::Lz4 => lz4::run_in_counted(dir, canonical, &args, stdin, &|_| None),
193 Family::Unzip => {
194 unzip::run_in_counted(unzip_flavour, dir, canonical, &args, stdin, &|_| None)
195 }
196 })
197}
198
199pub(crate) fn stdin_is_tty() -> bool {
201 io::stdin().is_terminal()
202}
203
204pub(crate) fn stdout_is_tty() -> bool {
206 io::stdout().is_terminal()
207}
208
209pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
218 copy_metadata_with_mtime(meta, to, None)
219}
220
221pub(crate) fn copy_metadata_with_mtime(
224 meta: &std::fs::Metadata,
225 to: &Path,
226 mtime: Option<std::time::SystemTime>,
227) -> io::Result<()> {
228 let mut times = std::fs::FileTimes::new();
229 if let Ok(t) = meta.accessed() {
230 times = times.set_accessed(t);
231 }
232 if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
233 times = times.set_modified(t);
234 }
235 std::fs::File::options()
236 .write(true)
237 .open(to)?
238 .set_times(times)?;
239 #[cfg(unix)]
240 {
241 use std::os::unix::fs::MetadataExt;
242 match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
243 Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
244 _ => {}
245 }
246 }
247 std::fs::set_permissions(to, meta.permissions())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 const G: gzip::Flavour = gzip::Flavour::Gnu;
257
258 const U: unzip::Flavour = unzip::Flavour::Apple;
261
262 #[test]
265 fn flavour_var_rejects_other_values() {
266 let d = unzip::default_flavour();
267 assert_eq!(unzip::flavour_from(None), Ok(d));
268 assert_eq!(unzip::flavour_from(Some("".into())), Ok(d));
269 assert_eq!(
270 unzip::flavour_from(Some("apple".into())),
271 Ok(unzip::Flavour::Apple)
272 );
273 assert_eq!(
274 unzip::flavour_from(Some("ubuntu".into())),
275 Ok(unzip::Flavour::Ubuntu)
276 );
277 for bad in ["Apple", "UBUNTU", " apple", "debian", "bsd"] {
278 assert_eq!(unzip::flavour_from(Some(bad.into())), Err(bad.into()));
279 }
280 }
281
282 #[test]
285 fn run_in_counted_reaches_the_unzip_family() {
286 let dir = std::env::temp_dir();
287 let run = |name: &str, f: unzip::Flavour| {
288 let (code, out, err, reads) =
289 run_in_counted(&dir, name, vec!["-h".into()], b"", G, f).unwrap();
290 assert!(reads.is_empty(), "{reads:?}");
291 (code, String::from_utf8(out).unwrap(), err)
292 };
293 let (code, out, _) = run("unzip", unzip::Flavour::Apple);
294 assert_eq!(code, 0);
295 assert!(out.contains("Please report bugs to Apple."), "{out}");
296 let (_, out, _) = run("unzip", unzip::Flavour::Ubuntu);
297 assert!(!out.contains("Apple"), "{out}");
298 let (code, out, _) = run("zipinfo", U);
299 assert_eq!(code, 0);
300 assert!(
301 out.starts_with("ZipInfo 3.00 of 20 April 2009, by stuffr "),
302 "{out}"
303 );
304 let (code, out, _) = run("funzip", U);
307 assert!(unzip::EXIT_CODES_APPLE.contains(&code), "exit {code}");
308 assert!(!out.contains("fUnZip"), "{out}");
309 }
310
311 #[test]
314 fn fuzz_names_match_the_dispatch_table() {
315 assert_eq!(stuffr::testing::COMPAT_NAMES, names());
316 }
317
318 #[test]
319 fn compat_names_are_recognised_by_file_name_only() {
320 use std::ffi::OsStr;
321 assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
322 assert_eq!(
323 family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
324 Some("bunzip2")
325 );
326 assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
327 assert_eq!(
328 family_for(OsStr::new("bzip2recover")).map(|f| f.1),
329 Some("bzip2recover")
330 );
331 assert!(family_for(OsStr::new("stuffr")).is_none());
332 assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
333 assert!(family_for(OsStr::new("bzip2x")).is_none());
334 #[cfg(windows)]
335 assert_eq!(
336 family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
337 Some("bzip2")
338 );
339 }
340
341 #[test]
342 fn stuffr_itself_does_not_dispatch() {
343 assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
344 }
345
346 #[test]
347 fn dispatch_ignores_an_empty_argv() {
348 assert!(dispatch(Vec::new()).is_none());
349 }
350
351 #[cfg(unix)]
352 #[test]
353 fn dispatch_ignores_a_non_utf8_argv0() {
354 use std::os::unix::ffi::OsStrExt;
355 let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
356 assert!(dispatch(vec![bad]).is_none());
357 let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
358 assert!(dispatch(vec![bad]).is_none());
359 }
360
361 #[test]
364 fn run_in_works_inside_its_directory() {
365 let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
366 let _ = std::fs::remove_dir_all(&dir);
367 std::fs::create_dir_all(&dir).unwrap();
368 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
369 let (code, out, err) =
370 run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
371 assert_eq!(
372 (code, out.len()),
373 (0, 0),
374 "{}",
375 String::from_utf8_lossy(&err)
376 );
377 let packed = std::fs::read(dir.join("a.bz2")).unwrap();
378 assert!(packed.starts_with(b"BZh9"));
379 let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
380 assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
381 let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
382 assert_eq!(
383 (code, out.len()),
384 (0, 0),
385 "{}",
386 String::from_utf8_lossy(&err)
387 );
388 assert!(dir.join("rec00001a.bz2").exists());
389 let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
391 assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
392 assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
393 let _ = std::fs::remove_dir_all(&dir);
394 }
395
396 #[test]
401 fn run_in_counted_counts_the_gzip_familys_inputs() {
402 let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
403 for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
404 let _ = std::fs::remove_dir_all(&dir);
405 std::fs::create_dir_all(&dir).unwrap();
406 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
407 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
408 let (code, _, err, reads) =
409 run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour, U).unwrap();
410 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
411 let a = reads[&dir.join("a")];
412 assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
413 assert_eq!(reads.len(), 1, "{reads:?}");
414
415 let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
416 for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
417 let (code, _, err, reads) =
418 run_in_counted(&dir, "gzip", args(mode), b"", flavour, U).unwrap();
419 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
420 let gz = reads[&dir.join("a.gz")];
421 assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
422 assert_eq!(gz.len, len);
423 assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
424 }
425 let (_, _, _, reads) =
426 run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour, U).unwrap();
427 assert!(reads.is_empty(), "{reads:?}");
428 }
429 let _ = std::fs::remove_dir_all(&dir);
430 }
431
432 #[test]
436 fn run_in_counted_counts_the_zstd_familys_inputs() {
437 let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
438 let _ = std::fs::remove_dir_all(&dir);
439 std::fs::create_dir_all(&dir).unwrap();
440 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
441 let run = |a: &[&str]| {
442 let (code, _, err, reads) =
443 run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu, U).unwrap();
444 assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
445 reads
446 };
447 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
448 std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
449 std::fs::write(dir.join("l"), b"a\n").unwrap();
450
451 let reads = run(&["-q", "-k", "a"]);
452 assert_eq!(
453 reads[&dir.join("a")],
454 InputReads {
455 opens: 1,
456 bytes: 18,
457 len: 18
458 }
459 );
460 let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
461
462 let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
463 let dict = reads[&dir.join("d.dict")];
464 assert_eq!(
465 dict,
466 InputReads {
467 opens: 1,
468 bytes: 17,
469 len: 17
470 }
471 );
472 assert_eq!(reads[&dir.join("a.zst")].len, zst);
473
474 let reads = run(&["-c", "--filelist=l"]);
475 assert_eq!(
476 reads[&dir.join("l")],
477 InputReads {
478 opens: 1,
479 bytes: 2,
480 len: 2
481 }
482 );
483 assert_eq!(reads[&dir.join("a")].opens, 1);
484 let _ = std::fs::remove_dir_all(&dir);
485 }
486
487 #[test]
489 fn run_in_caps_what_a_run_writes() {
490 let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
491 let _ = std::fs::remove_dir_all(&dir);
492 std::fs::create_dir_all(&dir).unwrap();
493 let big = vec![0u8; 9 * 1024 * 1024];
494 let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
495 assert_eq!(code, 0);
496 let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
497 let err = String::from_utf8_lossy(&err);
498 assert_eq!(code, 1, "{err}");
499 let cap = 8 * 1024 * 1024;
502 assert!(
503 out.len() <= cap && out.len() > cap - 64 * 1024,
504 "{}",
505 out.len()
506 );
507 assert!(err.contains("I/O or other error"), "{err}");
508 let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
510 let err = String::from_utf8_lossy(&err);
511 assert_eq!((code, out.len()), (1, 0), "{err}");
512 assert!(err.contains("I/O or other error"), "{err}");
513 let _ = std::fs::remove_dir_all(&dir);
514 }
515
516 #[test]
517 fn names_lists_the_table() {
518 assert_eq!(
519 names(),
520 [
521 "bzip2",
522 "bunzip2",
523 "bzcat",
524 "bzip2recover",
525 "gzip",
526 "gunzip",
527 "zcat",
528 "gzcat",
529 "xz",
530 "unxz",
531 "xzcat",
532 "lzma",
533 "unlzma",
534 "lzcat",
535 "zstd",
536 "unzstd",
537 "zstdcat",
538 "zstdmt",
539 "lz4",
540 "unlz4",
541 "lz4cat",
542 "lz4c",
543 "unzip",
544 "zipinfo",
545 "funzip"
546 ]
547 );
548 }
549
550 #[test]
551 fn copy_metadata_carries_mtime_and_permissions() {
552 let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
553 std::fs::create_dir_all(&dir).unwrap();
554 let (a, b) = (dir.join("a"), dir.join("b"));
555 std::fs::write(&a, b"x").unwrap();
556 std::fs::write(&b, b"y").unwrap();
557 let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
558 std::fs::File::options()
559 .write(true)
560 .open(&a)
561 .unwrap()
562 .set_modified(old)
563 .unwrap();
564 #[cfg(unix)]
565 {
566 use std::os::unix::fs::PermissionsExt;
567 std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
568 }
569 copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
570 let (ma, mb) = (
571 std::fs::metadata(&a).unwrap(),
572 std::fs::metadata(&b).unwrap(),
573 );
574 assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
575 assert_eq!(ma.permissions(), mb.permissions());
576 let _ = std::fs::remove_dir_all(&dir);
577 }
578}