667 lines
22 KiB
Rust
667 lines
22 KiB
Rust
//! Hostile-size/property tests. Every malformed, truncated, over-budget, or
|
|
//! non-canonical input must yield a structured `AssetDataError` — never a panic,
|
|
//! an allocation bomb, or a silently substituted value.
|
|
|
|
mod common;
|
|
|
|
use common::*;
|
|
use makepad_asset_data::*;
|
|
|
|
/// Decoding any strict prefix of a valid document errors cleanly, and any
|
|
/// appended byte is refused as trailing.
|
|
fn assert_total<T, F: Fn(&[u8]) -> Result<T, AssetDataError>>(bytes: &[u8], decode: F) {
|
|
for len in 0..bytes.len() {
|
|
assert!(decode(&bytes[..len]).is_err(), "prefix {len} decoded");
|
|
}
|
|
let mut extended = bytes.to_vec();
|
|
extended.push(0);
|
|
assert!(matches!(
|
|
decode(&extended).err(),
|
|
Some(AssetDataError::TrailingBytes) | Some(AssetDataError::OverBudget { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_document_decode_is_total() {
|
|
assert_total(
|
|
&weapon_manifest().to_canonical_bytes().unwrap(),
|
|
AssetManifest::from_canonical_bytes,
|
|
);
|
|
assert_total(
|
|
&lock().to_canonical_bytes().unwrap(),
|
|
ContentLock::from_canonical_bytes,
|
|
);
|
|
assert_total(
|
|
&game_revision_manifest().to_canonical_bytes().unwrap(),
|
|
GameRevisionManifest::from_canonical_bytes,
|
|
);
|
|
let game_rev = game_revision_manifest().revision().unwrap();
|
|
let baseline = ContentSetManifest::baseline(game_rev, &lock()).unwrap();
|
|
assert_total(
|
|
&baseline.to_canonical_bytes().unwrap(),
|
|
ContentSetManifest::from_canonical_bytes,
|
|
);
|
|
let plan = scene_plan(game_rev, baseline.id().unwrap());
|
|
assert_total(
|
|
&plan.to_canonical_bytes().unwrap(),
|
|
ScenePlan::from_canonical_bytes,
|
|
);
|
|
let mig = migration_plan(game_rev, GameRevisionId::hash_of(b"next"));
|
|
assert_total(
|
|
&mig.to_canonical_bytes().unwrap(),
|
|
SceneMigrationPlan::from_canonical_bytes,
|
|
);
|
|
let descriptor = RealmDescriptor {
|
|
ticket: JoinTicket {
|
|
transaction_id: txn(1),
|
|
realm_epoch: RealmEpoch(1),
|
|
game_revision: game_rev,
|
|
content_set: baseline.id().unwrap(),
|
|
},
|
|
variant_policy_version: RESOLUTION_POLICY_V1,
|
|
origins: vec!["http://198.51.100.1:8770".into()],
|
|
read_capability: vec![9; 32],
|
|
};
|
|
assert_total(
|
|
&descriptor.to_canonical_bytes().unwrap(),
|
|
RealmDescriptor::from_canonical_bytes,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_magic_kind_and_version_refuse() {
|
|
let bytes = weapon_manifest().to_canonical_bytes().unwrap();
|
|
|
|
let mut bad = bytes.clone();
|
|
bad[0] ^= 0xff;
|
|
assert_eq!(
|
|
AssetManifest::from_canonical_bytes(&bad).unwrap_err(),
|
|
AssetDataError::BadMagic
|
|
);
|
|
|
|
let mut bad = bytes.clone();
|
|
bad[4] = 200; // unknown document kind
|
|
assert!(matches!(
|
|
AssetManifest::from_canonical_bytes(&bad),
|
|
Err(AssetDataError::BadDocKind { .. })
|
|
));
|
|
|
|
let mut bad = bytes;
|
|
bad[5] = 0xff; // future schema version
|
|
assert!(matches!(
|
|
AssetManifest::from_canonical_bytes(&bad),
|
|
Err(AssetDataError::UnsupportedSchema { .. })
|
|
));
|
|
}
|
|
|
|
/// A forged file-count of 0xFFFF_FFFF must be refused before allocation.
|
|
#[test]
|
|
fn count_bomb_is_refused_before_allocation() {
|
|
let bytes = weapon_manifest().to_canonical_bytes().unwrap();
|
|
// Header (7) + asset_id (16) + kind (1), then the files count.
|
|
let count_at = 7 + 16 + 1;
|
|
let mut bad = bytes;
|
|
bad[count_at..count_at + 4].copy_from_slice(&0xffff_ffffu32.to_be_bytes());
|
|
assert!(matches!(
|
|
AssetManifest::from_canonical_bytes(&bad),
|
|
Err(AssetDataError::OverBudget { .. })
|
|
));
|
|
}
|
|
|
|
/// A forged string length larger than the remaining input must be refused
|
|
/// before allocation.
|
|
#[test]
|
|
fn string_bomb_is_refused() {
|
|
let descriptor = RealmDescriptor {
|
|
ticket: JoinTicket {
|
|
transaction_id: txn(1),
|
|
realm_epoch: RealmEpoch(1),
|
|
game_revision: GameRevisionId::hash_of(b"g"),
|
|
content_set: ContentSetId::hash_of(b"s"),
|
|
},
|
|
variant_policy_version: RESOLUTION_POLICY_V1,
|
|
origins: vec!["x".into()],
|
|
read_capability: vec![],
|
|
};
|
|
let bytes = descriptor.to_canonical_bytes().unwrap();
|
|
// Origin string length sits right after ticket (16+8+32+32), the
|
|
// variant policy version (4), and the origin count (4).
|
|
let len_at = 7 + 16 + 8 + 32 + 32 + 4 + 4;
|
|
let mut bad = bytes;
|
|
bad[len_at..len_at + 4].copy_from_slice(&0x7fff_ffffu32.to_be_bytes());
|
|
assert!(matches!(
|
|
RealmDescriptor::from_canonical_bytes(&bad),
|
|
Err(AssetDataError::OverBudget { .. })
|
|
));
|
|
}
|
|
|
|
/// NaN and -0.0 bit patterns are refused on decode even if bytes are forged
|
|
/// after encoding.
|
|
#[test]
|
|
fn non_canonical_floats_refuse_on_decode() {
|
|
let bytes = weapon_manifest().to_canonical_bytes().unwrap();
|
|
// bounds.min.x is -1.5: a distinctive, unique bit pattern in this fixture.
|
|
let needle = (-1.5f32).to_bits().to_be_bytes();
|
|
let at = bytes
|
|
.windows(4)
|
|
.position(|w| w == needle)
|
|
.expect("fixture float present");
|
|
for forged in [f32::NAN.to_bits(), 0x8000_0000, f32::INFINITY.to_bits()] {
|
|
let mut bad = bytes.clone();
|
|
bad[at..at + 4].copy_from_slice(&forged.to_be_bytes());
|
|
assert!(
|
|
matches!(
|
|
AssetManifest::from_canonical_bytes(&bad),
|
|
Err(AssetDataError::Malformed { .. })
|
|
),
|
|
"float bits {forged:08x} accepted"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn producer_side_nan_is_refused() {
|
|
let mut m = weapon_manifest();
|
|
m.bounds.min.x = f32::NAN;
|
|
assert!(m.to_canonical_bytes().is_err());
|
|
let mut m = weapon_manifest();
|
|
m.coordinate_system.units_per_meter = f32::INFINITY;
|
|
assert!(m.to_canonical_bytes().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn mesh_thumbnail_contract_fails_closed() {
|
|
// Missing thumbnail on a mesh-bearing kind.
|
|
let mut m = weapon_manifest();
|
|
m.thumbnail = None;
|
|
assert_eq!(
|
|
m.validate().unwrap_err(),
|
|
AssetDataError::Missing {
|
|
what: "mesh thumbnail"
|
|
}
|
|
);
|
|
|
|
// Under the 256px minimum.
|
|
let mut m = weapon_manifest();
|
|
m.thumbnail.as_mut().unwrap().width = 128;
|
|
assert!(matches!(
|
|
m.validate(),
|
|
Err(AssetDataError::TooSmall { .. })
|
|
));
|
|
|
|
// Over the maximum.
|
|
let mut m = weapon_manifest();
|
|
m.thumbnail.as_mut().unwrap().height = 100_000;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::OverBudget { .. })));
|
|
|
|
// A texture-only asset needs no thumbnail...
|
|
let mut t = weapon_manifest();
|
|
t.kind = AssetKind::Texture;
|
|
t.thumbnail = None;
|
|
t.metrics.total_bytes -= 300;
|
|
assert!(t.validate().is_ok());
|
|
// ...but a lying byte total is still refused.
|
|
t.metrics.total_bytes += 1;
|
|
assert!(matches!(t.validate(), Err(AssetDataError::Mismatch { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn asset_shape_violations_refuse() {
|
|
// Role/media mismatch: a GLB posing as a PNG texture.
|
|
let mut m = weapon_manifest();
|
|
m.files[3].media = MediaType::Glb;
|
|
m.files[3].dims = None;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Mismatch { .. })));
|
|
|
|
// Image without dimensions.
|
|
let mut m = weapon_manifest();
|
|
m.files[3].dims = None;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Missing { .. })));
|
|
|
|
// Duplicate (role, tier, lod).
|
|
let mut m = weapon_manifest();
|
|
let dup = m.files[0];
|
|
m.files.insert(1, dup);
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Duplicate { .. })));
|
|
|
|
// Duplicate anchor name.
|
|
let mut m = weapon_manifest();
|
|
let dup = m.anchors[0].clone();
|
|
m.anchors.insert(1, dup);
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Duplicate { .. })));
|
|
|
|
// Two pinned revisions of one dependency asset.
|
|
let mut m = weapon_manifest();
|
|
m.dependencies = vec![aref(2, 0x21), aref(2, 0x22)];
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Duplicate { .. })));
|
|
|
|
// Self-dependency.
|
|
let mut m = weapon_manifest();
|
|
m.dependencies = vec![aref(1, 0x21)];
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Malformed { .. })));
|
|
|
|
// Mesh without a render GLB role.
|
|
let mut m = weapon_manifest();
|
|
m.files.retain(|f| f.role != FileRole::RenderGlb);
|
|
m.metrics.total_bytes -= 1000;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Missing { .. })));
|
|
|
|
// Over metric ceilings.
|
|
let mut m = weapon_manifest();
|
|
m.metrics.triangles = u32::MAX;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::OverBudget { .. })));
|
|
|
|
// Missing license.
|
|
let mut m = weapon_manifest();
|
|
m.rights.license.clear();
|
|
assert_eq!(
|
|
m.validate().unwrap_err(),
|
|
AssetDataError::Missing { what: "license" }
|
|
);
|
|
|
|
// Degenerate up/forward axes.
|
|
let mut m = weapon_manifest();
|
|
m.coordinate_system.forward = Axis::YNeg;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Mismatch { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn lock_violations_refuse() {
|
|
// Entry revision absent from the closure.
|
|
let mut l = lock();
|
|
l.entries[0].revision = arev(0x99);
|
|
assert!(matches!(l.validate(), Err(AssetDataError::Missing { .. })));
|
|
|
|
// Closure with two revisions of one asset.
|
|
let mut l = lock();
|
|
l.closure.push(aref(4, 0x42));
|
|
l.canonicalize();
|
|
assert!(matches!(l.validate(), Err(AssetDataError::Duplicate { .. })));
|
|
|
|
// Unsorted entries refuse rather than reorder.
|
|
let mut l = lock();
|
|
l.entries.reverse();
|
|
assert!(matches!(l.validate(), Err(AssetDataError::NotSorted { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn content_set_violations_refuse() {
|
|
let game_rev = game_revision_manifest().revision().unwrap();
|
|
let baseline = ContentSetManifest::baseline(game_rev, &lock()).unwrap();
|
|
|
|
// Re-adding an existing revision cannot double-slot it.
|
|
assert!(matches!(
|
|
baseline.extended(&[aref(1, 0x11)]),
|
|
Err(AssetDataError::Duplicate { .. })
|
|
));
|
|
// A duplicate inside one delta is refused.
|
|
assert!(matches!(
|
|
baseline.extended(&[aref(5, 0x51), aref(5, 0x51)]),
|
|
Err(AssetDataError::Duplicate { .. })
|
|
));
|
|
|
|
// Tampering with an inherited slot breaks the parent prefix proof.
|
|
let mut forged = baseline.extended(&[aref(5, 0x51)]).unwrap();
|
|
forged.slots[0] = aref(9, 0x99);
|
|
assert!(matches!(
|
|
forged.extends(&baseline),
|
|
Err(AssetDataError::Mismatch { .. })
|
|
));
|
|
|
|
// Claiming a different parent fails the digest check.
|
|
let other_parent = ContentSetManifest {
|
|
game_revision: game_rev,
|
|
parent: None,
|
|
slots: vec![aref(9, 0x99)],
|
|
};
|
|
let extended = baseline.extended(&[aref(5, 0x51)]).unwrap();
|
|
assert!(matches!(
|
|
extended.extends(&other_parent),
|
|
Err(AssetDataError::Mismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn scene_plan_violations_refuse() {
|
|
let game_rev = game_revision_manifest().revision().unwrap();
|
|
let set_id = ContentSetManifest::baseline(game_rev, &lock())
|
|
.unwrap()
|
|
.id()
|
|
.unwrap();
|
|
|
|
// Duplicate stable keys.
|
|
let mut p = scene_plan(game_rev, set_id);
|
|
let dup = p.objects[0].clone();
|
|
p.objects.insert(1, dup);
|
|
assert!(matches!(p.validate(), Err(AssetDataError::Duplicate { .. })));
|
|
|
|
// Dangling key reference.
|
|
let mut p = scene_plan(game_rev, set_id);
|
|
p.objects.retain(|o| o.key.as_str() != "arena/switch");
|
|
assert!(matches!(p.validate(), Err(AssetDataError::Missing { .. })));
|
|
|
|
// State default of the wrong type.
|
|
let mut p = scene_plan(game_rev, set_id);
|
|
p.state_schemas[0].default = Value::Bool(true);
|
|
assert!(matches!(p.validate(), Err(AssetDataError::Mismatch { .. })));
|
|
|
|
// Zero scale is corruption, not a style.
|
|
let mut p = scene_plan(game_rev, set_id);
|
|
p.objects[0].transform.scale = Vec3::new(0.0, 1.0, 1.0);
|
|
assert!(matches!(p.validate(), Err(AssetDataError::Malformed { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn migration_plan_cannot_downgrade_or_collide() {
|
|
let from = game_revision_manifest().revision().unwrap();
|
|
let to = GameRevisionId::hash_of(b"next");
|
|
|
|
// Declared mode below the plan's own findings.
|
|
let mut m = migration_plan(from, to);
|
|
m.activation_mode = ActivationMode::HotPatch;
|
|
assert_eq!(
|
|
m.validate().unwrap_err(),
|
|
AssetDataError::Mismatch {
|
|
what: "activation mode below verified minimum"
|
|
}
|
|
);
|
|
|
|
// A hard-reset-class reason forces HardReset even with no ops.
|
|
let mut m = migration_plan(from, to);
|
|
m.reasons.push(MigrationReason {
|
|
code: MigrationReasonCode::OpaqueScriptState,
|
|
key: None,
|
|
detail: "closure captured".into(),
|
|
});
|
|
assert_eq!(m.verified_minimum(), ActivationMode::HardReset);
|
|
assert!(m.validate().is_err()); // still declared Migrate
|
|
m.activation_mode = ActivationMode::HardReset;
|
|
assert!(m.validate().is_ok());
|
|
|
|
// Escalation above the minimum is allowed (explicit host reset)...
|
|
let mut m = migration_plan(from, to);
|
|
m.activation_mode = ActivationMode::HardReset;
|
|
assert!(m.validate().is_ok());
|
|
|
|
// Rename target colliding with another op key.
|
|
let mut m = migration_plan(from, to);
|
|
m.ops[1].kind = SceneOpKind::RenameTo {
|
|
new_key: "arena/turret".parse().unwrap(),
|
|
};
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Duplicate { .. })));
|
|
|
|
// Rename to itself.
|
|
let mut m = migration_plan(from, to);
|
|
m.ops[1].kind = SceneOpKind::RenameTo {
|
|
new_key: "arena/old_wall".parse().unwrap(),
|
|
};
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Malformed { .. })));
|
|
|
|
// A no-op revision pair is refused.
|
|
let mut m = migration_plan(from, to);
|
|
m.to_game_revision = from;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Mismatch { .. })));
|
|
|
|
// State migration that does not advance its version.
|
|
let mut m = migration_plan(from, to);
|
|
m.state_migrations[0].to_version = 1;
|
|
assert!(matches!(m.validate(), Err(AssetDataError::Malformed { .. })));
|
|
}
|
|
|
|
/// An update whose component rules include a typed-state migrator is a
|
|
/// migration by definition: the executor snapshots and transforms live state,
|
|
/// which hot patch promises never to do.
|
|
#[test]
|
|
fn migrate_component_rule_forbids_hot_patch() {
|
|
let from = game_revision_manifest().revision().unwrap();
|
|
let to = GameRevisionId::hash_of(b"next");
|
|
let mut m = SceneMigrationPlan {
|
|
from_game_revision: from,
|
|
to_game_revision: to,
|
|
activation_mode: ActivationMode::HotPatch,
|
|
ops: vec![SceneOp {
|
|
key: "arena/main_gate".parse().unwrap(),
|
|
kind: SceneOpKind::Update {
|
|
components: vec![ComponentRule {
|
|
component: "door".into(),
|
|
rule: PreserveRule::Migrate,
|
|
}],
|
|
},
|
|
}],
|
|
state_migrations: vec![],
|
|
terrain_policy: TerrainPolicy::Keep,
|
|
rebuild_scopes: RebuildScopes::default(),
|
|
reasons: vec![MigrationReason {
|
|
code: MigrationReasonCode::CompatibleParamChange,
|
|
key: Some("arena/main_gate".parse().unwrap()),
|
|
detail: "door params".into(),
|
|
}],
|
|
requires_user_confirmation: false,
|
|
};
|
|
assert_eq!(m.verified_minimum(), ActivationMode::Migrate);
|
|
assert_eq!(
|
|
m.validate().unwrap_err(),
|
|
AssetDataError::Mismatch {
|
|
what: "activation mode below verified minimum"
|
|
}
|
|
);
|
|
m.activation_mode = ActivationMode::Migrate;
|
|
assert!(m.validate().is_ok());
|
|
|
|
// The same op with Preserve/Reset rules stays hot-patchable.
|
|
m.activation_mode = ActivationMode::HotPatch;
|
|
if let SceneOpKind::Update { components } = &mut m.ops[0].kind {
|
|
components[0].rule = PreserveRule::Preserve;
|
|
}
|
|
assert!(m.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn migration_reason_order_is_canonical() {
|
|
let from = game_revision_manifest().revision().unwrap();
|
|
let to = GameRevisionId::hash_of(b"next");
|
|
|
|
// Producer-shuffled reasons refuse rather than reorder silently.
|
|
let mut m = migration_plan(from, to);
|
|
m.reasons.reverse();
|
|
assert!(matches!(
|
|
m.to_canonical_bytes(),
|
|
Err(AssetDataError::NotSorted { .. })
|
|
));
|
|
|
|
// An exact duplicate finding is refused as noise.
|
|
let mut m = migration_plan(from, to);
|
|
let dup = m.reasons[0].clone();
|
|
m.reasons.insert(1, dup);
|
|
assert!(matches!(
|
|
m.to_canonical_bytes(),
|
|
Err(AssetDataError::Duplicate { .. })
|
|
));
|
|
|
|
// Same code and key with different detail is two findings, ordered by
|
|
// detail.
|
|
let mut m = migration_plan(from, to);
|
|
let mut second = m.reasons[0].clone();
|
|
second.detail = "second finding".into();
|
|
m.reasons.push(second);
|
|
m.canonicalize();
|
|
assert!(m.validate().is_ok());
|
|
}
|
|
|
|
/// Reordering the encoded reasons on the wire must be refused by the decoder,
|
|
/// proving decode really re-validates canonical order.
|
|
#[test]
|
|
fn forged_reason_order_refuses_on_decode() {
|
|
let from = game_revision_manifest().revision().unwrap();
|
|
let mig = migration_plan(from, GameRevisionId::hash_of(b"next"));
|
|
let bytes = mig.to_canonical_bytes().unwrap();
|
|
|
|
// Layout tail: ... count, reason1 (32 bytes, detail "new turret"),
|
|
// reason2 (36 bytes, detail "wall removed"), requires_confirmation bool.
|
|
let len = bytes.len();
|
|
let r2 = &bytes[len - 1 - 36..len - 1];
|
|
let r1 = &bytes[len - 1 - 36 - 32..len - 1 - 36];
|
|
assert!(r1.ends_with(b"new turret"), "fixture layout drifted");
|
|
assert!(r2.ends_with(b"wall removed"), "fixture layout drifted");
|
|
|
|
let mut forged = bytes[..len - 1 - 36 - 32].to_vec();
|
|
forged.extend_from_slice(r2);
|
|
forged.extend_from_slice(r1);
|
|
forged.push(bytes[len - 1]);
|
|
assert!(matches!(
|
|
SceneMigrationPlan::from_canonical_bytes(&forged),
|
|
Err(AssetDataError::NotSorted { .. })
|
|
));
|
|
}
|
|
|
|
/// Spawnability is a promise about a recipe: no flag without a bounded valid
|
|
/// recipe, no recipe on a non-spawnable asset, and no recipe-less prefab.
|
|
#[test]
|
|
fn spawnability_requires_bounded_recipe() {
|
|
// Positive: the weapon fixture is spawnable with a recipe.
|
|
assert!(weapon_manifest().validate().is_ok());
|
|
|
|
// Positive: a prefab carrying its recipe and the spawnable capability.
|
|
let mut p = weapon_manifest();
|
|
p.kind = AssetKind::Prefab;
|
|
assert!(p.validate().is_ok());
|
|
|
|
// Spawnable without a recipe refuses.
|
|
let mut m = weapon_manifest();
|
|
m.spawn_recipe = None;
|
|
assert_eq!(
|
|
m.validate().unwrap_err(),
|
|
AssetDataError::Missing {
|
|
what: "spawn recipe on spawnable asset"
|
|
}
|
|
);
|
|
|
|
// A recipe on a non-spawnable asset is a contradiction.
|
|
let mut m = weapon_manifest();
|
|
m.capabilities.spawnable = false;
|
|
assert_eq!(
|
|
m.validate().unwrap_err(),
|
|
AssetDataError::Mismatch {
|
|
what: "spawn recipe on non-spawnable asset"
|
|
}
|
|
);
|
|
|
|
// A prefab with neither flag nor recipe is an empty promise.
|
|
let mut p = weapon_manifest();
|
|
p.kind = AssetKind::Prefab;
|
|
p.capabilities.spawnable = false;
|
|
p.spawn_recipe = None;
|
|
assert_eq!(
|
|
p.validate().unwrap_err(),
|
|
AssetDataError::Missing {
|
|
what: "prefab spawn recipe"
|
|
}
|
|
);
|
|
|
|
// Non-spawnable, recipe-free non-prefab stays valid.
|
|
let mut m = weapon_manifest();
|
|
m.capabilities.spawnable = false;
|
|
m.spawn_recipe = None;
|
|
assert!(m.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn activation_dto_violations_refuse() {
|
|
let game_rev = GameRevisionId::hash_of(b"g");
|
|
// Hard reset may never travel the scene-change path.
|
|
let prepare = PrepareSceneChange {
|
|
transaction_id: txn(1),
|
|
realm_epoch: RealmEpoch(1),
|
|
parent_game_revision: game_rev,
|
|
next_game_revision: GameRevisionId::hash_of(b"g2"),
|
|
parent_scene_sequence: SceneSequence(1),
|
|
next_content_set: ContentSetId::hash_of(b"s"),
|
|
scene_plan_digest: ScenePlanDigest::hash_of(b"p"),
|
|
migration_plan_digest: MigrationPlanDigest::hash_of(b"m"),
|
|
activation_mode: ActivationMode::HardReset,
|
|
proposed_activation_tick: Tick(1),
|
|
readiness: readiness(),
|
|
};
|
|
assert!(prepare.to_canonical_bytes().is_err());
|
|
|
|
// A descriptor without origins is useless and refused.
|
|
let descriptor = RealmDescriptor {
|
|
ticket: JoinTicket {
|
|
transaction_id: txn(1),
|
|
realm_epoch: RealmEpoch(1),
|
|
game_revision: game_rev,
|
|
content_set: ContentSetId::hash_of(b"s"),
|
|
},
|
|
variant_policy_version: RESOLUTION_POLICY_V1,
|
|
origins: vec![],
|
|
read_capability: vec![],
|
|
};
|
|
assert!(descriptor.to_canonical_bytes().is_err());
|
|
|
|
// An unknown variant-resolution policy version refuses: a peer never
|
|
// guesses which resolution algorithm a realm meant.
|
|
let descriptor = RealmDescriptor {
|
|
ticket: JoinTicket {
|
|
transaction_id: txn(1),
|
|
realm_epoch: RealmEpoch(1),
|
|
game_revision: game_rev,
|
|
content_set: ContentSetId::hash_of(b"s"),
|
|
},
|
|
variant_policy_version: 99,
|
|
origins: vec!["http://198.51.100.1:8770".into()],
|
|
read_capability: vec![],
|
|
};
|
|
assert!(matches!(
|
|
descriptor.to_canonical_bytes(),
|
|
Err(AssetDataError::UnsupportedSchema { .. })
|
|
));
|
|
|
|
// An empty required delta means nothing to prepare: refused.
|
|
let prep = PrepareContentChange {
|
|
transaction_id: txn(1),
|
|
realm_epoch: RealmEpoch(1),
|
|
next_set: ContentSetId::hash_of(b"s2"),
|
|
required_delta: vec![],
|
|
proposed_activation_tick: Tick(1),
|
|
readiness: readiness(),
|
|
};
|
|
assert!(prep.to_canonical_bytes().is_err());
|
|
|
|
// Over-budget capability token.
|
|
let descriptor = RealmDescriptor {
|
|
ticket: JoinTicket {
|
|
transaction_id: txn(1),
|
|
realm_epoch: RealmEpoch(1),
|
|
game_revision: game_rev,
|
|
content_set: ContentSetId::hash_of(b"s"),
|
|
},
|
|
variant_policy_version: RESOLUTION_POLICY_V1,
|
|
origins: vec!["http://198.51.100.1:8770".into()],
|
|
read_capability: vec![0; 4096],
|
|
};
|
|
assert!(descriptor.to_canonical_bytes().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn oversized_document_is_refused_symmetrically() {
|
|
// A plan with the maximum object count would exceed the document budget;
|
|
// the writer refuses to emit it, so no decoder ever sees it.
|
|
let game_rev = game_revision_manifest().revision().unwrap();
|
|
let set_id = ContentSetId::hash_of(b"s");
|
|
let mut plan = scene_plan(game_rev, set_id);
|
|
let base: SceneObjectKey = "bulk".parse().unwrap();
|
|
for i in 0..20_000u32 {
|
|
plan.objects.push(SceneObject {
|
|
key: base.child_indexed("obj", i).unwrap(),
|
|
asset: None,
|
|
transform: Transform::IDENTITY,
|
|
fixed: true,
|
|
components: vec![],
|
|
});
|
|
}
|
|
plan.canonicalize();
|
|
assert!(matches!(
|
|
plan.to_canonical_bytes(),
|
|
Err(AssetDataError::OverBudget { .. })
|
|
));
|
|
}
|