356 lines
14 KiB
Rust
356 lines
14 KiB
Rust
//! Deterministic external-pack import: approved sources, the atomic import
|
|
//! transaction, idempotent replay, cross-server determinism, and fail-closed
|
|
//! refusals that leave no partial state behind.
|
|
|
|
mod common;
|
|
use common::*;
|
|
use makepad_asset_store::*;
|
|
use makepad_asset_data::*;
|
|
|
|
#[test]
|
|
fn source_registration_is_idempotent_and_approval_is_not_rewritable() {
|
|
let (_root, core) = open_core("import_sources");
|
|
let bytes = kenney_collection().to_canonical_bytes().unwrap();
|
|
let digest = core.imports().register_source(&bytes, NOW).unwrap();
|
|
// Same bytes: idempotent.
|
|
assert_eq!(core.imports().register_source(&bytes, NOW + 1).unwrap(), digest);
|
|
assert_eq!(core.imports().sources().unwrap(), vec![bytes.clone()]);
|
|
assert_eq!(core.imports().source_manifest("kenney").unwrap(), Some(bytes));
|
|
|
|
// A DIFFERENT collection under the same id refuses: approval is explicit.
|
|
let mut changed = kenney_collection();
|
|
changed.title = "Kenney assets, rebranded".into();
|
|
let changed_bytes = changed.to_canonical_bytes().unwrap();
|
|
assert!(matches!(
|
|
core.imports().register_source(&changed_bytes, NOW + 2),
|
|
Err(ServerError::Conflict { what: "source collection digest" })
|
|
));
|
|
|
|
// Garbage bytes refuse through the content contract.
|
|
assert!(matches!(
|
|
core.imports().register_source(b"junk", NOW),
|
|
Err(ServerError::Content(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn import_publishes_assets_aliases_and_entries_atomically() {
|
|
let (root, core) = open_core("import_run");
|
|
let generation_before = {
|
|
// Force schema/state creation before the raw side-channel read.
|
|
core.catalog().register_asset(&asset_id_n(9), "warmup", NOW).unwrap();
|
|
read_generation(&root.join("catalog.sqlite3"))
|
|
};
|
|
let report = run_kenney_import(&core, "1.0", NOW);
|
|
assert!(report.created);
|
|
assert_eq!(report.entries.len(), 2);
|
|
|
|
let manifest = kenney_pack("1.0");
|
|
let import_rev = manifest.revision().unwrap();
|
|
assert_eq!(report.import_revision, import_rev);
|
|
|
|
// Every entry: registered in the source namespace, published, aliased.
|
|
for (asset, entry) in manifest.assets.iter().zip(report.entries.iter()) {
|
|
assert_eq!(entry.key, asset.key.as_str());
|
|
assert_eq!(entry.asset_id, manifest.asset_id_for(&asset.key));
|
|
assert_eq!(
|
|
core.catalog().asset_namespace(&entry.asset_id).unwrap().as_deref(),
|
|
Some("kenney")
|
|
);
|
|
assert_eq!(
|
|
core.catalog()
|
|
.asset_candidate_state(&entry.asset_id, &entry.revision)
|
|
.unwrap(),
|
|
Some(CandidateState::Published)
|
|
);
|
|
let alias = manifest.alias_for(&asset.key).unwrap();
|
|
assert_eq!(
|
|
core.catalog().resolve_asset_alias(&alias).unwrap(),
|
|
Some(AssetRevisionRef {
|
|
asset_id: entry.asset_id,
|
|
revision: entry.revision,
|
|
})
|
|
);
|
|
// The stored manifest pins the exact import lineage.
|
|
let stored = core
|
|
.catalog()
|
|
.asset_revision_manifest(&entry.revision)
|
|
.unwrap()
|
|
.unwrap();
|
|
let decoded = AssetManifest::from_canonical_bytes(&stored).unwrap();
|
|
let prov = decoded.provenance.unwrap();
|
|
assert_eq!(prov.generator, "import");
|
|
assert_eq!(prov.params_digest, Some(*import_rev.as_bytes()));
|
|
assert_eq!(decoded.rights.license, "CC0-1.0");
|
|
assert!(decoded.rights.credits.contains("Kenney"));
|
|
}
|
|
// Entry rows are recorded and readable.
|
|
assert_eq!(core.imports().entries(&import_rev).unwrap(), report.entries);
|
|
// Alias writes went through the search choke point: generation advanced.
|
|
assert!(read_generation(&root.join("catalog.sqlite3")) > generation_before);
|
|
}
|
|
|
|
#[test]
|
|
fn import_replay_is_idempotent_and_two_servers_agree() {
|
|
let (_root_a, a) = open_core("import_det_a");
|
|
let (_root_b, b) = open_core("import_det_b");
|
|
let ra1 = run_kenney_import(&a, "1.0", NOW);
|
|
let ra2 = run_kenney_import(&a, "1.0", NOW + 500);
|
|
let rb = run_kenney_import(&b, "1.0", NOW + 999);
|
|
|
|
assert!(ra1.created);
|
|
// Replay on the same server: recorded result, no new work.
|
|
assert!(!ra2.created);
|
|
assert_eq!(ra1.import_revision, ra2.import_revision);
|
|
assert_eq!(ra1.entries, ra2.entries);
|
|
// A clean second server produces byte-identical identities.
|
|
assert!(rb.created);
|
|
assert_eq!(ra1.import_revision, rb.import_revision);
|
|
assert_eq!(ra1.entries, rb.entries);
|
|
for entry in &ra1.entries {
|
|
assert_eq!(
|
|
a.catalog().asset_revision_manifest(&entry.revision).unwrap(),
|
|
b.catalog().asset_revision_manifest(&entry.revision).unwrap(),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_new_pack_version_revises_the_same_assets() {
|
|
let (_root, core) = open_core("import_version");
|
|
let v1 = run_kenney_import(&core, "1.0", NOW);
|
|
|
|
let mut pack2 = kenney_pack("2.0");
|
|
// v2 ships a bigger watchtower mesh.
|
|
let new_glb = b"KENNEY-WATCHTOWER-GLB-v2-BIGGER";
|
|
core.put_blob(new_glb, NOW + 1).unwrap();
|
|
let watchtower = pack2
|
|
.assets
|
|
.iter_mut()
|
|
.find(|a| a.key.as_str() == "models/watchtower")
|
|
.unwrap();
|
|
watchtower.files[0].file.blob = BlobId::hash_of(new_glb);
|
|
watchtower.files[0].file.byte_len = new_glb.len() as u64;
|
|
watchtower.metrics.total_bytes =
|
|
(new_glb.len() + PACK_COLLIDER.len() + PACK_PREVIEW.len()) as u64;
|
|
let v2 = core
|
|
.imports()
|
|
.run_import(&pack2.to_canonical_bytes().unwrap(), NOW + 2)
|
|
.unwrap();
|
|
|
|
assert_ne!(v1.import_revision, v2.import_revision);
|
|
for (e1, e2) in v1.entries.iter().zip(v2.entries.iter()) {
|
|
// Same stable asset identity across versions; every entry gains a
|
|
// NEW revision because its provenance pins the new import revision
|
|
// (a changed pack creates new asset revisions — the plan's law).
|
|
assert_eq!(e1.asset_id, e2.asset_id);
|
|
assert_ne!(e1.revision, e2.revision);
|
|
// Prior revisions are never edited or unpublished by a later import.
|
|
assert_eq!(
|
|
core.catalog()
|
|
.asset_candidate_state(&e1.asset_id, &e1.revision)
|
|
.unwrap(),
|
|
Some(CandidateState::Published)
|
|
);
|
|
}
|
|
// Unchanged source bytes deduped at the blob layer: the v2 hull texture
|
|
// manifest names the exact v1 blob.
|
|
let stored = core
|
|
.catalog()
|
|
.asset_revision_manifest(&v2.entries[1].revision)
|
|
.unwrap()
|
|
.unwrap();
|
|
let decoded = AssetManifest::from_canonical_bytes(&stored).unwrap();
|
|
assert_eq!(decoded.files[0].blob, BlobId::hash_of(PACK_TEXTURE));
|
|
// The alias head advanced to the new revision.
|
|
let alias = pack2.alias_for(&pack2.assets[0].key).unwrap();
|
|
assert_eq!(
|
|
core.catalog().resolve_asset_alias(&alias).unwrap().unwrap().revision,
|
|
v2.entries[0].revision
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_import_publishes_nothing() {
|
|
let (_root, core) = open_core("import_atomic");
|
|
// Register the source but DO NOT upload the texture blob: the second
|
|
// asset's admission must fail after the first asset already staged.
|
|
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
let collection_bytes = kenney_collection().to_canonical_bytes().unwrap();
|
|
core.imports().register_source(&collection_bytes, NOW).unwrap();
|
|
let manifest = kenney_pack("1.0");
|
|
let manifest_bytes = manifest.to_canonical_bytes().unwrap();
|
|
assert!(matches!(
|
|
core.imports().run_import(&manifest_bytes, NOW),
|
|
Err(ServerError::NotFound { what: "asset file blob" })
|
|
));
|
|
|
|
// NOTHING is visible: no import row, no entries, no assets, no aliases.
|
|
let import_rev = manifest.revision().unwrap();
|
|
assert_eq!(core.imports().import_manifest_bytes(&import_rev).unwrap(), None);
|
|
assert_eq!(core.imports().entries(&import_rev).unwrap(), vec![]);
|
|
for asset in &manifest.assets {
|
|
let id = manifest.asset_id_for(&asset.key);
|
|
assert_eq!(core.catalog().asset_namespace(&id).unwrap(), None);
|
|
let alias = manifest.alias_for(&asset.key).unwrap();
|
|
assert_eq!(core.catalog().resolve_asset_alias(&alias).unwrap(), None);
|
|
}
|
|
|
|
// After the missing blob arrives, the same manifest imports cleanly.
|
|
core.put_blob(PACK_TEXTURE, NOW + 1).unwrap();
|
|
let report = core.imports().run_import(&manifest_bytes, NOW + 1).unwrap();
|
|
assert!(report.created);
|
|
}
|
|
|
|
#[test]
|
|
fn import_refuses_unapproved_or_divergent_sources() {
|
|
let (_root, core) = open_core("import_sources_gate");
|
|
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
let manifest_bytes = kenney_pack("1.0").to_canonical_bytes().unwrap();
|
|
// No registered source at all.
|
|
assert!(matches!(
|
|
core.imports().run_import(&manifest_bytes, NOW),
|
|
Err(ServerError::NotFound { what: "source collection" })
|
|
));
|
|
// A registered collection with the same id but different content: the
|
|
// manifest's pinned digest no longer matches the approval.
|
|
let mut other = kenney_collection();
|
|
other.title = "Different approval".into();
|
|
core.imports()
|
|
.register_source(&other.to_canonical_bytes().unwrap(), NOW)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
core.imports().run_import(&manifest_bytes, NOW),
|
|
Err(ServerError::Conflict { what: "source collection digest" })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn import_never_resurrects_quarantined_content() {
|
|
let (_root, core) = open_core("import_quarantine");
|
|
// Compute the exact revision the import WOULD produce for the
|
|
// watchtower (pure content math), publish it through the ordinary
|
|
// catalog path, then quarantine it — before any import has run.
|
|
let manifest = kenney_pack("1.0");
|
|
let import_rev = manifest.revision().unwrap();
|
|
let watchtower = &manifest.assets[0];
|
|
let produced = manifest
|
|
.asset_manifest_for(watchtower, &import_rev)
|
|
.unwrap();
|
|
let produced_bytes = produced.to_canonical_bytes().unwrap();
|
|
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
core.catalog()
|
|
.register_asset(&produced.asset_id, "kenney", NOW)
|
|
.unwrap();
|
|
let revision = core
|
|
.catalog()
|
|
.stage_asset_revision(&produced_bytes, NOW)
|
|
.unwrap();
|
|
core.catalog().publish_asset(&produced.asset_id, &revision, NOW).unwrap();
|
|
core.catalog()
|
|
.quarantine_asset(&produced.asset_id, &revision, NOW + 1)
|
|
.unwrap();
|
|
|
|
// The import now refuses whole rather than resurrecting pulled content —
|
|
// and atomicity means the OTHER entry publishes nothing either.
|
|
core.imports()
|
|
.register_source(&kenney_collection().to_canonical_bytes().unwrap(), NOW + 2)
|
|
.unwrap();
|
|
let manifest_bytes = manifest.to_canonical_bytes().unwrap();
|
|
assert!(matches!(
|
|
core.imports().run_import(&manifest_bytes, NOW + 2),
|
|
Err(ServerError::InvalidState { what: "imported revision", state: "quarantined" })
|
|
));
|
|
assert_eq!(core.imports().import_manifest_bytes(&import_rev).unwrap(), None);
|
|
let texture_id = manifest.asset_id_for(&manifest.assets[1].key);
|
|
assert_eq!(core.catalog().asset_namespace(&texture_id).unwrap(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn registered_source_terms_are_authoritative() {
|
|
let (_root, core) = open_core("import_rights_gate");
|
|
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
// Register a CC-BY-4.0 attribution-required source.
|
|
let ccby = collection_with_terms("attributed", cc_by_terms());
|
|
core.imports()
|
|
.register_source(&ccby.to_canonical_bytes().unwrap(), NOW)
|
|
.unwrap();
|
|
|
|
// A manifest that claims CC0 for the CC-BY source refuses: license
|
|
// laundering is a Conflict, not a warning.
|
|
let mut laundered = pack_with_terms(&ccby);
|
|
laundered.rights = cc0_rights("Example Author", "https://example.com/pack");
|
|
let laundered_bytes = laundered.to_canonical_bytes().unwrap();
|
|
assert!(matches!(
|
|
core.imports().run_import(&laundered_bytes, NOW),
|
|
Err(ServerError::Conflict { what: "import rights vs registered source" })
|
|
));
|
|
|
|
// Dropping the credits line refuses the same way (and could not even
|
|
// encode under attribution-required policy — try weakening to Allowed
|
|
// AND dropping credits, which encodes but diverges from the approval).
|
|
let mut uncredited = pack_with_terms(&ccby);
|
|
uncredited.rights.credits = String::new();
|
|
uncredited.rights.redistribution = Redistribution::Allowed;
|
|
uncredited.rights.derivatives = DerivativePolicy::Allowed;
|
|
let uncredited_bytes = uncredited.to_canonical_bytes().unwrap();
|
|
assert!(matches!(
|
|
core.imports().run_import(&uncredited_bytes, NOW),
|
|
Err(ServerError::Conflict { what: "import rights vs registered source" })
|
|
));
|
|
|
|
// Unpinning the terms digest refuses too: the pinned terms ARE the
|
|
// approval.
|
|
let mut unpinned = pack_with_terms(&ccby);
|
|
unpinned.rights.terms_digest = None;
|
|
let unpinned_bytes = unpinned.to_canonical_bytes().unwrap();
|
|
assert!(matches!(
|
|
core.imports().run_import(&unpinned_bytes, NOW),
|
|
Err(ServerError::Conflict { what: "import rights vs registered source" })
|
|
));
|
|
|
|
// The exact registered terms import cleanly, and every produced manifest
|
|
// carries them verbatim: identifier+revision, terms digest/URL, credits,
|
|
// upstream source, archive digest, and both policies survive.
|
|
let exact = pack_with_terms(&ccby);
|
|
let report = core
|
|
.imports()
|
|
.run_import(&exact.to_canonical_bytes().unwrap(), NOW)
|
|
.unwrap();
|
|
for entry in &report.entries {
|
|
let stored = core
|
|
.catalog()
|
|
.asset_revision_manifest(&entry.revision)
|
|
.unwrap()
|
|
.unwrap();
|
|
let decoded = AssetManifest::from_canonical_bytes(&stored).unwrap();
|
|
assert_eq!(decoded.rights, cc_by_terms());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn import_budget_is_enforced() {
|
|
let root = test_root("import_budget");
|
|
let mut budgets = Budgets::default_v1();
|
|
budgets.max_import_assets = 1;
|
|
let core = AssetServerCore::open(&root, budgets).unwrap();
|
|
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
|
|
core.put_blob(bytes, NOW).unwrap();
|
|
}
|
|
core.imports()
|
|
.register_source(&kenney_collection().to_canonical_bytes().unwrap(), NOW)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
core.imports()
|
|
.run_import(&kenney_pack("1.0").to_canonical_bytes().unwrap(), NOW),
|
|
Err(ServerError::OverBudget { what: "import assets", .. })
|
|
));
|
|
}
|