Files
photocraft/crates/format/tests/corruption.rs
T
Tanish MankuandClaude Opus 5.5 b6f5df3b3f pcraft directory saves: re-verify a rolling share of cached objects; stronger file signature (#821)
Follow-up to #752. After a directory save verifies an object, later saves trust it while its
file signature is unchanged. A file damaged in place with the same length and modification
time (bit rot, or a tool that restores timestamps) was trusted until the writer was recreated.

- The signature also carries inode, device and change time on Unix, which an in-place rewrite
  changes even when the modification time is restored.
- Each directory save re-reads and re-checks cached objects, oldest-checked first, up to
  DEFAULT_REVERIFY_BUDGET (16 MiB of compressed data; at least one object), so over enough
  saves every object is read again and a damaged one is rewritten. Repeat saves of small
  documents stay a full check; large ones pay a bounded cost per save.
  PcraftWriter::set_reverify_budget tunes it (0 = trust signatures, u64::MAX = check all).
- PcraftWriter's doc comment states what the cache trusts.
- Tests: a same-size, same-mtime tamper is repaired on the next save (fails with the budget
  at 0 on Windows, where there is no change time), and with a 1-byte budget the rolling
  re-check reaches the damaged object within one pass over the objects.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:20:36 -07:00

332 lines
13 KiB
Rust

//! Damaged or hostile bundles must fail cleanly, never panic.
mod common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_format::*;
use proptest::prelude::*;
use std::io::Read;
fn sample() -> Vec<u8> {
save_to_bytes(&rich_doc(ColorMode::Rgb, SampleType::U8), &SaveOptions::default()).unwrap()
}
fn rebuild(bytes: &[u8], f: impl Fn(&str, Vec<u8>) -> Option<Vec<u8>>) -> Vec<u8> {
let r = zip::ZipReader::new(bytes).unwrap();
let mut z = zip::ZipWriter::new();
for e in &r.entries {
if let Some(d) = f(&e.name, r.read(e, usize::MAX).unwrap()) {
z.add(&e.name, &d).unwrap();
}
}
z.finish().unwrap()
}
#[test]
fn truncation_never_panics_and_fails() {
let b = sample();
let step = (b.len() / 200).max(1);
for cut in (0..b.len()).step_by(step) {
assert!(load_from_bytes(&b[..cut]).is_err(), "cut at {cut}");
}
}
#[test]
fn byte_flips_detected() {
let b = sample();
let mut rng = common::Rng::new(9);
let mut failures = 0;
for _ in 0..300 {
let mut c = b.clone();
let i = (rng.next() as usize) % c.len();
c[i] ^= 0x20;
if load_from_bytes(&c).is_err() {
failures += 1;
}
}
// Some flips hit unused header fields; most must be caught (CRC + hashes).
assert!(failures > 200, "{failures}");
}
#[test]
fn missing_tile_is_error() {
let b = rebuild(&sample(), |n, d| (!n.starts_with("tiles/")).then_some(d));
assert!(matches!(load_from_bytes(&b), Err(FormatError::Corrupt(_))));
}
#[test]
fn missing_manifest_is_error() {
let b = rebuild(&sample(), |n, d| (n != "manifest.json").then_some(d));
assert!(load_from_bytes(&b).is_err());
}
#[test]
fn swapped_tile_content_fails_hash_check() {
let b = sample();
let r = zip::ZipReader::new(&b).unwrap();
let tiles: Vec<_> = r.entries.iter().filter(|e| e.name.starts_with("tiles/")).map(|e| e.name.clone()).collect();
let other = r.read_by_name(&tiles[1], usize::MAX).unwrap();
let first = tiles[0].clone();
let b2 = rebuild(&b, |n, d| Some(if n == first { other.clone() } else { d }));
let e = load_from_bytes(&b2).unwrap_err();
assert!(e.to_string().contains("hash"), "{e}");
}
#[test]
fn bad_manifest_json() {
let b = rebuild(&sample(), |n, d| Some(if n == "manifest.json" { b"{not json".to_vec() } else { d }));
assert!(matches!(load_from_bytes(&b), Err(FormatError::Json(_))));
}
/// Hostile nesting fails with an error instead of overflowing the stack; nesting as deep as the
/// deepest groups a bundle may hold (three JSON levels each) parses, and is then rejected for
/// lacking a format version.
#[test]
fn over_deep_manifest_fails_cleanly() {
let nested = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n)).into_bytes();
let with_manifest = |m: Vec<u8>| rebuild(&sample(), |n, d| Some(if n == "manifest.json" { m.clone() } else { d }));
let e = load_from_bytes(&with_manifest(nested(100_000))).unwrap_err();
assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}");
let e = load_from_bytes(&with_manifest(nested(3 * MAX_GROUP_DEPTH))).unwrap_err();
assert!(matches!(e, FormatError::Corrupt(_)), "{e}");
// Brackets inside strings (escaped quotes included) don't count.
let e = load_from_bytes(&with_manifest(format!(r#"{{"x":"{}\"{}"}}"#, "[".repeat(100_000), "{".repeat(10)).into_bytes())).unwrap_err();
assert!(matches!(e, FormatError::Corrupt(_)), "{e}");
}
#[test]
fn too_new_version_rejected() {
let b = rebuild(&sample(), |n, d| {
Some(if n == "manifest.json" {
let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap();
v["format_version"] = 999.into();
serde_json::to_vec(&v).unwrap()
} else {
d
})
});
assert!(matches!(load_from_bytes(&b), Err(FormatError::TooNew { found: 999, .. })));
}
#[test]
fn invalid_hash_strings_rejected() {
let b = rebuild(&sample(), |n, d| {
Some(if n == "manifest.json" {
let s = String::from_utf8(d).unwrap();
let i = s.find("\"hash\": \"").unwrap() + 9;
let mut s = s.into_bytes();
s[i] = b'/';
s
} else {
d
})
});
assert!(load_from_bytes(&b).is_err());
}
#[test]
fn manifest_size_limit() {
let b = sample();
let opts = LoadOptions { max_manifest_bytes: 100, ..Default::default() };
assert!(matches!(load_from_bytes_with(&b, &opts), Err(FormatError::LimitExceeded(_))));
}
#[test]
fn total_size_limit() {
let b = sample();
let opts = LoadOptions { max_total_bytes: 1000, ..Default::default() };
assert!(matches!(load_from_bytes_with(&b, &opts), Err(FormatError::LimitExceeded(_))));
}
#[test]
fn directory_bundle_resave_repairs_tampered_objects() {
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
let dir = temp_dir("tamper");
let mut writer = PcraftWriter::new();
writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
for object_dir in ["tiles", "blobs"] {
let object = std::fs::read_dir(dir.join(object_dir)).unwrap().next().unwrap().unwrap().path();
let original = std::fs::read(&object).unwrap();
let mut decoder = ruzstd::decoding::StreamingDecoder::new(&original[..]).unwrap();
let mut decoded = Vec::new();
decoder.read_to_end(&mut decoded).unwrap();
if let Some(first) = decoded.first_mut() {
*first ^= 1;
} else {
decoded.push(1);
}
let hash_mismatched = ruzstd::encoding::compress_to_vec(decoded.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
for damaged in [hash_mismatched, b"garbage".to_vec()] {
std::fs::write(&object, damaged).unwrap();
assert!(load_path(&dir).is_err());
let stats = writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
if object_dir == "tiles" {
assert_eq!(stats.tiles_written, 1);
} else {
assert_eq!(stats.blobs_written, 1);
}
assert_eq!(load_path(&dir).unwrap(), doc);
}
}
std::fs::remove_dir_all(dir).unwrap();
}
/// Damages one object in place without changing its length or modification time, the case a
/// size-and-mtime cache alone would trust. Returns the damaged file's path.
fn damage_in_place_keeping_size_and_mtime(dir: &std::path::Path) -> std::path::PathBuf {
let object = std::fs::read_dir(dir.join("tiles")).unwrap().next().unwrap().unwrap().path();
let original = std::fs::read(&object).unwrap();
let mtime = std::fs::metadata(&object).unwrap().modified().unwrap();
// Flip one byte of the compressed payload; try positions until the bundle no longer loads.
for at in (original.len() / 2..original.len()).chain(0..original.len() / 2) {
let mut damaged = original.clone();
damaged[at] ^= 0x5a;
std::fs::write(&object, &damaged).unwrap();
std::fs::File::options().write(true).open(&object).unwrap().set_modified(mtime).unwrap();
if load_path(dir).is_err() {
assert_eq!(std::fs::metadata(&object).unwrap().len(), original.len() as u64);
assert_eq!(std::fs::metadata(&object).unwrap().modified().unwrap(), mtime);
return object;
}
}
panic!("no single-byte change made the bundle fail to load");
}
#[test]
fn same_size_same_mtime_tamper_is_repaired_on_the_next_save() {
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
let dir = temp_dir("tamper-same-size");
let mut writer = PcraftWriter::new();
writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
// A second save trusts every object it verified in the first one.
writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
damage_in_place_keeping_size_and_mtime(&dir);
// The sample bundle is far below DEFAULT_REVERIFY_BUDGET, so the rolling re-check reads
// every cached object again (and on Unix the change time differs as well).
let stats = writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
assert_eq!(stats.tiles_written, 1);
assert_eq!(load_path(&dir).unwrap(), doc);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rolling_reverify_reaches_every_object_with_a_tiny_budget() {
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
let dir = temp_dir("tamper-rolling");
let mut writer = PcraftWriter::new();
writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
let objects = ["tiles", "blobs"].iter().map(|d| std::fs::read_dir(dir.join(d)).unwrap().count()).sum::<usize>();
// One object per save: the oldest-checked one.
writer.set_reverify_budget(1);
damage_in_place_keeping_size_and_mtime(&dir);
let mut repaired = false;
for _ in 0..=objects {
let stats = writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
if stats.tiles_written == 1 {
repaired = true;
break;
}
}
assert!(repaired, "a damaged object was not re-verified within {objects} saves");
assert_eq!(load_path(&dir).unwrap(), doc);
// A zero budget re-reads nothing whose signature is unchanged; a fresh writer still verifies
// everything on its first save.
let mut fresh = PcraftWriter::new();
fresh.set_reverify_budget(0);
damage_in_place_keeping_size_and_mtime(&dir);
assert_eq!(fresh.save_dir(&doc, &dir, &SaveOptions::default()).unwrap().tiles_written, 1);
assert_eq!(load_path(&dir).unwrap(), doc);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn deflated_zip_entries_are_readable() {
// Re-zip with DEFLATE (as a user's zip tool might) — must still load.
use std::io::Write;
let b = sample();
let r = zip::ZipReader::new(&b).unwrap();
let mut out = Vec::new();
let mut central = Vec::new();
for e in &r.entries {
let data = r.read(e, usize::MAX).unwrap();
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&data).unwrap();
let comp = enc.finish().unwrap();
let crc = zip::crc32(&data);
let off = out.len() as u32;
let name = e.name.as_bytes();
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&[20, 0, 0, 0, 8, 0, 0, 0, 0x21, 0]);
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&(comp.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&[0, 0]);
out.extend_from_slice(name);
out.extend_from_slice(&comp);
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&[20, 0, 20, 0, 0, 0, 8, 0, 0, 0, 0x21, 0]);
central.extend_from_slice(&crc.to_le_bytes());
central.extend_from_slice(&(comp.len() as u32).to_le_bytes());
central.extend_from_slice(&(data.len() as u32).to_le_bytes());
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
central.extend_from_slice(&[0; 12]);
central.extend_from_slice(&off.to_le_bytes());
central.extend_from_slice(name);
}
let cd_off = out.len() as u32;
let n = r.entries.len() as u16;
out.extend_from_slice(&central);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0; 4]);
out.extend_from_slice(&n.to_le_bytes());
out.extend_from_slice(&n.to_le_bytes());
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
out.extend_from_slice(&cd_off.to_le_bytes());
out.extend_from_slice(&[0, 0]);
assert_eq!(load_from_bytes(&out).unwrap(), rich_doc_ids_from(&b));
}
fn rich_doc_ids_from(b: &[u8]) -> photocraft_doc::Document {
load_from_bytes(b).unwrap()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 300, .. ProptestConfig::default() })]
#[test]
fn random_bytes_never_panic(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
let _ = load_from_bytes(&data);
let _ = read_manifest(&data);
}
#[test]
fn random_manifest_never_panics(json in "\\PC{0,300}") {
let mut z = zip::ZipWriter::new();
z.add("manifest.json", json.as_bytes()).unwrap();
let _ = load_from_bytes(&z.finish().unwrap());
}
#[test]
fn random_tile_payload_never_panics(data in proptest::collection::vec(any::<u8>(), 0..512), which in 0usize..8) {
let b = sample_cached();
let r = zip::ZipReader::new(b).unwrap();
let tiles: Vec<String> = r.entries.iter().filter(|e| e.name.starts_with("tiles/")).map(|e| e.name.clone()).collect();
let target = tiles[which % tiles.len()].clone();
let b2 = rebuild(b, |n, d| Some(if n == target { data.clone() } else { d }));
prop_assert!(load_from_bytes(&b2).is_err());
}
}
fn sample_cached() -> &'static [u8] {
static S: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
S.get_or_init(sample)
}