From 550062f46bf4cfe694b80ae0abc3abe3242a8f30 Mon Sep 17 00:00:00 2001 From: Ben_Kosytorz Date: Sat, 10 Oct 2026 23:52:47 +0200 Subject: [PATCH] feat(rust): extract .tgz archives (Rust-only short suffix) --- version-rust/src/extract.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/version-rust/src/extract.rs b/version-rust/src/extract.rs index 91b4ed3..92bda17 100644 --- a/version-rust/src/extract.rs +++ b/version-rust/src/extract.rs @@ -4,7 +4,7 @@ //! //! Extraction backends: `sevenz-rust2` for .7z, `zip` for .zip, `unrar` for //! .rar — pure Rust, no external binaries. Tarballs and bare compressed -//! files (.tar/.tar.gz/.tar.bz2/.tar.xz/.gz/.bz2/.xz) are handled by the +//! files (.tar/.tar.gz/.tgz/.tar.bz2/.tar.xz/.gz/.bz2/.xz) are handled by the //! system `tar`/`gzip`/`bzip2`/`xz` binaries, exactly like the python //! version shelled out to `7z`: merged output capture, exit-code parity //! (0 ok, 1 warnings), and the 4h per-archive timeout. The tar/flate2/ @@ -37,8 +37,11 @@ use std::time::{Duration, Instant}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -/// Archive suffixes recognized by the extractors (python `ARCHIVE_SUFFIXES`). -pub const ARCHIVE_SUFFIXES: [&str; 7] = [".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz"]; +/// Archive suffixes recognized by the extractors (python `ARCHIVE_SUFFIXES`, +/// plus `.tgz` — a Rust-only extension; the python version never extracted +/// those). `.tgz` is a gzip-compressed tar, so it maps onto the tarball path. +pub const ARCHIVE_SUFFIXES: [&str; 8] = + [".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".tgz"]; /// Stop extracting below this free space (python `MIN_FREE_BYTES`). pub const MIN_FREE_BYTES: u64 = 20 * 1024 * 1024 * 1024; @@ -388,6 +391,9 @@ impl Extractor { } } Some(".tar") => extract_tar_cmd(archive, &actual, None), + // .tgz is a gzip-compressed tar: same as .tar.gz, but the tar flag + // cannot be inferred from the name (Rust-only extension). + Some(".tgz") => extract_tar_cmd(archive, &actual, Some(".gz")), _ => Err(format!("no extractor for {name}")), }; match res { @@ -957,6 +963,10 @@ mod tests { let targz = gzip_stored(&make_ustar("delta.txt", b"tarred payload")); fs::write(s.join("logs.tar.gz"), targz).unwrap(); + // a .tgz (gzip-compressed tar under the short suffix — Rust-only) + let tgz = gzip_stored(&make_ustar("epsilon.txt", b"tgz payload")); + fs::write(s.join("bundle.tgz"), tgz).unwrap(); + // garbage zip -> must land in .broken fs::write(s.join("bad.zip"), b"this is not a zip archive at all").unwrap(); @@ -1081,6 +1091,10 @@ mod tests { fs::read_to_string(o.join("src1/logs/delta.txt")).unwrap(), "tarred payload" ); + assert_eq!( + fs::read_to_string(o.join("src1/bundle/epsilon.txt")).unwrap(), + "tgz payload" + ); // no archives left in the extracted tree (outside .broken) for p in walk_files_sorted(o) { @@ -1164,6 +1178,7 @@ mod tests { "The Twin Lance", ), ("src1/logs.tar.gz", "src1/logs", "logs"), + ("src1/bundle.tgz", "src1/bundle", "bundle"), ] .iter() .map(|(a, d, u)| (a.to_string(), d.to_string(), u.to_string())) @@ -1255,6 +1270,10 @@ mod tests { ); assert_eq!(display_unit("Pack.zip"), "Pack"); assert!(is_archive("c.7z.7z")); + // .tgz: Rust-only short suffix for a gzip-compressed tar + assert_eq!(archive_suffixes("x.tgz"), vec![".tgz"]); + assert_eq!(display_unit("x.tgz"), "x"); + assert!(is_archive("X.TGZ")); } #[test]