Files
Warhammer_DLP_Helper/src/extract.rs
T
Ben_Kosytorz 6c6a3273bb refactor: flatten the crate to the repo root and drop the python version
The Rust rewrite has replaced the original python implementation, so the transition layout is no longer needed:

- move version-rust/{Cargo.toml,Cargo.lock,README.md,.gitignore,src,data} to the repo root; the crate is now the repo.
- move docs/SORTING.md and data/gwshop_catalog.json to the root.
- remove version-python/ entirely, plus the dev-only version-rust/tools/dump_warhammer_data.py (its only job was regenerating data/warhammer_data.json from python).
- rewrite AGENTS.md and README.md for the single-project layout; strip all version-python/version-rust references from code comments.
- fix the gwshop credential lookup: algolia.json now sits at CARGO_MANIFEST_DIR (crate root == repo root), not its parent.
- restore the credentials/run-output ignores in .gitignore (algolia.json, .env, *.token, downloads/, state.json, manifest.jsonl, ...).
- docs/SORTING.md: retarget the extraction/sorter/dlp examples at the dlphelper binary instead of the deleted python scripts.

cargo test: 86/86 green.
2026-10-11 09:22:41 +02:00

1293 lines
48 KiB
Rust

//! Recursive archive extraction into a plain-file tree — Rust port of
//! `tools/extract_all.py` (extract_manifest.jsonl feeds the
//! sorter's provenance mode; failures go to .broken/; the run never stops).
//!
//! 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/.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/
//! bzip2/xz2 crates mentioned in the port plan are not declared
//! dependencies of this crate and Cargo.toml is off-limits, so the system
//! binaries are the parity-preserving fallback (they are universally
//! present on the target Linux hosts).
//!
//! Because the zip/7z paths are pure Rust, the python version's /var/tmp
//! fallback + cp437 name repair is not needed here (the zip crate decodes
//! legacy cp437 entry names itself).
//!
//! Behavior parity notes (vs. the python version):
//! - Skip decisions are made on manifest records only, never on "dir
//! non-empty"; a partial dir from a crash is re-extracted.
//! - Source roots are never modified except for the quarantine move of
//! failed archives into `<out>/.broken/`.
//! - The extracted tree holds no archives afterwards (nested archives are
//! expanded BFS-style and their copies unlinked).
//! - Plain files are hardlinked in (fallback: metadata-preserving copy).
//! - No individual failure ever aborts the run.
use std::collections::{HashSet, VecDeque};
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// 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;
/// Per-archive extraction timeout in seconds (python `EXTRACT_TIMEOUT`).
const EXTRACT_TIMEOUT_SECS: u64 = 4 * 3600;
/// Run artifacts that must never be treated as payload (python `RUN_ARTIFACTS`).
pub const RUN_ARTIFACTS: [&str; 14] = [
"export.json",
"export.html",
"state.json",
"manifest.jsonl",
"failed_urls.txt",
"catalogue.md",
"catalogue.jsonl",
"sorter_applied.jsonl",
"catalogue_moves.jsonl",
"urls.txt",
"urls_0807.txt",
"urls_gofile.txt",
"failed_0807.txt",
"extract_manifest.jsonl",
];
/// Recursive extractor writing into `out_root`.
pub struct Extractor {
pub out_root: PathBuf,
}
/// Python `Path.suffix` semantics for a plain file name: the trailing dot
/// segment, unless the name starts with a dot (hidden) or ends with a dot.
fn name_suffix(name: &str) -> Option<&str> {
let idx = name.rfind('.')?;
if idx == 0 || idx == name.len() - 1 {
return None;
}
Some(&name[idx..])
}
/// All trailing archive suffixes, lowercased, outermost first — e.g.
/// `x.tar.gz` -> `[".gz", ".tar"]`.
pub fn archive_suffixes(name: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = name;
while let Some(sfx) = name_suffix(cur) {
let lower = sfx.to_ascii_lowercase();
if !ARCHIVE_SUFFIXES.contains(&lower.as_str()) {
break;
}
out.push(lower);
cur = &cur[..cur.len() - sfx.len()];
}
out
}
/// True when the file name carries at least one archive suffix.
pub fn is_archive(name: &str) -> bool {
!archive_suffixes(name).is_empty()
}
/// Sniff the container format from magic bytes (7z/zip/rar). The python
/// version shells out to the 7z CLI, which sniffs content; extensions are
/// unreliable in the wild (we have seen real 7z archives named `.zip`).
fn sniff_container(path: &Path) -> Option<&'static str> {
use std::io::Read;
let mut buf = [0u8; 8];
let mut f = fs::File::open(path).ok()?;
let n = f.read(&mut buf).ok()?;
let b = &buf[..n];
if b.starts_with(&[0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]) {
return Some(".7z");
}
if b.starts_with(b"PK\x03\x04") || b.starts_with(b"PK\x05\x06") {
return Some(".zip");
}
if b.starts_with(b"Rar!\x1a\x07\x00") || b.starts_with(b"Rar!\x1a\x07\x01\x00") {
return Some(".rar");
}
None
}
/// Archive name with all archive suffixes stripped: the unit folder name.
pub fn display_unit(name: &str) -> String {
let mut cur = name;
while let Some(sfx) = name_suffix(cur) {
let lower = sfx.to_ascii_lowercase();
if !ARCHIVE_SUFFIXES.contains(&lower.as_str()) {
break;
}
cur = &cur[..cur.len() - sfx.len()];
}
cur.to_string()
}
/// Free disk space (bytes) on the file system containing `path`.
pub fn free_bytes(path: &Path) -> anyhow::Result<u64> {
Ok(fs4::available_space(path)?)
}
/// AppleDouble resource-fork "zips" (`__MACOSX/._x.zip`) — no content.
pub fn is_macosx_junk(p: &Path) -> bool {
p.components().any(|c| c.as_os_str() == "__MACOSX")
|| p.file_name()
.map(|n| n.to_string_lossy().starts_with("._"))
.unwrap_or(false)
}
/// Relative path helper: `p` relative to `root` (falls back to `p` itself).
fn rel_to(root: &Path, p: &Path) -> PathBuf {
p.strip_prefix(root).unwrap_or(p).to_path_buf()
}
/// Same as [`rel_to`] as a (lossy) string, matching python `str(rel_path)`.
fn rel_string(root: &Path, p: &Path) -> String {
rel_to(root, p).to_string_lossy().into_owned()
}
fn file_name_string(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Recursively collect regular files under `root`, sorted for determinism.
///
/// Matches python `sorted(rglob("*"))` + `is_file()` filtering: symlinked
/// directories are not descended into, symlinks to files count as files.
fn walk_files_sorted(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
match fs::symlink_metadata(&path) {
Ok(meta) if meta.is_dir() => dirs.push(path),
Ok(meta) if meta.is_file() => files.push(path),
Ok(meta) if meta.is_symlink() => {
if let Ok(target) = fs::metadata(&path) {
if target.is_file() {
files.push(path);
}
}
}
_ => {}
}
}
}
files.sort();
files
}
/// One provenance manifest line, compact JSON, raw UTF-8 (python
/// `json.dumps(..., ensure_ascii=False)`; keys order as archive/dest/unit).
fn write_manifest_line<W: Write>(
manifest: &mut W,
out_root: &Path,
archive_rel: &Path,
actual_dest: &Path,
) {
let dest_rel = rel_to(out_root, actual_dest);
let unit = archive_rel
.file_name()
.map(|n| display_unit(&n.to_string_lossy()))
.unwrap_or_default();
let line = serde_json::to_string(&serde_json::json!({
"archive": archive_rel.to_string_lossy().into_owned(),
"dest": dest_rel.to_string_lossy().into_owned(),
"unit": unit,
}))
.unwrap_or_else(|_| "{}".to_string());
if let Err(e) = writeln!(manifest, "{line}") {
log::error!("Cannot write manifest line: {e}");
}
}
/// Run an extraction command like python `_run_7z`: merged stdout+stderr
/// captured (stderr only when stdout is redirected to a file), tolerant
/// decoding, per-archive timeout, returns (rc, last-5-lines tail).
/// rc == -1 marks "failed to run" / timeout, like python.
fn run_extract_cmd(
program: &str,
args: &[std::ffi::OsString],
stdout_to: Option<&Path>,
) -> (i32, String) {
let mut cmd = Command::new(program);
cmd.args(args);
let mut stdout_thread = None;
let mut stderr_thread = None;
match stdout_to {
Some(path) => match File::create(path) {
Ok(f) => {
cmd.stdout(Stdio::from(f));
cmd.stderr(Stdio::piped());
}
Err(e) => return (-1, format!("{program} failed to run: {e}")),
},
None => {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
}
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return (-1, format!("{program} failed to run: {e}")),
};
if let Some(mut pipe) = child.stdout.take() {
stdout_thread = Some(std::thread::spawn(move || {
let mut s = String::new();
let _ = pipe.read_to_string(&mut s);
s
}));
}
if let Some(mut pipe) = child.stderr.take() {
stderr_thread = Some(std::thread::spawn(move || {
let mut s = String::new();
let _ = pipe.read_to_string(&mut s);
s
}));
}
let deadline = Instant::now() + Duration::from_secs(EXTRACT_TIMEOUT_SECS);
let mut timed_out = false;
let status = loop {
match child.try_wait() {
Ok(Some(st)) => break Some(st),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
break None;
}
std::thread::sleep(Duration::from_millis(25));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return (-1, format!("{program} wait failed: {e}"));
}
}
};
let mut text = String::new();
if let Some(t) = stdout_thread {
text.push_str(&t.join().unwrap_or_default());
}
if let Some(t) = stderr_thread {
text.push_str(&t.join().unwrap_or_default());
}
if timed_out {
return (
-1,
format!("extraction timed out after {EXTRACT_TIMEOUT_SECS}s"),
);
}
let code = status.and_then(|s| s.code()).unwrap_or(-1);
let tail = text
.lines()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
(code, tail)
}
impl Extractor {
fn space_low(&self) -> bool {
match free_bytes(&self.out_root) {
Ok(bytes) => bytes < MIN_FREE_BYTES,
Err(e) => {
log::warn!(
"free-space check failed on {}: {e}",
self.out_root.display()
);
false
}
}
}
/// Create the destination directory, reproducing the python collision
/// semantics: if the stem is occupied by a *file*, retry as `<name>_dup`
/// (python's loop always rebuilds from the original name, so repeated
/// `_dup` collisions exhaust the 5 attempts); a parent that is a file
/// (NotADirectoryError) fails immediately.
fn create_dest(&self, dest: &Path) -> Result<PathBuf, String> {
let mut actual = dest.to_path_buf();
for _ in 0..5 {
match fs::create_dir_all(&actual) {
Ok(()) => return Ok(actual),
Err(e) => match e.kind() {
io::ErrorKind::AlreadyExists => {
let name = file_name_string(dest);
actual = dest.with_file_name(format!("{name}_dup"));
}
_ => {
return Err(format!(
"cannot create destination {}: {e}",
actual.display()
));
}
},
}
}
Err(format!(
"cannot create destination {} (repeated collisions)",
dest.display()
))
}
/// Extract `archive` into `dest`. Returns (ok, error, dest_used); on a
/// name collision the destination gets a `_dup` suffix instead of
/// crashing. Never panics on archive content; every error is reported.
pub fn extract(&self, archive: &Path, dest: &Path) -> (bool, String, PathBuf) {
let actual = match self.create_dest(dest) {
Ok(a) => a,
Err(err) => return (false, err, dest.to_path_buf()),
};
let name = file_name_string(archive);
let sfx = archive_suffixes(&name);
// Container formats: trust the magic bytes over the extension (python's
// 7z CLI sniffs content too). Compressed streams (.gz/.bz2/.xz) and tar
// stay extension-driven, where the tar-inside logic needs the name.
let kind: Option<String> = sniff_container(archive)
.map(str::to_string)
.or_else(|| sfx.last().cloned());
let res: Result<(), String> = match kind.as_deref() {
Some(".7z") => extract_7z(archive, &actual),
Some(".zip") => extract_zip(archive, &actual),
Some(".rar") => extract_rar(archive, &actual),
Some(outer @ (".gz" | ".bz2" | ".xz")) => {
if sfx.len() >= 2 && sfx[1] == ".tar" {
// <stem>.tar.gz|bz2|xz — decompress stream and unpack in
// one go (python does it in two 7z passes via the nested
// expansion; final tree content is identical).
extract_tar_cmd(archive, &actual, Some(outer))
} else {
// Bare compressed file (one layer, like a single `7z x`):
// writes dest/<name-without-suffix>; anything that is
// still an archive afterwards is picked up by the
// nested/sweep expansion.
decompress_bare_cmd(archive, &actual, &name, outer)
}
}
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 {
Ok(()) => (true, String::new(), actual),
Err(err) => (false, err, actual),
}
}
/// Move a failed archive under `<out_root>/.broken/<rel>`; the source
/// root is otherwise never modified. Returns the quarantine path.
pub fn move_to_broken(&self, path: &Path, src_root: &Path, note: &str) -> PathBuf {
let rel = path.strip_prefix(src_root).unwrap_or(path);
let dest = self.out_root.join(".broken").join(rel);
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
}
let mut final_dest = dest.clone();
if dest.exists() {
let mut name = dest
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".1");
final_dest = dest.with_file_name(name);
}
log::debug!(
"Quarantining {} -> {} ({note})",
path.display(),
final_dest.display()
);
if let Err(e) = fs::rename(path, &final_dest) {
// cross-device fallback: copy + remove (shutil.move semantics)
match fs::copy(path, &final_dest).and_then(|_| fs::remove_file(path)) {
Ok(()) => {}
Err(e2) => {
log::error!("Cannot quarantine {}: {e} / {e2}", path.display());
}
}
}
final_dest
}
/// Extract archives inside `dest` until none remain (BFS). Returns the
/// number of nested archives extracted.
pub fn expand_nested<W: Write>(
&self,
dest: &Path,
manifest: &mut W,
done: &mut HashSet<String>,
) -> usize {
let mut done_count = 0;
let mut queue: VecDeque<PathBuf> = VecDeque::new();
queue.push_back(dest.to_path_buf());
while let Some(cur) = queue.pop_front() {
for p in walk_files_sorted(&cur) {
let name = file_name_string(&p);
if !is_archive(&name) {
continue;
}
if is_macosx_junk(&p) {
let _ = fs::remove_file(&p);
log::info!("Junk (macOS AppleDouble) removed: {name}");
continue;
}
let stem_dir = p.with_file_name(display_unit(&name));
if done.contains(&rel_string(&self.out_root, &stem_dir)) {
// already extracted in a previous run; drop the archive copy
let _ = fs::remove_file(&p);
log::info!("Nested {name} already extracted - archive removed");
continue;
}
if self.space_low() {
log::error!(
"Out of disk space - skipping {} (kept in place)",
p.display()
);
continue;
}
let (ok, err, actual) = self.extract(&p, &stem_dir);
if !ok {
log::error!("FAILED nested extraction {}: {}", p.display(), err);
self.move_to_broken(&p, &self.out_root, &err);
continue;
}
write_manifest_line(
manifest,
&self.out_root,
&rel_to(&self.out_root, &p),
&actual,
);
done.insert(rel_string(&self.out_root, &actual));
let _ = fs::remove_file(&p); // archive copy consumed
done_count += 1;
queue.push_back(actual);
}
}
done_count
}
/// Extract every archive directly under `src_root` (any depth) into
/// `<out_root>/<src_root.name>/<rel-with-unit-name>` and mirror plain
/// files via hardlink (fallback: metadata-preserving copy). Returns
/// (extracted, mirrored, failed).
pub fn process_root<W: Write>(
&self,
src_root: &Path,
manifest: &mut W,
done: &mut HashSet<String>,
) -> (usize, usize, usize) {
let idx = file_name_string(src_root);
let mut extracted = 0;
let mut mirrored = 0;
let mut failed = 0;
for f in walk_files_sorted(src_root) {
let fname = file_name_string(&f);
if RUN_ARTIFACTS.contains(&fname.as_str())
|| fname.starts_with('.')
|| fname.ends_with(".part")
{
continue;
}
let Ok(rel) = f.strip_prefix(src_root).map(|r| r.to_path_buf()) else {
continue;
};
let rel_str = rel.to_string_lossy().into_owned();
if is_archive(&fname) {
let unit = display_unit(&fname);
let dest_rel: PathBuf = Path::new(&idx).join(rel.with_file_name(unit));
let dest_rel_str = dest_rel.to_string_lossy().into_owned();
// Skip on a manifest record, never on "dir non-empty": a
// crash mid-extraction leaves a partial dir that must be
// redone.
if done.contains(&dest_rel_str) {
log::info!("Skip (already extracted): {rel_str}");
continue;
}
if self.space_low() {
log::error!("Out of disk space - skipping archive {}", f.display());
failed += 1;
continue;
}
let dest = self.out_root.join(&dest_rel);
let (ok, err, actual) = self.extract(&f, &dest);
if !ok {
log::error!("FAILED {rel_str}: {err}");
self.move_to_broken(&f, src_root, &err);
failed += 1;
continue;
}
write_manifest_line(
manifest,
&self.out_root,
&Path::new(&idx).join(&rel),
&actual,
);
let actual_rel = rel_string(&self.out_root, &actual);
done.insert(actual_rel.clone());
extracted += 1;
log::info!("Extracted {rel_str} -> {actual_rel}");
// python discards the nested count here too
let _ = self.expand_nested(&actual, manifest, done);
} else {
let link_dest = self.out_root.join(&idx).join(&rel);
if link_dest.exists() {
continue;
}
if let Some(parent) = link_dest.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::hard_link(&f, &link_dest) {
// fallback: copy (fs::copy keeps permission bits, like copy2)
log::debug!("hardlink {rel_str} failed ({e}) - falling back to copy");
if let Err(e2) = fs::copy(&f, &link_dest) {
log::error!("Cannot mirror {rel_str}: {e2}");
continue;
}
}
mirrored += 1;
}
}
(extracted, mirrored, failed)
}
/// Expand any archive left anywhere in the tree (interrupted nested
/// expansions leave stranded copies even when the parent has a record).
pub fn sweep<W: Write>(&self, manifest: &mut W, done: &mut HashSet<String>) -> usize {
let mut swept = 0;
for p in walk_files_sorted(&self.out_root) {
let rel = rel_to(&self.out_root, &p);
if rel.components().any(|c| c.as_os_str() == ".broken") {
continue;
}
let name = file_name_string(&p);
if !is_archive(&name) {
continue;
}
if is_macosx_junk(&p) {
let _ = fs::remove_file(&p);
log::info!("Junk (macOS AppleDouble) removed: {name}");
continue;
}
let stem_dir = p.with_file_name(display_unit(&name));
if done.contains(&rel_string(&self.out_root, &stem_dir)) {
let _ = fs::remove_file(&p);
log::info!("Sweep: {name} already extracted - archive removed");
continue;
}
if self.space_low() {
log::error!("Out of disk space - sweep skipping {}", p.display());
continue;
}
let (ok, err, actual) = self.extract(&p, &stem_dir);
if !ok {
log::error!("FAILED sweep extraction {}: {}", p.display(), err);
self.move_to_broken(&p, &self.out_root, &err);
continue;
}
write_manifest_line(manifest, &self.out_root, &rel, &actual);
done.insert(rel_string(&self.out_root, &actual));
let _ = fs::remove_file(&p);
swept += 1;
self.expand_nested(&actual, manifest, done);
}
swept
}
/// Full run over `sources`: process each root, then one sweep. Failures
/// are quarantined and logged; the run never stops on individual errors.
pub fn run(&self, sources: &[PathBuf]) -> anyhow::Result<()> {
fs::create_dir_all(&self.out_root)?;
let manifest_path = self.out_root.join("extract_manifest.jsonl");
let mut done = load_done(&manifest_path);
log::info!("{} destinations already recorded in manifest", done.len());
let mut manifest = fs::OpenOptions::new()
.append(true)
.create(true)
.open(&manifest_path)?;
let mut total_extracted = 0;
let mut total_mirrored = 0;
let mut total_failed = 0;
for src in sources {
log::info!("== processing {}", src.display());
let (e, m, f) = self.process_root(src, &mut manifest, &mut done);
log::info!(
"== {}: {e} extracted, {m} mirrored, {f} failed",
file_name_string(src)
);
total_extracted += e;
total_mirrored += m;
total_failed += f;
}
let swept = self.sweep(&mut manifest, &mut done);
log::info!(
"TOTAL: {total_extracted} archives extracted, {total_mirrored} files mirrored, \
{total_failed} failed, {swept} swept"
);
if total_failed > 0 {
log::warn!(
"Failures were moved under {} - inspect and re-run",
self.out_root.join(".broken").display()
);
}
Ok(())
}
}
/// Dest dirs already recorded in the manifest (relative to out root).
/// Malformed lines are skipped.
pub fn load_done(manifest_path: &Path) -> HashSet<String> {
let mut done = HashSet::new();
let Ok(bytes) = fs::read(manifest_path) else {
return done;
};
for line in bytes.split(|&b| b == b'\n') {
let line = String::from_utf8_lossy(line);
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(dest) = value.get("dest").and_then(|d| d.as_str()) {
done.insert(dest.to_string());
}
}
}
done
}
/// 7z via sevenz-rust2. Empty password: python passes `-p ""` (no password,
/// avoids interactive prompts) — `Password::empty()` is the same thing; the
/// password-less helper is retried once in case the with-password path
/// rejects an empty password outright. Password-protected archives still
/// fail, exactly like the python version (no password known).
fn extract_7z(archive: &Path, dest: &Path) -> Result<(), String> {
match sevenz_rust2::decompress_file_with_password(
archive,
dest,
sevenz_rust2::Password::empty(),
) {
Ok(()) => Ok(()),
Err(first) => match sevenz_rust2::decompress_file(archive, dest) {
Ok(()) => Ok(()),
Err(second) => Err(format!("7z: {first} / {second}")),
},
}
}
/// zip via the zip crate, entry by entry. Traversal-safe: entries whose raw
/// name contains `..` or that cannot be enclosed under dest are skipped
/// (python relies on 7z being safe — same minimum).
fn extract_zip(archive: &Path, dest: &Path) -> Result<(), String> {
let file = File::open(archive).map_err(|e| format!("zip: {e}"))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("zip: {e}"))?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| format!("zip: {e}"))?;
if entry.name().split('/').any(|part| part == "..") {
log::warn!("zip: rejecting traversal entry {:?}", entry.name_raw());
continue;
}
let Some(path) = entry.enclosed_name() else {
log::warn!("zip: skipping unsafe entry {:?}", entry.name_raw());
continue;
};
let out_path = dest.join(&path);
if entry.is_dir() {
fs::create_dir_all(&out_path).map_err(|e| format!("zip: {e}"))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("zip: {e}"))?;
}
let mut out_file = File::create(&out_path).map_err(|e| format!("zip: {e}"))?;
io::copy(&mut entry, &mut out_file).map_err(|e| format!("zip: {e}"))?;
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
}
}
}
Ok(())
}
/// rar via the unrar crate (C library), no password. Entries with `..`
/// components are skipped rather than extracted. (Untested offline: .rar
/// archives cannot be created without the proprietary tool.)
fn extract_rar(archive: &Path, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("rar: {e}"))?;
let mut arc = unrar::Archive::new(archive)
.open_for_processing()
.map_err(|e| format!("rar: {e}"))?;
loop {
match arc.read_header() {
Ok(None) => break,
Ok(Some(header)) => {
let fname = header.entry().filename.clone();
if fname
.components()
.any(|c| matches!(c, Component::ParentDir))
{
log::warn!("rar: skipping entry with '..': {}", fname.display());
arc = header.skip().map_err(|e| format!("rar: {e}"))?;
continue;
}
arc = header
.extract_with_base(dest)
.map_err(|e| format!("rar: {e}"))?;
}
Err(e) => return Err(format!("rar: {e}")),
}
}
Ok(())
}
/// Tarballs via the system `tar` binary — the same shape as the python
/// version's `7z` subprocess call: merged output, rc 0/1 = ok (1 logs a
/// warning), 4h timeout. `decompress` selects -z/-j/-J or None for bare tar.
fn extract_tar_cmd(archive: &Path, dest: &Path, decompress: Option<&str>) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("tar: {e}"))?;
let mut args: Vec<std::ffi::OsString> = vec!["-x".into()];
if let Some(flag) = decompress {
args.push(
match flag {
".gz" => "-z",
".bz2" => "-j",
".xz" => "-J",
_ => unreachable!("only gz/bz2/xz reach this branch"),
}
.into(),
);
}
args.push("-f".into());
args.push(archive.as_os_str().to_os_string());
args.push("-C".into());
args.push(dest.as_os_str().to_os_string());
let (rc, tail) = run_extract_cmd("tar", &args, None);
match rc {
0 => Ok(()),
1 => {
log::warn!(
"tar reported warnings extracting {}",
file_name_string(archive)
);
Ok(())
}
_ => Err(format!("tar exit {rc}: {tail}")),
}
}
/// Single-layer decompress of a bare `.gz`/`.bz2`/`.xz` (or any trailing
/// compressed suffix that is not a tarball) into `dest/<inner name>` via
/// the system decompressor — one layer, like a single `7z x` call.
fn decompress_bare_cmd(archive: &Path, dest: &Path, name: &str, outer: &str) -> Result<(), String> {
let inner = &name[..name.len() - outer.len()];
fs::create_dir_all(dest).map_err(|e| format!("{outer}: {e}"))?;
let out_path = dest.join(inner);
let (program, what) = match outer {
".gz" => ("gzip", "gz"),
".bz2" => ("bzip2", "bz2"),
_ => ("xz", "xz"),
};
let args = vec![
std::ffi::OsString::from("-dc"),
archive.as_os_str().to_os_string(),
];
let (rc, tail) = run_extract_cmd(program, &args, Some(&out_path));
if rc == 0 {
Ok(())
} else {
Err(format!("{what} exit {rc}: {tail}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::collections::BTreeSet;
/// Write a real zip archive containing the given (path, content) files.
fn write_zip(path: &Path, files: &[(&str, Vec<u8>)]) {
let file = File::create(path).unwrap();
let mut w = zip::ZipWriter::new(file);
for (name, data) in files {
w.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
w.write_all(data).unwrap();
}
w.finish().unwrap();
}
/// Minimal CRC32 (IEEE) for the hand-built gzip container in tests.
fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, slot) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB88320 ^ (c >> 1)
} else {
c >> 1
};
}
*slot = c;
}
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc = table[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
/// Valid gzip container wrapping a single *stored* (uncompressed)
/// deflate block — no compression crate needed to build a test fixture.
fn gzip_stored(data: &[u8]) -> Vec<u8> {
let mut v = vec![0x1f, 0x8b, 0x08, 0, 0, 0, 0, 0, 0, 0x03];
v.push(0x01); // BFINAL=1, BTYPE=00 (stored)
let len = data.len() as u16;
v.extend_from_slice(&len.to_le_bytes());
v.extend_from_slice(&(!len).to_le_bytes());
v.extend_from_slice(data);
v.extend_from_slice(&crc32(data).to_le_bytes());
v.extend_from_slice(&(data.len() as u32).to_le_bytes());
v
}
/// Minimal USTAR archive with one file entry, terminated by two zero
/// blocks — readable by the system tar.
fn make_ustar(name: &str, data: &[u8]) -> Vec<u8> {
let mut block = vec![0u8; 512];
block[..name.len()].copy_from_slice(name.as_bytes());
block[100..108].copy_from_slice(b"0000644\0"); // mode
block[108..116].copy_from_slice(b"0000000\0"); // uid
block[116..124].copy_from_slice(b"0000000\0"); // gid
let size_field = format!("{:011o}\0", data.len());
block[124..136].copy_from_slice(size_field.as_bytes()); // size
block[136..148].copy_from_slice(b"00000000000\0"); // mtime
block[148..156].copy_from_slice(b" "); // chksum placeholder
block[156] = b'0'; // typeflag: regular file
block[257..263].copy_from_slice(b"ustar\0");
block[263..265].copy_from_slice(b"00");
let sum: u32 = block.iter().map(|&b| b as u32).sum();
let chk = format!("{:06o}\0 ", sum);
block[148..156].copy_from_slice(chk.as_bytes());
let mut out = block;
out.extend_from_slice(data);
while out.len() % 512 != 0 {
out.push(0);
}
out.extend(std::iter::repeat_n(0u8, 1024));
out
}
/// Base dir for test tempdirs: /tmp is often a small tmpfs (<
/// MIN_FREE_BYTES), which would trip the real free-space check; prefer
/// /var/tmp when it clears the threshold (same convention as the
/// python version's /var/tmp staging).
fn test_base() -> PathBuf {
let var_tmp = Path::new("/var/tmp");
if cfg!(unix)
&& free_bytes(var_tmp)
.map(|b| b >= MIN_FREE_BYTES)
.unwrap_or(false)
{
var_tmp.to_path_buf()
} else {
std::env::temp_dir()
}
}
/// Build a source root (`src1`) + output root (`out`) inside a
/// container tempdir; returns (container, src, out). The source root's
/// file name (`src1`) is what the extractor uses as the index folder.
fn build_fixtures() -> (tempfile::TempDir, PathBuf, PathBuf) {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Info)
.is_test(true)
.try_init();
let container = tempfile::tempdir_in(test_base()).unwrap();
let src = container.path().join("src1");
let out = container.path().join("out");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&out).unwrap();
let s = src.as_path();
// Pack.zip: files + a nested zip inside
let inner_path = s.join(".tmp_inner.zip");
write_zip(&inner_path, &[("beta.txt", b"inner payload".to_vec())]);
let inner_bytes = fs::read(&inner_path).unwrap();
fs::remove_file(&inner_path).unwrap();
write_zip(
&s.join("Pack.zip"),
&[
("alpha.txt", b"alpha payload".to_vec()),
("nested/inner.zip", inner_bytes),
],
);
// a real .7z
let gamma = s.join("gamma.txt");
fs::write(&gamma, b"sevenz payload").unwrap();
let sub = s.join("Sub");
fs::create_dir_all(&sub).unwrap();
sevenz_rust2::compress_to_path(&gamma, sub.join("The Twin Lance.7z")).expect("compress 7z");
fs::remove_file(&gamma).unwrap();
// a tar.gz (hand-built ustar + gzip-stored container)
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();
// plain file (mirrored via hardlink)
fs::write(s.join("notes.txt"), b"plain notes").unwrap();
// run artifacts / hidden / partial: must be skipped
fs::write(s.join("state.json"), b"{}").unwrap();
fs::write(s.join(".hidden.txt"), b"secret").unwrap();
fs::write(s.join("partial.part"), b"partial").unwrap();
(container, src, out)
}
fn inventory(root: &Path) -> BTreeSet<String> {
walk_files_sorted(root)
.iter()
.map(|p| rel_to(root, p).to_string_lossy().into_owned())
.collect()
}
fn find_file_named(root: &Path, name: &str) -> Option<PathBuf> {
walk_files_sorted(root).into_iter().find(|p| {
p.file_name()
.map(|n| n.to_string_lossy() == name)
.unwrap_or(false)
})
}
fn manifest_values(out: &Path) -> Vec<Value> {
let text = fs::read_to_string(out.join("extract_manifest.jsonl")).unwrap();
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn sniff_container_reads_magic_not_extension() {
let dir = tempfile::tempdir().unwrap();
// real zip content, .rar name -> sniffed as zip
let z = dir.path().join("x.rar");
write_zip(&z, &[("a.txt", b"hi".to_vec())]);
assert_eq!(sniff_container(&z), Some(".zip"));
// 7z magic, .zip name
let seven = dir.path().join("y.zip");
fs::write(&seven, [0x37u8, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0, 4]).unwrap();
assert_eq!(sniff_container(&seven), Some(".7z"));
// rar5 magic
let rar = dir.path().join("z.7z");
fs::write(&rar, b"Rar!\x1a\x07\x01\x00").unwrap();
assert_eq!(sniff_container(&rar), Some(".rar"));
// gzip is not a container: extension keeps driving it
let gz = dir.path().join("g.gz");
fs::write(&gz, [0x1f, 0x8b, 0x08]).unwrap();
assert_eq!(sniff_container(&gz), None);
}
#[test]
fn mislabeled_zip_extracts_via_sniffing() {
// /tmp is a small tmpfs; the free-space guard needs a real fs
let dir = test_base().join(format!("dlp-mislabel-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let src = dir.join("src");
let out = dir.join("out");
fs::create_dir_all(&src).unwrap();
// a real zip with a .rar extension: python's 7z CLI would extract it too
write_zip(
&src.join("Pack.rar"),
&[("unit/part.txt", b"payload".to_vec())],
);
let extractor = Extractor {
out_root: out.clone(),
};
extractor.run(&[src.clone()]).unwrap();
assert_eq!(
fs::read_to_string(out.join("src/Pack/unit/part.txt")).unwrap(),
"payload"
);
let broken_empty = fs::read_dir(out.join(".broken"))
.map(|d| d.count() == 0)
.unwrap_or(true);
assert!(broken_empty, "nothing should be quarantined");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn run_extracts_nests_quarantines_and_mirrors() {
let (_c, src, out) = build_fixtures();
let extractor = Extractor {
out_root: out.clone(),
};
extractor.run(&[src.clone()]).unwrap();
let s = src.as_path();
let o = out.as_path();
// extracted content, including the nested zip inside Pack.zip
assert_eq!(
fs::read_to_string(o.join("src1/Pack/alpha.txt")).unwrap(),
"alpha payload"
);
assert_eq!(
fs::read_to_string(o.join("src1/Pack/nested/inner/beta.txt")).unwrap(),
"inner payload"
);
assert!(
!o.join("src1/Pack/nested/inner.zip").exists(),
"nested archive must be consumed"
);
// 7z and tar.gz
assert!(
o.join("src1/Sub/The Twin Lance").is_dir(),
"7z unit dir missing"
);
assert!(find_file_named(&o.join("src1/Sub/The Twin Lance"), "gamma.txt").is_some());
assert_eq!(
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) {
let rel = rel_to(o, &p);
if rel.components().any(|c| c.as_os_str() == ".broken") {
continue;
}
assert!(
!is_archive(&file_name_string(&p)),
"archive left in extracted tree: {}",
p.display()
);
}
// garbage zip quarantined under .broken
let broken = o.join(".broken/bad.zip");
assert!(broken.is_file(), "bad.zip not quarantined");
assert_eq!(
fs::read(&broken).unwrap(),
b"this is not a zip archive at all"
);
// plain file mirrored: same inode on hardlink, else copy fallback
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let sm = fs::metadata(s.join("notes.txt")).unwrap();
let om = fs::metadata(o.join("src1/notes.txt")).unwrap();
if sm.ino() == om.ino() {
assert_eq!(sm.dev(), om.dev(), "hardlink must be on the same fs");
} else {
assert_eq!(sm.len(), om.len(), "hardlink fell back to copy");
}
}
assert_eq!(
fs::read_to_string(o.join("src1/notes.txt")).unwrap(),
"plain notes"
);
// run artifacts / hidden / partial never enter the tree
for junk in ["state.json", ".hidden.txt", "partial.part"] {
assert!(
find_file_named(o, junk).is_none(),
"{junk} leaked into output"
);
}
// source root keeps its originals (except the quarantined bad.zip)
assert!(s.join("Pack.zip").is_file());
assert!(s.join("notes.txt").is_file());
assert!(s.join("Sub/The Twin Lance.7z").is_file());
assert!(
!s.join("bad.zip").exists(),
"failed archive must be moved away"
);
// manifest: one line per extraction, exactly the 3 provenance keys
let lines = manifest_values(o);
let triples: BTreeSet<(String, String, String)> = lines
.iter()
.map(|v| {
let obj = v.as_object().unwrap();
assert_eq!(obj.len(), 3, "unexpected keys in manifest line: {v}");
(
v["archive"].as_str().unwrap().to_string(),
v["dest"].as_str().unwrap().to_string(),
v["unit"].as_str().unwrap().to_string(),
)
})
.collect();
let expected: BTreeSet<(String, String, String)> = [
("src1/Pack.zip", "src1/Pack", "Pack"),
(
"src1/Pack/nested/inner.zip",
"src1/Pack/nested/inner",
"inner",
),
(
"src1/Sub/The Twin Lance.7z",
"src1/Sub/The Twin Lance",
"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()))
.collect();
assert_eq!(triples, expected);
}
#[test]
fn rerun_is_idempotent() {
let (_c, src, out) = build_fixtures();
let extractor = Extractor {
out_root: out.clone(),
};
extractor.run(&[src.clone()]).unwrap();
let manifest_before = fs::read_to_string(out.join("extract_manifest.jsonl")).unwrap();
let inv_before = inventory(&out);
assert!(!manifest_before.is_empty());
extractor.run(&[src.clone()]).unwrap();
let manifest_after = fs::read_to_string(out.join("extract_manifest.jsonl")).unwrap();
assert_eq!(
manifest_before, manifest_after,
"re-run must not append manifest lines"
);
assert_eq!(
inv_before,
inventory(&out),
"re-run must not change the tree"
);
assert!(
!out.as_path().join("src1/Pack_dup").exists()
&& !out.as_path().join("src1/Pack_dup_dup").exists(),
"re-run must not create duplicate destinations"
);
// the previously extracted content is still intact
assert_eq!(
fs::read_to_string(out.as_path().join("src1/Pack/alpha.txt")).unwrap(),
"alpha payload"
);
}
#[test]
fn dest_collision_appends_dup() {
let dir = tempfile::tempdir_in(test_base()).unwrap();
let ex = Extractor {
out_root: dir.path().join("out"),
};
fs::create_dir_all(&ex.out_root).unwrap();
// dest occupied by a FILE -> the *_dup sibling is used (python:
// mkdir exist_ok raises FileExistsError -> append "_dup")
let dest = dir.path().join("d1");
fs::write(&dest, b"occupied").unwrap();
let actual = ex.create_dest(&dest).unwrap();
assert_eq!(actual, dest.with_file_name("d1_dup"));
assert!(actual.is_dir());
// dest and *_dup both occupied by files -> graceful failure after
// the 5 attempts (python's loop rebuilds "_dup" from the original
// name, so it keeps hitting the same path)
let dest2 = dir.path().join("d2");
fs::write(&dest2, b"occupied").unwrap();
fs::write(dir.path().join("d2_dup"), b"occupied too").unwrap();
let err = ex.create_dest(&dest2).unwrap_err();
assert!(err.contains("repeated collisions"), "{err}");
// parent path is a file (NotADirectoryError) -> immediate failure
let blocker = dir.path().join("blocker");
fs::write(&blocker, b"file").unwrap();
let err2 = ex.create_dest(&blocker.join("sub")).unwrap_err();
assert!(err2.contains("cannot create destination"), "{err2}");
}
#[test]
fn suffix_helpers_match_python() {
assert_eq!(archive_suffixes("x.tar.gz"), vec![".gz", ".tar"]);
assert_eq!(display_unit("x.tar.gz"), "x");
assert_eq!(archive_suffixes("The Twin Lance.7z"), vec![".7z"]);
assert_eq!(display_unit("The Twin Lance.7z"), "The Twin Lance");
assert_eq!(archive_suffixes("X.ZIP"), vec![".zip"]);
assert_eq!(display_unit("X.ZIP"), "X");
assert!(archive_suffixes("plain.txt").is_empty());
assert!(!is_archive("plain.txt"));
assert!(
archive_suffixes(".hidden").is_empty(),
"dotfiles have no suffix"
);
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]
fn macosx_junk_detection() {
assert!(is_macosx_junk(Path::new("out/1/__MACOSX/._x.zip")));
assert!(is_macosx_junk(Path::new("._x.zip")));
assert!(is_macosx_junk(Path::new("deep/nested/._resource")));
assert!(is_macosx_junk(Path::new("a/__MACOSX")));
assert!(!is_macosx_junk(Path::new("a.zip")));
assert!(!is_macosx_junk(Path::new("plain/notes.txt")));
assert!(
!is_macosx_junk(Path::new("macosx/_a.zip")),
"__MACOSX matching is case-sensitive like python"
);
}
}