use crate::common::*;
#[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}"
);
}
#[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}"
);
}
#[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"
);
}
#[cfg(unix)]
#[test]
fn salvage_never_replaces_a_file_the_destination_already_held() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir();
let archive = dir.join("hostile.zip");
std::fs::write(
&archive,
stored_zip_of(&[
("a.txt", b"attacker a\n"),
(".ssh/authorized_keys", b"ssh-ed25519 ATTACKER\n"),
("fresh.txt", b"nothing was here\n"),
]),
)
.unwrap();
let dest = dir.join("home");
std::fs::create_dir_all(dest.join(".ssh")).unwrap();
std::fs::write(dest.join("a.txt"), b"mine\n").unwrap();
let keys = dest.join(".ssh/authorized_keys");
std::fs::write(&keys, b"ssh-ed25519 MINE\n").unwrap();
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o600)).unwrap();
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "stderr: {stderr}");
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"mine\n");
assert_eq!(std::fs::read(&keys).unwrap(), b"ssh-ed25519 MINE\n");
assert_eq!(
std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777,
0o600,
"the pre-existing file's mode must not change"
);
assert_eq!(
std::fs::read(dest.join("a.txt.salvaged-0")).unwrap(),
b"attacker a\n"
);
assert_eq!(
std::fs::read(dest.join(".ssh/authorized_keys.salvaged-1")).unwrap(),
b"ssh-ed25519 ATTACKER\n"
);
assert_eq!(
std::fs::read(dest.join("fresh.txt")).unwrap(),
b"nothing was here\n",
"an entry whose name was free still lands under it"
);
assert!(
stderr.contains("2 renamed"),
"the summary must count both renames: {stderr}"
);
assert!(
stderr.contains("a.txt.salvaged-0") && stderr.contains("authorized_keys.salvaged-1"),
"stderr must name where each recovered copy went: {stderr}"
);
}
#[test]
fn salvage_never_replaces_the_archive_it_is_reading() {
let dir = tmp_dir();
let work = dir.join("w");
std::fs::create_dir_all(&work).unwrap();
let archive = work.join("damaged.zip");
const LATER: &[u8] = &[b'x'; 1100];
let bytes = stored_zip_of(&[("damaged.zip", b"gone"), ("later.txt", LATER)]);
std::fs::write(&archive, &bytes).unwrap();
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
work.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(4), "stderr: {stderr}");
assert_eq!(
std::fs::read(&archive).unwrap(),
bytes,
"the archive must survive"
);
assert_eq!(
std::fs::read(work.join("damaged.zip.salvaged-0")).unwrap(),
b"gone"
);
assert_eq!(std::fs::read(work.join("later.txt")).unwrap(), LATER);
}
#[test]
fn salvage_output_refuses_to_name_the_archive_it_is_reading() {
let dir = tmp_dir();
let archive = dir.join("a.zip");
let bytes = stored_zip_of(&[("x.txt", b"recovered")]);
std::fs::write(&archive, &bytes).unwrap();
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"--index",
"0",
"-o",
archive.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(2),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(&archive).unwrap(), bytes);
}
#[cfg(unix)]
#[test]
fn salvage_leaves_a_symlink_at_its_target_alone() {
let dir = tmp_dir();
let archive = dir.join("links.zip");
std::fs::write(
&archive,
stored_zip_of(&[
("live", b"recovered live"),
("dangling", b"recovered dangling"),
]),
)
.unwrap();
let dest = dir.join("out");
std::fs::create_dir_all(&dest).unwrap();
let pointee = dir.join("pointee.txt");
std::fs::write(&pointee, b"untouched").unwrap();
let missing = dir.join("missing.txt");
std::os::unix::fs::symlink(&pointee, dest.join("live")).unwrap();
std::os::unix::fs::symlink(&missing, dest.join("dangling")).unwrap();
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read_link(dest.join("live")).unwrap(), pointee);
assert_eq!(std::fs::read_link(dest.join("dangling")).unwrap(), missing);
assert_eq!(std::fs::read(&pointee).unwrap(), b"untouched");
assert!(
!missing.exists(),
"nothing may be created through a dangling link"
);
assert_eq!(
std::fs::read(dest.join("live.salvaged-0")).unwrap(),
b"recovered live"
);
assert_eq!(
std::fs::read(dest.join("dangling.salvaged-1")).unwrap(),
b"recovered dangling"
);
}
#[cfg(unix)]
#[test]
fn salvage_never_replaces_the_archive_through_a_link_at_the_target() {
let dir = tmp_dir();
let archive = dir.join("damaged.zip");
let bytes = stored_zip_of(&[("soft.zip", b"one"), ("hard.zip", b"two")]);
std::fs::write(&archive, &bytes).unwrap();
let dest = dir.join("out");
std::fs::create_dir_all(&dest).unwrap();
std::os::unix::fs::symlink(&archive, dest.join("soft.zip")).unwrap();
std::fs::hard_link(&archive, dest.join("hard.zip")).unwrap();
let out = run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
]);
assert_eq!(
out.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(&archive).unwrap(), bytes);
assert_eq!(std::fs::read_link(dest.join("soft.zip")).unwrap(), archive);
assert_eq!(std::fs::read(dest.join("hard.zip")).unwrap(), bytes);
assert_eq!(
std::fs::read(dest.join("soft.zip.salvaged-0")).unwrap(),
b"one"
);
assert_eq!(
std::fs::read(dest.join("hard.zip.salvaged-1")).unwrap(),
b"two"
);
}
#[test]
fn a_salvage_rerun_writes_beside_the_previous_output() {
let dir = tmp_dir();
let archive = dir.join("a.zip");
std::fs::write(&archive, stored_zip_of(&[("x.txt", b"recovered")])).unwrap();
let dest = dir.join("out");
let run = || {
run_output(&[
"salvage",
archive.to_str().unwrap(),
"-C",
dest.to_str().unwrap(),
])
};
assert_eq!(run().status.code(), Some(0));
std::fs::write(dest.join("x.txt"), b"edited since").unwrap();
let second = run();
assert_eq!(
second.status.code(),
Some(4),
"stderr: {}",
String::from_utf8_lossy(&second.stderr)
);
assert_eq!(std::fs::read(dest.join("x.txt")).unwrap(), b"edited since");
assert_eq!(
std::fs::read(dest.join("x.txt.salvaged-0")).unwrap(),
b"recovered"
);
}
#[cfg(unix)]
#[test]
fn salvage_refuses_a_fifo_or_a_character_device_as_input() {
let dir = tmp_dir();
let fifo = dir.join("pipe.zip");
let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0);
let out_dir = dir.join("out");
for input in [fifo.to_str().unwrap(), "/dev/null"] {
let out = run_output(&["salvage", input, "-C", out_dir.to_str().unwrap()]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{input}: {stderr}");
assert!(stderr.contains("needs a real seekable file"), "{stderr}");
assert!(!out_dir.exists(), "nothing may be created for {input}");
}
}