Restore missing required asset/flow packages from the pinned nigig revision; retain upstream script isolation, X11 hover, and widget-core fixes. Validated makepad-widgets data feature with Rust 1.97.1.
296 lines
12 KiB
Rust
296 lines
12 KiB
Rust
//! CAS behavior: streaming admission, dedup, atomicity, restart recovery,
|
|
//! and corruption refusal.
|
|
|
|
mod common;
|
|
use common::*;
|
|
use makepad_asset_store::{AssetServerCore, Budgets, ServerError};
|
|
use makepad_asset_data::BlobId;
|
|
use std::fs;
|
|
|
|
/// Any committed object file, found by walking the two-level hash path
|
|
/// (`cas/objects/ab/cd/<64-hex>`).
|
|
fn any_object_path(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
|
for fan in fs::read_dir(root.join("cas/objects")).unwrap() {
|
|
for shard in fs::read_dir(fan.unwrap().path()).unwrap() {
|
|
let shard = shard.unwrap().path();
|
|
if !shard.is_dir() {
|
|
continue;
|
|
}
|
|
for obj in fs::read_dir(shard).unwrap() {
|
|
return Some(obj.unwrap().path());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[test]
|
|
fn put_read_roundtrip_and_digest() {
|
|
let (_root, core) = open_core("roundtrip");
|
|
let bytes = b"hello content addressed world".to_vec();
|
|
let commit = core.put_blob(&bytes, NOW).unwrap();
|
|
assert_eq!(commit.blob_id, BlobId::hash_of(&bytes));
|
|
assert_eq!(commit.size, bytes.len() as u64);
|
|
assert!(!commit.deduped);
|
|
assert_eq!(core.read_blob(&commit.blob_id).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_write_with_predeclared_digest() {
|
|
let (_root, core) = open_core("stream");
|
|
let data: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
|
|
let expected = BlobId::hash_of(&data);
|
|
let mut w = core.begin_blob().unwrap();
|
|
for chunk in data.chunks(7919) {
|
|
w.write(chunk).unwrap();
|
|
}
|
|
let commit = core.commit_blob(w, Some(expected), NOW).unwrap();
|
|
assert_eq!(commit.blob_id, expected);
|
|
assert_eq!(core.read_blob(&expected).unwrap(), data);
|
|
}
|
|
|
|
#[test]
|
|
fn predeclared_digest_mismatch_refused_and_nothing_lands() {
|
|
let (root, core) = open_core("mismatch");
|
|
let wrong = BlobId::hash_of(b"some other bytes");
|
|
let mut w = core.begin_blob().unwrap();
|
|
w.write(b"actual bytes").unwrap();
|
|
let err = core.commit_blob(w, Some(wrong), NOW).unwrap_err();
|
|
assert!(matches!(err, ServerError::DigestMismatch { .. }), "{err}");
|
|
// Nothing at the final path, nothing recorded, temp cleaned up.
|
|
assert!(!core.cas().contains(&BlobId::hash_of(b"actual bytes")));
|
|
assert!(matches!(
|
|
core.read_blob(&BlobId::hash_of(b"actual bytes")).unwrap_err(),
|
|
ServerError::NotFound { .. }
|
|
));
|
|
let tmp_entries = fs::read_dir(root.join("cas/tmp")).unwrap().count();
|
|
assert_eq!(tmp_entries, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn dedup_second_identical_write() {
|
|
let (_root, core) = open_core("dedup");
|
|
let bytes = b"identical payload".to_vec();
|
|
let first = core.put_blob(&bytes, NOW).unwrap();
|
|
assert!(!first.deduped);
|
|
let second = core.put_blob(&bytes, NOW + 1).unwrap();
|
|
assert!(second.deduped);
|
|
assert_eq!(first.blob_id, second.blob_id);
|
|
assert_eq!(core.read_blob(&first.blob_id).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn oversize_blob_refused_mid_stream() {
|
|
let root = test_root("oversize");
|
|
let budgets = Budgets {
|
|
max_blob_bytes: 8,
|
|
..Budgets::default_v1()
|
|
};
|
|
let core = AssetServerCore::open(&root, budgets).unwrap();
|
|
let mut w = core.begin_blob().unwrap();
|
|
let err = w.write(b"nine bytes").unwrap_err();
|
|
assert!(matches!(err, ServerError::OverBudget { what: "blob bytes", .. }), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_object_refused_on_read() {
|
|
let (root, core) = open_core("corrupt");
|
|
let bytes = b"soon to be corrupted".to_vec();
|
|
let commit = core.put_blob(&bytes, NOW).unwrap();
|
|
// Flip one byte of the committed object on disk.
|
|
let object_path = any_object_path(&root).expect("committed object on disk");
|
|
let mut on_disk = fs::read(&object_path).unwrap();
|
|
on_disk[0] ^= 0xff;
|
|
fs::write(&object_path, &on_disk).unwrap();
|
|
|
|
let err = core.read_blob(&commit.blob_id).unwrap_err();
|
|
assert!(matches!(err, ServerError::DigestMismatch { what: "cas object", .. }), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn restart_recovers_orphan_temps_and_keeps_objects() {
|
|
let root = test_root("restart");
|
|
let committed = b"survives restart".to_vec();
|
|
let blob_id;
|
|
{
|
|
let core = AssetServerCore::open(&root, Budgets::default_v1()).unwrap();
|
|
blob_id = core.put_blob(&committed, NOW).unwrap().blob_id;
|
|
// Simulate a crash mid-upload: an in-flight writer that never commits
|
|
// and never runs its Drop cleanup.
|
|
let mut w = core.begin_blob().unwrap();
|
|
w.write(b"partial upload lost in a crash").unwrap();
|
|
std::mem::forget(w);
|
|
}
|
|
assert_eq!(fs::read_dir(root.join("cas/tmp")).unwrap().count(), 1);
|
|
|
|
let core = AssetServerCore::open(&root, Budgets::default_v1()).unwrap();
|
|
let report = core.recover(NOW + 10).unwrap();
|
|
assert_eq!(report.cas_temps_removed, 1);
|
|
assert_eq!(fs::read_dir(root.join("cas/tmp")).unwrap().count(), 0);
|
|
// The committed object is intact and still verifies.
|
|
assert_eq!(core.read_blob(&blob_id).unwrap(), committed);
|
|
}
|
|
|
|
#[test]
|
|
fn stream_verified_emits_nothing_unless_whole_digest_verifies() {
|
|
// Tiny chunk budget: the blob spans many read chunks, so a streaming
|
|
// implementation that emitted per-chunk would have leaked most of the
|
|
// blob before discovering the corrupt final byte.
|
|
let root = test_root("stream_fail_closed");
|
|
let budgets = Budgets {
|
|
io_chunk_bytes: 8,
|
|
..Budgets::default_v1()
|
|
};
|
|
let core = AssetServerCore::open(&root, budgets).unwrap();
|
|
let bytes: Vec<u8> = (0..100u8).collect();
|
|
let commit = core.put_blob(&bytes, NOW).unwrap();
|
|
|
|
// Happy path: whole-digest verification first, then the sink gets all.
|
|
let mut sink = Vec::new();
|
|
let n = core.cas().stream_verified(&commit.blob_id, &mut sink).unwrap();
|
|
assert_eq!(n, bytes.len() as u64);
|
|
assert_eq!(sink, bytes);
|
|
|
|
// Missing object refuses with an untouched sink.
|
|
let mut sink = Vec::new();
|
|
let err = core
|
|
.cas()
|
|
.stream_verified(&BlobId::hash_of(b"absent"), &mut sink)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ServerError::NotFound { what: "cas object" }), "{err}");
|
|
assert!(sink.is_empty(), "refused stream leaked bytes into the sink");
|
|
|
|
// Corrupt the LAST byte on disk: the mismatch is only detectable after
|
|
// every chunk hashed, so fail-closed means zero bytes may have escaped.
|
|
let object_path = any_object_path(&root).expect("committed object on disk");
|
|
let mut on_disk = fs::read(&object_path).unwrap();
|
|
let last = on_disk.len() - 1;
|
|
on_disk[last] ^= 0xff;
|
|
fs::write(&object_path, &on_disk).unwrap();
|
|
|
|
let mut sink = Vec::new();
|
|
let err = core
|
|
.cas()
|
|
.stream_verified(&commit.blob_id, &mut sink)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ServerError::DigestMismatch { what: "cas object", .. }), "{err}");
|
|
assert!(sink.is_empty(), "corrupt stream leaked bytes into the sink");
|
|
}
|
|
|
|
/// Lowercase hex of a digest — the on-disk object name.
|
|
fn hex64(id: &BlobId) -> String {
|
|
id.as_bytes().iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn objects_land_in_a_two_level_hash_path() {
|
|
let (root, core) = open_core("shard_layout");
|
|
let bytes = b"sharded content".to_vec();
|
|
let commit = core.put_blob(&bytes, NOW).unwrap();
|
|
let hex = hex64(&commit.blob_id);
|
|
let sharded = root
|
|
.join("cas/objects")
|
|
.join(&hex[..2])
|
|
.join(&hex[2..4])
|
|
.join(&hex);
|
|
assert!(sharded.is_file(), "expected {sharded:?}");
|
|
// Nothing at the pre-v8 one-level path.
|
|
assert!(!root.join("cas/objects").join(&hex[..2]).join(&hex).is_file());
|
|
assert_eq!(fs::read(&sharded).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_one_level_objects_stay_readable_and_dedup() {
|
|
let (root, core) = open_core("shard_legacy_read");
|
|
let bytes = b"written by a pre-v8 server".to_vec();
|
|
let blob_id = BlobId::hash_of(&bytes);
|
|
let hex = hex64(&blob_id);
|
|
// Fabricate the old layout under a live root, exactly as a v7 server
|
|
// would have left it, and record it so catalog reads are allowed.
|
|
let legacy_dir = root.join("cas/objects").join(&hex[..2]);
|
|
fs::create_dir_all(&legacy_dir).unwrap();
|
|
fs::write(legacy_dir.join(&hex), &bytes).unwrap();
|
|
core.catalog()
|
|
.record_blob(&blob_id, bytes.len() as u64, NOW)
|
|
.unwrap();
|
|
|
|
assert!(core.cas().contains(&blob_id));
|
|
assert_eq!(core.read_blob(&blob_id).unwrap(), bytes);
|
|
// Re-admitting the same content must dedup against the legacy object,
|
|
// not write a second copy at the sharded path.
|
|
let again = core.put_blob(&bytes, NOW + 1).unwrap();
|
|
assert!(again.deduped, "legacy object must satisfy dedup");
|
|
assert!(!root
|
|
.join("cas/objects")
|
|
.join(&hex[..2])
|
|
.join(&hex[2..4])
|
|
.join(&hex)
|
|
.is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn shard_migration_moves_legacy_objects_and_repeats_cleanly() {
|
|
let (root, core) = open_core("shard_migrate");
|
|
// Three objects at the old one-level path, one already sharded.
|
|
let legacy: Vec<Vec<u8>> = (0..3u8).map(|i| vec![i; 64 + i as usize]).collect();
|
|
for bytes in &legacy {
|
|
let hex = hex64(&BlobId::hash_of(bytes));
|
|
let dir = root.join("cas/objects").join(&hex[..2]);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
fs::write(dir.join(&hex), bytes).unwrap();
|
|
}
|
|
let sharded_bytes = b"already sharded".to_vec();
|
|
let sharded_id = core.put_blob(&sharded_bytes, NOW).unwrap().blob_id;
|
|
|
|
let moved = core.cas().migrate_shards().unwrap();
|
|
assert_eq!(moved, 3, "every one-level object moves exactly once");
|
|
// A second run finds nothing left to do.
|
|
assert_eq!(core.cas().migrate_shards().unwrap(), 0);
|
|
|
|
for bytes in &legacy {
|
|
let id = BlobId::hash_of(bytes);
|
|
let hex = hex64(&id);
|
|
let objects = root.join("cas/objects");
|
|
assert!(objects.join(&hex[..2]).join(&hex[2..4]).join(&hex).is_file());
|
|
assert!(!objects.join(&hex[..2]).join(&hex).is_file());
|
|
assert!(core.cas().contains(&id));
|
|
assert_eq!(core.cas().read_verified(&id).unwrap(), *bytes);
|
|
}
|
|
// The object that was already in place is untouched.
|
|
assert_eq!(core.cas().read_verified(&sharded_id).unwrap(), sharded_bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn shard_migration_drops_a_legacy_duplicate_of_a_sharded_object() {
|
|
let (root, core) = open_core("shard_migrate_dup");
|
|
let bytes = b"held at both paths".to_vec();
|
|
let id = core.put_blob(&bytes, NOW).unwrap().blob_id;
|
|
let hex = hex64(&id);
|
|
// The crash window: an interrupted earlier migration left a copy behind.
|
|
let legacy_dir = root.join("cas/objects").join(&hex[..2]);
|
|
fs::write(legacy_dir.join(&hex), &bytes).unwrap();
|
|
|
|
assert_eq!(core.cas().migrate_shards().unwrap(), 0, "nothing to move");
|
|
assert!(!legacy_dir.join(&hex).is_file(), "stale copy removed");
|
|
assert_eq!(core.cas().read_verified(&id).unwrap(), bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn unrecorded_cas_object_is_invisible() {
|
|
let (_root, core) = open_core("unrecorded");
|
|
// Committed to the CAS directly, but never recorded in the catalog —
|
|
// the crash window between CAS commit and catalog record. Reads fail
|
|
// closed on the catalog; a later proper admission dedups and records.
|
|
let bytes = b"orphan object".to_vec();
|
|
let mut w = core.cas().begin().unwrap();
|
|
w.write(&bytes).unwrap();
|
|
let commit = core.cas().commit(w, None).unwrap();
|
|
assert!(core.cas().contains(&commit.blob_id));
|
|
assert!(matches!(
|
|
core.read_blob(&commit.blob_id).unwrap_err(),
|
|
ServerError::NotFound { what: "blob record" }
|
|
));
|
|
let readmitted = core.put_blob(&bytes, NOW).unwrap();
|
|
assert!(readmitted.deduped);
|
|
assert_eq!(core.read_blob(&commit.blob_id).unwrap(), bytes);
|
|
}
|