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 xz;
18pub mod zstd;
19
20#[doc(hidden)]
21pub use host::InputReads;
22
23#[doc(hidden)]
26pub type CountedRun = (i32, Vec<u8>, Vec<u8>, BTreeMap<PathBuf, InputReads>);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum Family {
32 Bzip2,
34 Gzip,
37 Xz,
39 Zstd,
41 Lz4,
43}
44
45const NAMES: &[(&str, Family)] = &[
48 ("bzip2", Family::Bzip2),
49 ("bunzip2", Family::Bzip2),
50 ("bzcat", Family::Bzip2),
51 ("bzip2recover", Family::Bzip2),
52 ("gzip", Family::Gzip),
53 ("gunzip", Family::Gzip),
54 ("zcat", Family::Gzip),
55 ("gzcat", Family::Gzip),
56 ("xz", Family::Xz),
57 ("unxz", Family::Xz),
58 ("xzcat", Family::Xz),
59 ("lzma", Family::Xz),
60 ("unlzma", Family::Xz),
61 ("lzcat", Family::Xz),
62 ("zstd", Family::Zstd),
63 ("unzstd", Family::Zstd),
64 ("zstdcat", Family::Zstd),
65 ("zstdmt", Family::Zstd),
66 ("lz4", Family::Lz4),
67 ("unlz4", Family::Lz4),
68 ("lz4cat", Family::Lz4),
69 ("lz4c", Family::Lz4),
70];
71
72pub fn names() -> &'static [&'static str] {
74 static LIST: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
75 LIST.get_or_init(|| NAMES.iter().map(|&(n, _)| n).collect())
76}
77
78pub fn family_for(argv0: &OsStr) -> Option<(Family, &'static str)> {
82 let file = Path::new(argv0).file_name()?;
83 let file = file.to_str()?;
84 #[cfg(windows)]
85 let file = file.strip_suffix(".exe").unwrap_or(file);
86 NAMES
87 .iter()
88 .find(|(n, _)| *n == file)
89 .map(|&(n, fam)| (fam, n))
90}
91
92pub fn dispatch(args: Vec<OsString>) -> Option<ExitCode> {
97 #[cfg(all(unix, debug_assertions))]
100 if Path::new(args.first()?).file_name() == Some(OsStr::new("__compat-signal-test")) {
101 return Some(host::signal_test(&args[1..]));
102 }
103 let (family, name) = family_for(args.first()?)?;
104 let mut args = args.into_iter();
105 let argv0 = args.next().unwrap_or_default();
106 let rest = args.collect();
107 Some(match family {
108 Family::Bzip2 if name == "bzip2recover" => bzip2recover::run(argv0, rest),
110 Family::Bzip2 => bzip2::run(name, rest),
111 Family::Gzip => gzip::run(name, rest),
112 Family::Xz => xz::run(name, &argv0, rest),
113 Family::Zstd => zstd::run(name, &argv0, rest),
114 Family::Lz4 => lz4::run(name, &argv0, rest),
115 })
116}
117
118#[doc(hidden)]
128pub fn run_in(
129 dir: &Path,
130 name: &str,
131 args: Vec<OsString>,
132 stdin: &[u8],
133 gzip_flavour: gzip::Flavour,
134) -> Option<(i32, Vec<u8>, Vec<u8>)> {
135 run_in_counted(dir, name, args, stdin, gzip_flavour).map(|(code, out, err, _)| (code, out, err))
136}
137
138#[doc(hidden)]
145pub fn run_in_counted(
146 dir: &Path,
147 name: &str,
148 args: Vec<OsString>,
149 stdin: &[u8],
150 gzip_flavour: gzip::Flavour,
151) -> Option<CountedRun> {
152 let (family, canonical) = family_for(OsStr::new(name))?;
153 Some(match family {
154 Family::Bzip2 if canonical == "bzip2recover" => {
155 let (code, err) = bzip2recover::run_in(dir, name.as_bytes(), &args);
156 (code, Vec::new(), err, BTreeMap::new())
157 }
158 Family::Bzip2 => {
159 let (code, out, err) = bzip2::run_in(dir, canonical, &args, stdin);
160 (code, out, err, BTreeMap::new())
161 }
162 Family::Gzip => gzip::run_in_counted(gzip_flavour, dir, canonical, &args, stdin, &|_| None),
163 Family::Xz => xz::run_in_counted(dir, canonical, &args, stdin, &|_| None),
164 Family::Zstd => zstd::run_in_counted(dir, canonical, &args, stdin, &|_| None),
165 Family::Lz4 => lz4::run_in_counted(dir, canonical, &args, stdin, &|_| None),
166 })
167}
168
169pub(crate) fn stdin_is_tty() -> bool {
171 io::stdin().is_terminal()
172}
173
174pub(crate) fn stdout_is_tty() -> bool {
176 io::stdout().is_terminal()
177}
178
179pub(crate) fn copy_metadata_from(meta: &std::fs::Metadata, to: &Path) -> io::Result<()> {
188 copy_metadata_with_mtime(meta, to, None)
189}
190
191pub(crate) fn copy_metadata_with_mtime(
194 meta: &std::fs::Metadata,
195 to: &Path,
196 mtime: Option<std::time::SystemTime>,
197) -> io::Result<()> {
198 let mut times = std::fs::FileTimes::new();
199 if let Ok(t) = meta.accessed() {
200 times = times.set_accessed(t);
201 }
202 if let Some(t) = mtime.or_else(|| meta.modified().ok()) {
203 times = times.set_modified(t);
204 }
205 std::fs::File::options()
206 .write(true)
207 .open(to)?
208 .set_times(times)?;
209 #[cfg(unix)]
210 {
211 use std::os::unix::fs::MetadataExt;
212 match std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
213 Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(e),
214 _ => {}
215 }
216 }
217 std::fs::set_permissions(to, meta.permissions())
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 const G: gzip::Flavour = gzip::Flavour::Gnu;
227
228 #[test]
231 fn fuzz_names_match_the_dispatch_table() {
232 assert_eq!(stuffr::testing::COMPAT_NAMES, names());
233 }
234
235 #[test]
236 fn compat_names_are_recognised_by_file_name_only() {
237 use std::ffi::OsStr;
238 assert_eq!(family_for(OsStr::new("bzip2")).map(|f| f.1), Some("bzip2"));
239 assert_eq!(
240 family_for(OsStr::new("/usr/local/bin/bunzip2")).map(|f| f.1),
241 Some("bunzip2")
242 );
243 assert_eq!(family_for(OsStr::new("bzcat")).map(|f| f.1), Some("bzcat"));
244 assert_eq!(
245 family_for(OsStr::new("bzip2recover")).map(|f| f.1),
246 Some("bzip2recover")
247 );
248 assert!(family_for(OsStr::new("stuffr")).is_none());
249 assert!(family_for(OsStr::new("/x/bzip2/stuffr")).is_none());
250 assert!(family_for(OsStr::new("bzip2x")).is_none());
251 #[cfg(windows)]
252 assert_eq!(
253 family_for(OsStr::new("bzip2.exe")).map(|f| f.1),
254 Some("bzip2")
255 );
256 }
257
258 #[test]
259 fn stuffr_itself_does_not_dispatch() {
260 assert!(dispatch(vec!["stuffr".into(), "list".into()]).is_none());
261 }
262
263 #[test]
264 fn dispatch_ignores_an_empty_argv() {
265 assert!(dispatch(Vec::new()).is_none());
266 }
267
268 #[cfg(unix)]
269 #[test]
270 fn dispatch_ignores_a_non_utf8_argv0() {
271 use std::os::unix::ffi::OsStrExt;
272 let bad = OsStr::from_bytes(b"bzip2\xff").to_os_string();
273 assert!(dispatch(vec![bad]).is_none());
274 let bad = OsStr::from_bytes(b"/x/\xffbzip2").to_os_string();
275 assert!(dispatch(vec![bad]).is_none());
276 }
277
278 #[test]
281 fn run_in_works_inside_its_directory() {
282 let dir = std::env::temp_dir().join(format!("stuffr-run-in-{}", std::process::id()));
283 let _ = std::fs::remove_dir_all(&dir);
284 std::fs::create_dir_all(&dir).unwrap();
285 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
286 let (code, out, err) =
287 run_in(&dir, "bzip2", vec!["-k".into(), "a".into()], b"", G).unwrap();
288 assert_eq!(
289 (code, out.len()),
290 (0, 0),
291 "{}",
292 String::from_utf8_lossy(&err)
293 );
294 let packed = std::fs::read(dir.join("a.bz2")).unwrap();
295 assert!(packed.starts_with(b"BZh9"));
296 let (code, out, _) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
297 assert_eq!((code, out.as_slice()), (0, &b"hello hello hello\n"[..]));
298 let (code, out, err) = run_in(&dir, "bzip2recover", vec!["a.bz2".into()], b"", G).unwrap();
299 assert_eq!(
300 (code, out.len()),
301 (0, 0),
302 "{}",
303 String::from_utf8_lossy(&err)
304 );
305 assert!(dir.join("rec00001a.bz2").exists());
306 let (code, _, err) = run_in(&dir, "bunzip2", vec![], b"BZh9garbage", G).unwrap();
308 assert_eq!(code, 2, "{}", String::from_utf8_lossy(&err));
309 assert!(run_in(&dir, "stuffr", vec![], b"", G).is_none());
310 let _ = std::fs::remove_dir_all(&dir);
311 }
312
313 #[test]
318 fn run_in_counted_counts_the_gzip_familys_inputs() {
319 let dir = std::env::temp_dir().join(format!("stuffr-run-in-count-{}", std::process::id()));
320 for flavour in [gzip::Flavour::Gnu, gzip::Flavour::Bsd] {
321 let _ = std::fs::remove_dir_all(&dir);
322 std::fs::create_dir_all(&dir).unwrap();
323 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
324 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
325 let (code, _, err, reads) =
326 run_in_counted(&dir, "gzip", args(&["-k", "a"]), b"", flavour).unwrap();
327 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
328 let a = reads[&dir.join("a")];
329 assert_eq!((a.opens, a.bytes, a.len), (1, 18, 18), "{flavour:?}");
330 assert_eq!(reads.len(), 1, "{reads:?}");
331
332 let len = std::fs::metadata(dir.join("a.gz")).unwrap().len();
333 for mode in [&["-t", "a.gz", "a.gz"][..], &["-l", "a.gz"]] {
334 let (code, _, err, reads) =
335 run_in_counted(&dir, "gzip", args(mode), b"", flavour).unwrap();
336 assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
337 let gz = reads[&dir.join("a.gz")];
338 assert_eq!(gz.opens, mode.len() as u64 - 1, "{flavour:?} {mode:?}");
339 assert_eq!(gz.len, len);
340 assert!(gz.bytes > 0 && gz.bytes <= 2 * len * gz.opens, "{gz:?}");
341 }
342 let (_, _, _, reads) =
343 run_in_counted(&dir, "bzip2", args(&["-k", "a"]), b"", flavour).unwrap();
344 assert!(reads.is_empty(), "{reads:?}");
345 }
346 let _ = std::fs::remove_dir_all(&dir);
347 }
348
349 #[test]
353 fn run_in_counted_counts_the_zstd_familys_inputs() {
354 let dir = std::env::temp_dir().join(format!("stuffr-run-in-zstd-{}", std::process::id()));
355 let _ = std::fs::remove_dir_all(&dir);
356 std::fs::create_dir_all(&dir).unwrap();
357 let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
358 let run = |a: &[&str]| {
359 let (code, _, err, reads) =
360 run_in_counted(&dir, "zstd", args(a), b"", gzip::Flavour::Gnu).unwrap();
361 assert_eq!(code, 0, "{a:?}: {}", String::from_utf8_lossy(&err));
362 reads
363 };
364 std::fs::write(dir.join("a"), b"hello hello hello\n").unwrap();
365 std::fs::write(dir.join("d.dict"), b"a raw dictionary\n").unwrap();
366 std::fs::write(dir.join("l"), b"a\n").unwrap();
367
368 let reads = run(&["-q", "-k", "a"]);
369 assert_eq!(
370 reads[&dir.join("a")],
371 InputReads {
372 opens: 1,
373 bytes: 18,
374 len: 18
375 }
376 );
377 let zst = std::fs::metadata(dir.join("a.zst")).unwrap().len();
378
379 let reads = run(&["-d", "-c", "-D", "d.dict", "a.zst"]);
380 let dict = reads[&dir.join("d.dict")];
381 assert_eq!(
382 dict,
383 InputReads {
384 opens: 1,
385 bytes: 17,
386 len: 17
387 }
388 );
389 assert_eq!(reads[&dir.join("a.zst")].len, zst);
390
391 let reads = run(&["-c", "--filelist=l"]);
392 assert_eq!(
393 reads[&dir.join("l")],
394 InputReads {
395 opens: 1,
396 bytes: 2,
397 len: 2
398 }
399 );
400 assert_eq!(reads[&dir.join("a")].opens, 1);
401 let _ = std::fs::remove_dir_all(&dir);
402 }
403
404 #[test]
406 fn run_in_caps_what_a_run_writes() {
407 let dir = std::env::temp_dir().join(format!("stuffr-run-in-cap-{}", std::process::id()));
408 let _ = std::fs::remove_dir_all(&dir);
409 std::fs::create_dir_all(&dir).unwrap();
410 let big = vec![0u8; 9 * 1024 * 1024];
411 let (code, packed, _) = run_in(&dir, "bzip2", vec![], &big, G).unwrap();
412 assert_eq!(code, 0);
413 let (code, out, err) = run_in(&dir, "bzcat", vec![], &packed, G).unwrap();
414 let err = String::from_utf8_lossy(&err);
415 assert_eq!(code, 1, "{err}");
416 let cap = 8 * 1024 * 1024;
419 assert!(
420 out.len() <= cap && out.len() > cap - 64 * 1024,
421 "{}",
422 out.len()
423 );
424 assert!(err.contains("I/O or other error"), "{err}");
425 let (code, out, err) = run_in(&dir, "bzip2", vec!["-t".into()], &packed, G).unwrap();
427 let err = String::from_utf8_lossy(&err);
428 assert_eq!((code, out.len()), (1, 0), "{err}");
429 assert!(err.contains("I/O or other error"), "{err}");
430 let _ = std::fs::remove_dir_all(&dir);
431 }
432
433 #[test]
434 fn names_lists_the_table() {
435 assert_eq!(
436 names(),
437 [
438 "bzip2",
439 "bunzip2",
440 "bzcat",
441 "bzip2recover",
442 "gzip",
443 "gunzip",
444 "zcat",
445 "gzcat",
446 "xz",
447 "unxz",
448 "xzcat",
449 "lzma",
450 "unlzma",
451 "lzcat",
452 "zstd",
453 "unzstd",
454 "zstdcat",
455 "zstdmt",
456 "lz4",
457 "unlz4",
458 "lz4cat",
459 "lz4c"
460 ]
461 );
462 }
463
464 #[test]
465 fn copy_metadata_carries_mtime_and_permissions() {
466 let dir = std::env::temp_dir().join(format!("stuffr-meta-{}", std::process::id()));
467 std::fs::create_dir_all(&dir).unwrap();
468 let (a, b) = (dir.join("a"), dir.join("b"));
469 std::fs::write(&a, b"x").unwrap();
470 std::fs::write(&b, b"y").unwrap();
471 let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
472 std::fs::File::options()
473 .write(true)
474 .open(&a)
475 .unwrap()
476 .set_modified(old)
477 .unwrap();
478 #[cfg(unix)]
479 {
480 use std::os::unix::fs::PermissionsExt;
481 std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o640)).unwrap();
482 }
483 copy_metadata_from(&std::fs::metadata(&a).unwrap(), &b).unwrap();
484 let (ma, mb) = (
485 std::fs::metadata(&a).unwrap(),
486 std::fs::metadata(&b).unwrap(),
487 );
488 assert_eq!(ma.modified().unwrap(), mb.modified().unwrap());
489 assert_eq!(ma.permissions(), mb.permissions());
490 let _ = std::fs::remove_dir_all(&dir);
491 }
492}