use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::{Builder, Header};
const STUFFR: &str = env!("CARGO_BIN_EXE_stuffr");
static RUN_TOKEN: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
use std::hash::{BuildHasher, Hasher};
let n = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
format!("{}-{n:016x}", std::process::id())
});
struct Scratch(PathBuf);
impl std::ops::Deref for Scratch {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl AsRef<Path> for Scratch {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl AsRef<std::ffi::OsStr> for Scratch {
fn as_ref(&self) -> &std::ffi::OsStr {
self.0.as_os_str()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
match std::fs::symlink_metadata(&self.0) {
Ok(m) if m.is_dir() => {
let _ = std::fs::remove_dir_all(&self.0);
}
Ok(_) => {
let _ = std::fs::remove_file(&self.0);
}
Err(_) => {}
}
}
}
fn tmp(name: &str) -> Scratch {
let mut p = std::env::temp_dir();
p.push(format!("stuffr-cli-{}-{name}", *RUN_TOKEN));
Scratch(p)
}
#[test]
fn a_failing_test_leaves_no_scratch_behind() {
let seen = std::cell::RefCell::new(None::<PathBuf>);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let probe = tmp("probe");
std::fs::write(&probe, b"left behind?").unwrap();
*seen.borrow_mut() = Some(probe.to_path_buf());
panic!("a failing test, on purpose");
}));
assert!(r.is_err());
let probe = seen.into_inner().expect("the closure ran");
assert!(!probe.exists(), "{} survived a panic", probe.display());
}
#[test]
fn formats_now_reports_gzip() {
let out = Command::new(STUFFR).arg("formats").output().unwrap();
assert!(out.status.success());
let text = String::from_utf8(out.stdout).unwrap();
assert!(text.contains("gzip"), "gzip must appear: {text}");
assert!(
!text.contains("0 formats"),
"the build is no longer empty: {text}"
);
}
#[test]
fn info_names_the_format_and_the_rung() {
let src = tmp("info.txt");
let gz = tmp("info.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let out = Command::new(STUFFR)
.args(["info", gz.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success());
let text = String::from_utf8(out.stdout).unwrap();
assert!(text.contains("gzip"), "{text}");
assert!(text.contains("exact"), "a file is read exactly: {text}");
}
#[test]
fn info_json_is_structured_and_names_how_the_format_was_detected() {
let src = tmp("json2.txt");
let gz = tmp("json2.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let out = Command::new(STUFFR)
.args(["info", "--json", gz.to_str().unwrap()])
.output()
.unwrap();
let text = String::from_utf8(out.stdout).unwrap();
let v: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("not JSON: {e}\n{text}"));
assert_eq!(v["format"], "gzip");
assert_eq!(v["rung"], "exact");
assert_eq!(
v["detected_by"], "magic",
"a real gzip file is identified by its magic, not its name"
);
assert!(v["warnings"].as_array().unwrap().is_empty());
}
#[test]
fn info_reports_the_resolved_memory_limit() {
let src = tmp("info-mem.txt");
let gz = tmp("info-mem.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let out = Command::new(STUFFR)
.args(["info", "--memory-limit", "512M", gz.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success());
let text = String::from_utf8(out.stdout).unwrap();
assert!(
text.contains("512 MiB"),
"an explicit --memory-limit must be reflected in the report: {text}"
);
let out = Command::new(STUFFR)
.args([
"info",
"--json",
"--memory-limit",
"512M",
gz.to_str().unwrap(),
])
.output()
.unwrap();
let text = String::from_utf8(out.stdout).unwrap();
let v: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("not JSON: {e}\n{text}"));
assert_eq!(v["memory_limit"], 512 * 1024 * 1024);
}
#[test]
fn info_withholds_the_fidelity_claim_only_where_a_forward_read_could_lose_something() {
let dir = tmp("info-fidelity-matrix");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let one = dir.join("one.txt");
std::fs::write(&one, b"payload").unwrap();
let mut archives = Vec::new();
for (ext, has_trailing_index) in [("zip", true), ("tar", false), ("cpio", false), ("a", false)]
{
let path = dir.join(format!("bundle.{ext}"));
assert!(
Command::new(STUFFR)
.args(["pack", one.to_str().unwrap(), "-o", path.to_str().unwrap()])
.status()
.unwrap()
.success(),
"packing a .{ext} must succeed"
);
archives.push((ext, path, has_trailing_index));
}
for (ext, path, has_trailing_index) in &archives {
let bytes = std::fs::read(path).unwrap();
let text = String::from_utf8(
Command::new(STUFFR)
.args(["info", path.to_str().unwrap()])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(text.contains("exact"), ".{ext} from a file: {text}");
assert!(
text.contains("nothing approximated"),
".{ext} from a FILE was read exactly — the claim is true and must be made: {text}"
);
let text = info_over_stdin(&bytes);
assert!(text.contains("forward-only"), ".{ext} on a pipe: {text}");
if *has_trailing_index {
assert!(
text.contains("not evaluated"),
".{ext} keeps its index at the END of the stream, so a forward read may well \
have approximated something and info has not looked: {text}"
);
assert!(!text.contains("nothing approximated"), ".{ext}: {text}");
} else {
assert!(
text.contains("nothing approximated"),
".{ext} carries every entry's metadata inline, so a forward read loses \
NOTHING — withholding the claim here is a false negative: {text}"
);
assert!(!text.contains("not evaluated"), ".{ext}: {text}");
}
let mut child = Command::new(STUFFR)
.args(["test", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(&bytes).unwrap();
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
if *has_trailing_index {
assert!(
stderr.contains("fidelity warning"),
".{ext} on a pipe must really have warnings, or info would be right to \
claim none: {stderr}"
);
} else {
assert!(
out.status.success() && !stderr.contains("fidelity warning"),
".{ext} on a pipe must really have NO warnings, which is what makes info's \
claim true: {stderr}"
);
}
}
}
#[test]
fn info_on_a_piped_zip_does_not_claim_nothing_was_approximated() {
let dir = tmp("info-zip-fidelity");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let one = dir.join("one.txt");
std::fs::write(&one, b"payload").unwrap();
let zip = dir.join("bundle.zip");
assert!(
Command::new(STUFFR)
.args(["pack", one.to_str().unwrap(), "-o", zip.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let bytes = std::fs::read(&zip).unwrap();
let text = info_over_stdin(&bytes);
assert!(
text.contains("forward-only"),
"the rung is still real and still reported: {text}"
);
assert!(
!text.contains("nothing approximated"),
"info has not opened the archive, so it must not claim this: {text}"
);
assert!(
text.contains("not evaluated"),
"the line must say the conclusion was withheld, not vanish: {text}"
);
let mut child = Command::new(STUFFR)
.args(["test", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(&bytes).unwrap();
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("central/trailing index was never read")
&& stderr.contains("entry count is unknown"),
"`test` opens the archive and must still report both losses: {stderr}"
);
let out = Command::new(STUFFR)
.args(["info", zip.to_str().unwrap()])
.output()
.unwrap();
let text = String::from_utf8(out.stdout).unwrap();
assert!(text.contains("exact"), "a file is read exactly: {text}");
assert!(
text.contains("nothing approximated"),
"a seekable zip reads its central directory, so nothing is approximated: {text}"
);
}
#[test]
fn info_on_a_bare_codec_stream_still_reports_nothing_approximated() {
let src = tmp("info-codec-fidelity.txt");
let gz = tmp("info-codec-fidelity.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let bytes = std::fs::read(&gz).unwrap();
for (what, text) in [
(
"a file",
String::from_utf8(
Command::new(STUFFR)
.args(["info", gz.to_str().unwrap()])
.output()
.unwrap()
.stdout,
)
.unwrap(),
),
("a pipe", info_over_stdin(&bytes)),
] {
assert!(
text.contains("nothing approximated"),
"{what}: a bare codec stream has no container to open, so this claim is real \
and must survive: {text}"
);
assert!(!text.contains("not evaluated"), "{what}: {text}");
}
let out = Command::new(STUFFR)
.args(["info", "--json", gz.to_str().unwrap()])
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&String::from_utf8(out.stdout).unwrap())
.expect("info --json must be JSON");
assert_eq!(v["fidelity_evaluated"], true, "{v}");
}
fn info_over_stdin(bytes: &[u8]) -> String {
let mut child = Command::new(STUFFR)
.args(["info", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(bytes).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "stuffr info - must succeed");
String::from_utf8(out.stdout).unwrap()
}
#[test]
fn info_over_a_pipe_reports_forward_only_not_exact() {
let src = tmp("pipe-info.txt");
let gz = tmp("pipe-info.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let gz_bytes = std::fs::read(&gz).unwrap();
let mut child = Command::new(STUFFR)
.args(["info", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(&gz_bytes).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let text = String::from_utf8(out.stdout).unwrap();
assert!(text.contains("gzip"), "{text}");
assert!(
text.contains("forward-only"),
"a pipe cannot seek, so the rung must not be exact: {text}"
);
}
#[test]
fn cat_exits_cleanly_when_the_reader_closes_early() {
let src = tmp("broken-pipe.txt");
let gz = tmp("broken-pipe.txt.gz");
let _ = std::fs::remove_file(&gz);
let payload = "the quick brown fox jumps over the lazy dog\n".repeat(500_000);
std::fs::write(&src, payload.as_bytes()).unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let mut child = Command::new(STUFFR)
.args(["cat", gz.to_str().unwrap()])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut prefix = [0u8; 16];
stdout
.read_exact(&mut prefix)
.expect("at least a few bytes before we stop reading");
drop(stdout);
let mut stderr = child.stderr.take().unwrap();
let mut err_text = String::new();
stderr.read_to_string(&mut err_text).unwrap();
let status = child.wait().unwrap();
assert!(
status.success(),
"an early-closing reader must not look like a failure: {status:?}"
);
assert!(
err_text.is_empty(),
"a clean broken-pipe exit prints nothing: {err_text}"
);
}
#[test]
fn formats_exits_cleanly_on_a_closed_stdout() {
let mut child = Command::new(STUFFR)
.arg("formats")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stdout.take().unwrap());
let mut stderr = child.stderr.take().unwrap();
let mut err_text = String::new();
stderr.read_to_string(&mut err_text).unwrap();
let status = child.wait().unwrap();
assert!(
status.success(),
"an early-closing reader must not look like a failure: {status:?}"
);
assert!(
err_text.is_empty(),
"a clean broken-pipe exit prints nothing: {err_text}"
);
}
#[test]
fn pack_then_unpack_round_trips_through_the_binary() {
let src = tmp("e2e.txt");
let gz = tmp("e2e.txt.gz");
let back = tmp("e2e-back.txt");
let _ = std::fs::remove_file(&gz);
let _ = std::fs::remove_file(&back);
let plain = b"the quick brown fox ".repeat(2000);
std::fs::write(&src, &plain).unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap()])
.status()
.unwrap()
.success()
);
assert!(
gz.exists(),
"pack must have written the .gz beside the input"
);
assert!(src.exists(), "pack must not destroy its input");
assert!(
Command::new(STUFFR)
.args(["unpack", gz.to_str().unwrap(), "-o", back.to_str().unwrap()])
.status()
.unwrap()
.success()
);
assert_eq!(
std::fs::read(&back).unwrap(),
plain,
"round trip must be byte-identical"
);
for p in [&src, &gz, &back] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn no_sync_is_accepted_by_pack_and_unpack_and_still_round_trips() {
let src = tmp("nosync-e2e.txt");
let gz = tmp("nosync-e2e.txt.gz");
let back = tmp("nosync-e2e-back.txt");
let _ = std::fs::remove_file(&gz);
let _ = std::fs::remove_file(&back);
let plain = b"the quick brown fox ".repeat(500);
std::fs::write(&src, &plain).unwrap();
assert!(
Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"--no-sync",
"--format",
"gzip"
])
.status()
.unwrap()
.success()
);
assert!(gz.exists(), "pack --no-sync must still write the output");
assert!(
Command::new(STUFFR)
.args([
"unpack",
gz.to_str().unwrap(),
"-o",
back.to_str().unwrap(),
"--no-sync",
])
.status()
.unwrap()
.success()
);
assert_eq!(
std::fs::read(&back).unwrap(),
plain,
"--no-sync must cost durability, never correctness"
);
for p in [&src, &gz, &back] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn cat_reads_a_stream_on_stdin() {
use std::io::Write;
use std::process::Stdio;
let src = tmp("pipe.txt");
let gz = tmp("pipe.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"needle in a haystack").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let packed = std::fs::read(&gz).unwrap();
let mut child = Command::new(STUFFR)
.args(["cat", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(&packed).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"needle in a haystack");
}
#[test]
fn pack_reads_input_from_stdin() {
let out = tmp("stdin-pack.gz");
let _ = std::fs::remove_file(&out);
let plain = b"stdin plaintext, packed then verified byte for byte".repeat(200);
let mut child = Command::new(STUFFR)
.args(["pack", "-", "-o", out.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(&plain).unwrap();
let result = child.wait_with_output().unwrap();
assert!(
result.status.success(),
"stderr: {}",
String::from_utf8_lossy(&result.stderr)
);
assert!(out.exists(), "pack must have written the output file");
let cat_out = Command::new(STUFFR)
.args(["cat", out.to_str().unwrap()])
.output()
.unwrap();
assert!(cat_out.status.success());
assert_eq!(cat_out.stdout, plain, "round trip must be byte-identical");
}
#[test]
fn pack_writes_output_to_stdout() {
let src = tmp("stdout-pack.txt");
let _ = std::fs::remove_file(&src);
let plain = b"payload written straight through to stdout by pack".repeat(200);
std::fs::write(&src, &plain).unwrap();
let pack_out = Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip", "-o", "-"])
.output()
.unwrap();
assert!(
pack_out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&pack_out.stderr)
);
assert_eq!(
&pack_out.stdout[..2],
&[0x1f, 0x8b],
"must be a real gzip stream"
);
let mut child = Command::new(STUFFR)
.args(["cat", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(&pack_out.stdout)
.unwrap();
let cat_out = child.wait_with_output().unwrap();
assert!(cat_out.status.success());
assert_eq!(
cat_out.stdout, plain,
"round trip through a piped stdout destination must be byte-identical"
);
}
#[test]
fn pack_over_a_pipe_says_no_fidelity_loss_not_a_read_rung() {
let out = tmp("pack-rung-pipe.gz");
let _ = std::fs::remove_file(&out);
let plain = b"payload".repeat(50);
let mut child = Command::new(STUFFR)
.args(["pack", "-", "-o", out.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(&plain).unwrap();
let result = child.wait_with_output().unwrap();
assert!(result.status.success());
let err_text = String::from_utf8_lossy(&result.stderr);
assert!(err_text.contains("no fidelity loss"), "{err_text}");
assert!(
!err_text.contains("forward-only") && !err_text.contains("exact"),
"a write line makes no read-rung claim: {err_text}"
);
}
#[test]
fn pack_of_a_seekable_file_says_no_fidelity_loss() {
let src = tmp("pack-rung-file.txt");
let gz = tmp("pack-rung-file.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
let out = Command::new(STUFFR)
.args(["pack", src.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success());
let err_text = String::from_utf8_lossy(&out.stderr);
assert!(err_text.contains("no fidelity loss"), "{err_text}");
}
#[test]
fn an_existing_output_is_refused_with_exit_two() {
let src = tmp("clash.txt");
let gz = tmp("clash.txt.gz");
std::fs::write(&src, b"payload").unwrap();
std::fs::write(&gz, b"PRE-EXISTING").unwrap();
let out = Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
assert!(String::from_utf8_lossy(&out.stderr).contains("--force"));
assert_eq!(
std::fs::read(&gz).unwrap(),
b"PRE-EXISTING",
"the refused command changed nothing"
);
}
#[test]
fn a_bomb_exits_six_and_leaves_no_partial_output() {
let src = tmp("boom.bin");
let gz = tmp("boom.bin.gz");
let out_path = tmp("boom-out.bin");
let _ = std::fs::remove_file(&gz);
let _ = std::fs::remove_file(&out_path);
std::fs::write(&src, vec![0u8; 2 * 1024 * 1024]).unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let out = Command::new(STUFFR)
.args([
"unpack",
gz.to_str().unwrap(),
"-o",
out_path.to_str().unwrap(),
"--max-ratio",
"100",
])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(6), "resource limits exit 6");
assert!(
!out_path.exists(),
"a refused decode must leave no partial file"
);
}
#[test]
fn an_unknown_verb_exits_two() {
let out = Command::new(STUFFR).arg("bogus").output().unwrap();
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn examples_page_covers_every_format_and_flag() {
use clap::CommandFactory;
use stuffr_cli::cli::Cli;
let out = Command::new(STUFFR).arg("--examples").output().unwrap();
assert!(out.status.success(), "stuffr --examples must exit 0");
let text = String::from_utf8(out.stdout).unwrap();
for row in stuffr::registry().matrix() {
let id = row.id.as_str();
assert!(
text.contains(id),
"the examples page does not mention the `{id}` format"
);
}
let root = Cli::command();
for sub in root.get_subcommands() {
let name = sub.get_name();
if name == "help" {
continue;
}
assert!(
text.contains(name),
"the examples page does not mention the `{name}` subcommand"
);
for arg in sub.get_arguments() {
let Some(long) = arg.get_long() else {
continue;
};
if long == "help" || long == "version" {
continue;
}
let flag = format!("--{long}");
assert!(
text.contains(flag.as_str()),
"the examples page does not mention {flag}"
);
}
}
}
#[test]
fn the_examples_page_cannot_carry_a_stale_count_or_a_shipped_still_to_come() {
let out = Command::new(STUFFR).arg("--examples").output().unwrap();
assert!(out.status.success());
let text = String::from_utf8(out.stdout).unwrap();
let rows = stuffr::registry().matrix();
let containers: Vec<&str> = rows
.iter()
.filter(|r| stuffr::registry().container(r.id).is_some())
.map(|r| r.id.as_str())
.collect();
let codecs: Vec<&str> = rows
.iter()
.filter(|r| stuffr::registry().container(r.id).is_none())
.map(|r| r.id.as_str())
.collect();
const LEGACY_ONLY_CONTAINERS: &[&str] = &["lha", "arj", "arc", "zoo"];
let base_containers: Vec<&str> = containers
.iter()
.copied()
.filter(|id| !LEGACY_ONLY_CONTAINERS.contains(id))
.collect();
let full_container_count = containers.len();
for sentence in text.split(['.', '\n']) {
let lower = sentence.to_lowercase();
if !(lower.contains("still to come") || lower.contains("not yet supported")) {
continue;
}
for id in rows.iter().map(|r| r.id.as_str()) {
assert!(
!sentence.contains(id),
"the page says `{id}` is still to come, but this build registers it: \
{sentence:?}"
);
}
}
const NUMBERS: &[(&str, usize)] = &[
("one", 1),
("two", 2),
("three", 3),
("four", 4),
("five", 5),
("six", 6),
("seven", 7),
("eight", 8),
("nine", 9),
("ten", 10),
("eleven", 11),
("twelve", 12),
("thirteen", 13),
("fourteen", 14),
("fifteen", 15),
("sixteen", 16),
("seventeen", 17),
("eighteen", 18),
("nineteen", 19),
("twenty", 20),
];
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
let words: Vec<&str> = flat.split(' ').collect();
for pair in words.windows(2) {
let (word, next) = (
pair[0].trim_matches(|c: char| !c.is_alphanumeric()),
pair[1],
);
let Some((_, value)) = NUMBERS.iter().find(|(w, _)| *w == word.to_lowercase()) else {
continue;
};
let noun = next
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
if noun == "containers" {
assert!(
*value == base_containers.len() || *value == full_container_count,
"the page says `{word} containers`, but this build has {} base containers \
({} once every legacy container is counted) — the counts on this page are \
what a user trusts before running `stuffr formats`",
base_containers.len(),
full_container_count
);
continue;
}
let expected = match noun.as_str() {
"formats" | "rows" => rows.len(),
"codecs" => codecs.len(),
_ => continue,
};
assert_eq!(
*value, expected,
"the page says `{word} {noun}`, but this build has {expected} — the counts \
on this page are what a user trusts before running `stuffr formats`"
);
}
let counting_sentences: Vec<&str> = flat
.split(". ")
.filter(|sentence| {
let l = sentence.to_lowercase();
NUMBERS
.iter()
.any(|(w, _)| l.contains(&format!("{w} containers")))
})
.collect();
assert!(
!counting_sentences.is_empty(),
"the page must state how many containers this build has"
);
let names_them_all = counting_sentences.iter().any(|sentence| {
containers.iter().all(|id| {
sentence
.split(|c: char| !c.is_alphanumeric())
.any(|word| word == *id)
})
});
assert!(
names_them_all,
"no sentence both counts the containers and names every one of them \
({containers:?}); the counting sentences are {counting_sentences:?}"
);
}
#[test]
fn examples_pages_first_worked_example_runs_as_documented() {
let dir = tmp("examples-first-example-dir");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let notes = dir.join("notes.txt");
let packed = dir.join("notes.txt.gz");
std::fs::write(¬es, b"the documented example's payload").unwrap();
let out = Command::new(STUFFR)
.arg("pack")
.arg("notes.txt")
.current_dir(&dir)
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
packed.exists(),
"the page says this writes notes.txt.gz beside it"
);
assert!(
notes.exists(),
"the page says the input, notes.txt, is left alone"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_in_a_walked_tree_is_never_refused_and_the_page_may_not_say_it_is() {
let dir = tmp("examples-symlink-verdict");
let _ = std::fs::remove_dir_all(&dir);
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("file.txt"), b"payload").unwrap();
std::os::unix::fs::symlink("file.txt", tree.join("link.txt")).unwrap();
let registry = stuffr::registry();
let mut measured = 0usize;
for row in registry.matrix() {
if registry.container(row.id).is_none() || !row.write {
continue;
}
let id = row.id.as_str();
let out_path = dir.join(format!("out-{id}"));
for strict in [false, true] {
let _ = std::fs::remove_file(&out_path);
let mut cmd = Command::new(STUFFR);
cmd.arg("pack")
.arg(&tree)
.arg("-o")
.arg(&out_path)
.arg("--format")
.arg(id);
if strict {
cmd.arg("--strict-fidelity");
}
let out = cmd.output().unwrap();
let code = out.status.code();
assert!(
matches!(code, Some(0) | Some(4)),
"`pack` over a tree containing a symlink answered exit {code:?} for `{id}` \
(strict-fidelity: {strict}). A kind a container cannot store is a fidelity \
LOSS, never a refusal — and exit 3 in particular is what `examples.txt` \
wrongly promised for `arj` until Phase 3c's final review measured it. \
stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
measured += 1;
}
assert!(
measured >= 6,
"only {measured} writable containers were exercised; this build registers six, so the \
registry walk has stopped finding them and the measurement proves nothing"
);
let _ = std::fs::remove_dir_all(&dir);
let out = Command::new(STUFFR).arg("--examples").output().unwrap();
assert!(out.status.success(), "stuffr --examples must exit 0");
let text = String::from_utf8(out.stdout).unwrap();
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
for sentence in flat.split(". ") {
let lower = sentence.to_lowercase();
if !lower.contains("symlink") {
continue;
}
if !(lower.contains("refused") || lower.contains("refuses")) {
continue;
}
assert!(
!lower.contains("exit 3"),
"the examples page says a symlink is refused at exit 3, which the measurement \
above proves no writable container does: {sentence:?}"
);
}
}
#[test]
fn pack_refuses_a_read_only_legacy_format_clearly() {
let registry = stuffr::registry();
let dir = tmp("legacy-format-refusal-dir");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("notes.txt");
std::fs::write(&src, b"payload").unwrap();
for (name, out_ext) in [("arc", "arc"), ("zoo", "zoo")] {
let registered = registry.container(stuffr::FormatId::new(name)).is_some()
|| registry.codec(stuffr::FormatId::new(name)).is_some();
if !registered {
continue;
}
let dst = dir.join(format!("out.{out_ext}"));
let res = Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"--format",
name,
"-o",
dst.to_str().unwrap(),
])
.output()
.unwrap();
let err = String::from_utf8_lossy(&res.stderr);
assert_eq!(
res.status.code(),
Some(3),
"`--format {name}` must refuse at exit 3 (this build cannot do that), got \
{:?}; stderr: {err}",
res.status.code()
);
let lower = err.to_lowercase();
assert!(
lower.contains(&format!(
"`{name}` can be read but not written by this build"
)),
"`--format {name}`'s refusal must be the one shared capability sentence, \
naming the format, rather than a per-format phrasing: {err}"
);
assert!(
!dst.exists(),
"a refused pack must not leave a partial `{}` behind",
dst.display()
);
}
}
#[test]
fn pack_writes_a_compress_stream_the_system_tool_reads() {
let uncompress_bin = require_bin("uncompress");
let dir = tmp("compress-write-dir");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("notes.txt");
let plain = b"the quick brown fox jumps over the lazy dog\n".repeat(200);
std::fs::write(&src, &plain).unwrap();
let dst = dir.join("notes.txt.Z");
let res = Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"--format",
"compress",
"-o",
dst.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
res.status.success(),
"pack --format compress must now succeed: {}",
String::from_utf8_lossy(&res.stderr)
);
assert!(dst.exists(), "pack must have written {}", dst.display());
let out = Command::new(&uncompress_bin)
.arg("-c")
.arg(&dst)
.output()
.unwrap();
assert!(
out.status.success(),
"the system uncompress must read this build's own .Z output: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.stdout, plain,
"the system uncompress's output must match the original plaintext exactly"
);
let cat = Command::new(STUFFR)
.args(["cat", dst.to_str().unwrap()])
.output()
.unwrap();
assert!(cat.status.success());
assert_eq!(cat.stdout, plain);
}
#[test]
fn pack_writes_an_lha_archive_the_system_tool_reads() {
let lha_bin = require_lhasa();
let banner = lhasa_banner(&lha_bin);
let dir = tmp("lha-write-dir");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("tree/sub")).unwrap();
let alpha = b"the quick brown fox jumps over the lazy dog\n".repeat(120);
std::fs::write(dir.join("tree/alpha.txt"), &alpha).unwrap();
std::fs::write(dir.join("tree/sub/beta.bin"), b"beta\n").unwrap();
let dst = dir.join("out.lzh");
let res = Command::new(STUFFR)
.args([
"pack",
dir.join("tree").to_str().unwrap(),
"--format",
"lha",
"-o",
dst.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
res.status.success(),
"pack --format lha must now succeed: {}",
String::from_utf8_lossy(&res.stderr)
);
assert!(dst.exists(), "pack must have written {}", dst.display());
let test = Command::new(&lha_bin).arg("t").arg(&dst).output().unwrap();
assert!(
test.status.success(),
"{banner} must accept an archive stuffr wrote: {}\n{}",
String::from_utf8_lossy(&test.stdout),
String::from_utf8_lossy(&test.stderr)
);
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
let extract = Command::new(&lha_bin)
.arg(format!("xfw={}", out.display()))
.arg(&dst)
.output()
.unwrap();
assert!(
extract.status.success(),
"{banner} must extract it: {}",
String::from_utf8_lossy(&extract.stderr)
);
assert_eq!(std::fs::read(out.join("tree/alpha.txt")).unwrap(), alpha);
assert_eq!(
std::fs::read(out.join("tree/sub/beta.bin")).unwrap(),
b"beta\n"
);
let list = Command::new(STUFFR)
.args(["list", dst.to_str().unwrap()])
.output()
.unwrap();
assert!(list.status.success());
let listing = String::from_utf8_lossy(&list.stdout);
for name in ["tree/alpha.txt", "tree/sub/beta.bin"] {
assert!(
listing.contains(name),
"`stuffr list` lost {name}: {listing}"
);
}
}
#[test]
fn pack_writes_an_arj_archive_stuffr_reads_back() {
let dir = tmp("arj-write-dir");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("tree/sub")).unwrap();
let alpha = b"the quick brown fox jumps over the lazy dog\n".repeat(120);
std::fs::write(dir.join("tree/alpha.txt"), &alpha).unwrap();
std::fs::write(dir.join("tree/sub/beta.bin"), b"beta\n").unwrap();
let dst = dir.join("out.arj");
let res = Command::new(STUFFR)
.args([
"pack",
dir.join("tree").to_str().unwrap(),
"--format",
"arj",
"-o",
dst.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
res.status.success(),
"pack --format arj must now succeed: {}",
String::from_utf8_lossy(&res.stderr)
);
assert!(dst.exists(), "pack must have written {}", dst.display());
let head = std::fs::read(&dst).unwrap();
assert_eq!(
&head[0..2],
&[0x60, 0xEA],
"an ARJ archive opens with its own header id"
);
let verify = Command::new(STUFFR)
.args(["test", dst.to_str().unwrap()])
.output()
.unwrap();
assert!(
verify.status.success(),
"`stuffr test` must accept an archive stuffr wrote: {}",
String::from_utf8_lossy(&verify.stderr)
);
let list = Command::new(STUFFR)
.args(["list", dst.to_str().unwrap()])
.output()
.unwrap();
assert!(list.status.success());
let listing = String::from_utf8_lossy(&list.stdout);
for name in ["tree/alpha.txt", "tree/sub/beta.bin"] {
assert!(
listing.contains(name),
"`stuffr list` lost {name}: {listing}"
);
}
let out = dir.join("out");
let unpack = Command::new(STUFFR)
.args(["unpack", dst.to_str().unwrap(), "-C", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
unpack.status.success(),
"unpack must restore the tree: {}",
String::from_utf8_lossy(&unpack.stderr)
);
assert_eq!(std::fs::read(out.join("tree/alpha.txt")).unwrap(), alpha);
assert_eq!(
std::fs::read(out.join("tree/sub/beta.bin")).unwrap(),
b"beta\n"
);
assert!(
out.join("tree/sub").is_dir(),
"the directory entries must come back as directories, which is what \
ContainerCaps::stores_dirs claims"
);
}
#[test]
fn a_corrupt_archive_exits_five_not_one() {
let src = tmp("cli-corrupt.txt");
let gz = tmp("cli-corrupt.txt.gz");
let out = tmp("cli-corrupt-out.txt");
let _ = std::fs::remove_file(&gz);
let _ = std::fs::remove_file(&out);
std::fs::write(&src, b"the quick brown fox ".repeat(200)).unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let mut bytes = std::fs::read(&gz).unwrap();
let mid = bytes.len() / 2;
bytes[mid] ^= 0xFF;
std::fs::write(&gz, &bytes).unwrap();
let res = Command::new(STUFFR)
.args(["unpack", gz.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(
res.status.code(),
Some(5),
"a corrupt archive must be distinguishable from a full disk"
);
}
#[test]
fn unpack_accepts_an_explicit_format() {
let src = tmp("fmt-unpack.txt");
let gz = tmp("fmt-unpack.txt.gz");
let out = tmp("fmt-unpack-out.txt");
let _ = std::fs::remove_file(&gz);
let _ = std::fs::remove_file(&out);
std::fs::write(&src, b"payload").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let res = Command::new(STUFFR)
.args([
"unpack",
gz.to_str().unwrap(),
"--format",
"gzip",
"-o",
out.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
res.status.success(),
"stderr: {}",
String::from_utf8_lossy(&res.stderr)
);
assert_eq!(std::fs::read(&out).unwrap(), b"payload");
for p in [&src, &gz, &out] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn cat_accepts_an_explicit_format() {
let src = tmp("fmt-cat.txt");
let gz = tmp("fmt-cat.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let res = Command::new(STUFFR)
.args(["cat", gz.to_str().unwrap(), "--format", "gzip"])
.output()
.unwrap();
assert!(
res.status.success(),
"stderr: {}",
String::from_utf8_lossy(&res.stderr)
);
assert_eq!(res.stdout, b"payload");
}
#[test]
fn pack_with_no_flags_defaults_to_gzip() {
let src = tmp("bare-default.txt");
let gz = tmp("bare-default.txt.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, b"payload").unwrap();
let out = Command::new(STUFFR)
.args(["pack", src.to_str().unwrap()])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
gz.exists(),
"a bare pack with no --format must default to gzip and write {}.gz",
src.display()
);
}
#[test]
fn unpack_format_flag_is_honoured_not_merely_accepted() {
let src = tmp("fmt-deflate.txt");
let packed = tmp("fmt-deflate.dat");
let out = tmp("fmt-deflate-out.txt");
for p in [&packed, &out] {
let _ = std::fs::remove_file(p);
}
let payload = b"the quick brown fox jumps over the lazy dog".repeat(50);
std::fs::write(&src, &payload).unwrap();
assert!(
Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"--format",
"deflate",
"-o",
packed.to_str().unwrap(),
])
.status()
.unwrap()
.success()
);
let no_format = Command::new(STUFFR)
.args([
"unpack",
packed.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--force",
])
.output()
.unwrap();
assert!(
!no_format.status.success(),
"a raw deflate stream must NOT be detectable without --format"
);
let with_format = Command::new(STUFFR)
.args([
"unpack",
packed.to_str().unwrap(),
"--format",
"deflate",
"-o",
out.to_str().unwrap(),
"--force",
])
.output()
.unwrap();
assert!(
with_format.status.success(),
"stderr: {}",
String::from_utf8_lossy(&with_format.stderr)
);
assert_eq!(std::fs::read(&out).unwrap(), payload);
for p in [&src, &packed, &out] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn cat_format_flag_is_honoured_not_merely_accepted() {
let src = tmp("fmt-cat-deflate.txt");
let packed = tmp("fmt-cat-deflate.dat");
let _ = std::fs::remove_file(&packed);
let payload = b"the quick brown fox jumps over the lazy dog".repeat(50);
std::fs::write(&src, &payload).unwrap();
assert!(
Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"--format",
"deflate",
"-o",
packed.to_str().unwrap(),
])
.status()
.unwrap()
.success()
);
let no_format = Command::new(STUFFR)
.args(["cat", packed.to_str().unwrap()])
.output()
.unwrap();
assert!(
!no_format.status.success(),
"a raw deflate stream must NOT be detectable without --format"
);
let with_format = Command::new(STUFFR)
.args(["cat", packed.to_str().unwrap(), "--format", "deflate"])
.output()
.unwrap();
assert!(
with_format.status.success(),
"stderr: {}",
String::from_utf8_lossy(&with_format.stderr)
);
assert_eq!(with_format.stdout, payload);
for p in [&src, &packed] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn an_unknown_format_name_is_a_usage_error_on_every_verb() {
for verb in ["unpack", "cat"] {
let out = Command::new(STUFFR)
.args([verb, "/dev/null", "--format", "bogus"])
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(2),
"{verb} --format bogus must be a usage error"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("gzip"),
"the error must name what this build does have"
);
}
}
#[test]
fn cat_honours_max_ratio() {
let src = tmp("cat-bomb.bin");
let gz = tmp("cat-bomb.bin.gz");
let _ = std::fs::remove_file(&gz);
std::fs::write(&src, vec![0u8; 2 * 1024 * 1024]).unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let out = Command::new(STUFFR)
.args(["cat", gz.to_str().unwrap(), "--max-ratio", "100"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(6), "resource limits exit 6");
}
#[test]
fn formats_lists_every_registered_format() {
let out = Command::new(STUFFR).arg("formats").output().unwrap();
let text = String::from_utf8(out.stdout).unwrap();
for row in stuffr::registry().matrix() {
assert!(
text.contains(row.id.as_str()),
"`stuffr formats` omits {}: {text}",
row.id
);
}
}
#[test]
fn the_formats_note_sits_below_the_table_after_a_blank_line() {
let out = Command::new(STUFFR).arg("formats").output().unwrap();
let text = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = text.lines().collect();
let note = lines
.iter()
.position(|l| l.starts_with("(WRITE shows"))
.unwrap_or_else(|| panic!("`stuffr formats` printed no WRITE note:\n{text}"));
for row in stuffr::registry().matrix() {
let at = lines
.iter()
.position(|l| l.starts_with(row.id.as_str()))
.unwrap_or_else(|| panic!("`stuffr formats` omits {}:\n{text}", row.id));
assert!(
at < note,
"{} is listed at line {at}, below the note at line {note}:\n{text}",
row.id
);
}
assert!(
note > 0,
"the note is the first line, so no table precedes it"
);
assert_eq!(
lines[note - 1],
"",
"the note must be separated from the table by a blank line, got {:?}:\n{text}",
lines[note - 1]
);
}
#[test]
fn a_malformed_memory_limit_is_a_usage_error() {
let src = tmp("mem-bad.txt");
let out_path = tmp("mem-bad.gz");
let _ = std::fs::remove_file(&out_path);
std::fs::write(&src, b"payload").unwrap();
let out = Command::new(STUFFR)
.args(["pack", "--memory-limit", "512MB", "--format", "gzip", "-o"])
.arg(&out_path)
.arg(&src)
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "malformed size must be exit 2");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("512M") && err.contains("2G"),
"must name the accepted forms concretely: {err}"
);
assert!(!out_path.exists(), "a refused pack must leave no output");
}
#[test]
fn threads_is_not_offered_on_decode_subcommands() {
for sub in ["unpack", "cat"] {
let out = Command::new(STUFFR)
.args([sub, "--threads", "4", "-"])
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("unexpected argument") || err.contains("--threads"),
"{sub} must reject --threads, got: {err}"
);
}
}
#[test]
fn the_binary_reports_its_version() {
let want = env!("CARGO_PKG_VERSION");
let out = Command::new(STUFFR).arg("--version").output().unwrap();
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains(want), "--version must report {want}, got: {s}");
}
static NEXT_DIR: AtomicU64 = AtomicU64::new(0);
fn tmp_dir() -> Scratch {
let n = NEXT_DIR.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("stuffr-cli-archive-{}-{n}", *RUN_TOKEN));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
Scratch(p)
}
fn append_tar_entry<W: Write>(builder: &mut Builder<W>, name: &str, data: &[u8]) {
let mut header = Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
builder.append_data(&mut header, name, data).unwrap();
}
fn write_fixture_tar(dir: &Path, entries: &[(&str, &[u8])]) -> PathBuf {
let path = dir.join("fixture.tar");
let mut builder = Builder::new(std::fs::File::create(&path).unwrap());
for (name, data) in entries {
append_tar_entry(&mut builder, name, data);
}
builder.finish().unwrap();
path
}
fn write_fixture_tar_gz(dir: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
let mut tar_bytes = Vec::new();
{
let mut builder = Builder::new(&mut tar_bytes);
for (n, data) in entries {
append_tar_entry(&mut builder, n, data);
}
builder.finish().unwrap();
}
let path = dir.join(name);
let mut enc = GzEncoder::new(
std::fs::File::create(&path).unwrap(),
Compression::default(),
);
enc.write_all(&tar_bytes).unwrap();
enc.finish().unwrap();
path
}
fn gzip_wrap(bytes: &[u8]) -> Vec<u8> {
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
enc.write_all(bytes).unwrap();
enc.finish().unwrap()
}
fn corrupt_midway(good: &Path) -> PathBuf {
let bytes = std::fs::read(good).unwrap();
let cut = bytes.len() / 2;
let corrupt = good.with_file_name("corrupt.tar");
std::fs::write(&corrupt, &bytes[..cut]).unwrap();
corrupt
}
fn run(args: &[&str]) -> std::process::ExitStatus {
Command::new(STUFFR).args(args).status().unwrap()
}
fn run_output(args: &[&str]) -> std::process::Output {
Command::new(STUFFR).args(args).output().unwrap()
}
fn run_with_stdin_output(args: &[&str], input: &[u8]) -> std::process::Output {
let mut child = Command::new(STUFFR)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
match child.stdin.take().unwrap().write_all(input) {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
r => r.unwrap(),
}
child.wait_with_output().unwrap()
}
fn run_with_stdin(args: &[&str], input: &[u8]) -> String {
let out = run_with_stdin_output(args, input);
String::from_utf8(out.stdout).unwrap()
}
#[test]
fn list_names_every_entry_and_extracts_nothing() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha"), ("b/c.bin", b"\x00\xff")]);
let out = run_output(&["list", archive.to_str().unwrap()]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8(out.stdout).unwrap();
assert!(text.contains("a.txt") && text.contains("b/c.bin"), "{text}");
assert!(!dir.join("a.txt").exists(), "list must not extract");
assert!(!dir.join("b").exists(), "list must not extract");
}
#[test]
fn ls_is_an_alias_for_list() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let out = run_output(&["ls", archive.to_str().unwrap()]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8(out.stdout).unwrap().contains("a.txt"));
}
#[test]
fn list_json_reports_name_kind_and_size() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let out = run_output(&["list", "--json", archive.to_str().unwrap()]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8(out.stdout).unwrap();
let v: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("not JSON: {e}\n{text}"));
let rows = v.as_array().expect("a JSON array of entries");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["name"], "a.txt");
assert_eq!(rows[0]["kind"], "file");
assert_eq!(rows[0]["size"], 5);
}
#[test]
fn test_verb_exits_five_on_a_corrupt_archive_and_zero_on_a_good_one() {
let dir = tmp_dir();
let good = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
assert_eq!(run(&["test", good.to_str().unwrap()]).code(), Some(0));
let bad = corrupt_midway(&good);
assert_eq!(run(&["test", bad.to_str().unwrap()]).code(), Some(5));
}
#[test]
fn list_works_on_a_pipe() {
let dir = tmp_dir();
let bytes = std::fs::read(write_fixture_tar(&dir, &[("a.txt", b"alpha")])).unwrap();
let text = run_with_stdin(&["list", "-"], &bytes);
assert!(text.contains("a.txt"), "{text}");
}
#[test]
fn list_and_test_succeed_on_a_real_tar_gz_from_a_file() {
let dir = tmp_dir();
let archive = write_fixture_tar_gz(
&dir,
"fixture.tar.gz",
&[("a.txt", b"alpha"), ("b/c.bin", b"\x00\xff")],
);
let list_out = run_output(&["list", archive.to_str().unwrap()]);
assert!(
list_out.status.success(),
"list stderr: {}",
String::from_utf8_lossy(&list_out.stderr)
);
let text = String::from_utf8(list_out.stdout).unwrap();
assert!(text.contains("a.txt") && text.contains("b/c.bin"), "{text}");
let test_out = run_output(&["test", archive.to_str().unwrap()]);
assert!(
test_out.status.success(),
"test stderr: {}",
String::from_utf8_lossy(&test_out.stderr)
);
}
#[test]
fn list_and_test_succeed_on_a_real_tar_gz_over_a_pipe() {
let dir = tmp_dir();
let archive = write_fixture_tar_gz(&dir, "fixture.tar.gz", &[("a.txt", b"alpha")]);
let bytes = std::fs::read(&archive).unwrap();
let list_out = run_with_stdin_output(&["list", "-"], &bytes);
assert!(
list_out.status.success(),
"list stderr: {}",
String::from_utf8_lossy(&list_out.stderr)
);
assert!(
String::from_utf8(list_out.stdout)
.unwrap()
.contains("a.txt")
);
let test_out = run_with_stdin_output(&["test", "-"], &bytes);
assert!(
test_out.status.success(),
"test stderr: {}",
String::from_utf8_lossy(&test_out.stderr)
);
}
#[test]
fn list_and_test_succeed_on_a_real_tgz_from_a_file() {
let dir = tmp_dir();
let archive = write_fixture_tar_gz(&dir, "fixture.tgz", &[("a.txt", b"alpha")]);
let list_out = run_output(&["list", archive.to_str().unwrap()]);
assert!(
list_out.status.success(),
"list stderr: {}",
String::from_utf8_lossy(&list_out.stderr)
);
assert!(
String::from_utf8(list_out.stdout)
.unwrap()
.contains("a.txt")
);
let test_out = run_output(&["test", archive.to_str().unwrap()]);
assert!(
test_out.status.success(),
"test stderr: {}",
String::from_utf8_lossy(&test_out.stderr)
);
}
#[test]
fn list_and_test_succeed_on_a_real_tgz_over_a_pipe() {
let dir = tmp_dir();
let archive = write_fixture_tar_gz(&dir, "fixture.tgz", &[("a.txt", b"alpha")]);
let bytes = std::fs::read(&archive).unwrap();
let list_out = run_with_stdin_output(&["list", "-"], &bytes);
assert!(
list_out.status.success(),
"list stderr: {}",
String::from_utf8_lossy(&list_out.stderr)
);
assert!(
String::from_utf8(list_out.stdout)
.unwrap()
.contains("a.txt")
);
let test_out = run_with_stdin_output(&["test", "-"], &bytes);
assert!(
test_out.status.success(),
"test stderr: {}",
String::from_utf8_lossy(&test_out.stderr)
);
}
#[test]
fn test_catches_a_payload_truncation() {
let dir = tmp_dir();
let payload = vec![b'x'; 8192];
let full = write_fixture_tar(&dir, &[("big.bin", &payload)]);
let bytes = std::fs::read(&full).unwrap();
let cut = 512 + payload.len() / 3;
let truncated = dir.join("truncated.tar");
std::fs::write(&truncated, &bytes[..cut]).unwrap();
let test_out = run_output(&["test", truncated.to_str().unwrap()]);
assert_eq!(
test_out.status.code(),
Some(5),
"test reads the payload and must catch the truncation: {}",
String::from_utf8_lossy(&test_out.stderr)
);
let test_err = String::from_utf8_lossy(&test_out.stderr);
assert!(
test_err.contains("big.bin") && test_err.contains("short of the size"),
"must be tar's own per-entry short-read diagnosis, reclassified from a raw \
io::ErrorKind::InvalidData via Error::from_decode_io: {test_err}"
);
let list_out = run_output(&["list", truncated.to_str().unwrap()]);
assert_eq!(
list_out.status.code(),
Some(5),
"list also exits 5 here, via the tar crate's own header-skip detection, not via a \
payload read: {}",
String::from_utf8_lossy(&list_out.stderr)
);
}
#[test]
fn a_zip_declaring_an_unreachable_local_header_offset_exits_5_not_1() {
const MINIMISED_REPRODUCER: &str = concat!(
"4b4d4d00504b010214031400000000000a3b375d0000000000000000000001010101e900000000000000237718c33a17",
"fbe047df010101010101010166722066757a7a7403034b50ffff1304504b05060000000001000008f500040000000000",
"000000000000005da481a9000007054b5000000a3b375dbc6ab89d2a00000000000000000b00000000000000000000a4",
"81250000bf5907054b5047bbd19819f9938fb5527f0414d3000000000a3b375d000000000010000000004f0102141403",
"1400000008000a3b375d06ff880a7374756666722066757a7a7403034b50ffff1304504b05060000000001000008f500",
"0000ed000000000000000000005da481a900000081a90000001400000008000a3b375d06ff880a737475666672206675",
"7a7a7403034b50ffff1304504b05060000000001000008f5000000ed000000000000000000005da481a900000081a900",
"0000000000ed000000000000000000005da481a900000081a9000000a481a900000081a900000008000a3b375d06ff88",
"0a7374756666722066757a7a7403034b50ffff1304504b05060000000001000008005da481a900000081000000000000",
"5da481a900000081a9000000000000ed000000000000000000005da481a900000081a9000000a481a900000081a90000",
"00000000000000000000005da481a900000081a9000000000000ed000000000000000000005da481a900000081a90000",
"00a481a900000081a9000000",
);
let bytes: Vec<u8> = (0..MINIMISED_REPRODUCER.len() / 2)
.map(|i| u8::from_str_radix(&MINIMISED_REPRODUCER[i * 2..i * 2 + 2], 16).unwrap())
.collect();
assert_eq!(bytes.len(), 540, "the reproducer must be carried verbatim");
let dir = tmp_dir();
let archive = dir.join("unreachable-offset.zip");
std::fs::write(&archive, &bytes).unwrap();
for verb in ["list", "test"] {
let out = run_output(&[verb, archive.to_str().unwrap()]);
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(5),
"`{verb}` must report the ARCHIVE as corrupt, never exit 1: {err}"
);
assert!(
err.contains("archive is corrupt")
&& err.contains("largest position any file can hold"),
"the refusal must name what the archive declared, not an errno: {err}"
);
assert!(
!err.contains("os error 22"),
"a bare errno reaching the user is the defect itself: {err}"
);
}
let out = run_output(&[
"unpack",
"-C",
dir.join("out").to_str().unwrap(),
archive.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(5),
"`unpack` too: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn zip_with_one_symlink(name: &str, target_len: usize) -> Vec<u8> {
let target = vec![b't'; target_len];
let crc = {
let mut c = 0xFFFF_FFFFu32;
for &b in &target {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 {
(c >> 1) ^ 0xEDB8_8320
} else {
c >> 1
};
}
}
!c
};
let size = target_len as u32;
let mut z = Vec::new();
z.extend_from_slice(b"PK\x03\x04");
z.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); z.extend_from_slice(&crc.to_le_bytes());
z.extend_from_slice(&size.to_le_bytes());
z.extend_from_slice(&size.to_le_bytes());
z.extend_from_slice(&(name.len() as u16).to_le_bytes());
z.extend_from_slice(&0u16.to_le_bytes());
z.extend_from_slice(name.as_bytes());
z.extend_from_slice(&target);
let cd_offset = z.len() as u32;
z.extend_from_slice(b"PK\x01\x02");
z.extend_from_slice(&[0x14, 0x03, 20, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); z.extend_from_slice(&crc.to_le_bytes());
z.extend_from_slice(&size.to_le_bytes());
z.extend_from_slice(&size.to_le_bytes());
z.extend_from_slice(&(name.len() as u16).to_le_bytes());
z.extend_from_slice(&[0; 8]); z.extend_from_slice(&(0o120_777u32 << 16).to_le_bytes());
z.extend_from_slice(&0u32.to_le_bytes()); z.extend_from_slice(name.as_bytes());
let cd_len = z.len() as u32 - cd_offset;
z.extend_from_slice(b"PK\x05\x06");
z.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0]);
z.extend_from_slice(&cd_len.to_le_bytes());
z.extend_from_slice(&cd_offset.to_le_bytes());
z.extend_from_slice(&0u16.to_le_bytes());
z
}
#[test]
fn a_zip_symlink_target_over_64_kib_exits_6_and_one_at_the_ceiling_does_not() {
let dir = tmp_dir();
let ok = dir.join("at-ceiling.zip");
std::fs::write(&ok, zip_with_one_symlink("link", 65_536)).unwrap();
let out = run_output(&["list", ok.to_str().unwrap()]);
assert_eq!(
out.status.code(),
Some(0),
"a 65,536-byte target is at the ceiling, not over it: {}",
String::from_utf8_lossy(&out.stderr)
);
let big = dir.join("over-ceiling.zip");
std::fs::write(&big, zip_with_one_symlink("link", 65_537)).unwrap();
for verb_args in [
vec!["list", big.to_str().unwrap()],
vec!["test", big.to_str().unwrap()],
] {
let out = run_output(&verb_args);
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(6),
"`{}` must refuse the target as a resource limit: {err}",
verb_args[0]
);
assert!(
err.contains("65537") && err.contains("65536"),
"the refusal names the declared length and the ceiling: {err}"
);
}
let out = run_output(&[
"unpack",
"-C",
dir.join("out").to_str().unwrap(),
big.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(6),
"`unpack` too: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_output(&["cat", big.to_str().unwrap(), "link"]);
assert_eq!(
out.status.code(),
Some(6),
"`cat` too: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_output(&[
"convert",
big.to_str().unwrap(),
dir.join("converted.tar").to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(6),
"`convert` too: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn list_refuses_a_plain_codec_stream_as_not_an_archive() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
let gz = dir.join("notes.txt.gz");
std::fs::write(&src, b"just plain text, not an archive").unwrap();
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "--format", "gzip"])
.status()
.unwrap()
.success()
);
let out = run_output(&["list", gz.to_str().unwrap()]);
assert_eq!(
out.status.code(),
Some(2),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("not an archive"), "{err}");
assert!(
err.contains("gzip"),
"must name what the input actually resolved to: {err}"
);
}
#[test]
fn list_refuses_nesting_past_the_depth_bound() {
let mut bytes = b"payload too shallow to be an archive".to_vec();
for _ in 0..6 {
bytes = gzip_wrap(&bytes);
}
let out = run_with_stdin_output(&["list", "-"], &bytes);
assert_eq!(
out.status.code(),
Some(6),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("nesting"), "{err}");
}
#[test]
fn list_reports_a_truncated_codec_stream_as_corrupt_not_as_an_io_failure() {
let dir = tmp_dir();
let trunc = dir.join("trunc.zz");
std::fs::write(&trunc, [0x78, 0xda, 0x0a]).unwrap();
let cat_out = run_output(&["cat", trunc.to_str().unwrap()]);
assert_eq!(
cat_out.status.code(),
Some(5),
"cat (unaffected by this bug) must still exit 5: {}",
String::from_utf8_lossy(&cat_out.stderr)
);
let list_out = run_output(&["list", trunc.to_str().unwrap()]);
assert_eq!(
list_out.status.code(),
Some(5),
"list must agree with cat on the SAME bytes — exit 1 here means stuffr claimed it \
failed, when the truth is the input is corrupt: {}",
String::from_utf8_lossy(&list_out.stderr)
);
let list_err = String::from_utf8_lossy(&list_out.stderr);
assert!(
list_err.contains("archive is corrupt"),
"must be Error::Corrupt's own wording, not Error::Io's: {list_err}"
);
}
#[test]
fn list_reports_an_empty_lzip_stream_as_corrupt_not_as_an_io_failure() {
let dir = tmp_dir();
let empty = dir.join("empty.lz");
std::fs::write(&empty, []).unwrap();
let cat_out = run_output(&["cat", empty.to_str().unwrap()]);
assert_eq!(
cat_out.status.code(),
Some(5),
"cat (unaffected by this bug) must still exit 5: {}",
String::from_utf8_lossy(&cat_out.stderr)
);
let list_out = run_output(&["list", empty.to_str().unwrap()]);
assert_eq!(
list_out.status.code(),
Some(5),
"list must agree with cat on the SAME bytes: {}",
String::from_utf8_lossy(&list_out.stderr)
);
let list_err = String::from_utf8_lossy(&list_out.stderr);
assert!(
list_err.contains("archive is corrupt") && list_err.contains("LZIP"),
"must be Error::Corrupt's own wording, naming what lzip's decoder actually \
complained about: {list_err}"
);
}
#[test]
fn a_directory_input_is_a_usage_error_for_every_read_verb() {
let dir = tmp_dir();
let d = dir.join("d");
std::fs::create_dir_all(&d).unwrap();
let out_dir = dir.join("out");
let d = d.to_str().unwrap();
let o = out_dir.to_str().unwrap();
let out_file = dir.join("out.bin");
let f = out_file.to_str().unwrap();
let cases: [&[&str]; 8] = [
&["list", d],
&["test", d],
&["cat", d],
&["cat", d, "x"],
&["unpack", d, "-o", f],
&["unpack", d, "-C", o],
&["info", d],
&["salvage", d, "-C", o],
];
for args in cases {
let out = run_output(args);
let err = stderr_text(&out);
assert_eq!(out.status.code(), Some(2), "{args:?}: {err}");
assert!(
err.contains("is a directory") && err.contains("stuffr pack"),
"{args:?}: {err}"
);
assert!(!out_dir.exists(), "{args:?} created its destination");
assert!(!out_file.exists(), "{args:?} created its output file");
}
}
#[test]
fn list_reports_a_zstd_stream_that_decodes_into_arj_magic_as_corrupt() {
const ZSTD_OVER_ARJ_MAGIC: &[u8] = &[
0x28, 0xb5, 0x2f, 0xfd, 0x24, 0x00, 0x30, 0x00, 0x00, 0x60, 0xea, 0x05, 0x06, 0x28, 0x0a,
0x0a, 0x0a, 0x0a, 0x0a, 0x70, 0x00,
];
let dir = tmp_dir();
let repro = dir.join("repro.zst");
std::fs::write(&repro, ZSTD_OVER_ARJ_MAGIC).unwrap();
let path = repro.to_str().unwrap();
let cat_out = run_output(&["cat", path]);
assert_eq!(
cat_out.status.code(),
Some(5),
"cat was already right on these bytes and must stay right: {}",
String::from_utf8_lossy(&cat_out.stderr)
);
for verb in ["list", "test"] {
let out = run_output(&[verb, path]);
assert_eq!(
out.status.code(),
Some(5),
"`{verb}` must agree with cat on the SAME bytes — exit 1 here means stuffr claimed \
it failed, when the truth is the input is corrupt: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("archive is corrupt"),
"must be Error::Corrupt's own wording, not Error::Io's: {err}"
);
}
}
#[test]
fn a_ratio_refusal_below_a_container_is_a_resource_limit_not_an_io_failure() {
const ZSTD_BOMB_OVER_ARJ_MAGIC: &[u8] = &[
0x28, 0xb5, 0x2f, 0xfd, 0xa4, 0x02, 0x00, 0x10, 0x00, 0x5c, 0x00, 0x00, 0x18, 0x60, 0xea,
0x00, 0x01, 0x00, 0xfa, 0xff, 0x39, 0x18, 0x02, 0x02, 0x00, 0x10, 0x00, 0x02, 0x00, 0x10,
0x00, 0x02, 0x00, 0x10, 0x00, 0x02, 0x00, 0x10, 0x00, 0x02, 0x00, 0x10, 0x00, 0x02, 0x00,
0x10, 0x00, 0x02, 0x00, 0x10, 0x00, 0x11, 0x00, 0x00, 0x00, 0x00, 0xbe, 0xb2, 0xa6, 0x83,
];
let dir = tmp_dir();
let bomb = dir.join("bomb.zst");
std::fs::write(&bomb, ZSTD_BOMB_OVER_ARJ_MAGIC).unwrap();
let path = bomb.to_str().unwrap();
let cat_out = run_output(&["cat", path]);
assert_eq!(
cat_out.status.code(),
Some(6),
"cat was already right and must stay right: {}",
String::from_utf8_lossy(&cat_out.stderr)
);
for verb in ["list", "test"] {
let out = run_output(&[verb, path]);
assert_eq!(
out.status.code(),
Some(6),
"`{verb}` must agree with cat: a refused expansion bomb is a limit, never \
\"stuffr failed\": {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("resource limit exceeded") && err.contains("--max-ratio"),
"must be Error::ResourceLimit's own wording, naming the knob: {err}"
);
}
}
fn write_bomb_tar_gz(dir: &Path) -> PathBuf {
write_fixture_tar_gz(
dir,
"bomb.tar.gz",
&[("bomb.bin", &vec![0u8; 2 * 1024 * 1024])],
)
}
#[test]
fn list_and_test_refuse_a_bomb_tar_gz_at_a_tight_max_ratio() {
let dir = tmp_dir();
let bomb = write_bomb_tar_gz(&dir);
let list_out = run_output(&["list", bomb.to_str().unwrap(), "--max-ratio", "100"]);
assert_eq!(
list_out.status.code(),
Some(6),
"list stderr: {}",
String::from_utf8_lossy(&list_out.stderr)
);
let list_err = String::from_utf8_lossy(&list_out.stderr);
assert!(
list_err.contains("100") && list_err.contains("--max-ratio"),
"must name the limit and the flag, matching how decompress reports it: {list_err}"
);
let test_out = run_output(&["test", bomb.to_str().unwrap(), "--max-ratio", "100"]);
assert_eq!(
test_out.status.code(),
Some(6),
"test stderr: {}",
String::from_utf8_lossy(&test_out.stderr)
);
let test_err = String::from_utf8_lossy(&test_out.stderr);
assert!(
test_err.contains("100") && test_err.contains("--max-ratio"),
"must name the limit and the flag: {test_err}"
);
}
#[test]
fn raising_max_ratio_lets_the_bomb_through() {
let dir = tmp_dir();
let bomb = write_bomb_tar_gz(&dir);
let list_out = run_output(&["list", bomb.to_str().unwrap(), "--max-ratio", "100000"]);
assert!(
list_out.status.success(),
"list stderr: {}",
String::from_utf8_lossy(&list_out.stderr)
);
let test_out = run_output(&["test", bomb.to_str().unwrap(), "--max-ratio", "100000"]);
assert!(
test_out.status.success(),
"test stderr: {}",
String::from_utf8_lossy(&test_out.stderr)
);
}
#[test]
fn a_legitimately_compressible_tar_gz_is_not_refused_at_the_default_ratio() {
let dir = tmp_dir();
let text = "the quick brown fox jumps over the lazy dog\n".repeat(200_000);
let archive = write_fixture_tar_gz(&dir, "legit.tar.gz", &[("log.txt", text.as_bytes())]);
let list_out = run_output(&["list", archive.to_str().unwrap()]);
assert!(
list_out.status.success(),
"a legitimately compressible archive must not be refused at the default ratio: {}",
String::from_utf8_lossy(&list_out.stderr)
);
assert!(
String::from_utf8(list_out.stdout)
.unwrap()
.contains("log.txt")
);
let test_out = run_output(&["test", archive.to_str().unwrap()]);
assert!(
test_out.status.success(),
"a legitimately compressible archive must not be refused at the default ratio: {}",
String::from_utf8_lossy(&test_out.stderr)
);
}
const FIXTURE_MTIME: u64 = 1_600_000_000;
fn raw_header(
name: &str,
kind: tar::EntryType,
link_target: Option<&str>,
size: u64,
mode: u32,
) -> Header {
let mut header = Header::new_gnu();
header.set_size(size);
header.set_mode(mode);
header.set_mtime(FIXTURE_MTIME);
header.set_entry_type(kind);
{
let gnu = header.as_gnu_mut().expect("new_gnu is a GNU header");
let bytes = name.as_bytes();
assert!(
bytes.len() <= gnu.name.len(),
"raw fixture names must fit tar's 100-byte name field"
);
gnu.name[..bytes.len()].copy_from_slice(bytes);
if let Some(target) = link_target {
let bytes = target.as_bytes();
assert!(
bytes.len() <= gnu.linkname.len(),
"raw fixture link targets must fit tar's 100-byte linkname field"
);
gnu.linkname[..bytes.len()].copy_from_slice(bytes);
}
}
header.set_cksum();
header
}
enum Raw<'a> {
File(&'a str, &'a [u8]),
ModedFile(&'a str, &'a [u8], u32),
Dir(&'a str),
Symlink(&'a str, &'a str),
Hardlink(&'a str, &'a str),
Fifo(&'a str),
}
fn write_raw_tar(path: &Path, entries: &[Raw<'_>]) -> PathBuf {
let mut builder = Builder::new(std::fs::File::create(path).unwrap());
for entry in entries {
match entry {
Raw::File(name, data) => {
let header = raw_header(
name,
tar::EntryType::Regular,
None,
data.len() as u64,
0o644,
);
builder.append(&header, *data).unwrap();
}
Raw::ModedFile(name, data, mode) => {
let header = raw_header(
name,
tar::EntryType::Regular,
None,
data.len() as u64,
*mode,
);
builder.append(&header, *data).unwrap();
}
Raw::Dir(name) => {
let header = raw_header(name, tar::EntryType::Directory, None, 0, 0o755);
builder.append(&header, std::io::empty()).unwrap();
}
Raw::Symlink(name, target) => {
let header = raw_header(name, tar::EntryType::Symlink, Some(target), 0, 0o777);
builder.append(&header, std::io::empty()).unwrap();
}
Raw::Hardlink(name, target) => {
let header = raw_header(name, tar::EntryType::Link, Some(target), 0, 0o644);
builder.append(&header, std::io::empty()).unwrap();
}
Raw::Fifo(name) => {
let header = raw_header(name, tar::EntryType::Fifo, None, 0, 0o644);
builder.append(&header, std::io::empty()).unwrap();
}
}
}
builder.finish().unwrap();
path.to_path_buf()
}
#[test]
fn a_traversing_entry_is_refused_and_writes_nothing_outside_the_destination() {
let dir = tmp_dir();
let evil = dir.join("evil.tar");
write_raw_tar(&evil, &[Raw::File("../escaped.txt", b"pwned")]);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dir.join("out").to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"a traversing entry must be refused as an unsafe path (exit 7), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dir.join("escaped.txt").exists(),
"the file escaped the destination"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("../escaped.txt"),
"the refusal must name the offending entry, got: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_file_entry_naming_the_destination_itself_is_refused() {
let dir = tmp_dir();
let evil = dir.join("dot.tar");
write_raw_tar(&evil, &[Raw::File(".", b"pwned")]);
let out_dir = dir.join("out");
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"a file entry naming the destination must be an unsafe path (exit 7), never exit 1 \
(`stuffr failed`), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out_dir.is_dir(),
"the destination must still be a directory, not a file stuffr created over it"
);
let ok = dir.join("dotdir.tar");
write_raw_tar(&ok, &[Raw::Dir("."), Raw::File("a.txt", b"hi")]);
let ok_dir = dir.join("ok");
let out = run_output(&[
"unpack",
ok.to_str().unwrap(),
"-C",
ok_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(0),
"`tar cf x.tar .`'s own leading directory entry must still extract, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(ok_dir.join("a.txt")).unwrap(), b"hi");
}
#[test]
fn an_lha_file_entry_naming_the_destination_itself_is_refused() {
let dir = tmp_dir();
let evil = dir.join("dot.lzh");
std::fs::write(&evil, raw_lha_entry(b".", b"-lh0-", b"pwned")).unwrap();
let out_dir = dir.join("out");
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"an LHA file entry naming the destination must be exit 7, never exit 1, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out_dir.is_dir(),
"the destination must still be a directory"
);
}
fn raw_lha_entry(name: &[u8], method: &[u8; 5], content: &[u8]) -> Vec<u8> {
let mut crc: u16 = 0;
for &b in content {
crc ^= u16::from(b);
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xA001
} else {
crc >> 1
};
}
}
let size = content.len() as u32;
let mut counted: Vec<u8> = Vec::new();
counted.extend_from_slice(method);
counted.extend_from_slice(&size.to_le_bytes()); counted.extend_from_slice(&size.to_le_bytes()); counted.extend_from_slice(&0u32.to_le_bytes()); counted.push(0x20); counted.push(1); counted.push(name.len() as u8);
counted.extend_from_slice(name);
counted.extend_from_slice(&crc.to_le_bytes());
counted.push(b'U'); counted.extend_from_slice(&0u16.to_le_bytes());
let mut out = vec![
counted.len() as u8,
counted.iter().fold(0u8, |a, &b| a.wrapping_add(b)),
];
out.extend_from_slice(&counted);
out.extend_from_slice(content);
out.push(0); out
}
#[test]
fn an_absolute_entry_path_is_refused() {
let dir = tmp_dir();
let evil = dir.join("abs.tar");
let escape = std::env::temp_dir().join(format!("stuffr-abs-escape-{}.txt", std::process::id()));
let _ = std::fs::remove_file(&escape);
write_raw_tar(&evil, &[Raw::File(escape.to_str().unwrap(), b"pwned")]);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"an absolute entry name must be refused as an unsafe path (exit 7), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!escape.exists(),
"an absolute entry name wrote outside the destination: {}",
escape.display()
);
}
#[test]
fn cat_streams_one_named_entry_from_a_pipe() {
let bytes = std::fs::read(write_fixture_tar(
&tmp_dir(),
&[("a.txt", b"alpha"), ("b.txt", b"beta")],
))
.unwrap();
assert_eq!(run_with_stdin(&["cat", "-", "b.txt"], &bytes), "beta");
}
#[test]
fn pack_collects_several_paths_into_one_archive() {
let dir = tmp_dir();
std::fs::write(dir.join("one.txt"), b"1").unwrap();
std::fs::write(dir.join("two.txt"), b"2").unwrap();
let out = dir.join("bundle.tar");
let packed = run_output(&[
"pack",
dir.join("one.txt").to_str().unwrap(),
dir.join("two.txt").to_str().unwrap(),
"-o",
out.to_str().unwrap(),
]);
assert!(
packed.status.success(),
"stderr: {}",
String::from_utf8_lossy(&packed.stderr)
);
let listed = String::from_utf8(run_output(&["list", out.to_str().unwrap()]).stdout).unwrap();
assert!(
listed.contains("one.txt") && listed.contains("two.txt"),
"both entries must be listed, got: {listed}"
);
assert!(
!listed.contains(dir.to_str().unwrap()),
"absolute paths must not be stored as entry names, got: {listed}"
);
}
#[test]
fn a_symlink_whose_target_escapes_the_destination_is_refused_before_it_is_created() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(
&dir.join("link.tar"),
&[Raw::Symlink("link", "../../etc/passwd")],
);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"an escaping symlink target must be refused (exit 7), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
std::fs::symlink_metadata(dest.join("link")).is_err(),
"the symlink must not have been created at all"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains(
"unsafe entry path `link` refused: symlink target `../../etc/passwd` \
climbs out of the destination"
),
"the refusal must name the offending entry, got: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_hard_link_whose_target_is_unsafe_quotes_the_target() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(
&dir.join("h.tar"),
&[Raw::Hardlink("hl", "../../etc/passwd")],
);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(7), "stderr: {stderr}");
assert!(
stderr.contains(
"unsafe entry path `hl` refused: its hard-link target `../../etc/passwd` \
is not a safe path inside the destination"
),
"got: {stderr}"
);
}
#[test]
fn a_symlink_target_with_control_characters_is_quoted_escaped() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(
&dir.join("esc.tar"),
&[Raw::Symlink("link", "../\x1b`x/../..")],
);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(7), "stderr: {stderr}");
assert!(!stderr.contains('\x1b'), "raw ESC leaked: {stderr:?}");
assert!(
stderr.contains("symlink target `../\\x1b`x/../..` climbs out of the destination"),
"got: {stderr:?}"
);
}
#[test]
fn an_entry_written_through_an_escaping_symlink_lands_nowhere() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(
&dir.join("through.tar"),
&[
Raw::Symlink("evil", dir.to_str().unwrap()),
Raw::File("evil/pwned.txt", b"pwned"),
],
);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"an absolute symlink target must be refused (exit 7), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dir.join("pwned.txt").exists(),
"the second entry was written through the symlink, outside the destination"
);
assert!(
std::fs::symlink_metadata(dest.join("evil")).is_err(),
"the symlink must not have been created"
);
}
#[test]
fn a_symlink_chain_cannot_walk_out_of_the_destination() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(
&dir.join("chain.tar"),
&[
Raw::Symlink("a", "b"),
Raw::Symlink("b", "../.."),
Raw::File("a/pwned.txt", b"pwned"),
],
);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"a chained symlink escape must be refused (exit 7), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dir.join("pwned.txt").exists() && !dir.parent().unwrap().join("pwned.txt").exists(),
"the chain wrote outside the destination"
);
}
#[test]
fn a_traversal_buried_inside_a_deeper_path_is_refused() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(
&dir.join("deep.tar"),
&[Raw::File("a/b/../../../escaped.txt", b"pwned")],
);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"a buried traversal must be refused (exit 7), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dir.join("escaped.txt").exists(),
"the buried traversal escaped the destination"
);
}
#[test]
fn a_directory_entry_that_escapes_the_destination_is_refused() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(&dir.join("dir.tar"), &[Raw::Dir("../escaped-dir/")]);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"an escaping directory entry must be refused (exit 7), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dir.join("escaped-dir").exists(),
"the directory escaped the destination"
);
}
#[test]
fn an_entry_named_dot_extracts_alongside_the_real_entries() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("dot.tar"),
&[Raw::Dir("./"), Raw::File("./a.txt", b"alpha")],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"a `./` entry must be accepted, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"alpha");
}
#[test]
fn a_system_tar_of_dot_extracts_cleanly() {
let Some(tar_bin) = which_tar() else {
eprintln!("system tar not found; skipping");
return;
};
let dir = tmp_dir();
let src = dir.join("src");
std::fs::create_dir_all(src.join("nested")).unwrap();
std::fs::write(src.join("top.txt"), b"top level").unwrap();
std::fs::write(src.join("nested/deep.txt"), b"nested payload").unwrap();
let archive = dir.join("dot.tar");
let status = Command::new(&tar_bin)
.arg("cf")
.arg(&archive)
.arg(".")
.current_dir(&src)
.status()
.unwrap();
assert!(
status.success(),
"the system tar failed to build the fixture"
);
let dest = dir.join("out");
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"`tar cf x.tar .` must extract cleanly, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(dest.join("top.txt")).unwrap(), b"top level");
assert_eq!(
std::fs::read(dest.join("nested/deep.txt")).unwrap(),
b"nested payload"
);
}
fn require_gnu_tar() -> PathBuf {
if let Some(gtar) = which("gtar") {
return gtar;
}
let tar = require_bin("tar");
let out = Command::new(&tar).arg("--version").output().unwrap();
assert!(
String::from_utf8_lossy(&out.stdout).contains("GNU tar"),
"no GNU tar: neither `gtar` nor a GNU `tar` on PATH (macOS: `brew install gnu-tar`)"
);
tar
}
#[test]
fn a_tar_pax_header_past_the_ceiling_exits_6() {
let dir = tmp_dir();
let mut builder = Builder::new(Vec::new());
let comment = vec![b'c'; 17 << 20];
builder
.append_pax_extensions([("comment", &comment[..])])
.unwrap();
let mut header = Header::new_ustar();
header.set_path("after.txt").unwrap();
header.set_mode(0o644);
header.set_size(5);
header.set_cksum();
builder.append(&header, &b"hello"[..]).unwrap();
let big = builder.into_inner().unwrap();
assert!(big.len() > 17 << 20, "the payload is really there");
let big_path = dir.join("big-pax.tar");
std::fs::write(&big_path, &big).unwrap();
let mut header = Header::new_ustar();
header.set_path("PaxHeaders/big").unwrap();
header.set_entry_type(tar::EntryType::XHeader);
header.set_size(4 << 30);
header.set_cksum();
let mut short = header.as_bytes().to_vec();
short.extend_from_slice(&[b'c'; 1024]);
let short_path = dir.join("short-pax.tar");
std::fs::write(&short_path, &short).unwrap();
for path in [&big_path, &short_path] {
let out = run_output(&["list", path.to_str().unwrap()]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(6), "{path:?}: {stderr}");
assert!(stderr.contains("pax extended header"), "{path:?}: {stderr}");
}
let mut child = Command::new(STUFFR)
.args(["list", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(&big);
});
let out = child.wait_with_output().unwrap();
writer.join().unwrap();
assert_eq!(
out.status.code(),
Some(6),
"a pipe: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_gnu_tar_pax_archive_with_a_300_byte_path_lists_normally() {
let dir = tmp_dir();
let src = dir.join("src");
let rel = format!("{}/{}", "d".repeat(200), "f".repeat(99));
assert_eq!(rel.len(), 300);
std::fs::create_dir_all(src.join("d".repeat(200))).unwrap();
std::fs::write(src.join(&rel), b"long path").unwrap();
let archive = dir.join("pax.tar");
run_tool(
&require_gnu_tar(),
&[os(&"--format=pax"), os(&"-cf"), os(&archive), os(&rel)],
&src,
b"",
);
let bytes = std::fs::read(&archive).unwrap();
assert!(
Header::from_byte_slice(&bytes[..512])
.entry_type()
.is_pax_local_extensions(),
"GNU tar carried the path in a pax `x` header"
);
let out = run_output(&["list", archive.to_str().unwrap()]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(stdout.contains(&rel), "{stdout}");
}
fn which_tar() -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join("tar"))
.find(|candidate| candidate.is_file())
}
#[cfg(unix)]
#[test]
fn extraction_writes_files_directories_and_contained_symlinks() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("good.tar"),
&[
Raw::Dir("sub/"),
Raw::File("sub/a.txt", b"alpha"),
Raw::Symlink("sub/link", "a.txt"),
],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(dest.join("sub").is_dir());
assert_eq!(std::fs::read(dest.join("sub/a.txt")).unwrap(), b"alpha");
let link = std::fs::symlink_metadata(dest.join("sub/link")).unwrap();
assert!(
link.file_type().is_symlink(),
"a symlink entry must become a symlink"
);
assert_eq!(
std::fs::read_link(dest.join("sub/link")).unwrap(),
Path::new("a.txt")
);
assert_eq!(std::fs::read(dest.join("sub/link")).unwrap(), b"alpha");
}
#[test]
fn extraction_selects_only_the_entries_a_pattern_names() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_fixture_tar(
&dir,
&[
("a.txt", b"alpha"),
("b.txt", b"beta"),
("d/c.txt", b"gamma"),
],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"b.txt",
"d",
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dest.join("a.txt").exists(),
"an unmatched entry was extracted"
);
assert_eq!(std::fs::read(dest.join("b.txt")).unwrap(), b"beta");
assert_eq!(std::fs::read(dest.join("d/c.txt")).unwrap(), b"gamma");
}
#[test]
fn a_pattern_matching_no_entry_is_a_usage_error_rather_than_a_silent_success() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dir.join("out").to_str().unwrap(),
"nosuch.txt",
]);
assert_eq!(
out.status.code(),
Some(2),
"extracting nothing at all must not report success, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_output(&["cat", archive.to_str().unwrap(), "nosuch.txt"]);
assert_eq!(out.status.code(), Some(2), "cat must agree with unpack");
}
#[test]
fn extraction_refuses_an_existing_file_unless_force_is_given() {
let dir = tmp_dir();
let dest = dir.join("out");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("a.txt"), b"mine").unwrap();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(2),
"an existing file must be refused like every other output, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"mine");
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--force",
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"alpha");
}
#[cfg(unix)]
#[test]
fn a_pre_existing_symlink_at_a_target_path_is_replaced_not_written_through() {
let dir = tmp_dir();
let dest = dir.join("out");
std::fs::create_dir_all(&dest).unwrap();
let outside = dir.join("outside.txt");
std::fs::write(&outside, b"untouched").unwrap();
std::os::unix::fs::symlink(&outside, dest.join("a.txt")).unwrap();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--force",
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read(&outside).unwrap(),
b"untouched",
"the entry was written through the pre-existing symlink"
);
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"alpha");
}
#[test]
fn extraction_of_a_corrupt_archive_exits_five_not_seven() {
let dir = tmp_dir();
let good = write_fixture_tar(&dir, &[("a.txt", b"alpha"), ("b.txt", b"beta")]);
let corrupt = corrupt_midway(&good);
let out = run_output(&[
"unpack",
corrupt.to_str().unwrap(),
"-C",
dir.join("out").to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(5),
"a corrupt archive is exit 5, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn extraction_of_a_bomb_exits_six_at_a_tight_max_ratio() {
let dir = tmp_dir();
let bomb = write_bomb_tar_gz(&dir);
let out = run_output(&[
"unpack",
bomb.to_str().unwrap(),
"-C",
dir.join("out").to_str().unwrap(),
"--max-ratio",
"2",
]);
assert_eq!(
out.status.code(),
Some(6),
"a bomb is a resource limit (exit 6), stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn cat_streams_every_entry_a_pattern_names_and_bounds_the_ratio() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha"), ("b.txt", b"beta")]);
let out = run_output(&["cat", archive.to_str().unwrap(), "a.txt"]);
assert!(out.status.success());
assert_eq!(String::from_utf8(out.stdout).unwrap(), "alpha");
let bomb = write_bomb_tar_gz(&dir);
let out = run_output(&[
"cat",
bomb.to_str().unwrap(),
"bomb.bin",
"--max-ratio",
"2",
]);
assert_eq!(
out.status.code(),
Some(6),
"cat must bound an entry the same way extraction does, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn an_archive_needs_a_destination_and_the_error_says_which_flag() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let out = run_output(&["unpack", archive.to_str().unwrap()]);
assert_eq!(
out.status.code(),
Some(2),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("-C") && stderr.contains("tar"),
"the error must name both the container and the flag, got: {stderr}"
);
assert!(
!dir.join("fixture").exists(),
"nothing may be written for a refused command"
);
}
#[test]
fn pack_refuses_several_paths_when_the_output_is_not_a_container() {
let dir = tmp_dir();
std::fs::write(dir.join("one.txt"), b"1").unwrap();
std::fs::write(dir.join("two.txt"), b"2").unwrap();
let out = run_output(&[
"pack",
dir.join("one.txt").to_str().unwrap(),
dir.join("two.txt").to_str().unwrap(),
"-o",
dir.join("bundle.gz").to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(2),
"a codec cannot hold two inputs, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dir.join("bundle.gz").exists(),
"a refused command must write nothing"
);
}
#[test]
fn pack_into_a_container_round_trips_through_extraction() {
let dir = tmp_dir();
std::fs::write(dir.join("one.txt"), b"first").unwrap();
std::fs::write(dir.join("two.txt"), b"second").unwrap();
let archive = dir.join("bundle.tar");
let out = run_output(&[
"pack",
dir.join("one.txt").to_str().unwrap(),
dir.join("two.txt").to_str().unwrap(),
"-o",
archive.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dest = dir.join("out");
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(dest.join("one.txt")).unwrap(), b"first");
assert_eq!(std::fs::read(dest.join("two.txt")).unwrap(), b"second");
}
#[test]
fn pack_refuses_two_inputs_that_would_share_one_entry_name() {
let dir = tmp_dir();
std::fs::create_dir_all(dir.join("a")).unwrap();
std::fs::create_dir_all(dir.join("b")).unwrap();
std::fs::write(dir.join("a/same.txt"), b"1").unwrap();
std::fs::write(dir.join("b/same.txt"), b"2").unwrap();
let archive = dir.join("dup.tar");
let out = run_output(&[
"pack",
dir.join("a/same.txt").to_str().unwrap(),
dir.join("b/same.txt").to_str().unwrap(),
"-o",
archive.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(2),
"two entries with one name would make an archive that cannot be \
extracted without --force, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!archive.exists(), "a refused command must write nothing");
}
#[test]
fn pack_of_an_empty_directory_stores_the_directory_itself() {
let dir = tmp_dir();
std::fs::create_dir_all(dir.join("tree")).unwrap();
let archive = dir.join("tree.tar");
let out = run_output(&[
"pack",
dir.join("tree").to_str().unwrap(),
"-o",
archive.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let listed = run_output(&["list", archive.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(
text.contains("tree"),
"the directory itself must be an entry, not an empty archive: {text}"
);
}
#[test]
fn flags_that_the_container_path_cannot_honour_are_refused_not_ignored() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let dest = dir.join("out");
for extra in [vec!["--format", "gzip"], vec!["-o", "somewhere"]] {
let mut args = vec![
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
];
args.extend_from_slice(&extra);
let out = run_output(&args);
assert_eq!(
out.status.code(),
Some(2),
"`{}` must be refused alongside -C, stderr: {}",
extra.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn pack_flags_the_container_path_cannot_honour_are_refused_not_ignored() {
let dir = tmp_dir();
let src = dir.join("a.txt");
std::fs::write(&src, b"alpha").unwrap();
let archive = dir.join("bundle.tar");
for extra in [
vec!["--threads", "4"],
vec!["--turbo"],
vec!["--allow-weak-encoder"],
] {
let mut args = vec![
"pack",
src.to_str().unwrap(),
"-o",
archive.to_str().unwrap(),
];
args.extend_from_slice(&extra);
let out = run_output(&args);
assert_eq!(
out.status.code(),
Some(2),
"`{}` must be refused on the container pack path, stderr: {}",
extra.join(" "),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!archive.exists(),
"a refused command must write nothing: {}",
extra.join(" ")
);
}
let out = run_output(&[
"pack",
src.to_str().unwrap(),
"--format",
"gzip",
"--threads",
"2",
"--force",
]);
assert!(
out.status.success(),
"the codec path still honours --threads, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[cfg(unix)]
#[test]
fn an_entry_written_through_a_symlinked_path_component_is_refused() {
let dir = tmp_dir();
let dest = dir.join("out");
let evil = write_raw_tar(
&dir.join("component.tar"),
&[
Raw::Dir("a/b/"),
Raw::Symlink("a/b/up", ".."),
Raw::Symlink("a/b/up/link", "../.."),
Raw::File("a/b/up/link/pwned.txt", b"pwned"),
],
);
let out = run_output(&[
"unpack",
evil.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"an entry resolving through a symlinked component must be refused (exit 7), \
stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!dir.join("pwned.txt").exists(),
"the entry escaped the destination through a symlinked path component"
);
}
#[cfg(unix)]
fn mode_of(path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).unwrap().permissions().mode() & 0o7777
}
fn mtime_secs(path: &Path) -> u64 {
std::fs::metadata(path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[cfg(unix)]
#[test]
fn extraction_restores_modes_and_mtimes_for_files_and_directories() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("meta.tar"),
&[
Raw::Dir("sub/"),
Raw::ModedFile("sub/script.sh", b"#!/bin/sh\n", 0o750),
Raw::ModedFile("readonly.txt", b"ro", 0o400),
],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(mode_of(&dest.join("sub/script.sh")), 0o750);
assert_eq!(mode_of(&dest.join("readonly.txt")), 0o400);
assert_eq!(mode_of(&dest.join("sub")), 0o755);
assert_eq!(mtime_secs(&dest.join("sub/script.sh")), FIXTURE_MTIME);
assert_eq!(mtime_secs(&dest.join("readonly.txt")), FIXTURE_MTIME);
assert_eq!(mtime_secs(&dest.join("sub")), FIXTURE_MTIME);
let strict = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dir.join("strict").to_str().unwrap(),
"--strict-fidelity",
]);
assert!(
strict.status.success(),
"an extraction that lost nothing must pass --strict-fidelity, stderr: {}",
String::from_utf8_lossy(&strict.stderr)
);
}
#[cfg(unix)]
#[test]
fn a_setuid_bit_is_not_restored_and_strict_fidelity_fails_on_the_loss() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("setuid.tar"),
&[Raw::ModedFile("suid", b"payload", 0o4755)],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"the extraction itself succeeds, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
mode_of(&dest.join("suid")),
0o755,
"the setuid bit must not be restored"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("suid") && stderr.contains("mode"),
"the loss must be reported, naming the entry and the field: {stderr}"
);
let strict = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dir.join("strict").to_str().unwrap(),
"--force",
"--strict-fidelity",
]);
assert_eq!(
strict.status.code(),
Some(4),
"a dropped mode must fail --strict-fidelity, stderr: {}",
String::from_utf8_lossy(&strict.stderr)
);
}
#[cfg(unix)]
#[test]
fn an_entry_with_no_shape_on_disk_is_skipped_and_said_out_loud() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("fifo.tar"),
&[Raw::File("a.txt", b"alpha"), Raw::Fifo("pipe")],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"alpha");
assert!(
std::fs::symlink_metadata(dest.join("pipe")).is_err(),
"a fifo entry must not become a regular file"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("skipped entry") && stderr.contains("pipe"),
"the skip must name the entry and why: {stderr}"
);
let strict = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dir.join("strict").to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(
strict.status.code(),
Some(4),
"a skipped entry must fail --strict-fidelity, stderr: {}",
String::from_utf8_lossy(&strict.stderr)
);
}
#[cfg(unix)]
#[test]
fn a_symlinks_own_mode_and_mtime_are_reported_as_lost() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("link.tar"),
&[Raw::File("a.txt", b"alpha"), Raw::Symlink("link", "a.txt")],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(
out.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("link") && stderr.contains("mtime"),
"the loss must name the entry and the field: {stderr}"
);
assert_eq!(
std::fs::read_link(dest.join("link")).unwrap(),
Path::new("a.txt")
);
}
#[test]
fn test_verb_passes_strict_fidelity_on_an_ordinary_tarball() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha")]);
let out = run_output(&["test", archive.to_str().unwrap(), "--strict-fidelity"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[cfg(unix)]
#[test]
fn an_st_mode_shaped_header_mode_raises_no_warning() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("commons.tar"),
&[Raw::ModedFile("a.txt", b"alpha", 0o100_644)],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--strict-fidelity",
]);
assert!(
out.status.success(),
"an st_mode-shaped mode field must not fail the gate, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("missing metadata"),
"nothing was lost, so nothing may be reported: {stderr}"
);
assert_eq!(
mode_of(&dest.join("a.txt")),
0o644,
"the permission bits apply; the type bits are not permissions"
);
let suid = write_raw_tar(
&dir.join("suid.tar"),
&[Raw::ModedFile("b.txt", b"beta", 0o104_755)],
);
let out = run_output(&[
"unpack",
suid.to_str().unwrap(),
"-C",
dir.join("suid-out").to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(
out.status.code(),
Some(4),
"setuid inside an st_mode-shaped field is still a dropped mode, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn craft_a_tar_lz_declaring_a_huge_dictionary(tag: &str) -> (PathBuf, PathBuf, Scratch) {
let dir = tmp(tag);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let tar_path = dir.join("bundle.tar");
write_raw_tar(
&tar_path,
&[Raw::File("a.txt", b"hello from inside the tar")],
);
assert!(
Command::new(STUFFR)
.args([
"pack",
tar_path.to_str().unwrap(),
"--format",
"lzip",
"--force"
])
.status()
.unwrap()
.success(),
"packing the fixture must succeed"
);
let legit = dir.join("bundle.tar.lz");
let mut bytes = std::fs::read(&legit).unwrap();
assert_eq!(&bytes[..4], b"LZIP", "fixture must really be lzip");
bytes[5] = 0x1D;
let crafted = dir.join("crafted.tar.lz");
std::fs::write(&crafted, &bytes).unwrap();
(legit, crafted, dir)
}
#[test]
fn a_container_under_a_codec_declaring_a_huge_dictionary_is_refused_on_every_verb() {
let (_legit, crafted, _dir) = craft_a_tar_lz_declaring_a_huge_dictionary("c1-refuse");
let path = crafted.to_str().unwrap();
const LIMIT: &str = "64M";
for args in [
vec!["list", path, "--memory-limit", LIMIT],
vec!["test", path, "--memory-limit", LIMIT],
vec!["cat", path, "a.txt", "--memory-limit", LIMIT],
] {
let out = Command::new(STUFFR).args(&args).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(
out.status.code(),
Some(6),
"`stuffr {}` must refuse a 512 MiB dictionary declaration at exit 6, \
not allocate for it; stderr: {stderr}",
args.join(" ")
);
assert!(
stderr.contains("memory-limit"),
"the refusal must name the flag that raises it; stderr: {stderr}"
);
}
let out = Command::new(STUFFR)
.args([
"unpack",
path,
"-o",
"/dev/null",
"--force",
"--memory-limit",
LIMIT,
])
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(6),
"the single-stream path's existing refusal must be unchanged; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let mut child = Command::new(STUFFR)
.args(["list", "-", "--memory-limit", LIMIT])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let bytes = std::fs::read(&crafted).unwrap();
child.stdin.take().unwrap().write_all(&bytes).unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(6),
"a piped crafted archive must be refused too; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_legitimate_archive_of_the_same_size_still_works_on_every_verb() {
let (legit, crafted, _dir) = craft_a_tar_lz_declaring_a_huge_dictionary("c1-allow");
let path = legit.to_str().unwrap();
assert_eq!(
std::fs::metadata(&legit).unwrap().len(),
std::fs::metadata(&crafted).unwrap().len(),
"the two fixtures must differ only in that one byte"
);
const LIMIT: &str = "64M";
let out = Command::new(STUFFR)
.args(["list", path, "--memory-limit", LIMIT])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("a.txt"),
"list must still show the entry"
);
let out = Command::new(STUFFR)
.args(["test", path, "--memory-limit", LIMIT])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(STUFFR)
.args(["cat", path, "a.txt", "--memory-limit", LIMIT])
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"hello from inside the tar");
let dest = tmp("c1-allow-dest");
let _ = std::fs::remove_dir_all(&dest);
let out = Command::new(STUFFR)
.args([
"unpack",
path,
"-C",
dest.to_str().unwrap(),
"--memory-limit",
LIMIT,
])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(dest.join("a.txt").exists());
}
#[test]
fn memory_limit_is_honoured_rather_than_refused_on_the_container_path() {
let (_legit, crafted, _dir) = craft_a_tar_lz_declaring_a_huge_dictionary("c1-flag");
let path = crafted.to_str().unwrap();
for args in [
vec!["list", path, "--memory-limit", "1G"],
vec!["test", path, "--memory-limit", "1G"],
vec!["cat", path, "a.txt", "--memory-limit", "1G"],
] {
let out = Command::new(STUFFR).args(&args).output().unwrap();
assert!(
out.status.success(),
"`stuffr {}` must succeed once the limit is raised past the declaration; \
stderr: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
let dest = tmp("c1-flag-dest");
let _ = std::fs::remove_dir_all(&dest);
let out = Command::new(STUFFR)
.args([
"unpack",
path,
"-C",
dest.to_str().unwrap(),
"--memory-limit",
"1G",
])
.output()
.unwrap();
assert!(
out.status.success(),
"unpack -C must accept --memory-limit rather than refusing it; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_symlink_pointing_at_the_extraction_root_is_extracted_not_refused() {
let dir = tmp_dir();
let dest = dir.join("out");
let archive = write_raw_tar(
&dir.join("root-link.tar"),
&[
Raw::Dir("sub"),
Raw::Symlink("sub/top", ".."),
Raw::File("sub/a.txt", b"alpha"),
],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert!(
out.status.success(),
"a symlink netting to the destination is contained, not an escape; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let link = dest.join("sub/top");
assert!(
std::fs::symlink_metadata(&link).is_ok(),
"the symlink must have been created"
);
assert_eq!(std::fs::read_link(&link).unwrap(), Path::new(".."));
assert!(dest.join("sub/a.txt").exists(), "extraction must not abort");
}
#[test]
fn a_symlink_one_step_past_the_extraction_root_is_still_refused() {
for (tag, target) in [
("i1-two-up", "../.."),
("i1-deep", "../../../etc"),
("i1-mixed", "../sub/../.."),
] {
let dir = tmp_dir();
let dest = dir.join(tag);
let archive = write_raw_tar(
&dir.join(format!("{tag}.tar")),
&[Raw::Dir("sub"), Raw::Symlink("sub/top", target)],
);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(7),
"`{target}` escapes and must still be refused, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("unsafe entry path `sub/top` refused"),
"the refusal must name the entry `sub/top`, got: {stderr}"
);
assert!(
std::fs::symlink_metadata(dest.join("sub/top")).is_err(),
"the symlink must not have been created"
);
}
}
#[cfg(unix)]
#[test]
fn a_gnu_tar_transform_rewritten_symlink_target_is_skipped_not_fatal() {
let gtar = require_gnu_tar();
let dir = tmp_dir();
let tree = dir.join("tree");
std::fs::create_dir_all(tree.join("bin")).unwrap();
std::fs::create_dir_all(tree.join("lib")).unwrap();
std::fs::write(tree.join("lib/foo"), b"lib-bytes").unwrap();
std::os::unix::fs::symlink("../lib/foo", tree.join("bin/foo")).unwrap();
let archive = dir.join("pkg.tar");
let made = Command::new(>ar)
.args([
"-cf",
archive.to_str().unwrap(),
"--transform",
"s,^,pkg-1.0/,",
"-C",
])
.arg(&tree)
.args(["bin", "lib"])
.output()
.unwrap();
assert!(
made.status.success(),
"{}",
String::from_utf8_lossy(&made.stderr)
);
let listed = Command::new(>ar)
.args(["-tvf", archive.to_str().unwrap()])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&listed.stdout).contains("pkg-1.0/bin/foo -> pkg-1.0/../lib/foo"),
"{}",
String::from_utf8_lossy(&listed.stdout)
);
for (strict, code) in [(false, 0), (true, 4)] {
let dest = dir.join(if strict { "strict" } else { "plain" });
let mut args = vec![
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
];
if strict {
args.push("--strict-fidelity");
}
let out = run_output(&args);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(
out.status.code(),
Some(code),
"strict={strict}, stderr: {stderr}"
);
assert_eq!(
std::fs::read(dest.join("pkg-1.0/lib/foo")).unwrap(),
b"lib-bytes",
"strict={strict}: extraction continues past the skip"
);
assert!(
std::fs::symlink_metadata(dest.join("pkg-1.0/bin/foo")).is_err(),
"strict={strict}: the skipped link is not created"
);
assert_eq!(
stderr
.matches(
"skipped entry `pkg-1.0/bin/foo`: symlink target uses `..` after a name, \
which can climb out through another link"
)
.count(),
1,
"strict={strict}: one named skip, stderr: {stderr}"
);
}
}
#[test]
fn info_does_not_claim_nothing_was_approximated_for_a_container_under_a_codec() {
let dir = tmp("info-codec-wrapped-matrix");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let one = dir.join("one.txt");
std::fs::write(&one, b"payload").unwrap();
for (ext, has_trailing_index) in [("zip", true), ("tar", false), ("cpio", false), ("a", false)]
{
let bare = dir.join(format!("bundle.{ext}"));
assert!(
Command::new(STUFFR)
.args(["pack", one.to_str().unwrap(), "-o", bare.to_str().unwrap()])
.status()
.unwrap()
.success(),
"packing a .{ext} must succeed"
);
assert!(
Command::new(STUFFR)
.args([
"pack",
bare.to_str().unwrap(),
"--format",
"gzip",
"--force"
])
.status()
.unwrap()
.success(),
"wrapping the .{ext} in gzip must succeed"
);
let wrapped = dir.join(format!("bundle.{ext}.gz"));
let bytes = std::fs::read(&wrapped).unwrap();
let strict = Command::new(STUFFR)
.args(["test", wrapped.to_str().unwrap(), "--strict-fidelity"])
.output()
.unwrap();
let strict_err = String::from_utf8_lossy(&strict.stderr).to_string();
let really_loses = !strict.status.success();
assert_eq!(
really_loses, has_trailing_index,
".{ext}.gz: only a trailing index can be missed by a forward read; \
stderr: {strict_err}"
);
let text = String::from_utf8(
Command::new(STUFFR)
.args(["info", wrapped.to_str().unwrap()])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
text.contains("exact"),
".{ext}.gz from a file: the rung is real and unchanged: {text}"
);
assert!(
text.contains(ext),
".{ext}.gz from a file must resolve the whole chain: {text}"
);
if has_trailing_index {
assert!(
text.contains("not evaluated"),
".{ext}.gz from a file: the zip is read out of a gzip decoder, which is \
forward-only however seekable the FILE is — `test` on these very bytes \
exits 4. info has not looked and must not claim: {text}"
);
assert!(!text.contains("nothing approximated"), "{text}");
} else {
assert!(
text.contains("nothing approximated"),
".{ext}.gz from a file carries every entry's metadata inline, so a forward \
read loses nothing — withholding here would be a false negative, the \
round-3 defect repeating: {text}"
);
assert!(!text.contains("not evaluated"), "{text}");
}
let text = info_over_stdin(&bytes);
assert!(text.contains("forward-only"), ".{ext}.gz on a pipe: {text}");
assert!(
text.contains("chain: gzip"),
".{ext}.gz on a pipe resolves to the codec alone — info does not decode to \
look inside: {text}"
);
let text = String::from_utf8(
Command::new(STUFFR)
.args(["info", bare.to_str().unwrap()])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
text.contains("nothing approximated"),
"a bare seekable .{ext} is unaffected by the codec-wrapped rule: {text}"
);
}
}
#[test]
fn every_entry_aware_verb_applies_the_same_expansion_bound() {
let dir = tmp_dir();
let payload = vec![0u8; 8 * 1024 * 1024];
let big = dir.join("big.bin");
std::fs::write(&big, &payload).unwrap();
let bomb = dir.join("bomb.zip");
assert!(
Command::new(STUFFR)
.args(["pack", big.to_str().unwrap(), "-o", bomb.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let compressed = std::fs::metadata(&bomb).unwrap().len();
assert!(
compressed * 10 < payload.len() as u64,
"the fixture must actually exceed a ratio of 10 ({compressed} compressed)"
);
let dest = dir.join("out");
for args in [
vec!["test", bomb.to_str().unwrap(), "--max-ratio", "10"],
vec![
"cat",
bomb.to_str().unwrap(),
"big.bin",
"--max-ratio",
"10",
],
vec![
"unpack",
bomb.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--max-ratio",
"10",
],
] {
let out = Command::new(STUFFR).args(&args).output().unwrap();
assert_eq!(
out.status.code(),
Some(6),
"`stuffr {}` must refuse the same bomb the other verbs refuse; stderr: {}",
args[0],
String::from_utf8_lossy(&out.stderr)
);
}
let _ = std::fs::remove_dir_all(&dest);
for args in [
vec!["test", bomb.to_str().unwrap()],
vec!["cat", bomb.to_str().unwrap(), "big.bin"],
vec![
"unpack",
bomb.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
],
] {
let out = Command::new(STUFFR).args(&args).output().unwrap();
assert!(
out.status.success(),
"`stuffr {}` must still accept a legitimate archive under the default ratio; \
stderr: {}",
args[0],
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn a_piped_archive_larger_than_the_ratio_floor_is_not_refused() {
let dir = tmp_dir();
let payload: Vec<u8> = (0..3u32 * 1024 * 1024).map(|i| (i % 251) as u8).collect();
let src = dir.join("big.bin");
std::fs::write(&src, &payload).unwrap();
let archive = dir.join("big.tar");
assert!(
Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"-o",
archive.to_str().unwrap()
])
.status()
.unwrap()
.success()
);
let bytes = std::fs::read(&archive).unwrap();
assert!(
bytes.len() as u64 > stuffr::RATIO_FLOOR,
"the fixture must exceed the floor or this test proves nothing"
);
let piped = |args: &[&str], input: &[u8]| {
let mut child = Command::new(STUFFR)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut sin = child.stdin.take().unwrap();
let data = input.to_vec();
let writer = std::thread::spawn(move || {
let _ = sin.write_all(&data);
});
let out = child.wait_with_output().unwrap();
writer.join().unwrap();
out
};
let dest = dir.join("out");
for args in [
vec!["test", "-"],
vec!["cat", "-", "big.bin"],
vec!["unpack", "-", "-C", dest.to_str().unwrap()],
] {
let out = piped(&args, &bytes);
assert!(
out.status.success(),
"`stuffr {}` over a pipe must not refuse a {}-byte archive; stderr: {}",
args.join(" "),
bytes.len(),
String::from_utf8_lossy(&out.stderr)
);
}
let zeros = vec![0u8; 8 * 1024 * 1024];
let zsrc = dir.join("zeros.bin");
std::fs::write(&zsrc, &zeros).unwrap();
let bomb = dir.join("bomb.zip");
assert!(
Command::new(STUFFR)
.args(["pack", zsrc.to_str().unwrap(), "-o", bomb.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let bomb_bytes = std::fs::read(&bomb).unwrap();
let piped_test = |extra: &[&str]| {
let mut argv = vec!["test", "-"];
argv.extend_from_slice(extra);
piped(&argv, &bomb_bytes)
};
assert_eq!(
piped_test(&["--max-ratio", "10"]).status.code(),
Some(6),
"a bomb piped in must still be refused"
);
assert!(
piped_test(&["--max-ratio", "100000"]).status.success(),
"raising --max-ratio must change the outcome on a pipe — that is what \
proves the flag reaches this path rather than being parsed and dropped"
);
}
#[test]
fn a_valid_odc_cpio_archive_reports_a_capability_limit_not_corruption() {
let dir = tmp_dir();
let src = dir.join("f.txt");
std::fs::write(&src, b"payload").unwrap();
let newc = dir.join("archive.cpio");
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", newc.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let mut bytes = std::fs::read(&newc).unwrap();
assert_eq!(&bytes[..6], b"070701");
bytes[..6].copy_from_slice(b"070707");
let odc = dir.join("odc.cpio");
std::fs::write(&odc, &bytes).unwrap();
let out = Command::new(STUFFR)
.args(["list", odc.to_str().unwrap()])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(
out.status.code(),
Some(3),
"an intact archive in an unreadable variant is a capability limit, \
not damage; stderr: {stderr}"
);
assert!(
stderr.contains("odc"),
"the message must name the variant: {stderr}"
);
assert!(
!stderr.contains("corrupt"),
"the message must not suggest damage: {stderr}"
);
let mut broken = std::fs::read(&newc).unwrap();
broken.truncate(30);
let cut = dir.join("cut.cpio");
std::fs::write(&cut, &broken).unwrap();
let out = Command::new(STUFFR)
.args(["list", cut.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(5),
"a truncated archive really is damaged; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn pointing_a_verb_at_the_wrong_file_always_exits_two() {
let dir = tmp_dir();
let plain = dir.join("plain.txt");
std::fs::write(&plain, b"just some text, no format at all").unwrap();
let gz = dir.join("plain.txt.gz");
assert!(
Command::new(STUFFR)
.args([
"pack",
plain.to_str().unwrap(),
"--format",
"gzip",
"-o",
gz.to_str().unwrap()
])
.status()
.unwrap()
.success()
);
for (label, path) in [
("a codec stream, which is not an archive", &gz),
("a file of no recognised format", &plain),
] {
let out = Command::new(STUFFR)
.args(["list", path.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(2),
"{label}: both are the caller pointing `list` at the wrong file, \
so both are exit 2; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn no_cli_message_contains_a_run_of_collapsed_indentation() {
let dir = tmp_dir();
let a = dir.join("a.txt");
let b = dir.join("b.txt");
std::fs::write(&a, b"alpha").unwrap();
std::fs::write(&b, b"beta").unwrap();
let archive = write_fixture_tar(&dir, &[("x.txt", b"x")]);
let cases: Vec<Vec<String>> = vec![
vec![
"pack".into(),
a.to_str().unwrap().into(),
b.to_str().unwrap().into(),
],
vec![
"pack".into(),
a.to_str().unwrap().into(),
b.to_str().unwrap().into(),
"--format".into(),
"tar".into(),
],
vec![
"unpack".into(),
archive.to_str().unwrap().into(),
"x.txt".into(),
],
];
for args in cases {
let out = Command::new(STUFFR).args(&args).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(
out.status.code(),
Some(2),
"`stuffr {}` must be a usage error; stderr: {stderr}",
args.join(" ")
);
assert!(
!stderr.contains(" "),
"a CLI message must not carry a run of collapsed continuation \
indentation; `stuffr {}` printed: {stderr:?}",
args.join(" ")
);
assert!(
!stderr.trim().is_empty(),
"and it must actually say something: {stderr:?}"
);
}
let out = Command::new(STUFFR)
.args(["pack", a.to_str().unwrap(), b.to_str().unwrap()])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("`-o bundle.tar`, or --format tar"),
"the phrase either side of the old collapse must read as one sentence: {stderr:?}"
);
}
#[test]
fn a_container_over_a_codec_is_written_in_one_step() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
std::fs::write(&src, b"hello from inside the tar\n").unwrap();
let out = dir.join("bundle.tar.gz");
let st = Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let t = Command::new("gunzip")
.args(["-t", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
t.status.success(),
"gunzip -t rejected it: {}",
String::from_utf8_lossy(&t.stderr)
);
let l = Command::new("tar")
.args(["tzf", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
l.status.success(),
"tar tzf failed: {}",
String::from_utf8_lossy(&l.stderr)
);
assert!(String::from_utf8_lossy(&l.stdout).contains("notes.txt"));
let r = Command::new(STUFFR)
.args(["list", out.to_str().unwrap()])
.output()
.unwrap();
assert!(r.status.success());
assert!(String::from_utf8_lossy(&r.stdout).contains("notes.txt"));
}
#[test]
fn a_dot_tar_dot_gz_output_is_never_silently_the_wrong_format() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
std::fs::write(&src, b"payload\n").unwrap();
for (name, extra) in [
("a.tar.gz", vec![]),
("b.tgz", vec![]),
("c.tar.gz", vec!["--format", "tar"]),
] {
let out = dir.join(name);
let mut args = vec!["pack", src.to_str().unwrap(), "-o", out.to_str().unwrap()];
args.extend(extra);
let st = Command::new(STUFFR).args(&args).output().unwrap();
assert!(
st.status.success(),
"{name}: {}",
String::from_utf8_lossy(&st.stderr)
);
let t = Command::new("gunzip")
.args(["-t", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
t.status.success(),
"{name} is not valid gzip: {}",
String::from_utf8_lossy(&t.stderr)
);
let l = Command::new("tar")
.args(["tzf", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
l.status.success(),
"{name} has no tar inside: {}",
String::from_utf8_lossy(&l.stderr)
);
assert!(
String::from_utf8_lossy(&l.stdout).contains("notes.txt"),
"{name}"
);
}
}
#[test]
fn a_codec_only_or_container_only_output_is_unaffected() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
std::fs::write(&src, b"payload\n").unwrap();
let gz = dir.join("plain.gz");
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", gz.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let out = Command::new("gunzip")
.args(["-c", gz.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(out.stdout, b"payload\n");
let tar = dir.join("plain.tar");
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", tar.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let l = Command::new("tar")
.args(["tf", tar.to_str().unwrap()])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&l.stdout).contains("notes.txt"));
}
#[test]
fn a_zip_round_trips_bare_and_inside_a_codec() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
std::fs::write(&src, b"zip payload\n").unwrap();
for name in ["bare.zip", "wrapped.zip.gz"] {
let out = dir.join(name);
let st = Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"{name}: {}",
String::from_utf8_lossy(&st.stderr)
);
if name.ends_with(".gz") {
let t = Command::new("gunzip")
.args(["-t", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
t.status.success(),
"{name} is not valid gzip: {}",
String::from_utf8_lossy(&t.stderr)
);
}
let r = Command::new(STUFFR)
.args(["list", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
r.status.success(),
"{name}: {}",
String::from_utf8_lossy(&r.stderr)
);
assert!(
String::from_utf8_lossy(&r.stdout).contains("notes.txt"),
"{name}"
);
}
}
#[test]
fn a_format_flag_contradicting_the_output_name_is_refused_before_anything_is_written() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
std::fs::write(&src, b"payload\n").unwrap();
for (fmt, name) in [
("zip", "x.tar.gz"),
("tar", "x.zip.gz"),
("tar", "x.cpio"),
("cpio", "x.tgz"),
] {
let out = dir.join(name);
let o = Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"--format",
fmt,
"-o",
out.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(
o.status.code(),
Some(2),
"--format {fmt} -o {name} must be a usage error, not a written file"
);
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
assert!(
stderr.contains(fmt) && stderr.contains(name),
"the message must name both sides of the contradiction: {stderr:?}"
);
assert!(!out.exists(), "--format {fmt} -o {name} left a file behind");
}
for (fmt, name) in [("tar", "ok.tar.gz"), ("tar", "ok.gz"), ("zip", "ok.zip.gz")] {
let out = dir.join(name);
let o = Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"--format",
fmt,
"-o",
out.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
o.status.success(),
"--format {fmt} -o {name} must still work: {}",
String::from_utf8_lossy(&o.stderr)
);
let l = Command::new(STUFFR)
.args(["list", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
l.status.success(),
"stuffr must be able to read back what it wrote to {name}: {}",
String::from_utf8_lossy(&l.stderr)
);
assert!(
String::from_utf8_lossy(&l.stdout).contains("notes.txt"),
"{name}"
);
}
}
#[test]
fn the_composed_path_honours_stuffr_threads_now_that_a_codec_is_present() {
let dir = tmp_dir();
let src = dir.join("big.txt");
let mut data = Vec::with_capacity(4 << 20);
let mut x: u32 = 0x1234_5678;
while data.len() < (4 << 20) {
x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
data.extend_from_slice(format!("line {} of the corpus\n", x >> 16).as_bytes());
}
std::fs::write(&src, &data).unwrap();
let plain = dir.join("plain.tar.xz");
let env = dir.join("env.tar.xz");
for (out, threads) in [(&plain, None), (&env, Some("4"))] {
let mut cmd = Command::new(STUFFR);
cmd.args([
"pack",
src.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--level",
"1",
]);
match threads {
Some(n) => cmd.env("STUFFR_THREADS", n),
None => cmd.env_remove("STUFFR_THREADS"),
};
let o = cmd.output().unwrap();
assert!(
o.status.success(),
"pack failed: {}",
String::from_utf8_lossy(&o.stderr)
);
}
assert_ne!(
std::fs::read(&plain).unwrap(),
std::fs::read(&env).unwrap(),
"STUFFR_THREADS=4 must now change the bytes on a composed write with a codec \
layer, the same way it already does on a single-stream one; identical bytes \
here means the governor silently went back to None"
);
let flagged = Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"-o",
dir.join("flag.tar.xz").to_str().unwrap(),
"--threads",
"4",
])
.output()
.unwrap();
assert!(
flagged.status.success(),
"--threads must be honoured where a codec is present: {}",
String::from_utf8_lossy(&flagged.stderr)
);
}
#[test]
fn encoder_flags_are_refused_only_where_there_is_no_codec_to_honour_them() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
std::fs::write(&src, b"payload\n").unwrap();
let st = Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"-o",
dir.join("a.tar").to_str().unwrap(),
"--threads",
"2",
])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(2),
"a bare container has no codec to hand threads to"
);
let out = dir.join("b.tar.gz");
let st = Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--threads",
"2",
])
.output()
.unwrap();
assert!(
st.status.success(),
"a composed write HAS a codec: {}",
String::from_utf8_lossy(&st.stderr)
);
assert!(
Command::new("gunzip")
.args(["-t", out.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let big = dir.join("big.txt");
let mut payload = String::new();
for i in 0..4000 {
payload.push_str(&format!(
"line {i} of a log with some repeated shape {}\n",
i % 97
));
}
std::fs::write(&big, payload.as_bytes()).unwrap();
let mut sizes = Vec::new();
for level in ["1", "9"] {
let out = dir.join(format!("c{level}.tar.gz"));
assert!(
Command::new(STUFFR)
.args([
"pack",
big.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--level",
level,
])
.status()
.unwrap()
.success(),
"--level {level} must be accepted on a composed write"
);
assert!(
Command::new("gunzip")
.args(["-t", out.to_str().unwrap()])
.status()
.unwrap()
.success(),
"--level {level} must still produce valid gzip"
);
sizes.push(std::fs::metadata(&out).unwrap().len());
}
assert!(
sizes[0] > sizes[1],
"--level must reach the codec beneath the container: level 1 produced \
{} bytes and level 9 produced {} — equal sizes mean the flag was \
accepted and dropped",
sizes[0],
sizes[1]
);
assert!(
Command::new(STUFFR)
.args([
"pack",
src.to_str().unwrap(),
"-o",
dir.join("d.gz").to_str().unwrap(),
"--threads",
"2",
])
.status()
.unwrap()
.success()
);
}
#[test]
fn pack_walks_a_directory_and_round_trips_it() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir(root.join("empty")).unwrap();
std::fs::write(root.join("README.md"), b"readme").unwrap();
std::fs::write(root.join("src/main.rs"), b"fn main() {}").unwrap();
let out = dir.join("p.tar");
let st = Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let back = dir.join("back");
assert!(
Command::new(STUFFR)
.args([
"unpack",
out.to_str().unwrap(),
"-C",
back.to_str().unwrap()
])
.status()
.unwrap()
.success()
);
assert_eq!(
std::fs::read(back.join("proj/README.md")).unwrap(),
b"readme"
);
assert_eq!(
std::fs::read(back.join("proj/src/main.rs")).unwrap(),
b"fn main() {}"
);
assert!(
back.join("proj/empty").is_dir(),
"an empty directory must survive the round trip"
);
}
#[test]
fn pack_walks_a_directory_into_a_container_inside_a_codec() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), b"fn main() {}").unwrap();
let out = dir.join("backup.tar.gz");
let st = Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let back = dir.join("back");
assert!(
Command::new(STUFFR)
.args([
"unpack",
out.to_str().unwrap(),
"-C",
back.to_str().unwrap()
])
.status()
.unwrap()
.success()
);
assert_eq!(
std::fs::read(back.join("proj/src/main.rs")).unwrap(),
b"fn main() {}"
);
}
#[cfg(unix)]
#[test]
fn pack_stores_a_symlink_inside_a_directory_rather_than_following_it() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("real.txt"), b"real").unwrap();
std::os::unix::fs::symlink("real.txt", root.join("link.txt")).unwrap();
let out = dir.join("p.tar");
assert!(
Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.status()
.unwrap()
.success()
);
let l = Command::new("tar")
.args(["tvf", out.to_str().unwrap()])
.output()
.expect("the system `tar` is this test's reference authority");
let text = String::from_utf8_lossy(&l.stdout);
assert!(
text.contains("link.txt -> real.txt"),
"tar tvf said: {text}"
);
}
#[cfg(unix)]
#[test]
fn pack_follows_a_symlink_named_on_the_command_line() {
let dir = tmp_dir();
std::fs::write(dir.join("real.txt"), b"real").unwrap();
std::os::unix::fs::symlink("real.txt", dir.join("link.txt")).unwrap();
let out = dir.join("named.tar");
assert!(
Command::new(STUFFR)
.args([
"pack",
dir.join("link.txt").to_str().unwrap(),
"-o",
out.to_str().unwrap()
])
.status()
.unwrap()
.success()
);
let back = dir.join("back");
assert!(
Command::new(STUFFR)
.args([
"unpack",
out.to_str().unwrap(),
"-C",
back.to_str().unwrap()
])
.status()
.unwrap()
.success()
);
assert_eq!(
std::fs::read(back.join("link.txt")).unwrap(),
b"real",
"a named symlink is followed, so the entry holds the target's bytes"
);
}
#[test]
fn packing_a_directory_into_a_container_that_cannot_hold_it_warns_rather_than_refusing() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/a.txt"), b"a").unwrap();
let out = dir.join("p.a");
let st = Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"must not refuse: {}",
String::from_utf8_lossy(&st.stderr)
);
let err = String::from_utf8_lossy(&st.stderr);
assert!(
err.contains("proj/sub"),
"it must SAY WHAT it dropped, by name; stderr: {err}"
);
let out2 = dir.join("p2.a");
let st2 = Command::new(STUFFR)
.args([
"pack",
root.to_str().unwrap(),
"-o",
out2.to_str().unwrap(),
"--strict-fidelity",
])
.output()
.unwrap();
assert_eq!(
st2.status.code(),
Some(4),
"strict fidelity must exit 4 on the write side too"
);
}
#[test]
fn strict_fidelity_passes_on_a_pack_that_lost_nothing() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), b"fn main() {}").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("main.rs", root.join("src/alias.rs")).unwrap();
let archive = dir.join("clean.tar");
let st = Command::new(STUFFR)
.args([
"pack",
root.to_str().unwrap(),
"-o",
archive.to_str().unwrap(),
"--strict-fidelity",
])
.output()
.unwrap();
assert!(
st.status.success(),
"an ordinary tree into tar loses nothing, symlink included: {}",
String::from_utf8_lossy(&st.stderr)
);
#[cfg(unix)]
{
let listed = Command::new("tar")
.args(["tvf", archive.to_str().unwrap()])
.output()
.expect("the system `tar` is this test's reference authority");
let text = String::from_utf8_lossy(&listed.stdout);
assert!(
text.contains("alias.rs -> main.rs"),
"the symlink must be stored as a link, not dropped or copied: {text}"
);
}
}
#[test]
fn two_directories_with_the_same_final_component_are_refused_before_writing() {
let dir = tmp_dir();
std::fs::create_dir_all(dir.join("a/proj")).unwrap();
std::fs::create_dir_all(dir.join("b/proj")).unwrap();
std::fs::write(dir.join("a/proj/x.txt"), b"x").unwrap();
std::fs::write(dir.join("b/proj/y.txt"), b"y").unwrap();
let out = dir.join("dup.tar");
let st = Command::new(STUFFR)
.args([
"pack",
dir.join("a/proj").to_str().unwrap(),
dir.join("b/proj").to_str().unwrap(),
"-o",
out.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(2),
"a name collision is a usage error"
);
assert!(
!out.exists(),
"nothing may be written: the refusal happens before the destination is opened"
);
}
#[test]
fn pack_of_dot_names_entries_after_the_directory_it_resolves_to() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), b"fn main() {}").unwrap();
let out = dir.join("dot.tar");
let st = Command::new(STUFFR)
.current_dir(&root)
.args(["pack", ".", "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"`pack .` must work: {}",
String::from_utf8_lossy(&st.stderr)
);
let listed = run_output(&["list", out.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(
text.contains("proj/src/main.rs"),
"`.` resolves to its canonical final component, so entries sit under \
`proj/` exactly as `pack ../proj` would name them: {text}"
);
}
#[test]
fn pack_of_dotdot_names_entries_after_the_parent_it_resolves_to() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), b"fn main() {}").unwrap();
let out = dir.join("dotdot.tar");
let st = Command::new(STUFFR)
.current_dir(root.join("src"))
.args(["pack", "..", "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"`pack ..` must work: {}",
String::from_utf8_lossy(&st.stderr)
);
let listed = run_output(&["list", out.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(
text.contains("proj/src/main.rs"),
"`..` must name entries after the directory it resolves to: {text}"
);
}
#[test]
fn a_trailing_slash_on_a_packed_directory_is_noise() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), b"fn main() {}").unwrap();
let out = dir.join("slash.tar");
let with_slash = format!("{}/", root.to_str().unwrap());
let st = Command::new(STUFFR)
.args(["pack", &with_slash, "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"a trailing slash must not change anything: {}",
String::from_utf8_lossy(&st.stderr)
);
let listed = run_output(&["list", out.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(
text.contains("proj/src/main.rs"),
"entries must sit under `proj/`, not under an empty name: {text}"
);
}
#[test]
fn pack_excludes_its_own_output_from_the_walk() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), b"a").unwrap();
let st = Command::new(STUFFR)
.current_dir(&root)
.args(["pack", ".", "-o", "backup.tar"])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let out = root.join("backup.tar");
let first_len = std::fs::metadata(&out).unwrap().len();
let st = Command::new(STUFFR)
.current_dir(&root)
.args(["pack", ".", "-o", "backup.tar", "--force"])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let err = String::from_utf8_lossy(&st.stderr);
assert!(
err.contains("proj/backup.tar"),
"it must SAY WHAT it excluded, by name; stderr: {err}"
);
let second_len = std::fs::metadata(&out).unwrap().len();
assert_eq!(
first_len, second_len,
"a --force re-run must not nest the previous archive inside the new one"
);
let listed = run_output(&["list", out.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(
!text.contains("backup.tar"),
"the archive must not contain an entry naming itself: {text}"
);
assert!(
text.contains("proj/a.txt"),
"everything else in the directory is still stored: {text}"
);
let st3 = Command::new(STUFFR)
.current_dir(&root)
.args([
"pack",
".",
"-o",
"backup.tar",
"--force",
"--strict-fidelity",
])
.output()
.unwrap();
assert_eq!(
st3.status.code(),
Some(0),
"excluding the archive from its own walk costs no fidelity, so the strict \
gate must not fire on it: {}",
String::from_utf8_lossy(&st3.stderr)
);
}
#[test]
fn pack_refuses_when_the_only_input_is_the_output_and_leaves_it_untouched() {
let dir = tmp_dir();
let root = dir.join("backups");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("data.txt"), b"last night's data").unwrap();
let st = Command::new(STUFFR)
.current_dir(&root)
.args(["pack", "data.txt", "-o", "backup.tar"])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let archive = root.join("backup.tar");
let before = std::fs::read(&archive).unwrap();
assert!(
before.len() > 1024,
"the fixture must be a real archive, not an empty one: {} bytes",
before.len()
);
let st = Command::new(STUFFR)
.current_dir(&root)
.args([
"pack",
"backup.tar",
"-o",
"backup.tar",
"--force",
"--strict-fidelity",
])
.output()
.unwrap();
let err = String::from_utf8_lossy(&st.stderr);
assert_eq!(
st.status.code(),
Some(2),
"an empty plan is a usage refusal, not a successful empty archive: {err}"
);
assert!(
err.contains("nothing to pack") && err.contains("backup.tar"),
"the refusal must name the cause and the destination: {err}"
);
assert_eq!(
std::fs::read(&archive).unwrap(),
before,
"refusing before the destination is opened means no temp file and no \
rename, so the existing archive must be byte-identical"
);
}
#[test]
fn pack_refuses_a_nightly_over_a_directory_holding_only_its_own_archive() {
let dir = tmp_dir();
let root = dir.join("backups");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(dir.join("data.txt"), b"last night's data").unwrap();
let st = Command::new(STUFFR)
.current_dir(&dir)
.args(["pack", "data.txt", "-o", "backups/nightly.tar"])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let archive = root.join("nightly.tar");
let before = std::fs::read(&archive).unwrap();
let st = Command::new(STUFFR)
.current_dir(&dir)
.args([
"pack",
"backups",
"-o",
"backups/nightly.tar",
"--force",
"--strict-fidelity",
])
.output()
.unwrap();
let err = String::from_utf8_lossy(&st.stderr);
assert_eq!(
st.status.code(),
Some(2),
"a plan reduced to the directory the output was removed from is a usage \
refusal, not an archive of one empty directory: {err}"
);
assert!(
err.contains("No archive was written") && err.contains("is unchanged"),
"the refusal must say plainly that the destination survives: {err}"
);
assert_eq!(
std::fs::read(&archive).unwrap(),
before,
"last night's archive must be byte-identical, not replaced by a shell"
);
}
#[test]
fn test_reports_a_zip_whose_index_declares_more_records_than_are_reachable() {
let dir = tmp_dir();
let src = dir.join("a.txt");
std::fs::write(&src, b"alpha").unwrap();
let zip_path = dir.join("dup.zip");
let st = Command::new(STUFFR)
.current_dir(&dir)
.args(["pack", "a.txt", "-o", "dup.zip"])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let st = Command::new(STUFFR)
.args(["test", zip_path.to_str().unwrap(), "--strict-fidelity"])
.output()
.unwrap();
let err = String::from_utf8_lossy(&st.stderr);
assert_eq!(st.status.code(), Some(0), "a clean zip is clean: {err}");
assert!(err.contains("exact fidelity"), "and still says so: {err}");
let mut bytes = std::fs::read(&zip_path).unwrap();
let eocd = bytes.len() - 22;
assert_eq!(&bytes[eocd..eocd + 4], b"PK\x05\x06", "no EOCD");
let le16 = |b: &[u8]| u16::from_le_bytes([b[0], b[1]]);
let cd_size = u32::from_le_bytes(bytes[eocd + 12..eocd + 16].try_into().unwrap()) as usize;
let cd_at = u32::from_le_bytes(bytes[eocd + 16..eocd + 20].try_into().unwrap()) as usize;
let cd = bytes[cd_at..cd_at + cd_size].to_vec();
let first = 46 + le16(&cd[28..]) as usize + le16(&cd[30..]) as usize + le16(&cd[32..]) as usize;
let entries = le16(&bytes[eocd + 10..]);
let mut out = bytes[..cd_at + cd_size].to_vec();
out.extend_from_slice(&cd[..first]);
let tail = &mut bytes[eocd..];
tail[8..10].copy_from_slice(&(entries + 1).to_le_bytes());
tail[10..12].copy_from_slice(&(entries + 1).to_le_bytes());
tail[12..16].copy_from_slice(&((cd_size + first) as u32).to_le_bytes());
out.extend_from_slice(tail);
let forged = dir.join("shadowed.zip");
std::fs::write(&forged, &out).unwrap();
let st = Command::new(STUFFR)
.args(["test", forged.to_str().unwrap()])
.output()
.unwrap();
let err = String::from_utf8_lossy(&st.stderr);
assert_eq!(
st.status.code(),
Some(0),
"a shadowed record is a fidelity loss, not corruption: {err}"
);
assert!(
err.contains("declares 2") && err.contains("but 1 were enumerated"),
"the warning must name both counts: {err}"
);
assert!(
!err.contains("exact fidelity"),
"a read that reached one of two records must not announce exact \
fidelity: {err}"
);
let st = Command::new(STUFFR)
.args(["test", forged.to_str().unwrap(), "--strict-fidelity"])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(4),
"--strict-fidelity must refuse it: {}",
String::from_utf8_lossy(&st.stderr)
);
}
fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).find_map(|dir| {
let candidate = dir.join(bin);
candidate.is_file().then_some(candidate)
})
}
fn require_bin(bin: &str) -> PathBuf {
which(bin).unwrap_or_else(|| {
panic!(
"no reference `{bin}` tool found on PATH — this test proved nothing, which is \
worth knowing rather than passing silently"
)
})
}
fn require_lhasa() -> PathBuf {
which("lha").or_else(|| which("lhasa")).unwrap_or_else(|| {
panic!(
"no reference lhasa tool found on PATH under either of its two names, `lha` \
(Homebrew) or `lhasa` (Debian/Ubuntu) — this test proved nothing, which is worth \
knowing rather than passing silently"
)
})
}
fn lhasa_banner(bin: &std::path::Path) -> String {
let out = Command::new(bin)
.output()
.expect("run lhasa with no arguments for its banner");
let text = if out.stdout.is_empty() {
String::from_utf8_lossy(&out.stderr).into_owned()
} else {
String::from_utf8_lossy(&out.stdout).into_owned()
};
text.lines().next().unwrap_or("<no banner>").to_string()
}
#[cfg(unix)]
fn perm_bits(md: &std::fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
md.permissions().mode() & 0o7777
}
#[cfg(unix)]
fn build_reference_tree(root: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::create_dir(root.join("empty")).unwrap();
std::fs::write(root.join("a.txt"), b"alpha").unwrap();
std::fs::write(root.join("sub/b.bin"), b"\x00\xff\x00beta").unwrap();
std::os::unix::fs::symlink("a.txt", root.join("link")).unwrap();
for (p, mode) in [
(root.to_path_buf(), 0o700),
(root.join("sub"), 0o700),
(root.join("empty"), 0o700),
(root.join("a.txt"), 0o600),
(root.join("sub/b.bin"), 0o600),
] {
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode)).unwrap();
}
}
#[cfg(unix)]
fn tree_snapshot(root: &Path) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
let rel = path.strip_prefix(root).unwrap().display().to_string();
let md = std::fs::symlink_metadata(&path).unwrap();
if md.is_symlink() {
out.push(format!(
"{rel}: symlink -> {}",
std::fs::read_link(&path).unwrap().display()
));
} else if md.is_dir() {
out.push(format!("{rel}: dir mode {:o}", perm_bits(&md)));
stack.push(path);
} else {
out.push(format!(
"{rel}: file mode {:o} bytes {:?}",
perm_bits(&md),
std::fs::read(&path).unwrap()
));
}
}
}
out.sort();
out
}
fn run_tool(bin: &Path, args: &[&std::ffi::OsStr], cwd: &Path, stdin: &[u8]) -> Vec<u8> {
let mut child = Command::new(bin)
.args(args)
.current_dir(cwd)
.env("COPYFILE_DISABLE", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stdin).unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"reference tool {} {:?} failed: {}",
bin.display(),
args,
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
fn os(s: &impl AsRef<std::ffi::OsStr>) -> &std::ffi::OsStr {
s.as_ref()
}
fn pack_ok(args: &[&std::ffi::OsStr]) -> String {
let out = Command::new(STUFFR)
.arg("pack")
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"stuffr pack {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stderr).to_string()
}
fn unpack_ok(archive: &Path, dest: &Path) {
let out = Command::new(STUFFR)
.args([
std::ffi::OsStr::new("unpack"),
archive.as_ref(),
std::ffi::OsStr::new("-C"),
dest.as_ref(),
])
.output()
.unwrap();
assert!(
out.status.success(),
"stuffr unpack {} failed: {}",
archive.display(),
String::from_utf8_lossy(&out.stderr)
);
}
#[cfg(unix)]
#[test]
fn every_container_round_trips_a_tree_against_its_reference_tool() {
let tar_bin = require_bin("tar");
let zip_bin = require_bin("zip");
let unzip_bin = require_bin("unzip");
let cpio_bin = require_bin("cpio");
let ar_bin = require_bin("ar");
let dir = tmp_dir();
let tree = dir.join("tree");
let root = tree.join("proj");
build_reference_tree(&root);
let want = tree_snapshot(&root);
assert_eq!(
want,
vec![
"a.txt: file mode 600 bytes [97, 108, 112, 104, 97]".to_string(),
"empty: dir mode 700".to_string(),
"link: symlink -> a.txt".to_string(),
"sub/b.bin: file mode 600 bytes [0, 255, 0, 98, 101, 116, 97]".to_string(),
"sub: dir mode 700".to_string(),
],
"the fixture must carry a nested dir, an EMPTY dir, a symlink and two \
differently-named files, or the comparisons below prove less than they claim"
);
let out_tar = dir.join("out.tar");
pack_ok(&[os(&root), os(&"-o"), os(&out_tar)]);
let back = dir.join("back-tar");
std::fs::create_dir(&back).unwrap();
run_tool(
&tar_bin,
&[os(&"-xf"), os(&out_tar), os(&"-C"), os(&back)],
&dir,
b"",
);
assert_eq!(
tree_snapshot(&back.join("proj")),
want,
"the system tar read our tar back as a different tree"
);
let ref_tar = dir.join("ref.tar");
run_tool(
&tar_bin,
&[os(&"-cf"), os(&ref_tar), os(&"proj")],
&tree,
b"",
);
let back = dir.join("back-ref-tar");
unpack_ok(&ref_tar, &back);
assert_eq!(
tree_snapshot(&back.join("proj")),
want,
"we read a tar the system tar wrote as a different tree"
);
let out_zip = dir.join("out.zip");
pack_ok(&[os(&root), os(&"-o"), os(&out_zip)]);
let back = dir.join("back-zip");
run_tool(
&unzip_bin,
&[os(&"-q"), os(&out_zip), os(&"-d"), os(&back)],
&dir,
b"",
);
assert_eq!(
tree_snapshot(&back.join("proj")),
want,
"unzip read our zip back as a different tree"
);
let ref_zip = dir.join("ref.zip");
run_tool(
&zip_bin,
&[os(&"-qry"), os(&ref_zip), os(&"proj")],
&tree,
b"",
);
let back = dir.join("back-ref-zip");
unpack_ok(&ref_zip, &back);
assert_eq!(
tree_snapshot(&back.join("proj")),
want,
"we read a zip the system zip wrote as a different tree"
);
let out_cpio = dir.join("out.cpio");
pack_ok(&[os(&root), os(&"-o"), os(&out_cpio)]);
let back = dir.join("back-cpio");
std::fs::create_dir(&back).unwrap();
run_tool(
&cpio_bin,
&[os(&"-i"), os(&"-d"), os(&"--quiet")],
&back,
&std::fs::read(&out_cpio).unwrap(),
);
assert_eq!(
tree_snapshot(&back.join("proj")),
want,
"the system cpio read our cpio back as a different tree"
);
let names = b"proj\nproj/a.txt\nproj/empty\nproj/link\nproj/sub\nproj/sub/b.bin\n";
let ref_cpio_bytes = run_tool(
&cpio_bin,
&[os(&"-o"), os(&"--format=newc"), os(&"--quiet")],
&tree,
names,
);
let ref_cpio = dir.join("ref.cpio");
std::fs::write(&ref_cpio, &ref_cpio_bytes).unwrap();
let back = dir.join("back-ref-cpio");
unpack_ok(&ref_cpio, &back);
assert_eq!(
tree_snapshot(&back.join("proj")),
want,
"we read a cpio the system cpio wrote as a different tree"
);
let out_ar = dir.join("out.a");
let stderr = pack_ok(&[os(&root), os(&"-o"), os(&out_ar)]);
for dropped in ["proj/empty", "proj/link", "proj/sub"] {
assert!(
stderr.contains(dropped),
"`ar` dropping `{dropped}` must be named in the fidelity report; stderr: {stderr}"
);
}
let listed = run_tool(&ar_bin, &[os(&"t"), os(&out_ar)], &dir, b"");
let mut members: Vec<String> = String::from_utf8_lossy(&listed)
.lines()
.map(str::to_string)
.collect();
members.sort();
assert_eq!(
members,
vec!["proj/a.txt".to_string(), "proj/sub/b.bin".to_string()],
"the system ar must list exactly the regular files, and nothing standing in for \
the directories or the symlink"
);
let ref_ar = dir.join("ref.a");
run_tool(
&ar_bin,
&[os(&"rcS"), os(&ref_ar), os(&"a.txt")],
&root,
b"",
);
let back = dir.join("back-ref-ar");
unpack_ok(&ref_ar, &back);
assert_eq!(
std::fs::read(back.join("a.txt")).unwrap(),
b"alpha",
"we read an ar the system ar wrote as different bytes"
);
}
#[test]
fn packing_the_same_tree_twice_produces_identical_bytes() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("b")).unwrap();
for n in ["z.txt", "m.txt", "a.txt"] {
std::fs::write(root.join(n), n.as_bytes()).unwrap();
}
std::fs::write(root.join("b/inner.txt"), b"inner").unwrap();
let readdir_order: Vec<String> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
let mut sorted = readdir_order.clone();
sorted.sort();
assert_ne!(
readdir_order, sorted,
"this filesystem hands back directory entries already sorted, so the ordering \
assertion below could not fail; the fixture needs a name order this filesystem \
does not reproduce"
);
let listing = dir.join("order.tar");
let st = Command::new(STUFFR)
.args([
"pack",
root.to_str().unwrap(),
"-o",
listing.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let listed = run_output(&["list", listing.to_str().unwrap()]);
let names: Vec<String> = String::from_utf8_lossy(&listed.stdout)
.lines()
.map(|l| l.split_whitespace().last().unwrap_or_default().to_string())
.collect();
assert_eq!(
names,
vec![
"proj",
"proj/a.txt",
"proj/b",
"proj/b/inner.txt",
"proj/m.txt",
"proj/z.txt",
],
"entries must be written in sorted, depth-first order — `readdir` order was {readdir_order:?}"
);
for ext in ["tar", "tar.gz", "zip"] {
let one = dir.join(format!("one.{ext}"));
let two = dir.join(format!("two.{ext}"));
for out in [&one, &two] {
let st = Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
}
assert_eq!(
std::fs::read(&one).unwrap(),
std::fs::read(&two).unwrap(),
"the same tree must pack to the same `.{ext}` bytes; readdir order is not stable"
);
}
}
#[cfg(unix)]
#[test]
fn a_failed_write_never_replaces_an_existing_archive() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("a.txt"), b"alpha").unwrap();
let big = root.join("zbig.bin");
let f = std::fs::File::create(&big).unwrap();
f.set_len(u64::from(u32::MAX) + 1).unwrap();
drop(f);
let md = std::fs::metadata(&big).unwrap();
assert!(
md.len() > u64::from(u32::MAX),
"the premise: the entry must exceed cpio's u32 size field"
);
assert!(
md.blocks() * 512 < md.len(),
"this filesystem did not make the fixture sparse ({} blocks for {} bytes); a \
non-sparse one would write 4 GiB to disk here",
md.blocks(),
md.len()
);
let out = dir.join("existing.cpio");
std::fs::write(&out, b"PRECIOUS EXISTING CONTENT").unwrap();
let st = Command::new(STUFFR)
.args([
"pack",
root.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--force",
])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(3),
"an entry this container cannot express is a capability limit, exit 3; stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
assert_eq!(
std::fs::read(&out).unwrap(),
b"PRECIOUS EXISTING CONTENT",
"a pack that failed mid-write must not have replaced the archive already there"
);
let mut siblings: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
siblings.sort();
assert_eq!(
siblings,
vec!["existing.cpio".to_string(), "proj".to_string()],
"a failed pack must leave no temp file behind"
);
let ro = dir.join("readonly");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap();
assert!(
std::fs::File::create(ro.join("probe")).is_err(),
"this test needs a non-root user; the read-only directory is still writable"
);
let target = ro.join("out.tar");
let st = Command::new(STUFFR)
.args([
"pack",
root.join("a.txt").to_str().unwrap(),
"-o",
target.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(1),
"a destination that cannot be created is an i/o failure, exit 1; stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
assert!(!target.exists(), "a failed pack must publish nothing");
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[cfg(unix)]
#[test]
fn an_unreadable_file_is_skipped_with_a_warning_rather_than_failing_the_pack() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("a.txt"), b"alpha").unwrap();
let unreadable = root.join("secret.txt");
std::fs::write(&unreadable, b"beta").unwrap();
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap();
assert!(
std::fs::File::open(&unreadable).is_err(),
"this test needs a non-root user; the unreadable file is still readable"
);
let out = dir.join("backup.tar");
let st = Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"one unreadable file must not lose the whole backup: {}",
String::from_utf8_lossy(&st.stderr)
);
let stderr = String::from_utf8_lossy(&st.stderr);
assert!(
stderr.contains("proj/secret.txt"),
"it must SAY WHAT it skipped, by name; stderr: {stderr}"
);
let listed = run_output(&["list", out.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(text.contains("proj/a.txt"), "{text}");
assert!(
!text.contains("secret.txt"),
"an entry that could not be read must not be written as an empty one: {text}"
);
let strict = dir.join("strict.tar");
let st = Command::new(STUFFR)
.args([
"pack",
root.to_str().unwrap(),
"-o",
strict.to_str().unwrap(),
"--strict-fidelity",
])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(4),
"strict fidelity must refuse a pack that skipped a file; stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o600)).unwrap();
}
fn workspace_source_files() -> Vec<PathBuf> {
let crates = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let mut out = Vec::new();
let mut stack: Vec<PathBuf> = std::fs::read_dir(crates)
.unwrap()
.map(|e| e.unwrap().path().join("src"))
.filter(|p| p.is_dir())
.collect();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
out.sort();
out
}
fn rust_string_literals(src: &str) -> Vec<(usize, String)> {
let c: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let mut i = 0usize;
let mut line = 1usize;
while i < c.len() {
if c[i] == '\n' {
line += 1;
i += 1;
continue;
}
if c[i] == '/' && c.get(i + 1) == Some(&'/') {
while i < c.len() && c[i] != '\n' {
i += 1;
}
continue;
}
if c[i] == '/' && c.get(i + 1) == Some(&'*') {
let mut depth = 1usize;
i += 2;
while i < c.len() && depth > 0 {
if c[i] == '/' && c.get(i + 1) == Some(&'*') {
depth += 1;
i += 2;
} else if c[i] == '*' && c.get(i + 1) == Some(&'/') {
depth -= 1;
i += 2;
} else {
if c[i] == '\n' {
line += 1;
}
i += 1;
}
}
continue;
}
if c[i] == '\'' {
if c.get(i + 1) == Some(&'\\') {
let mut j = i + 2;
while j < c.len() && c[j] != '\'' {
j += 1;
}
i = j + 1;
continue;
}
if c.get(i + 2) == Some(&'\'') {
i += 3;
continue;
}
i += 1;
continue;
}
let ident_before = i > 0 && (c[i - 1].is_alphanumeric() || c[i - 1] == '_');
if (c[i] == 'r' || c[i] == 'b') && !ident_before {
let mut j = i;
if c[j] == 'b' {
j += 1;
}
if c.get(j) == Some(&'r') {
j += 1;
let mut hashes = 0usize;
while c.get(j) == Some(&'#') {
hashes += 1;
j += 1;
}
if c.get(j) == Some(&'"') {
let start_line = line;
j += 1;
let mut text = String::new();
loop {
if j >= c.len() {
break;
}
if c[j] == '"' && c[j + 1..].iter().take(hashes).all(|h| *h == '#') {
j += 1 + hashes;
break;
}
if c[j] == '\n' {
line += 1;
}
text.push(c[j]);
j += 1;
}
out.push((start_line, text));
i = j;
continue;
}
}
}
if c[i] == '"' {
let start_line = line;
let mut text = String::new();
let mut j = i + 1;
while j < c.len() && c[j] != '"' {
if c[j] == '\\' && c.get(j + 1) == Some(&'\n') {
line += 1;
j += 2;
while matches!(c.get(j), Some(' ' | '\t' | '\r' | '\n')) {
if c[j] == '\n' {
line += 1;
}
j += 1;
}
continue;
}
if c[j] == '\\' {
text.push(c[j]);
if let Some(&e) = c.get(j + 1) {
text.push(e);
}
j += 2;
continue;
}
if c[j] == '\n' {
line += 1;
}
text.push(c[j]);
j += 1;
}
out.push((start_line, text));
i = j + 1;
continue;
}
i += 1;
}
out
}
fn first_space_run(text: &str) -> Option<usize> {
text.as_bytes().windows(3).position(|w| w == b" ")
}
fn is_column_label(prefix: &str) -> bool {
let tail = prefix
.rsplit('\n')
.next()
.unwrap_or(prefix)
.rsplit("\\n")
.next()
.unwrap_or(prefix);
match tail.strip_suffix(':') {
Some(label) => {
!label.is_empty()
&& !label.contains(' ')
&& label
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}
None => false,
}
}
fn is_verbatim_reference_messages(path: &std::path::Path) -> bool {
let parts: Vec<&std::ffi::OsStr> = path.components().map(|c| c.as_os_str()).collect();
matches!(
parts.as_slice(),
[.., src, compat, _family, file]
if *src == "src" && *compat == "compat" && *file == "messages.rs"
) || matches!(
parts.as_slice(),
[.., src, compat, _family, _flavour, file]
if *src == "src" && *compat == "compat" && *file == "messages.rs"
)
}
#[test]
fn no_message_literal_in_the_workspace_carries_a_run_of_collapsed_indentation() {
let defective = "fn f() { err(\"packing paths needs a container (`-o bundle.tar`, or --format tar)\"); }";
let found = rust_string_literals(defective);
assert_eq!(
found.len(),
1,
"the scanner must find the literal: {found:?}"
);
assert!(
first_space_run(&found[0].1).is_some_and(|at| !is_column_label(&found[0].1[..at])),
"the scanner must flag the historical defect: {:?}",
found[0].1
);
let continued = "fn f() { err(\"packing paths needs a container \\\n (`-o bundle.tar`, or --format tar)\"); }";
let found = rust_string_literals(continued);
assert_eq!(found.len(), 1);
assert_eq!(
found[0].1, "packing paths needs a container (`-o bundle.tar`, or --format tar)",
"a `\\`-continued literal carries no indentation, and the scanner must apply that \
rule rather than report the source text"
);
assert!(first_space_run(&found[0].1).is_none());
let aligned = "fn f() { println!(\"format: {}\", x); }";
let found = rust_string_literals(aligned);
let at = first_space_run(&found[0].1).expect("the column really is three spaces");
assert!(
is_column_label(&found[0].1[..at]),
"a deliberate column label must not be reported as a collapse"
);
let colon_prose = "fn f() { err(\"stuffr cannot do this: try force\"); }";
let found = rust_string_literals(colon_prose);
assert_eq!(found.len(), 1);
let at = first_space_run(&found[0].1).expect("the run is there");
assert!(
!is_column_label(&found[0].1[..at]),
"a prose clause ending in a colon is not a column label: {:?}",
found[0].1
);
assert!(is_column_label("MemAvailable:"));
assert!(!is_column_label("two words:"));
let quoted_char = "fn f() { let q = '\"'; err(\"a collapse\"); }";
let found = rust_string_literals(quoted_char);
assert_eq!(
found.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>(),
vec!["a collapse"],
"a `\"` inside a char literal must not desynchronise the scan"
);
use std::path::Path;
assert!(is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/bzip2/messages.rs"
)));
assert!(!is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/bzip2/run.rs"
)));
assert!(!is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/messages.rs"
)));
assert!(!is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/messages.rs"
)));
assert!(!is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/bzip2/xmessages.rs"
)));
assert!(is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/gzip/gnu/messages.rs"
)));
assert!(is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/gzip/bsd/messages.rs"
)));
assert!(!is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/gzip/bsd/run.rs"
)));
assert!(!is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/gzip/gnu/run.rs"
)));
assert!(!is_verbatim_reference_messages(Path::new(
"crates/stuffr-cli/src/compat/gzip/gnu/x/messages.rs"
)));
let files = workspace_source_files();
assert!(
files.len() > 20,
"the walk found only {} source files, which is not this workspace: {files:?}",
files.len()
);
assert!(
files.iter().any(|p| p.ends_with("stuffr-cli/src/main.rs")),
"the walk must reach the crate every user-facing message is printed from"
);
let mut scanned = 0usize;
let mut offenders = Vec::new();
let mut exempted = 0usize;
for file in &files {
if is_verbatim_reference_messages(file) {
exempted += 1;
continue;
}
let src = std::fs::read_to_string(file).unwrap();
for (line, text) in rust_string_literals(&src) {
scanned += 1;
let Some(at) = first_space_run(&text) else {
continue;
};
if is_column_label(&text[..at]) {
continue;
}
offenders.push(format!("{}:{line}: {text:?}", file.display()));
}
}
assert!(
scanned > 1000,
"only {scanned} literals scanned, which is not this workspace"
);
assert!(
exempted >= 1,
"the bzip2 message table was not found, so the exemption matched nothing"
);
assert!(
offenders.is_empty(),
"a string literal carries a run of three or more spaces, which reaches the user as \
collapsed continuation indentation mid-sentence. Re-join the sentence and end each \
continued line with `\\` (which strips the newline AND the next line's indentation); \
a deliberate column is written `label: value`. Offenders:\n{}",
offenders.join("\n")
);
}
#[test]
fn packing_the_filesystem_root_is_a_usage_error_that_says_why() {
let dir = tmp_dir();
let out = dir.join("root.tar");
let st = Command::new(STUFFR)
.args(["pack", "/", "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(2),
"packing `/` is the caller's mistake, not an i/o failure; stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let stderr = String::from_utf8_lossy(&st.stderr);
assert!(
stderr.contains("no final path component"),
"the message must say what is wrong with `/`: {stderr}"
);
assert!(
!out.exists(),
"the refusal happens before the destination is opened"
);
}
#[test]
fn a_nonexistent_dotdot_path_is_reported_as_an_io_error() {
let dir = tmp_dir();
let out = dir.join("dd.tar");
let missing = dir.join("nosuch/..");
let st = Command::new(STUFFR)
.args([
"pack",
missing.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(
st.status.code(),
Some(1),
"a path that does not exist is an i/o error, whatever its final component; \
stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
assert!(!out.exists());
}
#[cfg(unix)]
#[test]
fn pack_of_dot_from_a_symlinked_directory_names_entries_after_the_real_one() {
let dir = tmp_dir();
std::fs::create_dir_all(dir.join("real/src")).unwrap();
std::fs::write(dir.join("real/src/main.rs"), b"fn main() {}").unwrap();
std::os::unix::fs::symlink("real", dir.join("proj")).unwrap();
let out = dir.join("sym.tar");
let st = Command::new(STUFFR)
.current_dir(dir.join("proj"))
.args(["pack", ".", "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let listed = run_output(&["list", out.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(
text.contains("real/src/main.rs"),
"`.` resolves through the symlink, so entries sit under the real directory's \
name: {text}"
);
assert!(
!text.contains("proj/"),
"nothing may be named after the link itself: {text}"
);
}
#[test]
fn excluding_the_output_from_its_own_walk_is_a_note_not_a_fidelity_loss() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), b"payload\n").unwrap();
let out = root.join("backup.tar");
for run in 1..=3 {
let st = Command::new(STUFFR)
.args([
"pack",
root.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--force",
"--strict-fidelity",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&st.stderr).to_string();
assert_eq!(
st.status.code(),
Some(0),
"run {run}: declining to store the archive inside itself is not a \
fidelity loss, so the strict gate must stay green; stderr: {stderr}"
);
if run > 1 {
assert!(
stderr.contains("proj/backup.tar"),
"run {run}: the user must still be told, by name: {stderr}"
);
}
assert!(
stderr.contains("no fidelity loss"),
"run {run}: the count in the summary must agree with the (empty) \
warning list: {stderr}"
);
}
let listed = run_output(&["list", out.to_str().unwrap()]);
let text = String::from_utf8_lossy(&listed.stdout);
assert!(text.contains("proj/a.txt"), "{text}");
assert!(
!text.contains("backup.tar"),
"the archive must not be inside itself: {text}"
);
}
#[test]
fn a_tbz_output_writes_tar_inside_bzip2_and_not_a_bare_gzip() {
let dir = tmp_dir();
let src = dir.join("notes.txt");
std::fs::write(&src, b"payload\n").unwrap();
let out = dir.join("f.tbz");
let st = Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let bytes = std::fs::read(&out).unwrap();
assert_eq!(
&bytes[..3],
b"BZh",
"a .tbz must be bzip2 — it was gzip (1f 8b) before this: {:02x?}",
&bytes[..4.min(bytes.len())]
);
let listed = run_output(&["list", out.to_str().unwrap()]);
assert!(
listed.status.success(),
"a .tbz must open as an archive: {}",
String::from_utf8_lossy(&listed.stderr)
);
assert!(
String::from_utf8_lossy(&listed.stdout).contains("notes.txt"),
"the tar layer must be there: {}",
String::from_utf8_lossy(&listed.stdout)
);
let out2 = dir.join("f.tbz2");
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", out2.to_str().unwrap()])
.status()
.unwrap()
.success()
);
assert_eq!(&std::fs::read(&out2).unwrap()[..3], b"BZh");
let out3 = dir.join("f.bz");
assert!(
Command::new(STUFFR)
.args(["pack", src.to_str().unwrap(), "-o", out3.to_str().unwrap()])
.status()
.unwrap()
.success()
);
assert_ne!(
&std::fs::read(&out3).unwrap()[..3],
b"BZh",
"`bz` must not resolve to bzip2 outside the `t`-prefixed compound form"
);
}
#[test]
fn a_directory_on_the_single_stream_path_is_a_usage_error_naming_the_fix() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), b"payload\n").unwrap();
let out = dir.join("proj.gz");
let st = Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&st.stderr).to_string();
assert_eq!(
st.status.code(),
Some(2),
"asking a codec for a tree is the caller's mistake, not an i/o \
failure; stderr: {stderr}"
);
assert!(
!stderr.contains("os error 21"),
"the raw EISDIR must not reach the user: {stderr}"
);
assert!(
stderr.contains("proj.tar.gz"),
"the message must name the output that works: {stderr}"
);
assert!(
!out.exists(),
"the refusal happens before the destination is touched"
);
let good = dir.join("proj.tar.gz");
assert!(
Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", good.to_str().unwrap()])
.status()
.unwrap()
.success()
);
}
#[test]
fn the_pack_summary_counts_losses_rather_than_claiming_exact_fidelity() {
let dir = tmp_dir();
let root = dir.join("proj");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub").join("a.txt"), b"payload\n").unwrap();
let out = dir.join("p.a");
let st = Command::new(STUFFR)
.args(["pack", root.to_str().unwrap(), "-o", out.to_str().unwrap()])
.output()
.unwrap();
assert!(
st.status.success(),
"stderr: {}",
String::from_utf8_lossy(&st.stderr)
);
let stderr = String::from_utf8_lossy(&st.stderr).to_string();
assert!(
!stderr.contains("exact fidelity"),
"a pack that lost entries must not announce exact fidelity: {stderr}"
);
let reported: usize = stderr
.lines()
.find_map(|l| l.split_once(" fidelity loss(es)"))
.and_then(|(head, _)| head.rsplit(", ").next())
.and_then(|n| n.trim().parse().ok())
.unwrap_or_else(|| panic!("the summary must carry a loss count: {stderr}"));
let listed: usize = stderr
.lines()
.find_map(|l| l.strip_prefix("stuffr: "))
.and_then(|l| l.split_once(" fidelity warning(s):"))
.and_then(|(n, _)| n.parse().ok())
.unwrap_or_else(|| panic!("the warning header must carry a count: {stderr}"));
assert_eq!(
reported, listed,
"the summary's count and the warning list's count are the same fact: {stderr}"
);
assert!(
reported > 0,
"packing a tree into `ar` really does lose the \
directory entries: {stderr}"
);
let clean = dir.join("p.tar");
let st = Command::new(STUFFR)
.args([
"pack",
root.to_str().unwrap(),
"-o",
clean.to_str().unwrap(),
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&st.stderr).to_string();
assert!(
stderr.contains("no fidelity loss"),
"a lossless pack must say so: {stderr}"
);
}
fn write_zip_via_pack(dir: &Path, entries: &[(&str, &[u8])]) -> PathBuf {
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
let mut paths = Vec::new();
for (name, data) in entries {
let p = src.join(name);
std::fs::write(&p, data).unwrap();
paths.push(p);
}
let zip = dir.join("bundle.zip");
let mut args: Vec<String> = vec!["pack".into()];
args.extend(paths.iter().map(|p| p.to_string_lossy().into_owned()));
args.push("-o".into());
args.push(zip.to_string_lossy().into_owned());
let out = Command::new(STUFFR).args(&args).output().unwrap();
assert!(
out.status.success(),
"building the zip fixture failed: {}",
String::from_utf8_lossy(&out.stderr)
);
zip
}
#[test]
fn the_index_list_prints_is_the_index_cat_selects_by() {
let dir = tmp_dir();
let archive = write_fixture_tar(
&dir,
&[
("a.txt", b"alpha"),
("b.txt", b"bravo"),
("c.txt", b"charlie"),
],
);
let listing =
String::from_utf8(run_output(&["list", archive.to_str().unwrap()]).stdout).unwrap();
let rows: Vec<&str> = listing.lines().collect();
assert_eq!(rows.len(), 3, "{listing}");
for (want_index, (_, payload)) in [
("a.txt", &b"alpha"[..]),
("b.txt", &b"bravo"[..]),
("c.txt", &b"charlie"[..]),
]
.iter()
.enumerate()
{
let printed = rows[want_index].split_whitespace().next().unwrap();
assert_eq!(
printed,
want_index.to_string(),
"list's first column must be the 0-based archive position: {listing}"
);
let out = run_output(&[
"cat",
archive.to_str().unwrap(),
"--index",
&want_index.to_string(),
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.stdout, *payload,
"row {want_index} of the listing and `--index {want_index}` must be \
the same entry"
);
}
}
#[test]
fn index_selection_agrees_between_a_seekable_zip_and_the_same_zip_on_a_pipe() {
let dir = tmp_dir();
let payloads: [&[u8]; 4] = [b"first", b"second!!", b"third:::", b"fourth"];
let zip = write_zip_via_pack(
&dir,
&[
("one.txt", payloads[0]),
("two.txt", payloads[1]),
("three.txt", payloads[2]),
("four.txt", payloads[3]),
],
);
let bytes = std::fs::read(&zip).unwrap();
for (i, payload) in payloads.iter().enumerate() {
let n = i.to_string();
let seekable = run_output(&["cat", zip.to_str().unwrap(), "--index", &n]);
assert!(
seekable.status.success(),
"index {n} on a file: {}",
String::from_utf8_lossy(&seekable.stderr)
);
let piped = run_with_stdin_output(&["cat", "-", "--index", &n], &bytes);
assert!(
piped.status.success(),
"index {n} on a pipe: {}",
String::from_utf8_lossy(&piped.stderr)
);
assert_eq!(
seekable.stdout, piped.stdout,
"the random-access and counted routes disagreed at index {n}"
);
assert_eq!(
seekable.stdout,
payload.to_vec(),
"index {n} did not reach the entry the archive was built with"
);
}
}
#[test]
fn repeated_index_flags_are_deduplicated_and_delivered_in_archive_order() {
let dir = tmp_dir();
let archive = write_fixture_tar(
&dir,
&[
("a.txt", b"alpha"),
("b.txt", b"bravo"),
("c.txt", b"charlie"),
],
);
let out = run_output(&[
"cat",
archive.to_str().unwrap(),
"--index",
"2",
"--index",
"0",
"--index",
"2",
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.stdout, b"alphacharlie",
"archive order, once each — not typed order, and not twice"
);
}
#[test]
fn an_index_past_the_end_is_a_usage_error_naming_the_real_entry_count() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha"), ("b.txt", b"bravo")]);
let zip = write_zip_via_pack(&dir, &[("one.txt", b"first"), ("two.txt", b"second")]);
for path in [&archive, &zip] {
let out = run_output(&["cat", path.to_str().unwrap(), "--index", "9"]);
assert_eq!(
out.status.code(),
Some(2),
"{}: stderr {}",
path.display(),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("#9") && stderr.contains("0-1"),
"the refusal must name the index asked for and the VALID range, \
whichever route found it: {stderr}"
);
assert!(
out.stdout.is_empty(),
"an out-of-range index must not stream anything first"
);
}
}
#[test]
fn index_and_patterns_together_are_a_usage_error() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha"), ("b.txt", b"bravo")]);
let dest = dir.join("out");
let out = run_output(&["cat", archive.to_str().unwrap(), "a.txt", "--index", "1"]);
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--index"), "{stderr}");
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"a.txt",
"-C",
dest.to_str().unwrap(),
"--index",
"1",
]);
assert_eq!(out.status.code(), Some(2));
assert!(
!dest.exists(),
"the refusal must come before anything is created"
);
}
#[test]
fn unpack_by_index_without_a_directory_names_the_index_in_its_refusal() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha"), ("b.txt", b"bravo")]);
let out = run_output(&["unpack", archive.to_str().unwrap(), "--index", "1"]);
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--index 1") && stderr.contains("-C"),
"the refusal must name what was typed and what to do instead: {stderr}"
);
}
#[test]
fn unpack_by_index_extracts_only_the_selected_entry() {
let dir = tmp_dir();
let archive = write_fixture_tar(
&dir,
&[
("a.txt", b"alpha"),
("b.txt", b"bravo"),
("c.txt", b"charlie"),
],
);
let dest = dir.join("out");
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--index",
"1",
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(dest.join("b.txt")).unwrap(), b"bravo");
assert!(!dest.join("a.txt").exists(), "only the selected entry");
assert!(!dest.join("c.txt").exists(), "only the selected entry");
}
#[test]
fn list_json_carries_the_archive_index_of_every_entry() {
let dir = tmp_dir();
let archive = write_fixture_tar(&dir, &[("a.txt", b"alpha"), ("b.txt", b"bravo")]);
let out = run_output(&["list", "--json", archive.to_str().unwrap()]);
let text = String::from_utf8(out.stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let rows = v.as_array().unwrap();
assert_eq!(rows[0]["index"], 0);
assert_eq!(rows[0]["name"], "a.txt");
assert_eq!(rows[1]["index"], 1);
assert_eq!(rows[1]["name"], "b.txt");
}
#[test]
fn list_reports_the_same_fidelity_warnings_test_does() {
let dir = tmp_dir();
let zip = write_zip_via_pack(&dir, &[("one.txt", b"first"), ("two.txt", b"second")]);
let bytes = std::fs::read(&zip).unwrap();
let listed = run_with_stdin_output(&["list", "-"], &bytes);
assert!(
listed.status.success(),
"a warning is not a failure without --strict-fidelity"
);
let list_err = String::from_utf8_lossy(&listed.stderr).to_string();
let tested = run_with_stdin_output(&["test", "-"], &bytes);
let test_err = String::from_utf8_lossy(&tested.stderr).to_string();
assert!(
test_err.contains("fidelity warning(s)"),
"the fixture must be a shape that really does lose something: {test_err}"
);
assert!(
list_err.contains("fidelity warning(s)"),
"`list` must report what it lost, not print rows in silence: {list_err}"
);
let warnings_of = |s: &str| -> Vec<String> {
s.lines()
.filter(|l| l.trim_start().starts_with("- "))
.map(|l| l.trim().to_string())
.collect()
};
assert_eq!(
warnings_of(&list_err),
warnings_of(&test_err),
"list:\n{list_err}\ntest:\n{test_err}"
);
}
#[test]
fn list_strict_fidelity_turns_a_warning_into_exit_four() {
let dir = tmp_dir();
let zip = write_zip_via_pack(&dir, &[("one.txt", b"first")]);
let bytes = std::fs::read(&zip).unwrap();
let lenient = run_with_stdin_output(&["list", "-"], &bytes);
assert_eq!(lenient.status.code(), Some(0));
assert!(
String::from_utf8_lossy(&lenient.stdout).contains("one.txt"),
"the listing itself is still printed"
);
let strict = run_with_stdin_output(&["list", "-", "--strict-fidelity"], &bytes);
assert_eq!(
strict.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&strict.stderr)
);
assert!(
String::from_utf8_lossy(&strict.stdout).contains("one.txt"),
"the gate is a verdict on a listing that was still produced"
);
let clean = run_output(&["list", zip.to_str().unwrap(), "--strict-fidelity"]);
assert_eq!(
clean.status.code(),
Some(0),
"a seekable zip loses nothing: {}",
String::from_utf8_lossy(&clean.stderr)
);
}
#[test]
fn a_temp_directory_name_is_not_reproducible_from_the_pid_and_a_counter() {
let dir = tmp_dir();
assert_eq!(
std::fs::read_dir(&dir).unwrap().count(),
0,
"a fresh temp directory must be empty — inheriting one with contents \
in it is the whole failure this guards against"
);
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
let derivable = format!("stuffr-cli-archive-{}-", std::process::id());
let tail = name
.strip_prefix(&derivable)
.unwrap_or_else(|| panic!("unexpected temp directory name: {name}"));
assert!(
tail.contains('-') && !tail.chars().all(|c| c.is_ascii_digit()),
"everything after the pid was the counter alone, so a second run on a \
recycled pid reproduced this name exactly: {name}"
);
let other = tmp("token-check");
let other_name = other.file_name().unwrap().to_string_lossy().into_owned();
assert_ne!(
other_name,
format!("stuffr-cli-{}-token-check", std::process::id()),
"tmp() must carry the run token too"
);
assert!(
other_name.ends_with("-token-check"),
"…and must still end in the caller's own name: {other_name}"
);
}
#[test]
fn documented_install_commands_name_features_this_crate_declares() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let manifest = std::fs::read_to_string(manifest_dir.join("Cargo.toml")).unwrap();
let features: Vec<String> = manifest
.split("\n[")
.find(|section| section.starts_with("features]"))
.expect(
"stuffr-cli must declare a [features] section: without one, every \
documented `cargo install stuffr-cli --features …` fails",
)
.lines()
.filter_map(|line| line.split_once('='))
.filter(|(_, rhs)| rhs.trim_start().starts_with('['))
.map(|(name, _)| name.trim().to_string())
.collect();
assert!(
features.contains(&"legacy".to_string()),
"the parse found {features:?}; if `legacy` is missing the scan itself is broken, \
not the manifest"
);
let docs = user_facing_documents();
let mut commands_checked = 0usize;
for doc in docs {
let text = std::fs::read_to_string(&doc).unwrap();
for line in text.lines() {
let Some(rest) = line.split("cargo install stuffr-cli").nth(1) else {
continue;
};
let Some(after) = rest.split("--features").nth(1) else {
continue;
};
let name = after.split_whitespace().next().unwrap_or("");
commands_checked += 1;
assert!(
features.contains(&name.to_string()),
"{} prints `cargo install stuffr-cli --features {name}`, but stuffr-cli \
declares only {features:?} — that command fails with \"the package \
'stuffr-cli' does not contain this feature\"",
doc.display()
);
}
}
assert!(
commands_checked >= 2,
"expected the READMEs to print at least the c-backed and legacy install \
commands; found {commands_checked} — the scan has stopped finding them"
);
}
fn user_facing_documents() -> Vec<PathBuf> {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let root = manifest_dir.parent().unwrap().parent().unwrap();
vec![
manifest_dir.join("README.md"),
manifest_dir.join("src").join("examples.txt"),
root.join("README.md"),
root.join("CONTRIBUTING.md"),
root.join("crates").join("stuffr").join("README.md"),
root.join("crates").join("stuffr-core").join("README.md"),
root.join("crates").join("stuffr-formats").join("README.md"),
]
}
#[test]
fn no_document_carries_a_stale_unqualified_codec_or_container_count() {
const LEGACY_ONLY_CODECS: &[&str] = &["compress"];
const LEGACY_ONLY_CONTAINERS: &[&str] = &["lha", "arj", "arc", "zoo"];
const CUES: &[&str] = &[
"pure",
"pure-tier",
"writable",
"round-trip",
"read-only",
"legacy",
"no-default-features",
];
const QUALIFIERS: &[&str] = &["round-trip", "read-only", "pure-tier"];
const CUE_WINDOW: usize = 8;
let registry = stuffr::registry();
let rows = registry.matrix();
let is_container = |id| registry.container(id).is_some();
let count = |container: bool, pred: &dyn Fn(&stuffr::FormatRow) -> bool| {
rows.iter()
.filter(|r| is_container(r.id) == container && pred(r))
.count()
};
let legacy = |container: bool, id: &str| {
if container {
LEGACY_ONLY_CONTAINERS.contains(&id)
} else {
LEGACY_ONLY_CODECS.contains(&id)
}
};
let full = |container: bool| count(container, &|_| true);
let narrowed = |container: bool| {
[
count(container, &|r| r.write),
count(container, &|r| !r.write),
count(container, &|r| !legacy(container, r.id.as_str())),
]
};
const NUMBERS: &[(&str, usize)] = &[
("one", 1),
("two", 2),
("three", 3),
("four", 4),
("five", 5),
("six", 6),
("seven", 7),
("eight", 8),
("nine", 9),
("ten", 10),
("eleven", 11),
("twelve", 12),
("thirteen", 13),
("fourteen", 14),
("fifteen", 15),
("sixteen", 16),
("seventeen", 17),
("eighteen", 18),
("nineteen", 19),
("twenty", 20),
];
let mut claims_checked = 0usize;
for doc in user_facing_documents() {
let text = std::fs::read_to_string(&doc).unwrap();
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
for clause in flat.split(". ").flat_map(|s| s.split('|')) {
let words: Vec<String> = clause
.split(' ')
.map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric() && c != '-')
.to_lowercase()
})
.collect();
for i in 0..words.len() {
let Some((_, value)) = NUMBERS.iter().find(|(w, _)| *w == words[i]) else {
continue;
};
let Some(noun) = words.get(i + 1) else {
continue;
};
if QUALIFIERS.contains(&noun.as_str()) {
continue;
}
let container = match noun.as_str() {
"containers" => true,
"codecs" => false,
_ => continue,
};
claims_checked += 1;
let from = i.saturating_sub(CUE_WINDOW);
let cued = words[from..=i + 1]
.iter()
.any(|w| CUES.contains(&w.as_str()));
let want_full = full(container);
let ok = if cued {
*value == want_full || narrowed(container).contains(value)
} else {
*value == want_full
};
assert!(
ok,
"{} says `{} {noun}`, but this build registers {want_full} {noun} \
(narrowed counts, allowed only in a clause naming a narrowing — \
{CUES:?}, within {CUE_WINDOW} words — are {:?}). The clause is \
{clause:?}",
doc.display(),
words[i],
narrowed(container),
);
}
}
}
assert!(
claims_checked >= 5,
"the scan found only {claims_checked} unqualified counts — it is matching nothing \
and proving nothing"
);
}
#[test]
fn no_document_carries_a_stale_round_trip_or_read_only_count() {
const LEGACY_ONLY_CODECS: &[&str] = &["compress"];
const LEGACY_ONLY_CONTAINERS: &[&str] = &["lha", "arj", "arc", "zoo"];
let registry = stuffr::registry();
let rows = registry.matrix();
let is_container = |id| registry.container(id).is_some();
let count = |container: bool, pred: &dyn Fn(&stuffr::FormatRow) -> bool| {
rows.iter()
.filter(|r| is_container(r.id) == container && pred(r))
.count()
};
let legacy = |container: bool, id: &str| {
if container {
LEGACY_ONLY_CONTAINERS.contains(&id)
} else {
LEGACY_ONLY_CODECS.contains(&id)
}
};
let expected = |qualifier: &str, container: bool| -> usize {
match qualifier {
"round-trip" => count(container, &|r| r.write),
"read-only" => count(container, &|r| !r.write),
"pure-tier" => count(container, &|r| !legacy(container, r.id.as_str())),
other => unreachable!("unhandled qualifier {other}"),
}
};
const NUMBERS: &[(&str, usize)] = &[
("one", 1),
("two", 2),
("three", 3),
("four", 4),
("five", 5),
("six", 6),
("seven", 7),
("eight", 8),
("nine", 9),
("ten", 10),
("eleven", 11),
("twelve", 12),
("thirteen", 13),
("fourteen", 14),
("fifteen", 15),
("sixteen", 16),
("seventeen", 17),
("eighteen", 18),
("nineteen", 19),
("twenty", 20),
];
let docs = user_facing_documents();
let mut claims_checked = 0usize;
for doc in &docs {
let text = std::fs::read_to_string(doc).unwrap();
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
let words: Vec<String> = flat
.split(' ')
.map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric() && c != '-')
.to_lowercase()
})
.collect();
for i in 0..words.len() {
let Some((_, value)) = NUMBERS.iter().find(|(w, _)| *w == words[i]) else {
continue;
};
let Some(qualifier) = words.get(i + 1) else {
continue;
};
if !["round-trip", "read-only", "pure-tier"].contains(&qualifier.as_str()) {
continue;
}
let noun = match words.get(i + 2).map(String::as_str) {
Some("legacy") => words.get(i + 3),
other => other.and_then(|_| words.get(i + 2)),
};
let container = match noun.map(String::as_str) {
Some("containers") => true,
Some("codecs") => false,
_ => continue,
};
claims_checked += 1;
let want = expected(qualifier, container);
assert_eq!(
*value,
want,
"{} says `{} {qualifier}{} {}`, but this build has {want}. This is the \
check that closes the class three stale write-capability claims were found \
by hand in: the row-count guard cannot see the WRITE column, so nothing \
else would say so",
doc.display(),
words[i],
if words.get(i + 2).map(String::as_str) == Some("legacy") {
" legacy"
} else {
""
},
if container { "containers" } else { "codecs" },
);
}
}
assert!(
claims_checked >= 8,
"the scan found only {claims_checked} qualified counts across the {} documents — \
it is matching nothing and proving nothing",
docs.len()
);
}
fn salvage_fixture_crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
struct SalvageFixtureLocalRecord {
offset: u32,
name: &'static str,
crc: u32,
len: u32,
bytes: Vec<u8>,
}
fn salvage_fixture_local_record(
offset: u32,
name: &'static str,
payload: &'static [u8],
) -> SalvageFixtureLocalRecord {
let crc = salvage_fixture_crc32(payload);
let len = u32::try_from(payload.len()).unwrap();
let mut bytes = Vec::new();
bytes.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]); bytes.extend_from_slice(&20u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0x21u16.to_le_bytes()); bytes.extend_from_slice(&crc.to_le_bytes());
bytes.extend_from_slice(&len.to_le_bytes()); bytes.extend_from_slice(&len.to_le_bytes()); bytes.extend_from_slice(&u16::try_from(name.len()).unwrap().to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(name.as_bytes());
bytes.extend_from_slice(payload);
SalvageFixtureLocalRecord {
offset,
name,
crc,
len,
bytes,
}
}
fn salvage_fixture_central_record(rec: &SalvageFixtureLocalRecord) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]); bytes.extend_from_slice(&20u16.to_le_bytes()); bytes.extend_from_slice(&20u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0x21u16.to_le_bytes()); bytes.extend_from_slice(&rec.crc.to_le_bytes());
bytes.extend_from_slice(&rec.len.to_le_bytes()); bytes.extend_from_slice(&rec.len.to_le_bytes()); bytes.extend_from_slice(&u16::try_from(rec.name.len()).unwrap().to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&rec.offset.to_le_bytes()); bytes.extend_from_slice(rec.name.as_bytes());
bytes
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SalvageFixtureDuplicates {
Identical,
Distinct,
}
fn build_shadowing_zip_fixture() -> Vec<u8> {
build_duplicating_zip_fixture(SalvageFixtureDuplicates::Identical)
}
fn build_duplicate_name_zip_fixture() -> Vec<u8> {
build_duplicating_zip_fixture(SalvageFixtureDuplicates::Distinct)
}
fn build_duplicating_zip_fixture(duplicates: SalvageFixtureDuplicates) -> Vec<u8> {
let originals_src: [(&str, &[u8]); 6] = [
("one.txt", b"payload for entry one"),
("two.txt", b"payload for entry two"),
(
"shadowed-a.bin",
b"payload for the first record that will be shadowed",
),
(
"shadowed-b.bin",
b"payload for the second record that will be shadowed",
),
("five.txt", b"payload for entry five"),
("six.txt", b"payload for entry six"),
];
let mut out = Vec::new();
let mut originals = Vec::new();
for (name, payload) in originals_src {
let rec = salvage_fixture_local_record(u32::try_from(out.len()).unwrap(), name, payload);
out.extend_from_slice(&rec.bytes);
originals.push(rec);
}
const DIFFERENT_A: &[u8] = b"DIFFERENT bytes under the first duplicated name!!!";
const DIFFERENT_B: &[u8] = b"DIFFERENT bytes under the second duplicated name!!!";
let (payload_a, payload_b) = match duplicates {
SalvageFixtureDuplicates::Identical => (originals_src[2].1, originals_src[3].1),
SalvageFixtureDuplicates::Distinct => {
assert_eq!(originals_src[2].1.len(), DIFFERENT_A.len());
assert_eq!(originals_src[3].1.len(), DIFFERENT_B.len());
(DIFFERENT_A, DIFFERENT_B)
}
};
let dup_a = salvage_fixture_local_record(
u32::try_from(out.len()).unwrap(),
"shadowed-a.bin",
payload_a,
);
out.extend_from_slice(&dup_a.bytes);
let dup_b = salvage_fixture_local_record(
u32::try_from(out.len()).unwrap(),
"shadowed-b.bin",
payload_b,
);
out.extend_from_slice(&dup_b.bytes);
let cd_start = u32::try_from(out.len()).unwrap();
for rec in &originals {
out.extend_from_slice(&salvage_fixture_central_record(rec));
}
out.extend_from_slice(&salvage_fixture_central_record(&dup_a));
out.extend_from_slice(&salvage_fixture_central_record(&dup_b));
let cd_size = u32::try_from(out.len()).unwrap() - cd_start;
out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&8u16.to_le_bytes()); out.extend_from_slice(&8u16.to_le_bytes()); out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
#[test]
fn unpack_refuses_a_nul_bearing_entry_name_rather_than_failing() {
let dir = tmp_dir();
let rec = salvage_fixture_local_record(0, "zt\0ee\0a.txt", b"hello world\n");
let mut out = rec.bytes.clone();
let cd_start = u32::try_from(out.len()).unwrap();
out.extend_from_slice(&salvage_fixture_central_record(&rec));
let cd_size = u32::try_from(out.len()).unwrap() - cd_start;
out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
let archive = dir.join("nul.zip");
std::fs::write(&archive, &out).unwrap();
let listed = run_output(&["list", archive.to_str().unwrap()]);
assert_eq!(listed.status.code(), Some(0), "list reads the archive fine");
let unpacked = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dir.join("out").to_str().unwrap(),
]);
let stderr = String::from_utf8(unpacked.stderr).unwrap();
assert_eq!(
unpacked.status.code(),
Some(7),
"hostile input must never be exit 1: {stderr}"
);
assert!(
stderr.contains("NUL"),
"and the refusal must name what it refused: {stderr}"
);
}
fn write_shadowing_zip_fixture(dir: &Path) -> PathBuf {
let path = dir.join("shadowing.zip");
std::fs::write(&path, build_shadowing_zip_fixture()).unwrap();
path
}
fn write_duplicate_name_zip_fixture(dir: &Path) -> PathBuf {
let path = dir.join("duplicate-names.zip");
std::fs::write(&path, build_duplicate_name_zip_fixture()).unwrap();
path
}
#[test]
fn the_two_salvage_duplicate_fixtures_share_a_shape_and_differ_in_content() {
let identical = build_shadowing_zip_fixture();
let distinct = build_duplicate_name_zip_fixture();
assert_eq!(
identical.len(),
distinct.len(),
"length-preserving by construction"
);
assert_ne!(identical, distinct, "but not the same bytes");
let first_dup_at = identical
.windows(4)
.enumerate()
.filter(|(_, w)| *w == b"PK\x03\x04")
.map(|(i, _)| i)
.nth(6)
.expect("the fixture has eight local headers");
assert_eq!(identical[..first_dup_at], distinct[..first_dup_at]);
}
#[test]
fn the_duplicate_name_fixture_reads_the_same_as_the_identical_one() {
let dir = tmp_dir();
let archive = write_duplicate_name_zip_fixture(&dir);
let list_out = run_output(&["list", archive.to_str().unwrap()]);
let listing = String::from_utf8(list_out.stdout).unwrap();
assert_eq!(
listing.lines().count(),
6,
"`list` collapses a repeated name whatever the bytes under it: {listing}"
);
let warnings = String::from_utf8(list_out.stderr).unwrap();
assert!(
warnings.contains("8") && warnings.contains("6"),
"`list` must still warn that the index declares more records than it reaches: {warnings}"
);
let salvage_out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stdout = String::from_utf8(salvage_out.stdout).unwrap();
assert_eq!(
stdout.lines().count(),
8,
"`salvage --list` must reach every scanned record here too: {stdout}"
);
assert_eq!(
stdout.matches("Intact").count(),
8,
"every record verifies: the duplicates carry their own correct CRC-32, which is what \
makes losing one of them a real loss: {stdout}"
);
}
#[test]
fn salvage_recovers_both_records_under_a_repeated_name() {
let dir = tmp_dir();
let archive = write_duplicate_name_zip_fixture(&dir);
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"a run that had to change a name must not exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let mut names: Vec<String> = std::fs::read_dir(&out_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(
names,
[
"five.txt",
"one.txt",
"shadowed-a.bin",
"shadowed-a.bin.salvaged-6",
"shadowed-b.bin",
"shadowed-b.bin.salvaged-7",
"six.txt",
"two.txt",
],
"eight records written must be eight files, with the later record of each pair \
under a name derived from its own scan position"
);
let first = std::fs::read(out_dir.join("shadowed-a.bin")).unwrap();
let second = std::fs::read(out_dir.join("shadowed-a.bin.salvaged-6")).unwrap();
assert_eq!(
first, b"payload for the first record that will be shadowed",
"record 2 must keep its own name and its own bytes"
);
assert_eq!(
second, b"DIFFERENT bytes under the first duplicated name!!!",
"record 6's bytes must be on disk too, not lost to an overwrite"
);
assert_ne!(first, second, "the fixture's whole premise");
let summary = String::from_utf8(out.stderr).unwrap();
assert!(
summary.contains("8 written"),
"every written record is counted, and now there are eight files to match: {summary}"
);
assert!(
summary.contains("2 renamed to avoid a name collision"),
"the summary must say a name had to be changed: {summary}"
);
}
#[test]
fn a_proven_duplicate_is_still_skipped_rather_than_renamed() {
let dir = tmp_dir();
let archive = write_shadowing_zip_fixture(&dir);
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(4));
assert_eq!(
std::fs::read_dir(&out_dir).unwrap().count(),
6,
"six distinct names, and the two byte-identical duplicates add nothing"
);
let summary = String::from_utf8(out.stderr).unwrap();
assert!(
summary.contains("2 skipped"),
"a proven copy is skipped, as it always was: {summary}"
);
assert!(
summary.contains("0 renamed to avoid a name collision"),
"and nothing needed renaming: {summary}"
);
}
#[test]
fn a_name_collision_and_a_shadow_are_two_different_annotations() {
let dir = tmp_dir();
let identical = write_shadowing_zip_fixture(&dir);
let distinct = write_duplicate_name_zip_fixture(&dir);
let id_rows =
String::from_utf8(run_output(&["salvage", identical.to_str().unwrap(), "--list"]).stdout)
.unwrap();
assert_eq!(
id_rows.matches("[shadowed: dup of #").count(),
2,
"byte-identical duplicates are shadows: {id_rows}"
);
assert_eq!(
id_rows.matches("[name collision:").count(),
0,
"a proven copy reports the stronger fact and only that one: {id_rows}"
);
let di_rows =
String::from_utf8(run_output(&["salvage", distinct.to_str().unwrap(), "--list"]).stdout)
.unwrap();
assert_eq!(
di_rows.matches("[shadowed: dup of #").count(),
0,
"no checksum agrees here, so nothing is a proven copy: {di_rows}"
);
assert!(
di_rows.contains("[name collision: #2 uses this name too]"),
"record 6 repeats record 2's name: {di_rows}"
);
assert!(
di_rows.contains("[name collision: #3 uses this name too]"),
"record 7 repeats record 3's name: {di_rows}"
);
assert_eq!(
di_rows.matches("[name collision:").count(),
2,
"exactly the two repeated names, never the six originals: {di_rows}"
);
}
#[test]
fn salvage_list_reaches_every_record() {
let dir = tmp_dir();
let archive = write_shadowing_zip_fixture(&dir);
let listing =
String::from_utf8(run_output(&["list", archive.to_str().unwrap()]).stdout).unwrap();
assert_eq!(
listing.lines().count(),
6,
"sanity check on the fixture itself: `list` must collapse to 6 rows: {listing}"
);
let out_dir = dir.join("recovered");
let salvage_out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
"--list",
]);
let stdout = String::from_utf8(salvage_out.stdout).unwrap();
assert_eq!(
stdout.lines().count(),
8,
"`salvage --list` must reach every scanned record, shadowed ones included: {stdout}"
);
let shadow_lines: Vec<&str> = stdout
.lines()
.filter(|l| l.contains("[shadowed: dup of #"))
.collect();
assert_eq!(
shadow_lines.len(),
2,
"exactly the two duplicate records must be annotated as shadows: {stdout}"
);
assert!(
shadow_lines[0].contains("[shadowed: dup of #2]"),
"the first shadow must name scan position 2 (shadowed-a.bin) as its original: {stdout}"
);
assert!(
shadow_lines[1].contains("[shadowed: dup of #3]"),
"the second shadow must name scan position 3 (shadowed-b.bin) as its original: {stdout}"
);
}
#[test]
fn salvage_index_names_a_scan_position_not_a_list_index() {
let dir = tmp_dir();
let archive = write_shadowing_zip_fixture(&dir);
let list_out = run_output(&["list", archive.to_str().unwrap(), "--index", "6"]);
assert!(
!list_out.status.success(),
"list's own numbering has only 6 rows (0-5); --index 6 must not succeed"
);
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
"--index",
"6",
"--list",
]);
assert_eq!(
out.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
let rows: Vec<&str> = stdout.lines().collect();
assert_eq!(
rows.len(),
8,
"--list must keep showing the full scan under --index too: {stdout}"
);
for (pos, row) in rows.iter().enumerate() {
assert!(
row.starts_with(&format!("{pos} ")),
"row {pos} must lead with its own scan position: {stdout}"
);
match pos {
6 => assert!(
row.contains("[shadowed: dup of #2]") && !row.contains("[not selected]"),
"the selected row must show its shadow annotation, not a not-selected one: \
{row}"
),
7 => assert!(
row.contains("[shadowed: dup of #3]") && !row.contains("[not selected]"),
"an unselected shadow must still show its shadow annotation: {row}"
),
_ => assert!(
row.contains("[not selected]"),
"every non-shadow position other than the selected one must be tagged \
not selected: {row}"
),
}
}
let out_dir2 = dir.join("recovered2");
let out2 = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir2.to_str().unwrap(),
"--index",
"0",
]);
assert!(
out2.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out2.stderr)
);
let names: Vec<String> = std::fs::read_dir(&out_dir2)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec!["one.txt".to_string()],
"-C DIR --index 0 must leave ONLY the selected record in DIR; found {names:?}"
);
let bad = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
"--index",
"99",
]);
assert!(!bad.status.success());
let stderr = String::from_utf8_lossy(&bad.stderr);
assert!(
stderr.contains("scan position"),
"an out-of-range --index must name it a scan position: {stderr}"
);
assert!(
!stderr.to_lowercase().contains("index "),
"the message must never say \"index\", to avoid being read as list's numbering: {stderr}"
);
}
#[test]
fn salvage_without_directory_is_refused() {
let dir = tmp_dir();
let archive = write_shadowing_zip_fixture(&dir);
let out = run_output(&["salvage", archive.to_str().unwrap()]);
assert!(!out.status.success());
assert_eq!(out.status.code(), Some(2), "missing -C is a usage error");
}
#[test]
fn salvage_default_run_recovers_and_reports_the_shadow_skips() {
let dir = tmp_dir();
let archive = write_shadowing_zip_fixture(&dir);
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"the default report goes to stderr, not stdout: {:?}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("8 scanned")
&& stderr.contains("6 written")
&& stderr.contains("2 skipped"),
"the summary must count the two shadow skips: {stderr}"
);
assert_eq!(
std::fs::read(out_dir.join("one.txt")).unwrap(),
b"payload for entry one"
);
assert_eq!(
std::fs::read(out_dir.join("shadowed-a.bin")).unwrap(),
b"payload for the first record that will be shadowed"
);
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 6);
}
#[test]
fn salvage_recovers_past_an_entry_whose_name_containment_refuses() {
let dir = tmp_dir();
let mut bytes = Vec::new();
for (name, payload) in [
("first.txt", &b"AAAAAAAAAA"[..]),
("", &b"BBBBBBBBBB"[..]),
("third.txt", &b"CCCCCCCCCC"[..]),
] {
let rec = salvage_fixture_local_record(u32::try_from(bytes.len()).unwrap(), name, payload);
bytes.extend_from_slice(&rec.bytes);
}
let archive = dir.join("midbad.zip");
std::fs::write(&archive, &bytes).unwrap();
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8(out.stderr).unwrap();
assert_eq!(
out.status.code(),
Some(7),
"the hostile-path contract is unchanged — the RUN still answers 7: {stderr}"
);
assert_eq!(
std::fs::read(out_dir.join("third.txt")).unwrap(),
b"CCCCCCCCCC",
"the record AFTER the refused one is exactly what the old behaviour lost"
);
assert_eq!(
std::fs::read(out_dir.join("first.txt")).unwrap(),
b"AAAAAAAAAA"
);
assert_eq!(
std::fs::read_dir(&out_dir).unwrap().count(),
2,
"and nothing at all is written for the refused entry"
);
assert!(
stderr.contains("3 scanned")
&& stderr.contains("2 written")
&& stderr.contains("1 skipped"),
"the summary must print — a run that returns Err never prints one, so a user was \
never told what WAS recovered: {stderr}"
);
assert!(
stderr.contains("empty entry name"),
"and the refusal must still be named, on stderr, every run: {stderr}"
);
}
fn salvage_fixture_local_record_with(
offset: u32,
name: &'static str,
payload: &'static [u8],
method: u16,
crc: u32,
) -> SalvageFixtureLocalRecord {
let len = u32::try_from(payload.len()).unwrap();
let mut bytes = Vec::new();
bytes.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
bytes.extend_from_slice(&20u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&method.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0x21u16.to_le_bytes());
bytes.extend_from_slice(&crc.to_le_bytes());
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(&u16::try_from(name.len()).unwrap().to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(name.as_bytes());
bytes.extend_from_slice(payload);
SalvageFixtureLocalRecord {
offset,
name,
crc,
len,
bytes,
}
}
fn build_partial_and_unverified_zip_fixture() -> Vec<u8> {
let mismatched_payload: &[u8] = b"this payload's crc field below is wrong on purpose";
let undecodable_payload: &[u8] = b"this method is recognised but this build cannot decode it";
let mut out = Vec::new();
let rec0 = salvage_fixture_local_record_with(
u32::try_from(out.len()).unwrap(),
"mismatched.bin",
mismatched_payload,
0, 0xDEAD_BEEF,
);
out.extend_from_slice(&rec0.bytes);
let rec1 = salvage_fixture_local_record_with(
u32::try_from(out.len()).unwrap(),
"undecodable.bin",
undecodable_payload,
99, salvage_fixture_crc32(undecodable_payload),
);
out.extend_from_slice(&rec1.bytes);
let cd_start = u32::try_from(out.len()).unwrap();
out.extend_from_slice(&salvage_fixture_central_record(&rec0));
out.extend_from_slice(&salvage_fixture_central_record(&rec1));
let cd_size = u32::try_from(out.len()).unwrap() - cd_start;
out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&2u16.to_le_bytes());
out.extend_from_slice(&2u16.to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
#[test]
fn salvage_list_names_partial_and_unverified_causes() {
let dir = tmp_dir();
let path = dir.join("mixed.zip");
std::fs::write(&path, build_partial_and_unverified_zip_fixture()).unwrap();
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
path.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
"--list",
]);
assert_eq!(
out.status.code(),
Some(3),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
let rows: Vec<&str> = stdout.lines().collect();
assert_eq!(rows.len(), 2, "{stdout}");
assert!(
rows[0].contains("Partial (checksum mismatch)"),
"a full decode that disagrees with the declared CRC-32 must name that cause: {stdout}"
);
assert!(
rows[1].contains("Unverified (undecodable method)"),
"a recognised-but-undecodable method must name that cause: {stdout}"
);
assert_eq!(
std::fs::read(out_dir.join("mismatched.bin.partial")).unwrap(),
b"this payload's crc field below is wrong on purpose"
);
assert!(!out_dir.join("mismatched.bin").exists());
assert!(!out_dir.join("undecodable.bin").exists());
}
#[test]
fn salvage_skipped_partial_names_its_cause() {
let dir = tmp_dir();
let path = dir.join("mixed.zip");
std::fs::write(&path, build_partial_and_unverified_zip_fixture()).unwrap();
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
path.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
"--partial=skip",
"--list",
]);
assert_eq!(
out.status.code(),
Some(3),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
let rows: Vec<&str> = stdout.lines().collect();
assert_eq!(rows.len(), 2, "{stdout}");
assert!(
rows[0].contains("Partial (checksum mismatch)"),
"--partial=skip must still name the cause, not fall back to bare \"Partial\": {stdout}"
);
assert!(!out_dir.join("mismatched.bin").exists());
assert!(!out_dir.join("mismatched.bin.partial").exists());
}
#[test]
fn salvage_list_works_with_no_destination() {
let dir = tmp_dir();
let archive = write_shadowing_zip_fixture(&dir);
let out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
assert_eq!(
out.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
stdout.lines().count(),
8,
"a report-only run must still list the full scan: {stdout}"
);
assert_eq!(
std::fs::read_dir(&dir).unwrap().count(),
1,
"report-only `--list` must touch nothing on disk but the archive itself"
);
}
#[test]
fn salvage_output_recovers_one_entry_to_a_named_file() {
let dir = tmp_dir();
let archive = write_shadowing_zip_fixture(&dir);
let out_file = dir.join("recovered-one.txt");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-o",
out_file.to_str().unwrap(),
"--index",
"0",
]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(&out_file).unwrap(), b"payload for entry one");
let too_many = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-o",
out_file.to_str().unwrap(),
"--index",
"0",
"--index",
"1",
]);
assert_eq!(too_many.status.code(), Some(2));
let both = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-o",
out_file.to_str().unwrap(),
"-C",
dir.join("out").to_str().unwrap(),
"--index",
"0",
]);
assert_eq!(both.status.code(), Some(2));
}
#[test]
fn salvage_output_suffixes_a_partial_entry() {
let dir = tmp_dir();
let path = dir.join("mixed.zip");
std::fs::write(&path, build_partial_and_unverified_zip_fixture()).unwrap();
let out_file = dir.join("wanted.bin");
let out = run_output(&[
"salvage",
path.to_str().unwrap(),
"-o",
out_file.to_str().unwrap(),
"--index",
"0",
]);
assert_eq!(
out.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out_file.exists(),
"a Partial entry must never land under the literal -o path"
);
let partial_path = dir.join("wanted.bin.partial");
assert_eq!(
std::fs::read(&partial_path).unwrap(),
b"this payload's crc field below is wrong on purpose"
);
}
struct RealZipLocalEntry {
offset: usize,
name: String,
crc_offset: usize,
payload: std::ops::Range<usize>,
record_len: usize,
}
fn parse_real_zip_local_entries(bytes: &[u8]) -> Vec<RealZipLocalEntry> {
let mut out = Vec::new();
let mut i = 0usize;
while i + 30 <= bytes.len() && bytes[i..i + 4] == [0x50, 0x4b, 0x03, 0x04] {
let compressed_size =
u32::from_le_bytes(bytes[i + 18..i + 22].try_into().unwrap()) as usize;
let name_len = u16::from_le_bytes([bytes[i + 26], bytes[i + 27]]) as usize;
let extra_len = u16::from_le_bytes([bytes[i + 28], bytes[i + 29]]) as usize;
let name_start = i + 30;
let name = String::from_utf8(bytes[name_start..name_start + name_len].to_vec())
.expect("this section's own fixture names are plain ASCII");
let payload_start = name_start + name_len + extra_len;
let payload = payload_start..payload_start + compressed_size;
let record_len = payload.end - i;
out.push(RealZipLocalEntry {
offset: i,
name,
crc_offset: i + 14,
payload,
record_len,
});
i += record_len;
}
out
}
fn find_real_zip_eocd(bytes: &[u8]) -> usize {
bytes
.windows(4)
.rposition(|w| w == b"PK\x05\x06")
.expect("every archive this section builds must carry an EOCD record")
}
fn build_real_healthy_zip(dir: &Path) -> (PathBuf, [(&'static str, &'static [u8]); 3]) {
let entries: [(&str, &[u8]); 3] = [
("alpha.txt", b"alpha entry payload, first in the archive"),
("beta.txt", b"beta entry payload, sits in the middle"),
(
"gamma.bin",
b"gamma entry payload, the last one in the archive",
),
];
let src = dir.join("src");
std::fs::create_dir(&src).unwrap();
for (name, data) in &entries {
std::fs::write(src.join(name), data).unwrap();
}
let zip_bin = require_bin("zip");
let archive = dir.join("healthy.zip");
run_tool(
&zip_bin,
&[
os(&"-q"),
os(&"-0"),
os(&"-X"),
os(&archive),
os(&"alpha.txt"),
os(&"beta.txt"),
os(&"gamma.bin"),
],
&src,
b"",
);
(archive, entries)
}
fn salvage_list_rows(archive: &Path) -> Vec<String> {
let out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
String::from_utf8(out.stdout)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
fn mutate_zeroed_central_directory(healthy: &[u8]) -> Vec<u8> {
let mut bytes = healthy.to_vec();
let eocd = find_real_zip_eocd(&bytes);
let cd_offset = u32::from_le_bytes(bytes[eocd + 16..eocd + 20].try_into().unwrap()) as usize;
for b in &mut bytes[cd_offset..] {
*b = 0;
}
bytes
}
fn mutate_hole_punched_mid_payload(healthy: &[u8]) -> Vec<u8> {
let mut bytes = healthy.to_vec();
let real_entries = parse_real_zip_local_entries(&bytes);
let beta = &real_entries[1];
assert!(
beta.payload.len() >= 15,
"sanity: room to punch a hole without running past this entry's own payload"
);
for b in &mut bytes[beta.payload.start + 5..beta.payload.start + 15] {
*b ^= 0xFF;
}
bytes
}
fn mutate_one_crc_corrupted(healthy: &[u8]) -> Vec<u8> {
let mut bytes = healthy.to_vec();
let real_entries = parse_real_zip_local_entries(&bytes);
let alpha_crc_offset = real_entries[0].crc_offset;
bytes[alpha_crc_offset] ^= 0xFF;
bytes
}
fn duplicate_first_central_directory_record(healthy: &[u8]) -> Vec<u8> {
let bytes = healthy.to_vec();
let eocd = find_real_zip_eocd(&bytes);
let disk_no = u16::from_le_bytes(bytes[eocd + 4..eocd + 6].try_into().unwrap());
let disk_cd = u16::from_le_bytes(bytes[eocd + 6..eocd + 8].try_into().unwrap());
let cd_total = u16::from_le_bytes(bytes[eocd + 10..eocd + 12].try_into().unwrap());
let cd_size = u32::from_le_bytes(bytes[eocd + 12..eocd + 16].try_into().unwrap()) as usize;
let cd_offset = u32::from_le_bytes(bytes[eocd + 16..eocd + 20].try_into().unwrap()) as usize;
let comment_len = u16::from_le_bytes(bytes[eocd + 20..eocd + 22].try_into().unwrap());
let real_entries = parse_real_zip_local_entries(&bytes);
let first = &real_entries[0];
let duplicate_record = bytes[first.offset..first.offset + first.record_len].to_vec();
let old_cd = bytes[cd_offset..cd_offset + cd_size].to_vec();
assert_eq!(
&old_cd[0..4],
&[0x50, 0x4b, 0x01, 0x02],
"sanity: the central directory's first record"
);
let name_len = u16::from_le_bytes(old_cd[28..30].try_into().unwrap()) as usize;
let extra_len = u16::from_le_bytes(old_cd[30..32].try_into().unwrap()) as usize;
let comment_len_rec = u16::from_le_bytes(old_cd[32..34].try_into().unwrap()) as usize;
let first_cd_len = 46 + name_len + extra_len + comment_len_rec;
let mut duplicate_cd_record = old_cd[0..first_cd_len].to_vec();
let new_local_offset = cd_offset as u32;
duplicate_cd_record[42..46].copy_from_slice(&new_local_offset.to_le_bytes());
let new_cd_start = cd_offset + duplicate_record.len();
let mut new_cd = old_cd.clone();
new_cd.extend_from_slice(&duplicate_cd_record);
let new_total = cd_total + 1;
let mut out = bytes[..cd_offset].to_vec();
out.extend_from_slice(&duplicate_record);
out.extend_from_slice(&new_cd);
out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
out.extend_from_slice(&disk_no.to_le_bytes());
out.extend_from_slice(&disk_cd.to_le_bytes());
out.extend_from_slice(&new_total.to_le_bytes());
out.extend_from_slice(&new_total.to_le_bytes());
out.extend_from_slice(&(new_cd.len() as u32).to_le_bytes());
out.extend_from_slice(&(new_cd_start as u32).to_le_bytes());
out.extend_from_slice(&comment_len.to_le_bytes());
out
}
#[test]
fn damage_catalogue_truncated_tail() {
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let real_entries = parse_real_zip_local_entries(&healthy);
assert_eq!(
real_entries
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>(),
vec!["alpha.txt", "beta.txt", "gamma.bin"],
"sanity: the reference archive's own three entries, in file order"
);
let gamma = &real_entries[2];
let cut = gamma.payload.start + gamma.payload.len() / 2;
let path = dir.join("truncated.zip");
std::fs::write(&path, &healthy[..cut]).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(
rows.len(),
3,
"all three local headers are there, and the third one being uncompletable is a \
fact worth a row: {rows:?}"
);
assert!(
rows[0].contains("alpha.txt") && rows[0].contains("Intact"),
"{rows:?}"
);
assert!(
rows[1].contains("beta.txt") && rows[1].contains("Intact"),
"{rows:?}"
);
assert!(
rows[2].contains("gamma.bin") && rows[2].contains("Partial (truncated)"),
"the truncated entry must be named, and named as truncated rather than as a bad \
checksum — the payload ran out, nothing disagreed: {rows:?}"
);
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
path.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"a run that dropped something must not exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let mut names: Vec<String> = std::fs::read_dir(&out_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(
names,
["alpha.txt", "beta.txt", "gamma.bin.partial"],
"the surviving prefix lands under `.partial`, never under `gamma.bin`"
);
let recovered = std::fs::read(out_dir.join("gamma.bin.partial")).unwrap();
let declared = gamma.payload.len();
assert!(
!recovered.is_empty() && recovered.len() < declared,
"{} recovered bytes against a declared {declared}",
recovered.len()
);
assert_eq!(
recovered[..],
healthy[gamma.payload.start..cut],
"every recovered byte must come from the truncated file itself — `zip -FF` fills \
the gap from its own buffer and calls the result fixed"
);
}
#[test]
fn every_truncation_point_inside_the_last_entry_is_reported() {
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let real_entries = parse_real_zip_local_entries(&healthy);
let gamma = &real_entries[2];
let declared = gamma.payload.len();
for keep in [0, 1, declared / 4, declared / 2, declared - 1] {
let path = dir.join(format!("truncated-{keep}.zip"));
std::fs::write(&path, &healthy[..gamma.payload.start + keep]).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(
rows.len(),
3,
"keep={keep}: the third header is there and must be counted: {rows:?}"
);
assert!(
rows[2].contains("Partial (truncated)"),
"keep={keep}: {rows:?}"
);
let out_dir = dir.join(format!("out-{keep}"));
let out = run_output(&[
"salvage",
path.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"keep={keep}: must not exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let whole = dir.join("whole.zip");
std::fs::write(&whole, &healthy).unwrap();
let out = run_output(&["salvage", whole.to_str().unwrap(), "--list"]);
assert_eq!(out.status.code(), Some(0));
let rows = String::from_utf8(out.stdout).unwrap();
assert_eq!(rows.matches("Partial").count(), 0, "{rows}");
}
#[test]
fn damage_catalogue_zeroed_central_directory() {
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let path = dir.join("zeroed_cd.zip");
std::fs::write(&path, mutate_zeroed_central_directory(&healthy)).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), 3, "{rows:?}");
for (row, name) in rows.iter().zip(["alpha.txt", "beta.txt", "gamma.bin"]) {
assert!(
row.contains(name) && row.contains("Intact"),
"every entry must still be found by the raw scan and verify Intact: {row}"
);
}
}
#[test]
fn damage_catalogue_hole_punched_mid_payload() {
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let path = dir.join("hole_punched.zip");
std::fs::write(&path, mutate_hole_punched_mid_payload(&healthy)).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), 3, "{rows:?}");
assert!(
rows[0].contains("alpha.txt") && rows[0].contains("Intact"),
"{rows:?}"
);
assert!(
rows[1].contains("beta.txt") && rows[1].contains("Partial"),
"the punched entry's content no longer matches its own declared CRC-32: {rows:?}"
);
assert!(
rows[2].contains("gamma.bin") && rows[2].contains("Intact"),
"{rows:?}"
);
}
#[test]
fn damage_catalogue_one_crc_corrupted() {
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let path = dir.join("crc_corrupted.zip");
std::fs::write(&path, mutate_one_crc_corrupted(&healthy)).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), 3, "{rows:?}");
assert!(
rows[0].contains("alpha.txt") && rows[0].contains("Partial"),
"the corrupted entry's declared checksum no longer matches its (untouched) payload: \
{rows:?}"
);
assert!(
rows[1].contains("beta.txt") && rows[1].contains("Intact"),
"{rows:?}"
);
assert!(
rows[2].contains("gamma.bin") && rows[2].contains("Intact"),
"{rows:?}"
);
}
#[test]
fn damage_catalogue_duplicated_record() {
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let path = dir.join("duplicated.zip");
std::fs::write(&path, duplicate_first_central_directory_record(&healthy)).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), 4, "3 names, 4 physical records: {rows:?}");
assert!(
rows[0].contains("alpha.txt") && rows[0].contains("Intact"),
"{rows:?}"
);
assert!(
rows[1].contains("beta.txt") && rows[1].contains("Intact"),
"{rows:?}"
);
assert!(
rows[2].contains("gamma.bin") && rows[2].contains("Intact"),
"{rows:?}"
);
assert!(
rows[3].contains("alpha.txt")
&& rows[3].contains("Intact")
&& rows[3].contains("[shadowed: dup of #0]"),
"the extra physical copy must be reported Intact (nothing in it is damaged) AND \
flagged as shadowing scan position 0: {rows:?}"
);
}
#[cfg(unix)]
fn run_zip_ff(zip_bin: &Path, archive: &Path, out: &Path) -> std::process::Output {
let mut child = Command::new(zip_bin)
.args([os(&"-FF"), os(&archive), os(&"--out"), os(&out)])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"y\ny\ny\ny\ny\n")
.unwrap();
let pid = child.id();
let finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let finished_for_watchdog = finished.clone();
let watchdog = std::thread::spawn(move || {
for _ in 0..200 {
if finished_for_watchdog.load(Ordering::Relaxed) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let _ = Command::new("kill").args(["-9", &pid.to_string()]).status();
});
let output = child.wait_with_output().unwrap();
finished.store(true, Ordering::Relaxed);
watchdog.join().unwrap();
output
}
fn unzip_test_lines(unzip_bin: &Path, archive: &Path) -> Vec<String> {
let out = Command::new(unzip_bin)
.args(["-t", archive.to_str().unwrap()])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::to_string)
.collect()
}
fn unzip_test_line_for<'a>(lines: &'a [String], name: &str) -> &'a str {
lines
.iter()
.map(String::as_str)
.find(|l| l.contains(name))
.unwrap_or_else(|| panic!("`unzip -t` must still report on `{name}`: {lines:?}"))
}
#[cfg(unix)]
#[test]
fn info_zip_recovers_the_same_entries_we_do() {
let zip_bin = require_bin("zip");
let unzip_bin = require_bin("unzip");
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let damaged = dir.join("zeroed_cd_for_witness.zip");
std::fs::write(&damaged, mutate_zeroed_central_directory(&healthy)).unwrap();
let rows = salvage_list_rows(&damaged);
assert_eq!(rows.len(), 3, "{rows:?}");
for (row, name) in rows.iter().zip(["alpha.txt", "beta.txt", "gamma.bin"]) {
assert!(
row.contains(name) && row.contains("Intact"),
"our own recovery must find every entry, Intact: {row}"
);
}
let fixed = dir.join("witness_fixed.zip");
let out = run_zip_ff(&zip_bin, &damaged, &fixed);
assert!(
out.status.success(),
"zip -FF failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let test_out = Command::new(&unzip_bin)
.args(["-t", fixed.to_str().unwrap()])
.output()
.unwrap();
assert!(
test_out.status.success(),
"unzip -t reported a problem with zip -FF's own recovered archive: {}",
String::from_utf8_lossy(&test_out.stdout)
);
let test_text = String::from_utf8_lossy(&test_out.stdout);
for name in ["alpha.txt", "beta.txt", "gamma.bin"] {
let line = test_text
.lines()
.find(|l| l.contains(name))
.unwrap_or_else(|| {
panic!("zip -FF's own recovery must still contain `{name}`: {test_text}")
});
assert!(
line.contains("OK"),
"we call `{name}` Intact; Info-Zip's own independent recovery must agree it \
decodes cleanly: {line}"
);
}
}
#[cfg(unix)]
#[test]
fn info_zip_agrees_on_a_hole_punched_payload() {
let unzip_bin = require_bin("unzip");
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let path = dir.join("hole_punched_for_witness.zip");
std::fs::write(&path, mutate_hole_punched_mid_payload(&healthy)).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), 3, "{rows:?}");
let lines = unzip_test_lines(&unzip_bin, &path);
for (name, intact) in [
("alpha.txt", true),
("beta.txt", false),
("gamma.bin", true),
] {
let our_row = rows
.iter()
.find(|r| r.contains(name))
.unwrap_or_else(|| panic!("our own recovery must still report `{name}`: {rows:?}"));
assert_eq!(
our_row.contains("Intact"),
intact,
"our own verdict for `{name}`: {our_row}"
);
let their_line = unzip_test_line_for(&lines, name);
assert_eq!(
their_line.contains("OK"),
intact,
"Info-Zip's own independent verdict for `{name}` must agree with ours: {their_line}"
);
}
}
#[cfg(unix)]
#[test]
fn info_zip_agrees_on_a_corrupted_crc() {
let unzip_bin = require_bin("unzip");
let dir = tmp_dir();
let (archive, _entries) = build_real_healthy_zip(&dir);
let healthy = std::fs::read(&archive).unwrap();
let path = dir.join("crc_corrupted_for_witness.zip");
std::fs::write(&path, mutate_one_crc_corrupted(&healthy)).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), 3, "{rows:?}");
let lines = unzip_test_lines(&unzip_bin, &path);
for (name, intact) in [
("alpha.txt", false),
("beta.txt", true),
("gamma.bin", true),
] {
let our_row = rows
.iter()
.find(|r| r.contains(name))
.unwrap_or_else(|| panic!("our own recovery must still report `{name}`: {rows:?}"));
assert_eq!(
our_row.contains("Intact"),
intact,
"our own verdict for `{name}`: {our_row}"
);
let their_line = unzip_test_line_for(&lines, name);
assert_eq!(
their_line.contains("OK"),
intact,
"Info-Zip's own independent verdict for `{name}` must agree with ours: {their_line}"
);
}
}
#[test]
fn a_salvage_entry_that_cannot_be_written_is_named_on_stderr_without_list() {
fn stored_local_record(name: &str, payload: &[u8]) -> Vec<u8> {
let crc = salvage_fixture_crc32(payload);
let len = u32::try_from(payload.len()).unwrap();
let mut bytes = Vec::new();
bytes.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
bytes.extend_from_slice(&20u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0x21u16.to_le_bytes()); bytes.extend_from_slice(&crc.to_le_bytes());
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(&u16::try_from(name.len()).unwrap().to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(name.as_bytes());
bytes.extend_from_slice(payload);
bytes
}
let dir = tmp_dir();
let long_name = format!("{}.txt", "L".repeat(400));
let mut zip = stored_local_record("a.txt", b"AAAA");
zip.extend_from_slice(&stored_local_record(&long_name, b"BBBB"));
zip.extend_from_slice(&stored_local_record("z.txt", b"ZZZZ"));
let archive = dir.join("unwritable-name.zip");
std::fs::write(&archive, &zip).unwrap();
let out_dir = dir.join("recovered");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"--format",
"zip",
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(
out.status.code(),
Some(4),
"one unwritable name is a degraded run, never a failed one (and never exit 1, the \
code reserved for stuffr itself failing, on input the archive chose): {stderr}"
);
assert!(
stderr.contains("could not be written"),
"the run must SAY an entry was not written, on stderr, without --list — the whole \
fold rests on this not being silent: {stderr}"
);
assert!(
stderr.contains("#1"),
"and it must name WHICH entry by scan position, or a user cannot act on it: {stderr}"
);
assert_eq!(std::fs::read(out_dir.join("a.txt")).unwrap(), b"AAAA");
assert_eq!(std::fs::read(out_dir.join("z.txt")).unwrap(), b"ZZZZ");
}
#[test]
fn the_documented_salvage_scanner_list_matches_what_the_binary_actually_scans() {
fn documented(text: &str, label: &str) -> Vec<String> {
let at = text
.find("Scanners exist for:")
.unwrap_or_else(|| panic!("{label} must carry a `Scanners exist for:` sentence"));
let rest = &text[at + "Scanners exist for:".len()..];
let end = rest.find('.').unwrap_or_else(|| {
panic!("{label}'s `Scanners exist for:` sentence must end in a `.`")
});
let mut names: Vec<String> = rest[..end]
.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
names.sort();
names
}
let tmp = tmp_dir();
let junk = tmp.join("salvage-doc-guard-not-an-archive.bin");
std::fs::write(&junk, b"these bytes are no format's magic at all\n").unwrap();
let usage = run_output(&[
"salvage",
"--format",
"definitely-not-a-format",
junk.to_str().unwrap(),
"--list",
]);
let usage_text = String::from_utf8_lossy(&usage.stderr).to_string();
let recognised: Vec<String> = usage_text
.split_once("salvage recognizes:")
.unwrap_or_else(|| panic!("expected the `salvage recognizes:` usage message: {usage_text}"))
.1
.split(',')
.map(|n| n.trim().trim_end_matches('\n').to_string())
.filter(|n| !n.is_empty())
.collect();
assert!(
recognised.len() >= 8,
"the parse found {recognised:?}; if that is short the scan is broken, not the table"
);
let mut wired: Vec<String> = Vec::new();
for name in &recognised {
let out = run_output(&[
"salvage",
"--format",
name,
junk.to_str().unwrap(),
"--list",
]);
let stderr = String::from_utf8_lossy(&out.stderr);
let unwired = stderr.contains("salvage has no scanner for");
if !unwired {
assert!(
stderr.contains("found nothing recoverable"),
"`--format {name}` over junk answered neither refusal nor an empty scan, so \
this test can no longer tell wired from unwired: {stderr}"
);
wired.push(name.clone());
}
}
wired.sort();
assert!(
!wired.is_empty(),
"no format scanned at all — the discriminator is broken, not the docs"
);
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let examples = std::fs::read_to_string(manifest_dir.join("src").join("examples.txt")).unwrap();
let help = String::from_utf8_lossy(&run_output(&["salvage", "--help"]).stdout).to_string();
assert_eq!(
documented(&examples, "examples.txt"),
wired,
"`examples.txt` names a different set of salvage scanners than this binary has"
);
assert_eq!(
documented(&help, "`salvage --format`'s help"),
wired,
"`--format`'s help names a different set of salvage scanners than this binary has"
);
}
#[test]
fn the_examples_page_documents_the_salvage_column_stuffr_formats_prints() {
let formats = run_output(&["formats"]);
assert!(formats.status.success(), "stuffr formats must exit 0");
let table = String::from_utf8(formats.stdout).unwrap();
let mut lines = table.lines();
let header: Vec<&str> = lines.next().unwrap().split_whitespace().collect();
let at = header
.iter()
.position(|c| *c == "SALVAGE")
.unwrap_or_else(|| panic!("stuffr formats must print a SALVAGE column: {header:?}"));
let mut shown: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for line in lines.take_while(|l| !l.trim().is_empty()) {
let cells: Vec<&str> = line.split_whitespace().collect();
shown
.entry(cells[at].to_string())
.or_default()
.push(cells[0].to_string());
}
for value in shown.keys() {
assert!(
["yes", "weak", "-"].contains(&value.as_str()),
"SALVAGE printed `{value}`, which the page does not define"
);
}
let out = run_output(&["--examples"]);
let examples = String::from_utf8(out.stdout).unwrap();
for value in ["yes", "weak"] {
let marker = format!("SALVAGE reads {value} for:");
let at = examples
.find(&marker)
.unwrap_or_else(|| panic!("the examples page must carry a `{marker}` sentence"));
let rest = &examples[at + marker.len()..];
let end = rest
.find('.')
.unwrap_or_else(|| panic!("the `{marker}` sentence must end in a `.`"));
let mut documented: Vec<String> = rest[..end]
.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
documented.sort();
let mut printed = shown.get(value).cloned().unwrap_or_default();
printed.sort();
assert!(
!printed.is_empty(),
"no row printed SALVAGE `{value}` — the column is broken, not the page"
);
assert_eq!(
documented, printed,
"`--examples` says SALVAGE reads `{value}` for a different set of formats than \
`stuffr formats` prints"
);
}
}
fn legacy_fixture(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("stuffr-formats")
.join("fixtures")
.join("legacy")
.join(rel)
}
fn legacy_truncated_tail_row(
label: &str,
fixture: &str,
drop_bytes: usize,
expected_names: &[&str],
) {
let dir = tmp_dir();
let healthy_path = legacy_fixture(fixture);
let healthy = std::fs::read(&healthy_path).unwrap();
let rows = salvage_list_rows(&healthy_path);
assert_eq!(
rows.len(),
expected_names.len(),
"{label}: sanity, the UNDAMAGED archive's own row count: {rows:?}"
);
for (row, name) in rows.iter().zip(expected_names) {
assert!(
row.contains(name) && row.contains("Intact"),
"{label}: every entry of the undamaged archive must verify Intact: {row}"
);
}
let last_index = expected_names.len() - 1;
let out = run_output(&[
"cat",
"--index",
&last_index.to_string(),
healthy_path.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(0),
"{label}: the undamaged archive's last entry must read back cleanly: {}",
String::from_utf8_lossy(&out.stderr)
);
let went_in = out.stdout;
assert!(
!went_in.is_empty(),
"{label}: the last entry must carry a payload for this row to mean anything"
);
let cut = healthy.len() - drop_bytes;
let damaged = dir.join(format!("truncated-{}", fixture.replace('/', "-")));
std::fs::write(&damaged, &healthy[..cut]).unwrap();
let rows = salvage_list_rows(&damaged);
assert_eq!(
rows.len(),
expected_names.len(),
"{label}: the cut entry's header is still there and its being uncompletable is a \
fact worth a row, never silence: {rows:?}"
);
for (i, (row, name)) in rows.iter().zip(expected_names).enumerate() {
assert!(row.contains(name), "{label}: {row}");
if i == last_index {
assert!(
row.contains("Partial (truncated)"),
"{label}: the cut entry must be named as truncated: {row}"
);
} else {
assert!(
row.contains("Intact"),
"{label}: an entry before the cut is untouched: {row}"
);
}
}
let out_dir = dir.join(format!("recovered-{}", fixture.replace('/', "-")));
let out = run_output(&[
"salvage",
damaged.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"{label}: a run that could not complete something must not exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let partial = out_dir.join(format!("{}.partial", expected_names[last_index]));
assert!(
partial.is_file(),
"{label}: expected {} on disk",
partial.display()
);
assert!(
!out_dir.join(expected_names[last_index]).exists(),
"{label}: an incomplete entry must never land under its real name"
);
for name in &expected_names[..last_index] {
let recovered = std::fs::read(out_dir.join(name)).unwrap_or_else(|e| {
panic!("{label}: an entry before the cut must be recovered whole: {e}")
});
assert!(!recovered.is_empty(), "{label}: `{name}` came back empty");
}
let recovered = std::fs::read(&partial).unwrap();
assert!(
!recovered.is_empty() && recovered.len() < went_in.len(),
"{label}: {} recovered bytes against {} that went in",
recovered.len(),
went_in.len()
);
assert_eq!(
recovered[..],
went_in[..recovered.len()],
"{label}: every recovered byte must be one the archive itself still held"
);
}
#[test]
fn damage_catalogue_arc_truncated_tail() {
legacy_truncated_tail_row("arc", "arc/cpm.arc", 40, &["DDTZ.COM", "READ.COM"]);
}
#[test]
fn damage_catalogue_arc_truncated_tail_over_a_stored_entry() {
legacy_truncated_tail_row("arc/stored", "arc/store.arc", 5_000, &["LICENSE"]);
}
#[test]
fn damage_catalogue_zoo_truncated_tail() {
legacy_truncated_tail_row("zoo", "zoo/store.zoo", 5_000, &["license"]);
}
#[test]
fn damage_catalogue_lha_truncated_tail() {
legacy_truncated_tail_row(
"lha",
"sample.lzh",
4,
&["sample/hello.txt", "sample/sub/b.bin"],
);
}
#[test]
fn damage_catalogue_arj_truncated_tail() {
legacy_truncated_tail_row(
"arj",
"sample.arj",
7,
&["sample/hello.txt", "sample/sub/b.bin"],
);
}
#[test]
fn lhasa_agrees_with_the_lha_damage_catalogue() {
let lha_bin = require_lhasa();
let banner = lhasa_banner(&lha_bin);
let dir = tmp_dir();
std::fs::create_dir_all(dir.join("tree/sub")).unwrap();
let alpha = b"alpha payload, the first entry, repeated often.\n".repeat(120);
let beta = b"beta payload, the middle entry, repeated often.\n".repeat(90);
let gamma = b"gamma payload, the last entry, repeated often.\n".repeat(70);
std::fs::write(dir.join("tree/alpha.txt"), &alpha).unwrap();
std::fs::write(dir.join("tree/beta.txt"), &beta).unwrap();
std::fs::write(dir.join("tree/sub/gamma.bin"), &gamma).unwrap();
let archive = dir.join("healthy.lzh");
let packed = run_output(&[
"pack",
dir.join("tree").to_str().unwrap(),
"--format",
"lha",
"-o",
archive.to_str().unwrap(),
]);
assert_eq!(packed.status.code(), Some(0));
fn lhasa_verdicts(output: &[u8]) -> Vec<(String, bool)> {
let text = String::from_utf8_lossy(output).replace('\r', "\n");
let mut out: Vec<(String, bool)> = Vec::new();
for chunk in text.split('\n') {
for (marker, ok) in [("\t- Tested", true), ("\t- CRC error", false)] {
if let Some(at) = chunk.find(marker) {
let name = chunk[..at].rsplit(':').next().unwrap().trim().to_string();
if !name.is_empty() && !out.iter().any(|(n, _)| *n == name) {
out.push((name, ok));
}
}
}
}
out
}
let healthy_test = Command::new(&lha_bin)
.arg("t")
.arg(&archive)
.output()
.unwrap();
assert!(
healthy_test.status.success(),
"{banner} must accept the undamaged archive: {}{}",
String::from_utf8_lossy(&healthy_test.stdout),
String::from_utf8_lossy(&healthy_test.stderr)
);
let mut combined = healthy_test.stdout.clone();
combined.extend_from_slice(&healthy_test.stderr);
let before = lhasa_verdicts(&combined);
assert_eq!(
before
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(","),
"tree/alpha.txt,tree/beta.txt,tree/sub/gamma.bin",
"sanity: {banner} must report on all three entries — otherwise the comparison below \
is against a partial account"
);
assert!(
before.iter().all(|(_, ok)| *ok),
"{banner} must call every entry of the undamaged archive good: {before:?}"
);
let healthy = std::fs::read(&archive).unwrap();
let beta_header = healthy
.windows(5)
.enumerate()
.filter(|(_, w)| *w == b"-lh5-")
.map(|(i, _)| i)
.nth(1)
.expect("the middle entry's own method identifier");
let mut bytes = healthy.clone();
let beta_start = beta_header - 2;
let payload_at = beta_start + bytes[beta_start] as usize + 2 + 4;
bytes[payload_at] ^= 0xFF;
let damaged = dir.join("damaged.lzh");
std::fs::write(&damaged, &bytes).unwrap();
let damaged_test = Command::new(&lha_bin)
.arg("t")
.arg(&damaged)
.output()
.unwrap();
assert!(
!damaged_test.status.success(),
"{banner} must refuse the damaged archive, or this test proved nothing"
);
let mut combined = damaged_test.stdout.clone();
combined.extend_from_slice(&damaged_test.stderr);
let after = lhasa_verdicts(&combined);
let condemned: Vec<&str> = after
.iter()
.filter(|(_, ok)| !*ok)
.map(|(n, _)| n.as_str())
.collect();
assert_eq!(
condemned,
["tree/beta.txt"],
"{banner} must condemn exactly the entry this test damaged: {after:?}"
);
let rows = salvage_list_rows(&damaged);
assert_eq!(
rows.len(),
5,
"three files and two directory entries: {rows:?}"
);
for (name, ok) in &after {
let row = rows
.iter()
.find(|r| r.contains(name.as_str()))
.unwrap_or_else(|| {
panic!("salvage lost `{name}` that {banner} still reports: {rows:?}")
});
if *ok {
assert!(
row.contains("Intact"),
"{banner} calls `{name}` good and salvage does not: {row}"
);
} else {
assert!(
row.contains("Partial"),
"{banner} calls `{name}` bad and salvage calls it whole: {row}"
);
}
}
}
#[test]
fn no_dedicated_arj_tool_is_obtainable_to_witness_this_catalogue() {
for candidate in ["arj", "unarj", "arj32"] {
assert!(
which(candidate).is_none(),
"`{candidate}` is on PATH now. ARJ's catalogue has been checked against nothing \
but this project's own reading of the spec since Phase 3b — promote \
`damage_catalogue_arj_truncated_tail` and \
`legacy::arj_salvage`'s own `mod damage_catalogue` to use this tool as a real \
external witness (the shape `lhasa_agrees_with_the_lha_damage_catalogue` \
above already has), and delete this test."
);
}
let sample = std::fs::read(legacy_fixture("sample.arj")).unwrap();
assert_eq!(
sample.len(),
173,
"`sample.arj` is no longer the 173-byte fixture MANIFEST.md describes; re-read its \
provenance before trusting anything this catalogue asserts"
);
}
#[test]
fn damage_catalogue_the_three_partial_causes_are_distinguishable() {
let dir = tmp_dir();
let stored = std::fs::read(legacy_fixture("zoo/store.zoo")).unwrap();
let lzd = std::fs::read(legacy_fixture("zoo/default.zoo")).unwrap();
let payload_at = |bytes: &[u8]| -> usize {
u32::from_le_bytes(bytes[42 + 10..42 + 14].try_into().unwrap()) as usize
};
let declared_len = |bytes: &[u8]| -> usize {
u32::from_le_bytes(bytes[42 + 24..42 + 28].try_into().unwrap()) as usize
};
let cut = dir.join("cut.zoo");
std::fs::write(&cut, &stored[..payload_at(&stored) + 100]).unwrap();
let mut bytes = lzd.clone();
let at = payload_at(&lzd) + declared_len(&lzd) / 2;
bytes[at] ^= 0xFF;
let damaged_lzd = dir.join("lzd.zoo");
std::fs::write(&damaged_lzd, &bytes).unwrap();
let mut bytes = stored.clone();
let at = payload_at(&stored) + declared_len(&stored) / 2;
bytes[at] ^= 0xFF;
let damaged_stored = dir.join("stored.zoo");
std::fs::write(&damaged_stored, &bytes).unwrap();
for (path, cause, why) in [
(
&cut,
"Partial (truncated)",
"bytes this entry declares are genuinely missing from the file",
),
(
&damaged_lzd,
"Partial (decode failed)",
"every declared byte is present and the decoder could not turn them into the \
declared content — saying `truncated` here sent a user looking for a longer \
copy of a file that is not short",
),
(
&damaged_stored,
"Partial (checksum mismatch)",
"every declared byte is present, it all decoded, and it is not what was written",
),
] {
let rows = salvage_list_rows(path);
assert_eq!(rows.len(), 1, "{}: {rows:?}", path.display());
assert!(
rows[0].contains(cause),
"{}: expected `{cause}` — {why}: {}",
path.display(),
rows[0]
);
for other in [
"Partial (truncated)",
"Partial (decode failed)",
"Partial (checksum mismatch)",
] {
if other != cause {
assert!(
!rows[0].contains(other),
"{}: row claims `{other}` as well as `{cause}`: {}",
path.display(),
rows[0]
);
}
}
}
for (label, bytes) in [("store.zoo", &stored), ("default.zoo", &lzd)] {
let whole = dir.join(format!("whole-{label}"));
std::fs::write(&whole, bytes).unwrap();
let rows = salvage_list_rows(&whole);
assert_eq!(rows.len(), 1);
assert!(
rows[0].contains("Intact"),
"{label} must be healthy before anything is done to it: {}",
rows[0]
);
}
}
#[test]
fn salvage_recovers_a_damaged_tar_as_complete() {
let dir = tmp_dir();
let good = write_fixture_tar(
&dir,
&[
("first.txt", b"lost with its header"),
("second.txt", b"recovered"),
("third.txt", b"also recovered"),
],
);
let mut bytes = std::fs::read(&good).unwrap();
bytes[0] ^= 0x01;
let archive = dir.join("damaged.tar");
std::fs::write(&archive, &bytes).unwrap();
let listed = run_output(&["list", archive.to_str().unwrap()]);
assert_eq!(
listed.status.code(),
Some(5),
"the ordinary reader must refuse it: {}",
String::from_utf8_lossy(&listed.stderr)
);
let out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{stdout}{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(stdout.lines().count(), 2, "{stdout}");
assert_eq!(stdout.matches("Complete").count(), 2, "{stdout}");
assert!(!stdout.contains("Intact"), "{stdout}");
assert!(
stdout.contains("second.txt") && stdout.contains("third.txt"),
"{stdout}"
);
let out_dir = dir.join("recovered");
let written = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
written.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&written.stderr)
);
assert_eq!(
std::fs::read(out_dir.join("second.txt")).unwrap(),
b"recovered"
);
assert_eq!(
std::fs::read(out_dir.join("third.txt")).unwrap(),
b"also recovered"
);
assert!(!out_dir.join("first.txt").exists());
}
#[test]
fn salvage_says_exit_3_for_a_tar_header_it_cannot_gate() {
let dir = tmp_dir();
let mut b = v7_tar_entry("j.txt", b"junk", b"JUNKJUNK");
b.resize(2048, 0);
let archive = dir.join("junkmagic.tar");
std::fs::write(&archive, &b).unwrap();
assert_eq!(run(&["list", archive.to_str().unwrap()]).code(), Some(0));
let out = run_output(&[
"salvage",
"--format",
"tar",
archive.to_str().unwrap(),
"--list",
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(3), "{stderr}");
assert!(stderr.contains("magic field"), "{stderr}");
assert!(!stderr.contains("nothing recoverable"), "{stderr}");
}
#[test]
fn salvage_calls_a_tar_whose_only_header_lost_byte_0_nothing_recoverable() {
let dir = tmp_dir();
for (label, files) in [
("one", &[("a.txt", &b"alpha"[..])][..]),
(
"two",
&[("a.txt", &b"alpha"[..]), ("b.txt", &b"beta"[..])][..],
),
] {
let mut bytes: Vec<u8> = files
.iter()
.flat_map(|(name, data)| ustar_tar_entry(name, data))
.collect();
bytes.resize(bytes.len() + 1024, 0);
let late = &bytes[1..513];
let field = &late[148..156];
let digits = &field[..field.iter().position(|&x| x == 0).unwrap()];
let recorded = std::str::from_utf8(digits).unwrap();
let summed: u32 = late[..148]
.iter()
.chain(&late[156..])
.map(|&x| u32::from(x))
.sum::<u32>()
+ 8 * 32;
assert_eq!(u32::from_str_radix(recorded, 8).unwrap(), summed, "{label}");
bytes[0] = 0;
let archive = dir.join(format!("nul0-{label}.tar"));
std::fs::write(&archive, &bytes).unwrap();
assert_eq!(
run(&["list", archive.to_str().unwrap()]).code(),
Some(5),
"{label}"
);
let out = run_output(&[
"salvage",
"--format",
"tar",
archive.to_str().unwrap(),
"--list",
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if label == "one" {
assert_eq!(out.status.code(), Some(5), "{label}: {stdout}{stderr}");
assert!(stderr.contains("nothing recoverable"), "{label}: {stderr}");
} else {
assert_eq!(out.status.code(), Some(0), "{label}: {stdout}{stderr}");
assert_eq!(stdout.lines().count(), 1, "{label}: {stdout}");
assert!(stdout.contains("b.txt"), "{label}: {stdout}");
assert_eq!(stderr, "", "{label}: no sighting note");
}
assert!(!stderr.contains("stuffr list"), "{label}: {stderr}");
}
}
#[test]
fn salvage_reports_a_tar_header_too_many_chains_converge_on() {
fn ustar_block(name: &[u8], typeflag: u8, size: u64) -> Vec<u8> {
let mut b = vec![0u8; 512];
b[..name.len()].copy_from_slice(name);
b[100..108].copy_from_slice(b"0000644\0");
b[108..116].copy_from_slice(b"0000000\0");
b[116..124].copy_from_slice(b"0000000\0");
b[124..136].copy_from_slice(format!("{size:011o}\0").as_bytes());
b[136..148].copy_from_slice(b"00000000000\0");
b[156] = typeflag;
b[257..263].copy_from_slice(b"ustar\0");
b[263..265].copy_from_slice(b"00");
b[148..156].fill(b' ');
let sum: u32 = b.iter().map(|&x| u32::from(x)).sum();
b[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
b
}
fn converging(prefix: &[u8]) -> (Vec<u8>, usize) {
let forged = 5;
let terminal_at = prefix.len() + forged * 512;
let mut out = prefix.to_vec();
for k in 0..forged {
let span = (terminal_at - prefix.len() - k * 512 - 512) as u64;
out.extend_from_slice(&ustar_block(b"", b'x', span));
}
out.extend_from_slice(&ustar_block(b"", b'0', 0));
out.extend_from_slice(&[0u8; 1024]);
(out, terminal_at)
}
const SHAPE: &str =
"header(s) reached by more than four extension chains, whose further chains were not read";
let dir = tmp_dir();
let salvage_list = |p: &Path| run_output(&["salvage", p.to_str().unwrap(), "--list"]);
let (bytes, terminal_at) = converging(&[]);
let archive = dir.join("converged.tar");
std::fs::write(&archive, &bytes).unwrap();
let out = salvage_list(&archive);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(5), "{stdout}{stderr}");
assert!(stdout.is_empty(), "{stdout}");
assert!(stderr.contains(SHAPE), "{stderr}");
assert!(
stderr.contains(&format!("offset(s) {terminal_at}")),
"{stderr}"
);
assert!(stderr.contains("nothing recoverable"), "{stderr}");
assert!(
stderr.contains(&format!(
"salvage -> this build's salvage scanner bounds the work it spends on extension \
chains, and did not read the chains in front of 1 tar {SHAPE} at offset(s) \
{terminal_at} — so whatever those chains describe is not listed or recovered there, \
though the scan went on past them; `stuffr list` and `stuffr unpack` may read these \
headers normally\n"
)),
"{stderr}"
);
assert!(!stderr.contains("no gate for"), "{stderr}");
let mut entry = ustar_block(b"ok.txt", b'0', 4);
entry.extend_from_slice(b"fine");
entry.resize(1024, 0);
let (bytes, terminal_at) = converging(&entry);
let archive = dir.join("mixed-converged.tar");
std::fs::write(&archive, &bytes).unwrap();
let out = salvage_list(&archive);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{stdout}{stderr}");
assert_eq!(stdout.lines().count(), 1, "{stdout}");
assert!(
stdout.contains("ok.txt") && stdout.contains("Complete"),
"{stdout}"
);
assert!(stderr.contains(SHAPE), "{stderr}");
assert!(
stderr.contains(&format!("offset(s) {terminal_at}")),
"{stderr}"
);
assert!(
stderr.contains("bounds the work it spends on extension chains"),
"{stderr}"
);
assert!(!stderr.contains("no gate for"), "{stderr}");
}
#[test]
fn salvage_refusal_carries_the_bounded_sighting() {
fn block(name: &[u8], typeflag: u8, size: u64, magic: &[u8; 8]) -> Vec<u8> {
let mut b = vec![0u8; 512];
b[..name.len()].copy_from_slice(name);
b[100..108].copy_from_slice(b"0000644\0");
b[108..116].copy_from_slice(b"0000000\0");
b[116..124].copy_from_slice(b"0000000\0");
b[124..136].copy_from_slice(format!("{size:011o}\0").as_bytes());
b[136..148].copy_from_slice(b"00000000000\0");
b[156] = typeflag;
b[257..265].copy_from_slice(magic);
b[148..156].fill(b' ');
let sum: u32 = b.iter().map(|&x| u32::from(x)).sum();
b[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
b
}
let mut bytes = block(b"j.txt", b'0', 0, b"JUNKJUNK");
let terminal_at = bytes.len() + 5 * 512;
let first_head = bytes.len();
for k in 0..5 {
let span = (terminal_at - first_head - k * 512 - 512) as u64;
bytes.extend_from_slice(&block(b"", b'x', span, b"ustar\x0000"));
}
bytes.extend_from_slice(&block(b"", b'0', 0, b"ustar\x0000"));
bytes.extend_from_slice(&[0u8; 1024]);
let dir = tmp_dir();
let archive = dir.join("refusal-with-note.tar");
std::fs::write(&archive, &bytes).unwrap();
let out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(3), "{stdout}{stderr}");
assert!(stderr.contains("magic field"), "{stderr}");
assert!(stderr.contains("did not read the chains"), "{stderr}");
assert!(
stderr.contains(&format!("offset(s) {terminal_at}")),
"{stderr}"
);
}
#[test]
fn salvage_reports_zip_headers_its_verdict_budget_did_not_judge() {
fn forged(prefix: &[u8]) -> Vec<u8> {
const LEN: usize = 32 * 1024;
let mut out = prefix.to_vec();
let end = prefix.len() + LEN;
while out.len() + 64 <= end {
let name_len = end - out.len() - 30;
if name_len & 0x8080 == 0 {
out.extend_from_slice(b"PK\x03\x04");
for field in [20u16, 0, 0, 0, 0] {
out.extend_from_slice(&field.to_le_bytes());
}
for field in [0u32, 0x7F7F_7F7F, 0x7F7F_7F7F] {
out.extend_from_slice(&field.to_le_bytes());
}
out.extend_from_slice(&u16::try_from(name_len).unwrap().to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.resize(out.len() + 34, b'A');
} else {
out.resize(out.len() + 64, b'A');
}
}
out.resize(end - 1, b'A');
out.push(0xFF);
out
}
const SHAPE: &str = "local header(s) whose name it did not read once that work was spent";
const OPENING: &str = "salvage -> this build's salvage scanner bounds the work it spends \
judging candidate headers across the whole scan, and did not judge \
";
const CLOSING: &str = " — so whatever those headers describe is not listed or recovered \
there, though the scan went on past them; `stuffr list` and `stuffr \
unpack` may read these headers normally\n";
let dir = tmp_dir();
let salvage_list = |p: &Path| run_output(&["salvage", p.to_str().unwrap(), "--list"]);
let archive = dir.join("budget.zip");
std::fs::write(&archive, forged(&[])).unwrap();
let out = salvage_list(&archive);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(5), "{stdout}{stderr}");
assert!(stdout.is_empty(), "{stdout}");
let note = stderr
.lines()
.find(|l| l.contains("bounds the work"))
.unwrap_or_else(|| panic!("no budget note: {stderr}"));
assert!(note.starts_with(OPENING), "{note}");
assert!(format!("{note}\n").ends_with(CLOSING), "{note}");
assert!(
note.contains(&format!(" zip {SHAPE} at offset(s) ")),
"{note}"
);
assert!(!stderr.contains("no gate for"), "{stderr}");
assert!(!stderr.contains("extension chains"), "{stderr}");
assert!(stderr.contains("nothing recoverable"), "{stderr}");
let mut entry = b"PK\x03\x04".to_vec();
for field in [20u16, 0, 0, 0, 0] {
entry.extend_from_slice(&field.to_le_bytes());
}
for field in [0u32, 0, 0] {
entry.extend_from_slice(&field.to_le_bytes());
}
entry.extend_from_slice(&6u16.to_le_bytes());
entry.extend_from_slice(&0u16.to_le_bytes());
entry.extend_from_slice(b"ok.txt");
let archive = dir.join("mixed-budget.zip");
std::fs::write(&archive, forged(&entry)).unwrap();
let out = salvage_list(&archive);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{stdout}{stderr}");
assert_eq!(stdout.lines().count(), 1, "{stdout}");
assert!(
stdout.contains("ok.txt") && stdout.contains("Intact"),
"{stdout}"
);
assert!(
stderr.contains(OPENING) && stderr.contains(SHAPE),
"{stderr}"
);
assert!(!stderr.contains("no gate for"), "{stderr}");
}
fn ustar_tar_entry(name: &str, data: &[u8]) -> Vec<u8> {
let mut b = v7_tar_entry(name, data, b"ustar\x0000");
b[156] = b'0';
b[148..156].fill(b' ');
let sum: u32 = b[..512].iter().map(|&x| u32::from(x)).sum();
b[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
b
}
fn v7_tar_entry(name: &str, data: &[u8], magic: &[u8; 8]) -> Vec<u8> {
let mut b = vec![0u8; 512];
b[..name.len()].copy_from_slice(name.as_bytes());
b[100..108].copy_from_slice(b"0000644\0");
b[108..116].copy_from_slice(b"0000000\0");
b[116..124].copy_from_slice(b"0000000\0");
b[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
b[136..148].copy_from_slice(b"14727046122\0");
b[257..265].copy_from_slice(magic);
b[148..156].fill(b' ');
let sum: u32 = b.iter().map(|&x| u32::from(x)).sum();
b[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
b.extend_from_slice(data);
b.resize(b.len().div_ceil(512) * 512, 0);
b
}
#[test]
fn salvage_names_an_ungateable_tar_header_in_a_mixed_run() {
let dir = tmp_dir();
let mut mixed = v7_tar_entry("j.txt", b"junk", b"JUNKJUNK");
let ok_at = mixed.len();
mixed.extend_from_slice(&v7_tar_entry("ok.txt", b"fine", &[0; 8]));
mixed.extend_from_slice(&[0u8; 1024]);
let healthy = mixed[ok_at..].to_vec();
let archive = dir.join("mixed.tar");
std::fs::write(&archive, &mixed).unwrap();
let whole = dir.join("healthy.tar");
std::fs::write(&whole, &healthy).unwrap();
let listed = run_output(&["list", archive.to_str().unwrap()]);
assert_eq!(listed.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&listed.stdout).lines().count(), 2);
let salvage_list =
|p: &Path| run_output(&["salvage", "--format", "tar", p.to_str().unwrap(), "--list"]);
let out = salvage_list(&archive);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{stdout}{stderr}");
assert_eq!(stdout.lines().count(), 1, "{stdout}");
assert!(
stdout.contains("ok.txt") && !stdout.contains("j.txt"),
"{stdout}"
);
for needle in ["magic field", "offset(s) 0", "stuffr list"] {
assert!(stderr.contains(needle), "{needle:?} missing: {stderr}");
}
let out_dir = dir.join("recovered");
let written = run_output(&[
"salvage",
"--format",
"tar",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&written.stderr);
assert_eq!(written.status.code(), Some(4), "{stderr}");
assert!(
stderr.contains("magic field") && stderr.contains("offset(s) 0"),
"{stderr}"
);
assert_eq!(std::fs::read(out_dir.join("ok.txt")).unwrap(), b"fine");
assert!(!out_dir.join("j.txt").exists());
let picked_dir = dir.join("picked");
let picked = run_output(&[
"salvage",
"--format",
"tar",
archive.to_str().unwrap(),
"--index",
"0",
"-C",
picked_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&picked.stderr);
assert_eq!(picked.status.code(), Some(0), "{stderr}");
assert!(stderr.contains("magic field"), "{stderr}");
assert_eq!(std::fs::read(picked_dir.join("ok.txt")).unwrap(), b"fine");
let out = salvage_list(&whole);
assert_eq!(out.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&out.stderr),
"",
"a healthy tar says nothing"
);
let healthy_dir = dir.join("healthy-out");
let written = run_output(&[
"salvage",
"--format",
"tar",
whole.to_str().unwrap(),
"-C",
healthy_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&written.stderr);
assert_eq!(written.status.code(), Some(0), "{stderr}");
assert_eq!(stderr.lines().count(), 1, "{stderr}");
assert!(
stderr.contains("1 scanned: 1 written") && !stderr.contains("stuffr list"),
"{stderr}"
);
}
fn newc_entry(magic: &[u8; 6], name: &str, data: &[u8]) -> Vec<u8> {
let fields: [u32; 13] = [
1,
0o100644,
0,
0,
1,
0x5F00_0000,
data.len() as u32,
0,
0,
0,
0,
name.len() as u32 + 1,
0,
];
let mut out = magic.to_vec();
for field in fields {
out.extend_from_slice(format!("{field:08X}").as_bytes());
}
out.extend_from_slice(name.as_bytes());
out.push(0);
out.resize(out.len().div_ceil(4) * 4, 0);
out.extend_from_slice(data);
out.resize(out.len().div_ceil(4) * 4, 0);
out
}
fn newc_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut out: Vec<u8> = entries
.iter()
.flat_map(|(name, data)| newc_entry(b"070701", name, data))
.collect();
out.extend_from_slice(&newc_entry(b"070701", "TRAILER!!!", b""));
out
}
#[test]
fn salvage_recovers_a_damaged_cpio_as_unattested() {
let dir = tmp_dir();
let mut bytes = newc_archive(&[
("first.txt", b"lost with its header"),
("second.txt", b"recovered"),
("third.txt", b"also recovered"),
]);
bytes[6] = b'x';
let archive = dir.join("damaged.cpio");
std::fs::write(&archive, &bytes).unwrap();
let listed = run_output(&["list", archive.to_str().unwrap()]);
assert_eq!(
listed.status.code(),
Some(5),
"{}",
String::from_utf8_lossy(&listed.stderr)
);
let out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
out.status.code(),
Some(4),
"{stdout}{}",
String::from_utf8_lossy(&out.stderr)
);
let rows: Vec<&str> = stdout.lines().collect();
assert_eq!(rows.len(), 2, "{stdout}");
for (row, name) in rows.iter().zip(["second.txt", "third.txt"]) {
assert!(row.contains("Unattested") && row.contains(name), "{row}");
assert!(
row.contains("[no checksum and no header self-check in this format]"),
"{row}"
);
}
assert!(
!stdout.contains("Complete") && !stdout.contains("Intact"),
"{stdout}"
);
let out_dir = dir.join("recovered");
let written = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&written.stderr);
assert_eq!(written.status.code(), Some(4), "{stderr}");
assert!(stderr.contains("2 entries are unattested"), "{stderr}");
assert_eq!(
std::fs::read(out_dir.join("second.txt")).unwrap(),
b"recovered"
);
assert_eq!(
std::fs::read(out_dir.join("third.txt")).unwrap(),
b"also recovered"
);
assert!(!out_dir.join("first.txt").exists());
}
#[test]
fn salvage_says_why_an_unattested_entry_written_with_dash_o_exits_4() {
let dir = tmp_dir();
let archive = dir.join("healthy.cpio");
std::fs::write(
&archive,
newc_archive(&[("a.txt", b"one"), ("b.txt", b"two")]),
)
.unwrap();
let target = dir.join("picked.txt");
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"--index",
"1",
"-o",
target.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{stderr}");
assert!(stderr.contains("salvage -> wrote"), "{stderr}");
assert!(
stderr.contains("1 entry is unattested: this format carries no checksum"),
"exit 4 must say why: {stderr}"
);
assert_eq!(std::fs::read(&target).unwrap(), b"two");
}
#[test]
fn salvage_says_exit_3_for_a_cpio_variant_it_cannot_read() {
let dir = tmp_dir();
let mut crc: Vec<u8> = newc_entry(b"070702", "a.txt", b"alpha");
crc.extend_from_slice(&newc_entry(b"070702", "TRAILER!!!", b""));
let archive = dir.join("crc.cpio");
std::fs::write(&archive, &crc).unwrap();
assert_eq!(
run_output(&["list", archive.to_str().unwrap()])
.status
.code(),
Some(3)
);
let out = run_output(&[
"salvage",
"--format",
"cpio",
archive.to_str().unwrap(),
"--list",
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(3), "{stderr}");
assert!(
stderr.contains("070702") && stderr.contains("newc-crc"),
"{stderr}"
);
assert!(!stderr.contains("nothing recoverable"), "{stderr}");
assert!(out.stdout.is_empty());
}
fn ar_member(name: &str, data: &[u8]) -> Vec<u8> {
let mut out = format!(
"{name:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n",
0,
0,
0,
"100644",
data.len()
)
.into_bytes();
assert_eq!(out.len(), 60);
out.extend_from_slice(data);
if data.len() % 2 == 1 {
out.push(b'\n');
}
out
}
fn ar_archive(entries: &[(&str, &[u8])]) -> (Vec<u8>, Vec<usize>) {
let mut out = b"!<arch>\n".to_vec();
let mut offsets = Vec::new();
for (name, data) in entries {
offsets.push(out.len());
out.extend(ar_member(name, data));
}
(out, offsets)
}
#[test]
fn salvage_of_an_ar_with_a_hole_says_what_lies_past_it_is_unreachable() {
let dir = tmp_dir();
let (mut bytes, offsets) = ar_archive(&[
("one.txt", b"first"),
("two.txt", b"second"),
("three.txt", b"third"),
("four.txt", b"fourth"),
("five.txt", b"fifth"),
]);
let hole = offsets[2];
bytes[hole + 16..hole + 28].copy_from_slice(b"XXXXXXXXXXXX");
let archive = dir.join("holed.a");
std::fs::write(&archive, &bytes).unwrap();
assert_eq!(
run_output(&["list", archive.to_str().unwrap()])
.status
.code(),
Some(5)
);
let says_why = |stderr: &str, mode: &str| {
for needle in [
format!("stopped at offset {hole}"),
format!("{} bytes still to go", bytes.len() - hole),
"unreachable by construction — not absent".to_string(),
"timestamp".to_string(),
] {
assert!(
stderr.contains(&needle),
"{mode}: missing {needle:?}: {stderr}"
);
}
};
let out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{stdout}{stderr}");
let rows: Vec<&str> = stdout.lines().collect();
assert_eq!(rows.len(), 2, "{stdout}");
for (row, name) in rows.iter().zip(["one.txt", "two.txt"]) {
assert!(row.contains(name) && row.contains("Unattested"), "{row}");
}
for later in ["three.txt", "four.txt", "five.txt"] {
assert!(!stdout.contains(later), "{stdout}");
}
says_why(&stderr, "--list");
let out_dir = dir.join("recovered");
let written = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&written.stderr);
assert_eq!(written.status.code(), Some(4), "{stderr}");
says_why(&stderr, "-C");
assert!(stderr.contains("2 entries are unattested"), "{stderr}");
assert_eq!(std::fs::read(out_dir.join("one.txt")).unwrap(), b"first");
assert_eq!(std::fs::read(out_dir.join("two.txt")).unwrap(), b"second");
assert!(!out_dir.join("four.txt").exists());
let target = dir.join("picked.txt");
let one = run_output(&[
"salvage",
archive.to_str().unwrap(),
"--index",
"1",
"-o",
target.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&one.stderr);
assert_eq!(one.status.code(), Some(4), "{stderr}");
says_why(&stderr, "-o");
assert_eq!(std::fs::read(&target).unwrap(), b"second");
}
#[test]
fn salvage_of_a_truncated_ar_recovers_the_tail_and_says_no_more_than_that() {
let dir = tmp_dir();
let (bytes, offsets) = ar_archive(&[("a.txt", b"alpha"), ("b.txt", b"bravo and more")]);
let payload_cut = &bytes[..offsets[1] + 60 + 5];
let archive = dir.join("cut.a");
std::fs::write(&archive, payload_cut).unwrap();
let out_dir = dir.join("cut-out");
let run = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&run.stderr);
assert_eq!(run.status.code(), Some(4), "{stderr}");
assert!(!stderr.contains("unreachable"), "{stderr}");
assert!(!stderr.contains("walks the archive"), "{stderr}");
assert_eq!(std::fs::read(out_dir.join("a.txt")).unwrap(), b"alpha");
assert_eq!(
std::fs::read(out_dir.join("b.txt.partial")).unwrap(),
b"bravo"
);
let header_cut = &bytes[..offsets[1] + 30];
std::fs::write(&archive, header_cut).unwrap();
let run = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stderr = String::from_utf8_lossy(&run.stderr);
assert_eq!(run.status.code(), Some(4), "{stderr}");
assert!(
stderr.contains(&format!(
"the file ends 30 bytes into the record at offset {}",
offsets[1]
)),
"{stderr}"
);
assert!(stderr.contains("cut short"), "{stderr}");
assert!(!stderr.contains("unreachable"), "{stderr}");
}
#[test]
fn list_of_an_ar_cut_inside_a_member_exits_5_from_a_file_and_from_stdin() {
let dir = tmp_dir();
let (bytes, offsets) = ar_archive(&[("a.txt", b"alpha"), ("b.txt", b"bravo and more")]);
let cut = &bytes[..offsets[1] + 60 + 5];
let archive = dir.join("cut.a");
std::fs::write(&archive, cut).unwrap();
let out = run_output(&["list", archive.to_str().unwrap()]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(5), "{stderr}");
assert!(stderr.contains("truncated"), "{stderr}");
let out = run_with_stdin_output(&["list", "-"], cut);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(5), "{stderr}");
assert!(stderr.contains("truncated"), "{stderr}");
}
#[test]
fn salvage_of_an_ar_whose_first_header_is_destroyed_exits_5_and_says_why() {
let dir = tmp_dir();
let (mut bytes, _) = ar_archive(&[("a.txt", b"alpha"), ("b.txt", b"bravo")]);
bytes[8 + 48..8 + 58].copy_from_slice(b"??????????");
let archive = dir.join("first.a");
std::fs::write(&archive, &bytes).unwrap();
let run = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stderr = String::from_utf8_lossy(&run.stderr);
assert_eq!(run.status.code(), Some(5), "{stderr}");
let note = stderr
.find("stopped at offset 8")
.unwrap_or_else(|| panic!("{stderr}"));
let nothing = stderr.find("found nothing recoverable").unwrap();
assert!(note < nothing, "{stderr}");
}
#[test]
fn gnu_ar_on_mach_o_with_a_blank_mode_symbol_table_lists_unpacks_and_salvages() {
if !cfg!(target_os = "macos") {
return;
}
let gnu = PathBuf::from("/opt/homebrew/opt/binutils/bin/ar");
assert!(
gnu.is_file(),
"GNU ar not at {} (`brew install binutils`)",
gnu.display()
);
let cc = which("cc").expect("no `cc` on PATH — this test needs Mach-O objects");
let dir = tmp_dir();
for (name, body) in [
("f", "int f(void){return 1;}\n"),
("g", "int g(void){return 2;}\n"),
] {
std::fs::write(dir.join(format!("{name}.c")), body).unwrap();
let ok = Command::new(&cc)
.args(["-c", "-o", &format!("{name}.o"), &format!("{name}.c")])
.current_dir(&dir)
.status()
.unwrap();
assert!(ok.success(), "cc -c {name}.c");
}
let made = Command::new(&gnu)
.args(["rcs", "lib.a", "f.o", "g.o"])
.current_dir(&dir)
.status()
.unwrap();
assert!(made.success(), "GNU ar rcs");
let archive = dir.join("lib.a");
let bytes = std::fs::read(&archive).unwrap();
assert_eq!(&bytes[8..8 + 16], format!("{:<16}", "__.SYMDEF").as_bytes());
assert_eq!(
bytes[8 + 40..8 + 48],
[b' '; 8],
"GNU no longer writes a blank mode"
);
let out = run_output(&["list", archive.to_str().unwrap()]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{stdout}{stderr}");
let names: Vec<&str> = stdout
.lines()
.map(|l| l.split_whitespace().last().unwrap())
.collect();
assert_eq!(names, ["__.SYMDEF", "f.o", "g.o"], "{stdout}");
let out_dir = dir.join("unpacked");
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{stderr}");
for obj in ["f.o", "g.o"] {
assert_eq!(
std::fs::read(out_dir.join(obj)).unwrap(),
std::fs::read(dir.join(obj)).unwrap(),
"{obj}"
);
}
assert!(out_dir.join("__.SYMDEF").is_file());
let out = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "{stdout}{stderr}");
let rows: Vec<&str> = stdout.lines().collect();
assert_eq!(rows.len(), 3, "{stdout}");
for (row, name) in rows.iter().zip(["__.SYMDEF", "f.o", "g.o"]) {
assert!(row.contains(name) && row.contains("Unattested"), "{row}");
}
assert!(!stderr.contains("stopped at offset"), "{stderr}");
assert!(!stderr.contains("symbol table"), "{stderr}");
}
#[test]
fn salvage_recovers_what_stuffr_pack_wrote_as_ar() {
let dir = tmp_dir();
let tree = dir.join("tree");
std::fs::create_dir_all(tree.join("sub")).unwrap();
std::fs::write(tree.join("a.txt"), b"alpha\n").unwrap();
std::fs::write(tree.join("sub").join("b.bin"), vec![7u8; 3001]).unwrap();
let archive = dir.join("packed.a");
let packed = run_output(&[
"pack",
tree.to_str().unwrap(),
"-o",
archive.to_str().unwrap(),
]);
assert!(
packed.status.success(),
"{}",
String::from_utf8_lossy(&packed.stderr)
);
let out_dir = dir.join("packed-out");
let run = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&run.stderr);
assert_eq!(run.status.code(), Some(4), "{stderr}");
assert!(!stderr.contains("walks the archive"), "{stderr}");
assert_eq!(
std::fs::read(out_dir.join("tree").join("a.txt")).unwrap(),
b"alpha\n"
);
assert_eq!(
std::fs::read(out_dir.join("tree").join("sub").join("b.bin")).unwrap(),
vec![7u8; 3001]
);
}
#[test]
fn salvage_refuses_a_thin_archive_and_an_unreadable_symbol_table_at_exit_3() {
let dir = tmp_dir();
let mut thin = b"!<thin>\n".to_vec();
thin.extend_from_slice(&ar_member("a.txt/", b"")[..60]);
thin[8 + 48..8 + 58].copy_from_slice(format!("{:<10}", 18).as_bytes());
thin.extend_from_slice(&ar_member("bb.bin/", b"")[..60]);
let archive = dir.join("thin.a");
std::fs::write(&archive, &thin).unwrap();
let out_dir = dir.join("thin-out");
let run = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&run.stderr);
assert_eq!(run.status.code(), Some(3), "{stderr}");
assert!(stderr.contains("thin archive"), "{stderr}");
assert!(!out_dir.join("a.txt").exists());
let mut symdef = b"!<arch>\n".to_vec();
let mut table = ar_member("__.SYMDEF", b"\0\0\0\0");
table[40..48].copy_from_slice(b"zzzzzzzz");
symdef.extend(table);
symdef.extend(ar_member("f.o", b"fff"));
let archive = dir.join("symdef.a");
std::fs::write(&archive, &symdef).unwrap();
let run = run_output(&["salvage", archive.to_str().unwrap(), "--list"]);
let stderr = String::from_utf8_lossy(&run.stderr);
assert_eq!(run.status.code(), Some(3), "{stderr}");
assert!(stderr.contains("symbol table (`__.SYMDEF`)"), "{stderr}");
assert!(!stderr.contains("nothing recoverable"), "{stderr}");
}
fn checksumless_inputs() -> Vec<(&'static str, Vec<u8>)> {
vec![
(
"alpha.txt",
(0..200)
.flat_map(|i| format!("alpha line {i}\n").into_bytes())
.collect(),
),
(
"beta.txt",
(0..301)
.flat_map(|i| format!("beta {i};").into_bytes())
.collect(),
),
(
"gamma.bin",
(0..1500u32).map(|i| ((i * 7 + 3) % 256) as u8).collect(),
),
(
"delta.txt",
(0..151)
.flat_map(|i| format!("delta row {i}\n").into_bytes())
.collect(),
),
]
}
fn checksumless_archive(format: &str, dir: &Path) -> Vec<u8> {
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
let inputs = checksumless_inputs();
for (name, data) in &inputs {
std::fs::write(tree.join(name), data).unwrap();
}
let names: Vec<&std::ffi::OsStr> = inputs.iter().map(|(n, _)| os(n)).collect();
match format {
"tar" => {
let out = dir.join("ref.tar");
let mut args = vec![os(&"--format=ustar"), os(&"-cf"), os(&out)];
args.extend(&names);
run_tool(&require_bin("tar"), &args, &tree, b"");
std::fs::read(out).unwrap()
}
"cpio" => {
let list: Vec<&str> = inputs.iter().map(|(n, _)| *n).collect();
run_tool(
&require_bin("cpio"),
&[os(&"-o"), os(&"-H"), os(&"newc")],
&tree,
list.join("\n").as_bytes(),
)
}
"ar" => {
let out = dir.join("ref.a");
let mut args = vec![os(&"rcS"), os(&out)];
args.extend(&names);
run_tool(&require_bin("ar"), &args, &tree, b"");
std::fs::read(out).unwrap()
}
other => panic!("no writer for {other}"),
}
}
fn payload_offset(archive: &[u8], data: &[u8]) -> usize {
let hits: Vec<usize> = archive
.windows(data.len())
.enumerate()
.filter(|(_, w)| *w == data)
.map(|(i, _)| i)
.collect();
assert_eq!(hits.len(), 1, "an input's bytes must appear exactly once");
hits[0]
}
fn checksumless_catalogue_end_to_end(format: &str, tier: &str, whole_exit: i32) {
let dir = tmp_dir();
let healthy = checksumless_archive(format, &dir);
let inputs = checksumless_inputs();
let row_of = |rows: &[String], name: &str| -> String {
rows.iter()
.find(|r| r.split_whitespace().any(|w| w == name))
.unwrap_or_else(|| panic!("{format}: no row for {name}: {rows:?}"))
.clone()
};
let path = dir.join(format!("healthy.{format}"));
std::fs::write(&path, &healthy).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), inputs.len(), "{format}: {rows:?}");
for (name, _) in &inputs {
assert!(row_of(&rows, name).contains(tier), "{format}: {rows:?}");
}
let (beta, beta_data) = (&inputs[1].0, &inputs[1].1);
let mid = beta_data.len() / 2;
let mut flipped = healthy.clone();
flipped[payload_offset(&healthy, beta_data) + mid] ^= 0xFF;
let path = dir.join(format!("flipped.{format}"));
std::fs::write(&path, &flipped).unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), inputs.len(), "{format}: {rows:?}");
assert!(row_of(&rows, beta).contains(tier), "{format}: {rows:?}");
assert!(
rows.iter().all(|r| !r.contains("Intact")),
"{format}: `Intact` claims a content check this format does not carry: {rows:?}"
);
let out_dir = dir.join(format!("flipped-{format}"));
let out = run_output(&[
"salvage",
path.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(whole_exit),
"{format}: {}",
String::from_utf8_lossy(&out.stderr)
);
let mut wrong = beta_data.clone();
wrong[mid] ^= 0xFF;
assert_eq!(
std::fs::read(out_dir.join(beta)).unwrap(),
wrong,
"{format}: the silently wrong file, wrong in exactly the flipped byte"
);
for (name, data) in inputs.iter().filter(|(n, _)| n != beta) {
assert_eq!(
&std::fs::read(out_dir.join(name)).unwrap(),
data,
"{format}"
);
}
let (delta, delta_data) = (&inputs[3].0, &inputs[3].1);
let keep = delta_data.len() / 2;
let path = dir.join(format!("cut.{format}"));
std::fs::write(
&path,
&healthy[..payload_offset(&healthy, delta_data) + keep],
)
.unwrap();
let rows = salvage_list_rows(&path);
assert_eq!(rows.len(), inputs.len(), "{format}: {rows:?}");
assert!(
row_of(&rows, delta).contains("Partial (truncated)"),
"{format}: {rows:?}"
);
let out_dir = dir.join(format!("cut-{format}"));
let out = run_output(&[
"salvage",
path.to_str().unwrap(),
"-C",
out_dir.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"{format}: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read(out_dir.join(format!("{delta}.partial"))).unwrap(),
delta_data[..keep],
"{format}: exactly the surviving prefix, nothing invented"
);
assert!(!out_dir.join(delta).exists(), "{format}");
for (name, data) in &inputs[..3] {
assert!(row_of(&rows, name).contains(tier), "{format}: {rows:?}");
assert_eq!(
&std::fs::read(out_dir.join(name)).unwrap(),
data,
"{format}"
);
}
}
#[test]
fn damage_catalogue_tar_end_to_end() {
checksumless_catalogue_end_to_end("tar", "Complete", 0);
let dir = tmp_dir();
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
let inputs = checksumless_inputs();
for (name, data) in &inputs {
std::fs::write(tree.join(name), data).unwrap();
}
let gnu = which("gtar").unwrap_or_else(|| require_bin("tar"));
let out = dir.join("gnu.tar");
let mut args = vec![os(&"-cf"), os(&out)];
args.extend(inputs.iter().map(|(n, _)| os(n)));
run_tool(&gnu, &args, &tree, b"");
let mut bytes = std::fs::read(&out).unwrap();
bytes[257..263].copy_from_slice(b"bogus\0");
bytes[148..156].copy_from_slice(b" ");
let sum: u32 = bytes[..512].iter().map(|&x| u32::from(x)).sum();
bytes[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
let damaged = dir.join("junk-magic.tar");
std::fs::write(&damaged, &bytes).unwrap();
let out = run_output(&["salvage", damaged.to_str().unwrap(), "--list"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("saw 1 tar header(s)") && stderr.contains("offset(s) 0"),
"one header at offset 0, not its late copy too: {stderr}"
);
assert!(!stderr.contains("offset(s) 0, 1"), "{stderr}");
}
#[test]
fn damage_catalogue_cpio_end_to_end() {
checksumless_catalogue_end_to_end("cpio", "Unattested", 4);
}
#[test]
fn damage_catalogue_ar_end_to_end() {
checksumless_catalogue_end_to_end("ar", "Unattested", 4);
}
#[test]
fn a_tar_sparse_header_the_tar_crate_would_overflow_on_exits_5() {
let dir = tmp_dir();
let mut header = Header::new_gnu();
header.set_path("sparse.bin").unwrap();
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::GNUSparse);
header.set_size(u64::MAX - 1023);
header.as_gnu_mut().unwrap().set_is_extended(true);
header.set_cksum();
let mut bytes = header.as_bytes().to_vec();
bytes.extend_from_slice(tar::GnuExtSparseHeader::new().as_bytes());
bytes.extend_from_slice(&[0u8; 1024]);
assert_eq!(bytes.len(), 2048);
let path = dir.join("overflow.tar");
std::fs::write(&path, &bytes).unwrap();
let p = path.to_str().unwrap();
for args in [
vec!["list", p],
vec!["test", p],
vec!["unpack", p, "-C", dir.join("out").to_str().unwrap()],
] {
let out = run_output(&args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(5), "{args:?}: {stderr}");
assert!(stderr.contains("past 2^64"), "{args:?}: {stderr}");
}
let out = run_with_stdin_output(&["list", "-"], &bytes);
assert_eq!(
out.status.code(),
Some(5),
"a pipe: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_output(&["salvage", p, "--list"]);
let code = out.status.code();
assert!(
code.is_some_and(|c| c != 1 && c != 101),
"salvage: {code:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_gnu_tar_sparse_archive_lists_tests_and_unpacks_byte_identical() {
let dir = tmp_dir();
let mut data = vec![0u8; 12 * 16384 + 3000];
for run in 0..12 {
let at = run * 16384 + 4096;
for (i, b) in data[at..at + 1000].iter_mut().enumerate() {
*b = ((i * 7 + run) % 251) as u8 + 1;
}
}
let len: u64 = 64 << 20;
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
{
let mut file = std::fs::File::create(src.join("holey.bin")).unwrap();
file.write_all(&data).unwrap();
file.set_len(len).unwrap();
file.sync_all().unwrap();
}
let gnu = require_gnu_tar();
let archive = dir.join("sparse.tar");
run_tool(
&gnu,
&[
os(&"--format=gnu"),
os(&"--sparse"),
os(&"--hole-detection=raw"),
os(&"-cf"),
os(&archive),
os(&"holey.bin"),
],
&src,
b"",
);
let bytes = std::fs::read(&archive).unwrap();
let header = Header::from_byte_slice(&bytes[..512]);
assert!(
header.entry_type().is_gnu_sparse(),
"typeflag `S` — GNU tar did not see a hole in the file"
);
assert!(
header.as_gnu().is_some_and(|gnu| gnu.is_extended()),
"the map spills into extension blocks"
);
let p = archive.to_str().unwrap();
let out = run_output(&["list", p]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.contains("holey.bin") && stdout.contains(&len.to_string()),
"{stdout}"
);
let out = run_output(&["test", p]);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let dest = dir.join("out");
let out = run_output(&["unpack", p, "-C", dest.to_str().unwrap()]);
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let got = std::fs::read(dest.join("holey.bin")).unwrap();
assert_eq!(got.len() as u64, len);
assert!(got[..data.len()] == data[..], "the data runs differ");
assert!(
got[data.len()..].iter().all(|&b| b == 0),
"the hole unpacks as zeros"
);
}
fn convert_payloads() -> (Vec<u8>, Vec<u8>) {
(
b"first file, compressible enough to matter\n".repeat(50),
(0u8..=255).collect(),
)
}
fn convert_fixture() -> (Scratch, PathBuf) {
let dir = tmp_dir();
let d = dir.join("d");
std::fs::create_dir_all(d.join("sub")).unwrap();
let (one, two) = convert_payloads();
std::fs::write(d.join("one.txt"), one).unwrap();
std::fs::write(d.join("sub/two.bin"), two).unwrap();
let a = dir.join("a.tar.gz");
let out = run_output(&["pack", d.to_str().unwrap(), "-o", a.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
(dir, a)
}
fn stderr_text(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn assert_unpacks_to_the_fixture(dir: &Path, archive: &Path, label: &str) {
let dest = dir.join(label);
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let (one, two) = convert_payloads();
assert_eq!(std::fs::read(dest.join("d/one.txt")).unwrap(), one);
assert_eq!(std::fs::read(dest.join("d/sub/two.bin")).unwrap(), two);
}
#[test]
fn convert_recompresses_a_tar_gz_to_tar_zst() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar.zst");
let out = run_output(&[
"convert",
a.to_str().unwrap(),
"-o",
b.to_str().unwrap(),
"--allow-weak-encoder",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("-> tar over zstd ("),
"{}",
stderr_text(&out)
);
assert_unpacks_to_the_fixture(&dir, &b, "out");
}
#[test]
fn convert_accepts_the_destination_as_a_second_positional() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar.xz");
let out = run_output(&["convert", a.to_str().unwrap(), b.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_unpacks_to_the_fixture(&dir, &b, "out");
}
#[test]
fn convert_refuses_both_destination_forms_at_once() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar.xz");
let c = dir.join("c.tar.xz");
let out = run_output(&[
"convert",
a.to_str().unwrap(),
b.to_str().unwrap(),
"-o",
c.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(!b.exists() && !c.exists());
let out = run_output(&["convert", a.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
}
#[test]
fn convert_refuses_a_plain_stream_into_a_container() {
let dir = tmp_dir();
let x = dir.join("x");
std::fs::write(&x, b"just bytes\n".repeat(20)).unwrap();
let gz = dir.join("x.gz");
assert!(run(&["pack", x.to_str().unwrap(), "-o", gz.to_str().unwrap()]).success());
let zip = dir.join("x.zip");
let out = run_output(&["convert", gz.to_str().unwrap(), "-o", zip.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
let err = stderr_text(&out);
assert!(
err.contains("is gzip") && err.contains("would be zip"),
"both chains must be named: {err}"
);
assert!(!zip.exists());
let (fdir, a) = convert_fixture();
let plain = fdir.join("a.gz");
let out = run_output(&[
"convert",
a.to_str().unwrap(),
"-o",
plain.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
let err = stderr_text(&out);
assert!(
err.contains("is tar over gzip") && err.contains("would be gzip"),
"{err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_refuses_nothing_to_convert() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar.gz");
let out = run_output(&["convert", a.to_str().unwrap(), "-o", b.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("nothing to convert"),
"{}",
stderr_text(&out)
);
assert!(!b.exists());
let out = run_output(&[
"convert",
a.to_str().unwrap(),
"-o",
b.to_str().unwrap(),
"--level",
"9",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_unpacks_to_the_fixture(&dir, &b, "out");
}
#[cfg(unix)]
#[test]
fn convert_refuses_overwriting_its_own_input() {
let (dir, a) = convert_fixture();
let before = std::fs::read(&a).unwrap();
let a_str = a.to_str().unwrap();
let check = |out: std::process::Output| {
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("same file"),
"{}",
stderr_text(&out)
);
assert_eq!(std::fs::read(&a).unwrap(), before, "the input was touched");
};
check(run_output(&[
"convert", a_str, a_str, "--force", "--level", "9",
]));
let link = dir.join("l.tar.gz");
std::os::unix::fs::symlink(&a, &link).unwrap();
check(run_output(&[
"convert",
a_str,
link.to_str().unwrap(),
"--force",
"--level",
"9",
]));
check(
Command::new(STUFFR)
.current_dir(&dir)
.args([
"convert",
"a.tar.gz",
"./a.tar.gz",
"--force",
"--level",
"9",
])
.output()
.unwrap(),
);
let hard = dir.join("h.tar.gz");
std::fs::hard_link(&a, &hard).unwrap();
check(run_output(&[
"convert",
a_str,
"-o",
hard.to_str().unwrap(),
"--force",
"--level",
"9",
]));
}
#[test]
fn convert_to_stdout_needs_format() {
let dir = tmp_dir();
let payload = b"payload for the pipe\n".repeat(40);
let x = dir.join("x");
std::fs::write(&x, &payload).unwrap();
let gz = dir.join("x.gz");
assert!(run(&["pack", x.to_str().unwrap(), "-o", gz.to_str().unwrap()]).success());
let out = run_output(&["convert", gz.to_str().unwrap(), "-o", "-"]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(out.stdout.is_empty());
let out = run_output(&["convert", gz.to_str().unwrap(), "-o", "-", "--format", "xz"]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert!(out.stdout.starts_with(b"\xfd7zXZ\x00"), "not an xz stream");
let back = run_with_stdin_output(&["cat", "-"], &out.stdout);
assert_eq!(back.status.code(), Some(0), "{}", stderr_text(&back));
assert_eq!(back.stdout, payload);
}
#[test]
fn convert_reads_stdin() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar.xz");
let out = run_with_stdin_output(
&["convert", "-", "-o", b.to_str().unwrap()],
&std::fs::read(&a).unwrap(),
);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_unpacks_to_the_fixture(&dir, &b, "out");
}
#[test]
fn convert_refuses_a_format_that_contradicts_the_name() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar.xz");
let out = run_output(&[
"convert",
a.to_str().unwrap(),
"-o",
b.to_str().unwrap(),
"--format",
"zstd",
]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(!b.exists());
let out = run_output(&[
"convert",
a.to_str().unwrap(),
"-o",
b.to_str().unwrap(),
"--format",
"zip",
]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
let out = run_output(&[
"convert",
a.to_str().unwrap(),
"-o",
b.to_str().unwrap(),
"--format",
"xz",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_unpacks_to_the_fixture(&dir, &b, "out");
}
#[test]
fn convert_refuses_encoder_flags_a_codecless_target_cannot_honour() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar");
for flag in [
&["--threads", "2"][..],
&["--turbo"][..],
&["--allow-weak-encoder"][..],
] {
let mut args = vec!["convert", a.to_str().unwrap(), "-o", b.to_str().unwrap()];
args.extend_from_slice(flag);
let out = run_output(&args);
assert_eq!(
out.status.code(),
Some(2),
"{flag:?}: {}",
stderr_text(&out)
);
let err = stderr_text(&out);
assert!(err.contains(flag[0]), "{err}");
assert!(!err.contains("no container in this build"), "{err}");
assert!(err.contains("no codec layer"), "{err}");
assert!(!b.exists());
}
let out = run_output(&[
"convert",
a.to_str().unwrap(),
"-o",
b.to_str().unwrap(),
"--level",
"9",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("-> tar ("),
"{}",
stderr_text(&out)
);
}
#[test]
fn convert_points_an_unrecognised_input_at_pack() {
let dir = tmp_dir();
let notes = dir.join("notes.txt");
std::fs::write(¬es, b"plain text that no probe recognises\n").unwrap();
let out_path = dir.join("notes.txt.gz");
let out = run_output(&[
"convert",
notes.to_str().unwrap(),
"-o",
out_path.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("stuffr pack"),
"{}",
stderr_text(&out)
);
assert!(!out_path.exists());
}
#[test]
fn convert_to_a_bare_stream_names_raw_in_its_progress_line() {
let dir = tmp_dir();
let payload = b"decoded by convert\n".repeat(30);
let x = dir.join("x");
std::fs::write(&x, &payload).unwrap();
let gz = dir.join("x.gz");
assert!(run(&["pack", x.to_str().unwrap(), "-o", gz.to_str().unwrap()]).success());
let back = dir.join("back");
let out = run_output(&[
"convert",
gz.to_str().unwrap(),
"-o",
back.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("-> raw ("),
"{}",
stderr_text(&out)
);
assert_eq!(std::fs::read(&back).unwrap(), payload);
}
fn convert_corrupt_gz(dir: &Path) -> PathBuf {
let bad = dir.join("bad.tar.gz");
let mut bytes = vec![0x1f, 0x8b, 0x08, 0xff];
bytes.extend_from_slice(&[0xde; 64]);
std::fs::write(&bad, bytes).unwrap();
bad
}
#[test]
fn convert_refuses_usage_errors_before_opening_the_input() {
let dir = tmp_dir();
let bad = convert_corrupt_gz(&dir);
let bad_s = bad.to_str().unwrap();
let q = dir.join("q.tar.xz");
let q_s = q.to_str().unwrap();
let out = run_output(&["convert", bad_s, "-o", q_s]);
assert_eq!(out.status.code(), Some(5), "{}", stderr_text(&out));
let out = run_output(&["convert", bad_s, "-o", q_s, "--level", "99"]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(!q.exists());
let out = run_with_stdin_output(&["convert", "-", "-o", q_s, "--level", "99"], &[0xde; 64]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
let zst = dir.join("q.tar.zst");
let out = run_output(&["convert", bad_s, "-o", zst.to_str().unwrap()]);
if stderr_text(&out).contains("weak") {
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
}
}
#[test]
fn convert_refuses_a_codecless_identical_chain_whatever_the_flags() {
let (dir, a) = convert_fixture();
let tar = dir.join("a.tar");
let out = run_output(&["convert", a.to_str().unwrap(), "-o", tar.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let i = dir.join("i.tar");
for extra in [&[][..], &["--level", "9"][..]] {
let mut args = vec!["convert", tar.to_str().unwrap(), "-o", i.to_str().unwrap()];
args.extend_from_slice(extra);
let out = run_output(&args);
assert_eq!(
out.status.code(),
Some(2),
"{extra:?}: {}",
stderr_text(&out)
);
let err = stderr_text(&out);
assert!(err.contains("nothing to convert"), "{err}");
assert!(
!err.contains("Pass --level"),
"no hint without a codec: {err}"
);
assert!(!i.exists());
}
}
#[cfg(unix)]
#[test]
fn convert_refuses_stdin_redirected_from_its_own_output() {
let (dir, a) = convert_fixture();
let big = dir.join("big.tar.xz");
assert!(run(&["convert", a.to_str().unwrap(), "-o", big.to_str().unwrap()]).success());
let before = std::fs::read(&big).unwrap();
let link = dir.join("l.tar.xz");
std::os::unix::fs::symlink(&big, &link).unwrap();
let out = Command::new(STUFFR)
.args([
"convert",
"-",
"-o",
link.to_str().unwrap(),
"--force",
"--level",
"1",
])
.stdin(std::fs::File::open(&big).unwrap())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("same file"),
"{}",
stderr_text(&out)
);
assert_eq!(
std::fs::read(&big).unwrap(),
before,
"the input was truncated"
);
}
#[test]
fn convert_progress_line_says_what_the_write_lost_in_packs_words() {
let (dir, a) = convert_fixture();
let b = dir.join("b.tar.xz");
let out = run_output(&["convert", a.to_str().unwrap(), b.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains(" bytes, no fidelity loss)"),
"{}",
stderr_text(&out)
);
let data = std::fs::read(&a).unwrap();
let c = dir.join("c.tar.xz");
let out = run_with_stdin_output(&["convert", "-", "-o", c.to_str().unwrap()], &data);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let err = stderr_text(&out);
assert!(err.contains(" bytes, no fidelity loss)"), "{err}");
assert!(!err.contains("forward-only"), "{err}");
}
#[test]
fn convert_container_to_stdout_plain_points_at_the_pipe() {
let (dir, a) = convert_fixture();
let out = run_output(&["convert", a.to_str().unwrap(), "-o", "-", "--format", "xz"]);
assert_eq!(out.status.code(), Some(2), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("stuffr pack - -o - --format xz"),
"{}",
stderr_text(&out)
);
let first = Command::new(STUFFR)
.args(["convert", a.to_str().unwrap(), "-o", "-", "--format", "tar"])
.output()
.unwrap();
assert_eq!(first.status.code(), Some(0));
let second = run_with_stdin_output(&["pack", "-", "-o", "-", "--format", "xz"], &first.stdout);
assert_eq!(second.status.code(), Some(0), "{}", stderr_text(&second));
let xz = dir.join("piped.tar.xz");
std::fs::write(&xz, &second.stdout).unwrap();
let tar = dir.join("piped.tar");
assert!(run(&["unpack", xz.to_str().unwrap(), "-o", tar.to_str().unwrap()]).success());
assert_eq!(std::fs::read(&tar).unwrap(), first.stdout);
}
#[test]
fn convert_tar_gz_to_zip_round_trips() {
let (dir, a) = convert_fixture();
let zip = dir.join("a.zip");
let out = run_output(&["convert", a.to_str().unwrap(), "-o", zip.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let err = stderr_text(&out);
assert!(err.contains("-> zip ("), "{err}");
assert!(err.contains(" bytes, no fidelity loss)"), "{err}");
assert_unpacks_to_the_fixture(&dir, &zip, "out");
}
fn gnu_padded_tar_gz() -> (Scratch, PathBuf, PathBuf) {
let dir = tmp_dir();
let payload: Vec<u8> = (0..4000u32)
.map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
.collect();
let mut tar_bytes = Vec::new();
{
let mut builder = Builder::new(&mut tar_bytes);
append_tar_entry(&mut builder, "t/f.txt", &payload);
builder.finish().unwrap();
}
tar_bytes.resize(tar_bytes.len().div_ceil(10240) * 10240, 0);
let mut enc = GzEncoder::new(Vec::new(), Compression::none());
enc.write_all(&tar_bytes).unwrap();
let mut gz = enc.finish().unwrap();
let clean = dir.join("clean.tar.gz");
std::fs::write(&clean, &gz).unwrap();
let probe = &payload[1000..1032];
let at = gz
.windows(probe.len())
.position(|w| w == probe)
.expect("premise: an uncompressed deflate stream stores the payload verbatim");
gz[at + 16] ^= 0x01;
let corrupt = dir.join("corrupt.tar.gz");
std::fs::write(&corrupt, &gz).unwrap();
let out = run_output(&[
"unpack",
corrupt.to_str().unwrap(),
"-o",
dir.join("premise.tar").to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(5), "{}", stderr_text(&out));
(dir, clean, corrupt)
}
#[test]
fn convert_refuses_a_tar_gz_whose_gzip_trailer_disagrees() {
let (dir, _, corrupt) = gnu_padded_tar_gz();
let zip = dir.join("out.zip");
let out = run_output(&["convert", corrupt.to_str().unwrap(), zip.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(5), "{}", stderr_text(&out));
assert!(!zip.exists(), "a corrupt source publishes nothing");
}
#[test]
fn every_read_verb_checks_the_gzip_trailer_under_a_padded_tar() {
let (dir, _, corrupt) = gnu_padded_tar_gz();
let c = corrupt.to_str().unwrap();
let x = dir.join("x");
for args in [
vec!["test", c],
vec!["list", c],
vec!["cat", c],
vec!["unpack", c, "-C", x.to_str().unwrap()],
] {
let out = run_output(&args);
assert_eq!(
out.status.code(),
Some(5),
"{args:?}: {}",
stderr_text(&out)
);
}
}
#[test]
fn a_clean_gnu_padded_tar_gz_converts_and_tests_clean() {
let (dir, clean, _) = gnu_padded_tar_gz();
let c = clean.to_str().unwrap();
let out = run_output(&["test", c]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let zip = dir.join("out.zip");
let out = run_output(&["convert", c, zip.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let out = run_output(&["test", zip.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
}
fn writable_containers() -> Vec<(String, String, stuffr::ContainerCaps)> {
let registry = stuffr::registry();
registry
.matrix()
.into_iter()
.filter(|row| row.write)
.filter_map(|row| {
let caps = registry.container(row.id)?.caps();
let ext = row.extensions.first()?;
Some((row.id.to_string(), ext.to_string(), caps))
})
.collect()
}
fn predicted_convert_warnings(source: &Path, dst: &stuffr::ContainerCaps) -> (usize, usize) {
let (metas, outcome) = stuffr::entries::list(
stuffr::ops::Input::Path(source.to_path_buf()),
stuffr::DEFAULT_MAX_RATIO,
None,
)
.unwrap();
assert!(
!outcome.fidelity.has_warnings(),
"the source itself reads with warnings, so the prediction would be wrong: {:?}",
outcome.fidelity.warnings
);
let skips = metas
.iter()
.filter(|m| match m.kind {
stuffr::EntryKind::Dir => !dst.stores_dirs,
stuffr::EntryKind::Symlink { .. } => !dst.stores_symlinks,
_ => false,
})
.count();
let ownership = metas
.iter()
.filter(|m| dst.stores_ownership && (m.uid.is_none() || m.gid.is_none()))
.count();
(skips, ownership)
}
#[test]
fn convert_every_container_into_every_other() {
let containers = writable_containers();
assert!(
containers.len() >= 4,
"tar, ar, cpio and zip are in every build: {containers:?}"
);
let dir = tmp_dir();
let d = dir.join("d");
std::fs::create_dir_all(d.join("sub")).unwrap();
let (one, two) = convert_payloads();
std::fs::write(d.join("one.txt"), &one).unwrap();
std::fs::write(d.join("sub/two.bin"), &two).unwrap();
for (src_id, src_ext, _) in &containers {
let s = dir.join(format!("s-{src_id}.{src_ext}"));
let out = run_output(&["pack", d.to_str().unwrap(), "-o", s.to_str().unwrap()]);
assert_eq!(
out.status.code(),
Some(0),
"pack {src_id}: {}",
stderr_text(&out)
);
for (dst_id, dst_ext, dst_caps) in &containers {
if dst_id == src_id {
continue;
}
let label = format!("{src_id}-to-{dst_id}");
let t = dir.join(format!("t-{label}.{dst_ext}"));
let out = run_output(&["convert", s.to_str().unwrap(), "-o", t.to_str().unwrap()]);
let err = stderr_text(&out);
assert_eq!(out.status.code(), Some(0), "{label}: {err}");
assert!(err.contains(&format!("-> {dst_id} (")), "{label}: {err}");
let (skips, ownership) = predicted_convert_warnings(&s, dst_caps);
let total = skips + ownership;
assert_eq!(
err.matches("skipped entry").count(),
skips,
"{label}: skip warnings: {err}"
);
assert_eq!(
err.matches("missing metadata: uid_gid").count(),
ownership,
"{label}: ownership warnings: {err}"
);
if total == 0 {
assert!(err.contains("no fidelity loss)"), "{label}: {err}");
assert!(!err.contains("fidelity warning"), "{label}: {err}");
} else {
assert!(
err.contains(&format!("{total} fidelity loss(es))")),
"{label}: {err}"
);
assert!(
err.contains(&format!("stuffr: {total} fidelity warning(s):")),
"{label}: {err}"
);
}
let back = dir.join(format!("back-{label}"));
let out = run_output(&["unpack", t.to_str().unwrap(), "-C", back.to_str().unwrap()]);
assert_eq!(
out.status.code(),
Some(0),
"unpack {label}: {}",
stderr_text(&out)
);
assert_eq!(
std::fs::read(back.join("d/one.txt")).unwrap(),
one,
"{label}"
);
assert_eq!(
std::fs::read(back.join("d/sub/two.bin")).unwrap(),
two,
"{label}"
);
}
}
}
#[test]
fn convert_refuses_a_directory_input_as_a_usage_error() {
let (dir, _) = convert_fixture();
let zip = dir.join("x.zip");
let out = run_output(&[
"convert",
dir.join("d").to_str().unwrap(),
zip.to_str().unwrap(),
]);
let err = stderr_text(&out);
assert_eq!(out.status.code(), Some(2), "{err}");
assert!(
err.contains("is a directory") && err.contains("stuffr pack"),
"{err}"
);
assert!(!zip.exists());
}
#[test]
fn convert_reports_missing_ownership_only_into_a_target_that_stores_one() {
let (dir, _) = convert_fixture();
let d = dir.join("d");
let lha = dir.join("s.lzh");
let out = run_output(&["pack", d.to_str().unwrap(), "-o", lha.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let zip = dir.join("t.zip");
let out = run_output(&[
"convert",
lha.to_str().unwrap(),
zip.to_str().unwrap(),
"--strict-fidelity",
]);
let err = stderr_text(&out);
assert_eq!(out.status.code(), Some(0), "{err}");
assert!(!err.contains("uid_gid"), "{err}");
assert!(err.contains("no fidelity loss)"), "{err}");
let tar = dir.join("t.tar");
let out = run_output(&["convert", zip.to_str().unwrap(), tar.to_str().unwrap()]);
let err = stderr_text(&out);
assert_eq!(out.status.code(), Some(0), "{err}");
assert!(err.contains("missing metadata: uid_gid"), "{err}");
}
#[test]
fn convert_reads_archives_the_reference_tools_wrote() {
let dir = tmp_dir();
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
let (one, two) = convert_payloads();
std::fs::write(tree.join("one.txt"), &one).unwrap();
std::fs::write(tree.join("two.bin"), &two).unwrap();
let names = [os(&"one.txt"), os(&"two.bin")];
let mut sources: Vec<(&str, PathBuf)> = Vec::new();
let zip = dir.join("ref.zip");
let mut args = vec![os(&"-q"), os(&zip)];
args.extend(names);
run_tool(&require_bin("zip"), &args, &tree, b"");
sources.push(("zip", zip));
for (label, bin) in [("tar", require_bin("tar")), ("gtar", require_gnu_tar())] {
let tar = dir.join(format!("ref-{label}.tar"));
let mut args = vec![os(&"-cf"), os(&tar)];
args.extend(names);
run_tool(&bin, &args, &tree, b"");
sources.push((label, tar));
}
let cpio = dir.join("ref.cpio");
let bytes = run_tool(
&require_bin("cpio"),
&[os(&"-o"), os(&"-H"), os(&"newc")],
&tree,
b"one.txt\ntwo.bin\n",
);
std::fs::write(&cpio, bytes).unwrap();
sources.push(("cpio", cpio));
let ar = dir.join("ref.a");
let mut args = vec![os(&"rcS"), os(&ar)];
args.extend(names);
run_tool(&require_bin("ar"), &args, &tree, b"");
sources.push(("ar", ar));
for (label, src) in sources {
let x = dir.join(format!("x-{label}.tar.xz"));
let out = run_output(&["convert", src.to_str().unwrap(), "-o", x.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{label}: {}", stderr_text(&out));
let back = dir.join(format!("back-{label}"));
let out = run_output(&["unpack", x.to_str().unwrap(), "-C", back.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{label}: {}", stderr_text(&out));
assert_eq!(std::fs::read(back.join("one.txt")).unwrap(), one, "{label}");
assert_eq!(std::fs::read(back.join("two.bin")).unwrap(), two, "{label}");
}
}
#[test]
fn convert_writes_a_zip_the_system_unzip_reads() {
let (dir, a) = convert_fixture();
let tar = dir.join("s.tar");
let out = run_output(&["convert", a.to_str().unwrap(), "-o", tar.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let zip = dir.join("t.zip");
let out = run_output(&[
"convert",
tar.to_str().unwrap(),
"-o",
zip.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let listing = run_tool(&require_bin("unzip"), &[os(&"-l"), os(&zip)], &dir, b"");
let listing = String::from_utf8_lossy(&listing);
assert!(listing.contains("d/one.txt"), "{listing}");
assert!(listing.contains("d/sub/two.bin"), "{listing}");
}
#[test]
fn convert_reads_a_descriptor_zip_from_stdin() {
let dir = tmp_dir();
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
let (one, two) = convert_payloads();
std::fs::write(tree.join("one.txt"), &one).unwrap();
std::fs::write(tree.join("two.bin"), &two).unwrap();
let zip = run_tool(
&require_bin("zip"),
&[os(&"-q"), os(&"-"), os(&"one.txt"), os(&"two.bin")],
&tree,
b"",
);
assert_eq!(&zip[..4], b"PK\x03\x04");
assert_ne!(
u16::from_le_bytes([zip[6], zip[7]]) & 0x08,
0,
"the system zip did not write data descriptors, so this proves nothing"
);
let out_tar = dir.join("out.tar");
let out = run_with_stdin_output(&["convert", "-", "-o", out_tar.to_str().unwrap()], &zip);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let back = dir.join("back");
let out = run_output(&[
"unpack",
out_tar.to_str().unwrap(),
"-C",
back.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_eq!(std::fs::read(back.join("one.txt")).unwrap(), one);
assert_eq!(std::fs::read(back.join("two.bin")).unwrap(), two);
}
#[cfg(unix)]
#[test]
fn convert_strict_fidelity_exits_4_and_keeps_the_output() {
let ar_caps = stuffr::registry()
.container(stuffr::FormatId::new("ar"))
.expect("ar is in every build")
.caps();
assert!(!ar_caps.stores_symlinks, "the premise: ar has no symlinks");
let dir = tmp_dir();
let d = dir.join("d");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("real.txt"), b"real").unwrap();
std::os::unix::fs::symlink("real.txt", d.join("link.txt")).unwrap();
let tar = dir.join("s.tar");
let out = run_output(&["pack", d.to_str().unwrap(), "-o", tar.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let a = dir.join("t.a");
let out = run_output(&[
"convert",
tar.to_str().unwrap(),
"-o",
a.to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(out.status.code(), Some(4), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains("d/link.txt"),
"the warning names the entry: {}",
stderr_text(&out)
);
assert!(a.exists(), "the output is written, as pack writes it");
let back = dir.join("back");
let out = run_output(&["unpack", a.to_str().unwrap(), "-C", back.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_eq!(std::fs::read(back.join("d/real.txt")).unwrap(), b"real");
}
#[test]
fn convert_strict_fidelity_passes_a_lossless_piped_source() {
let (dir, a) = convert_fixture();
let tar = dir.join("s.tar");
let out = run_output(&["convert", a.to_str().unwrap(), "-o", tar.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let zip = dir.join("t.zip");
let out = run_with_stdin_output(
&[
"convert",
"-",
"-o",
zip.to_str().unwrap(),
"--strict-fidelity",
],
&std::fs::read(&tar).unwrap(),
);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert!(
stderr_text(&out).contains(" bytes, no fidelity loss)"),
"{}",
stderr_text(&out)
);
assert_unpacks_to_the_fixture(&dir, &zip, "out");
}
#[test]
fn convert_into_lha_with_a_name_too_long_matches_pack() {
let dir = tmp_dir();
let d = dir.join("d");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join(format!("{}.txt", "n".repeat(240))),
b"too long for lha",
)
.unwrap();
let packed = dir.join("p.lzh");
let pack = run_output(&["pack", d.to_str().unwrap(), "-o", packed.to_str().unwrap()]);
assert_eq!(pack.status.code(), Some(3), "{}", stderr_text(&pack));
assert!(
stderr_text(&pack).contains("LHA cannot store"),
"{}",
stderr_text(&pack)
);
assert!(!packed.exists());
let tar = dir.join("s.tar");
let out = run_output(&["pack", d.to_str().unwrap(), "-o", tar.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let converted = dir.join("c.lzh");
let convert = run_output(&[
"convert",
tar.to_str().unwrap(),
"-o",
converted.to_str().unwrap(),
]);
assert_eq!(
convert.status.code(),
pack.status.code(),
"pack: {}\nconvert: {}",
stderr_text(&pack),
stderr_text(&convert)
);
assert!(
stderr_text(&convert).contains("LHA cannot store"),
"{}",
stderr_text(&convert)
);
assert!(!converted.exists(), "a failed convert leaves no OUT");
}
#[test]
fn convert_into_a_read_only_container_exits_3_and_writes_nothing() {
let registry = stuffr::registry();
let read_only: Vec<(String, String)> = registry
.matrix()
.into_iter()
.filter(|row| registry.container(row.id).is_some() && row.read && !row.write)
.map(|row| (row.id.to_string(), row.extensions[0].to_string()))
.collect();
assert!(
!read_only.is_empty(),
"the premise: arc and zoo are read-only"
);
let (dir, a) = convert_fixture();
let data = std::fs::read(&a).unwrap();
for (id, ext) in read_only {
let t = dir.join(format!("t.{ext}"));
let out = run_output(&["convert", a.to_str().unwrap(), "-o", t.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(3), "{id}: {}", stderr_text(&out));
assert!(!t.exists(), "{id}: no OUT");
let out = run_with_stdin_output(&["convert", "-", "-o", t.to_str().unwrap()], &data);
assert_eq!(
out.status.code(),
Some(3),
"{id} stdin: {}",
stderr_text(&out)
);
assert!(!t.exists(), "{id} stdin: no OUT");
}
}
fn bsd_ar_with_names(members: &[(&[u8], &[u8])]) -> Vec<u8> {
let mut out = b"!<arch>\n".to_vec();
for (name, data) in members {
let field = |s: String, w: usize| format!("{s:<w$}").into_bytes();
out.extend(field(format!("#1/{}", name.len()), 16));
out.extend(field("0".into(), 12));
out.extend(field("0".into(), 6));
out.extend(field("0".into(), 6));
out.extend(field("100644".into(), 8));
out.extend(field((name.len() + data.len()).to_string(), 10));
out.extend(b"`\n");
out.extend(*name);
out.extend(*data);
if out.len() % 2 == 1 {
out.push(b'\n');
}
}
out
}
#[test]
fn convert_skips_a_name_with_a_nul_its_target_cannot_store() {
let dir = tmp_dir();
let src = dir.join("in.a");
std::fs::write(
&src,
bsd_ar_with_names(&[(b"keep.txt", b"kept\n"), (b"a\0bc.txt", b"nul\n")]),
)
.unwrap();
let tar = dir.join("out.tar");
let out = run_output(&[
"convert",
src.to_str().unwrap(),
"-o",
tar.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let err = stderr_text(&out);
assert!(
err.contains("skipped entry `a\\0bc.txt`") && err.contains("NUL"),
"the warning names the entry and why: {err}"
);
let back = dir.join("back");
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
back.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_eq!(std::fs::read(back.join("keep.txt")).unwrap(), b"kept\n");
assert_eq!(
std::fs::read_dir(&back).unwrap().count(),
1,
"only the entry tar can hold"
);
let strict = dir.join("strict.tar");
let out = run_output(&[
"convert",
src.to_str().unwrap(),
"-o",
strict.to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(out.status.code(), Some(4), "{}", stderr_text(&out));
}
#[cfg(target_os = "macos")]
#[test]
fn bsd_ar_with_odd_names_lists_and_unpacks() {
fn header(identifier: &str, size: usize) -> Vec<u8> {
let h = format!(
"{identifier:<16}{:<12}{:<6}{:<6}{:<8}{size:<10}`\n",
0, 0, 0, "100644"
);
assert_eq!(h.len(), 60);
h.into_bytes()
}
let members: [(&str, bool, &[u8]); 3] = [
("abc", true, b"ABCD"),
("hello", true, b"HELLO"),
("plain.txt", false, b"xyz"),
];
let mut bytes = b"!<arch>\n".to_vec();
for (name, extended, data) in members {
let body = if extended {
[name.as_bytes(), data].concat()
} else {
data.to_vec()
};
let identifier = if extended {
format!("#1/{}", name.len())
} else {
name.to_string()
};
bytes.extend(header(&identifier, body.len()));
bytes.extend_from_slice(&body);
if body.len() % 2 == 1 {
bytes.push(b'\n');
}
}
let dir = tmp_dir();
let archive = dir.join("odd.a");
std::fs::write(&archive, &bytes).unwrap();
let names: Vec<&str> = members.iter().map(|(name, _, _)| *name).collect();
let ar = require_bin("/usr/bin/ar");
let out = Command::new(&ar).arg("t").arg(&archive).output().unwrap();
assert!(out.status.success(), "{}", stderr_text(&out));
assert_eq!(
String::from_utf8(out.stdout)
.unwrap()
.lines()
.collect::<Vec<_>>(),
names,
"the system `ar` reads the fixture"
);
let out = run_output(&["list", "--json", archive.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let listed: Vec<&str> = v
.as_array()
.expect("a JSON array of entries")
.iter()
.map(|row| row["name"].as_str().unwrap())
.collect();
assert_eq!(listed, names);
let back = dir.join("back");
let out = run_output(&[
"unpack",
archive.to_str().unwrap(),
"-C",
back.to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
for (name, _, data) in members {
assert_eq!(std::fs::read(back.join(name)).unwrap(), data, "{name}");
}
}
#[test]
fn llvm_ar_with_a_sym64_symbol_table_lists() {
let llvm_ar = which("llvm-ar").or_else(|| {
let p = PathBuf::from("/opt/homebrew/opt/llvm/bin/llvm-ar");
p.is_file().then_some(p)
});
let (Some(llvm_ar), Some(cc)) = (llvm_ar, which("cc")) else {
eprintln!("skipped: needs llvm-ar and cc");
return;
};
let dir = tmp_dir();
std::fs::write(dir.join("obj.c"), "int f(void) { return 1; }\n").unwrap();
let st = Command::new(cc)
.current_dir(&dir)
.args(["-c", "obj.c", "-o", "obj.o"])
.status()
.unwrap();
assert!(st.success());
let st = Command::new(llvm_ar)
.current_dir(&dir)
.env("SYM64_THRESHOLD", "0")
.args(["rcs", "--format=gnu", "out.a", "obj.o"])
.status()
.unwrap();
assert!(st.success());
let bytes = std::fs::read(dir.join("out.a")).unwrap();
if !bytes[8..].starts_with(b"/SYM64/") {
eprintln!("skipped: this llvm-ar did not write /SYM64/");
let _ = std::fs::remove_dir_all(&dir);
return;
}
let out = run_output(&["list", dir.join("out.a").to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert!(String::from_utf8_lossy(&out.stdout).contains("obj.o"));
}
#[test]
fn a_hostile_name_in_a_corrupt_archive_error_is_escaped() {
let dir = tmp_dir();
let src = dir.join("in.a");
let mut bytes = bsd_ar_with_names(&[(b"a\x1b[31mbc", b"0123456789")]);
bytes.truncate(bytes.len() - 6);
std::fs::write(&src, bytes).unwrap();
let out = run_output(&["test", src.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(5), "{}", stderr_text(&out));
assert!(!out.stderr.contains(&0x1b), "raw ESC on stderr");
let err = stderr_text(&out);
assert!(err.contains("a\\x1b[31mbc"), "{err}");
}
#[test]
fn control_bytes_in_entry_names_never_reach_the_terminal_raw() {
let dir = tmp_dir();
let tar = dir.join("x.tar");
let evil = "a\x1b[31mb\nc";
write_raw_tar(&tar, &[Raw::File(evil, b"hi"), Raw::Fifo("p\x1b[2Jipe")]);
let t = tar.to_str().unwrap();
let out = run_output(&["list", t]);
assert!(out.status.success());
assert!(!out.stdout.contains(&0x1b), "raw ESC in list stdout");
let text = String::from_utf8(out.stdout).unwrap();
assert!(text.contains("a\\x1b[31mb\\nc"), "{text}");
assert_eq!(text.lines().count(), 2, "{text}");
let dest = dir.join("out");
let out = run_output(&["unpack", t, "-C", dest.to_str().unwrap()]);
assert!(!out.stderr.contains(&0x1b), "raw ESC on unpack stderr");
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(err.contains("p\\x1b[2Jipe"), "{err}");
let out = run_output(&["salvage", t, "--list"]);
assert!(!out.stdout.contains(&0x1b), "raw ESC in salvage stdout");
assert!(!out.stderr.contains(&0x1b), "raw ESC in salvage stderr");
let text = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(text.contains("a\\x1b[31mb\\nc"), "{text}");
let out = run_output(&["list", "--json", t]);
let rows: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(rows[0]["name"].as_str().unwrap(), evil);
}
fn require_gnu_cpio() -> PathBuf {
if cfg!(target_os = "macos") {
let gnu = PathBuf::from("/opt/homebrew/opt/cpio/bin/cpio");
assert!(
gnu.is_file(),
"GNU cpio not at {} (`brew install cpio`)",
gnu.display()
);
return gnu;
}
let cpio = require_bin("cpio");
let out = Command::new(&cpio).arg("--version").output().unwrap();
assert!(
String::from_utf8_lossy(&out.stdout).contains("GNU cpio"),
"the `cpio` on PATH is not GNU cpio"
);
cpio
}
#[cfg(unix)]
fn hard_link_tree(dir: &Path) -> PathBuf {
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("a"), b"alpha payload").unwrap();
std::fs::hard_link(tree.join("a"), tree.join("a1")).unwrap();
std::fs::hard_link(tree.join("a"), tree.join("a2")).unwrap();
std::fs::write(tree.join("e"), b"").unwrap();
std::fs::hard_link(tree.join("e"), tree.join("e2")).unwrap();
std::fs::write(tree.join("p"), b"plain").unwrap();
tree
}
#[cfg(unix)]
fn assert_links_recreated(out: &std::process::Output, dest: &Path) {
use std::os::unix::fs::MetadataExt;
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "stderr: {stderr}");
assert!(
!stderr.contains("skipped") && !stderr.contains("warning"),
"a recreated link is no loss: {stderr}"
);
let md = |n: &str| std::fs::symlink_metadata(dest.join(n)).unwrap();
let a = md("a");
assert_eq!(a.nlink(), 3, "`a` and its two links");
for n in ["a1", "a2"] {
assert_eq!(md(n).ino(), a.ino(), "{n} shares `a`'s inode");
assert_eq!(std::fs::read(dest.join(n)).unwrap(), b"alpha payload");
}
assert_eq!(std::fs::read(dest.join("a")).unwrap(), b"alpha payload");
let e = md("e");
assert_eq!(e.nlink(), 2);
assert_eq!(md("e2").ino(), e.ino());
assert_eq!(e.len(), 0);
assert_eq!(md("p").nlink(), 1);
assert_eq!(std::fs::read(dest.join("p")).unwrap(), b"plain");
}
#[cfg(unix)]
#[test]
fn unpack_recreates_gnu_tar_hard_links() {
let dir = tmp_dir();
let tree = hard_link_tree(&dir);
let tar = dir.join("x.tar");
run_tool(
&require_gnu_tar(),
&[os(&"-cf"), os(&tar), os(&"-C"), os(&tree), os(&".")],
&dir,
b"",
);
let dest = dir.join("out");
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--strict-fidelity",
]);
assert_links_recreated(&out, &dest);
}
#[cfg(unix)]
#[test]
fn unpack_recreates_gnu_cpio_hard_links() {
let dir = tmp_dir();
let tree = hard_link_tree(&dir);
let bytes = run_tool(
&require_gnu_cpio(),
&[os(&"-o"), os(&"-H"), os(&"newc")],
&tree,
b".\n./a\n./a1\n./a2\n./e\n./e2\n./p\n",
);
let cpio = dir.join("x.cpio");
std::fs::write(&cpio, bytes).unwrap();
let dest = dir.join("out");
let out = run_output(&[
"unpack",
cpio.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--strict-fidelity",
]);
assert_links_recreated(&out, &dest);
}
#[cfg(unix)]
#[test]
fn unpack_recreates_hard_links_from_a_piped_tar() {
let dir = tmp_dir();
let tree = hard_link_tree(&dir);
let tar = dir.join("x.tar");
run_tool(
&require_gnu_tar(),
&[os(&"-cf"), os(&tar), os(&"-C"), os(&tree), os(&".")],
&dir,
b"",
);
let dest = dir.join("out");
let out = run_with_stdin_output(
&[
"unpack",
"-",
"-C",
dest.to_str().unwrap(),
"--strict-fidelity",
],
&std::fs::read(&tar).unwrap(),
);
assert_links_recreated(&out, &dest);
}
#[cfg(unix)]
#[test]
fn unpack_skips_a_link_whose_target_was_not_selected() {
let dir = tmp_dir();
let tree = hard_link_tree(&dir);
let tar = dir.join("x.tar");
run_tool(
&require_gnu_tar(),
&[
os(&"-cf"),
os(&tar),
os(&"-C"),
os(&tree),
os(&"a"),
os(&"a1"),
],
&dir,
b"",
);
let dest = dir.join("out");
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
"--index",
"1",
"--strict-fidelity",
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "stderr: {stderr}");
assert!(
stderr.contains("skipped entry `a1`: its hard-link target `a` was not extracted"),
"{stderr}"
);
assert!(std::fs::symlink_metadata(dest.join("a1")).is_err());
}
#[cfg(unix)]
fn gnu_tar_of_hard_link_tree(dir: &Path) -> PathBuf {
let tree = hard_link_tree(dir);
let tar = dir.join("x.tar");
run_tool(
&require_gnu_tar(),
&[os(&"-cf"), os(&tar), os(&"-C"), os(&tree), os(&".")],
dir,
b"",
);
tar
}
#[cfg(unix)]
#[test]
fn a_gnu_tar_naming_a_file_twice_lists_and_unpacks() {
let dir = tmp_dir();
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a"), b"shared payload").unwrap();
std::fs::hard_link(src.join("a"), src.join("b")).unwrap();
let tar = dir.join("x.tar");
run_tool(
&require_gnu_tar(),
&[os(&"-cf"), os(&tar), os(&"a"), os(&"a"), os(&"b")],
&src,
b"",
);
let listing = run_tool(&require_gnu_tar(), &[os(&"-tvf"), os(&tar)], &dir, b"");
let listing = String::from_utf8_lossy(&listing);
assert!(listing.contains("a link to a"), "the shape: {listing}");
let p = tar.to_str().unwrap();
let out = run_output(&["list", p]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.lines().count(), 2, "a and b, once each: {stdout}");
let out = run_output(&["test", p]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let dest = dir.join("out");
let out = run_output(&[
"unpack",
p,
"-C",
dest.to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_eq!(std::fs::read(dest.join("a")).unwrap(), b"shared payload");
assert_eq!(std::fs::read(dest.join("b")).unwrap(), b"shared payload");
}
#[cfg(unix)]
#[test]
fn convert_gnu_tar_with_hard_links_to_zip_copies_them() {
let dir = tmp_dir();
let tar = gnu_tar_of_hard_link_tree(&dir);
let zip = dir.join("out.zip");
let out = run_output(&[
"convert",
tar.to_str().unwrap(),
zip.to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let unzip = require_bin("unzip");
for (name, want) in [
("./a", &b"alpha payload"[..]),
("./a1", b"alpha payload"),
("./a2", b"alpha payload"),
("./e", b""),
("./e2", b""),
("./p", b"plain"),
] {
let got = run_tool(&unzip, &[os(&"-p"), os(&zip), os(&name)], &dir, b"");
assert_eq!(got, want, "{name}");
}
}
#[cfg(unix)]
#[test]
fn convert_gnu_cpio_with_hard_links_to_tar_keeps_links() {
let dir = tmp_dir();
let tree = hard_link_tree(&dir);
let bytes = run_tool(
&require_gnu_cpio(),
&[os(&"-o"), os(&"-H"), os(&"newc")],
&tree,
b"./a\n./a1\n./a2\n./e\n./e2\n./p\n",
);
let cpio = dir.join("x.cpio");
std::fs::write(&cpio, bytes).unwrap();
let tar = dir.join("t.tar");
let out = run_output(&[
"convert",
cpio.to_str().unwrap(),
tar.to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let listing = run_tool(&require_gnu_tar(), &[os(&"-tvf"), os(&tar)], &dir, b"");
let listing = String::from_utf8_lossy(&listing);
assert_eq!(listing.matches(" link to ").count(), 3, "{listing}");
}
#[cfg(unix)]
#[test]
fn convert_strict_fidelity_exits_4_when_a_link_cannot_be_copied() {
let dir = tmp_dir();
let mut tar = tar::Builder::new(Vec::new());
let big = vec![0x5au8; 64 * 1024 * 1024 + 512];
let mut h = tar::Header::new_gnu();
h.set_size(big.len() as u64);
h.set_mode(0o644);
h.set_mtime(1_000_000_000);
h.set_entry_type(tar::EntryType::Regular);
tar.append_data(&mut h, "big", &big[..]).unwrap();
let mut l = tar::Header::new_gnu();
l.set_size(0);
l.set_mode(0o644);
l.set_mtime(1_000_000_000);
l.set_entry_type(tar::EntryType::Link);
tar.append_link(&mut l, "link", "big").unwrap();
let bytes = tar.into_inner().unwrap();
drop(big);
let out_path = dir.join("out.cpio");
let out = run_with_stdin_output(
&[
"convert",
"-",
out_path.to_str().unwrap(),
"--strict-fidelity",
],
&bytes,
);
let stderr = stderr_text(&out);
assert_eq!(out.status.code(), Some(4), "{stderr}");
assert!(
stderr
.contains("skipped entry `link`: its hard-link target `big` was not kept for copying"),
"{stderr}"
);
}
#[cfg(unix)]
#[test]
fn cat_of_a_link_writes_its_targets_bytes() {
let dir = tmp_dir();
let tar = gnu_tar_of_hard_link_tree(&dir);
let out = run_output(&["cat", tar.to_str().unwrap(), "./a", "./a1", "./a2"]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
assert_eq!(out.stdout, b"alpha payload".repeat(3));
}
#[cfg(unix)]
#[test]
fn cat_of_a_link_without_its_target_is_classified_not_1() {
let dir = tmp_dir();
let tree = hard_link_tree(&dir);
let tar = dir.join("x.tar");
run_tool(
&require_gnu_tar(),
&[
os(&"-cf"),
os(&tar),
os(&"-C"),
os(&tree),
os(&"a"),
os(&"a1"),
],
&dir,
b"",
);
let out = run_output(&["cat", tar.to_str().unwrap(), "a1"]);
let stderr = stderr_text(&out);
assert_eq!(out.status.code(), Some(2), "{stderr}");
assert!(
stderr.contains("`a1` is a hard link to `a`, so its bytes are `a`'s; select `a` too"),
"{stderr}"
);
assert!(
!stderr.contains("not found"),
"`a` IS in the archive: {stderr}"
);
assert!(out.stdout.is_empty());
}
#[cfg(unix)]
#[test]
fn convert_gnu_cpio_with_hard_links_to_zip_copies_them_strictly() {
let dir = tmp_dir();
let tree = hard_link_tree(&dir);
let bytes = run_tool(
&require_gnu_cpio(),
&[os(&"-o"), os(&"-H"), os(&"newc")],
&tree,
b"./a\n./a1\n./a2\n./e\n./e2\n./p\n",
);
let cpio = dir.join("x.cpio");
std::fs::write(&cpio, bytes).unwrap();
let zip = dir.join("out.zip");
let out = run_output(&[
"convert",
cpio.to_str().unwrap(),
zip.to_str().unwrap(),
"--strict-fidelity",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr_text(&out));
let unzip = require_bin("unzip");
for (name, want) in [
("a", &b"alpha payload"[..]),
("a1", b"alpha payload"),
("a2", b"alpha payload"),
("e", b""),
("e2", b""),
("p", b"plain"),
] {
let got = run_tool(&unzip, &[os(&"-p"), os(&zip), os(&name)], &dir, b"");
assert_eq!(got, want, "{name}");
}
}
#[test]
fn unpack_skips_entries_an_archive_contradicts() {
let dir = tmp_dir();
let tar = dir.join("c.tar");
{
let mut builder = Builder::new(std::fs::File::create(&tar).unwrap());
let mut d = Header::new_gnu();
d.set_entry_type(tar::EntryType::Directory);
d.set_size(0);
d.set_mode(0o755);
builder.append_data(&mut d, "x", std::io::empty()).unwrap();
append_tar_entry(&mut builder, "x", b"file over dir");
append_tar_entry(&mut builder, "a", b"alpha");
append_tar_entry(&mut builder, "a/b", b"beneath a file");
append_tar_entry(&mut builder, "ok.txt", b"fine");
builder.finish().unwrap();
}
let expect = [
"skipped entry `x`: a directory `x` from earlier in this archive is in the way",
"skipped entry `a/b`: `a`, earlier in this archive, is not a directory, so `a/b` \
cannot be placed beneath it",
];
for (strict, code) in [(false, 0), (true, 4)] {
let dest = dir.join(format!("out-{strict}"));
let mut args = vec![
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
];
if strict {
args.push("--strict-fidelity");
}
let out = run_output(&args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(code), "strict={strict}: {stderr}");
for line in expect {
assert!(stderr.contains(line), "strict={strict}: {stderr}");
}
assert_eq!(std::fs::read(dest.join("ok.txt")).unwrap(), b"fine");
assert_eq!(std::fs::read(dest.join("a")).unwrap(), b"alpha");
assert!(std::fs::symlink_metadata(dest.join("x")).unwrap().is_dir());
}
}
#[test]
fn unpack_skips_a_name_too_long_for_the_filesystem() {
let dir = tmp_dir();
let tar = dir.join("long.tar");
let long = format!("{}.txt", "L".repeat(300));
{
let mut builder = Builder::new(std::fs::File::create(&tar).unwrap());
append_tar_entry(&mut builder, "a.txt", b"alpha");
append_tar_entry(&mut builder, &long, b"long");
append_tar_entry(&mut builder, "z.txt", b"zulu");
builder.finish().unwrap();
}
let expect = format!("skipped entry `{long}`: its path is too long for this filesystem");
for (strict, code) in [(false, 0), (true, 4)] {
let dest = dir.join(format!("out-{strict}"));
let mut args = vec![
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
];
if strict {
args.push("--strict-fidelity");
}
let out = run_output(&args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(code), "strict={strict}: {stderr}");
assert!(stderr.contains(&expect), "strict={strict}: {stderr}");
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"alpha");
assert_eq!(std::fs::read(dest.join("z.txt")).unwrap(), b"zulu");
}
}
fn unpack_folded(entries: &[(&str, &[u8])], fold_a: &str, fold_b: &str) -> Option<String> {
let dir = tmp_dir();
std::fs::write(dir.join(fold_a), b"probe").unwrap();
let folds = std::fs::symlink_metadata(dir.join(fold_b)).is_ok();
let _ = std::fs::remove_dir_all(&dir);
if !folds {
return None;
}
let dir = tmp_dir();
let tar = dir.join("f.tar");
{
let mut builder = Builder::new(std::fs::File::create(&tar).unwrap());
for (name, data) in entries {
append_tar_entry(&mut builder, name, data);
}
builder.finish().unwrap();
}
let dest = dir.join("out");
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(out.status.code(), Some(0), "{stderr}");
let _ = std::fs::remove_dir_all(&dir);
Some(stderr)
}
#[test]
fn skip_reason_names_the_case_folded_ancestor_as_archived() {
let Some(stderr) = unpack_folded(&[("A", b"alpha"), ("a/b", b"beneath")], "A", "a") else {
return;
};
assert!(
stderr.contains(
"skipped entry `a/b`: `A`, earlier in this archive, is not a directory, so `a/b` \
cannot be placed beneath it"
),
"{stderr}"
);
}
#[test]
fn skip_reason_names_the_normalisation_folded_ancestor_as_archived() {
let nfc = "\u{e9}";
let nfd = "e\u{301}";
let Some(stderr) = unpack_folded(
&[(nfc, b"alpha"), (&format!("{nfd}/x"), b"beneath")],
nfc,
nfd,
) else {
return;
};
assert!(
stderr.contains(&format!(
"`{nfc}`, earlier in this archive, is not a directory"
)),
"{stderr}"
);
}
#[test]
fn skip_reason_names_the_folded_directory_as_archived() {
let dir = tmp_dir();
let tar = dir.join("d.tar");
{
let mut builder = Builder::new(std::fs::File::create(&tar).unwrap());
let mut d = Header::new_gnu();
d.set_entry_type(tar::EntryType::Directory);
d.set_size(0);
d.set_mode(0o755);
builder.append_data(&mut d, "A", std::io::empty()).unwrap();
append_tar_entry(&mut builder, "a", b"file");
builder.finish().unwrap();
}
std::fs::write(dir.join("A.probe"), b"p").unwrap();
if std::fs::symlink_metadata(dir.join("a.probe")).is_err() {
let _ = std::fs::remove_dir_all(&dir);
return;
}
let dest = dir.join("out");
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{stderr}");
assert!(
stderr.contains("a directory `A` from earlier in this archive is in the way"),
"{stderr}"
);
}
#[test]
fn skip_reason_for_a_prior_destination_file_is_unchanged() {
let dir = tmp_dir();
let tar = dir.join("p.tar");
{
let mut builder = Builder::new(std::fs::File::create(&tar).unwrap());
append_tar_entry(&mut builder, "a/b", b"beneath");
builder.finish().unwrap();
}
let dest = dir.join("out");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("a"), b"prior").unwrap();
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(out.status.code(), Some(2), "{stderr}");
assert!(
stderr.contains(&format!(
"usage error: `{}` exists and is not a directory, so `a/b` cannot be placed beneath it",
dest.join("a").display()
)),
"{stderr}"
);
assert!(!stderr.contains("earlier in this archive"), "{stderr}");
assert_eq!(std::fs::read(dest.join("a")).unwrap(), b"prior");
}
#[cfg(unix)]
#[test]
fn skip_reason_never_names_a_hard_link_as_the_ancestor() {
let dir = tmp_dir();
let tar = dir.join("h.tar");
{
let mut builder = Builder::new(std::fs::File::create(&tar).unwrap());
append_tar_entry(&mut builder, "a", b"alpha");
let mut h = Header::new_gnu();
h.set_entry_type(tar::EntryType::Link);
h.set_size(0);
h.set_mode(0o644);
builder.append_link(&mut h, "B", "a").unwrap();
append_tar_entry(&mut builder, "a/x", b"beneath");
builder.finish().unwrap();
}
let dest = dir.join("out");
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{stderr}");
assert!(
stderr.contains("skipped entry `a/x`: `a`, earlier in this archive, is not a directory"),
"{stderr}"
);
}
#[test]
fn skip_reason_keeps_the_exact_spelling_when_a_folded_directory_was_rerecorded() {
let dir = tmp_dir();
std::fs::write(dir.join("A.probe"), b"p").unwrap();
if std::fs::symlink_metadata(dir.join("a.probe")).is_err() {
let _ = std::fs::remove_dir_all(&dir);
return;
}
let tar = dir.join("r.tar");
{
let mut builder = Builder::new(std::fs::File::create(&tar).unwrap());
for n in ["A", "a"] {
let mut d = Header::new_gnu();
d.set_entry_type(tar::EntryType::Directory);
d.set_size(0);
d.set_mode(0o755);
builder.append_data(&mut d, n, std::io::empty()).unwrap();
}
append_tar_entry(&mut builder, "A", b"file");
builder.finish().unwrap();
}
let dest = dir.join("out");
let out = run_output(&[
"unpack",
tar.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{stderr}");
assert!(
stderr.contains("skipped entry `A`: a directory `A` from earlier in this archive"),
"{stderr}"
);
}