mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-30 21:07:04 -06:00
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>
332 lines
13 KiB
Rust
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(¢ral);
|
|
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)
|
|
}
|