use crate::common::*;
use std::path::Path;
use std::process::Command;
#[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)]
#[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"
);
}
}