makepad/libs/model/tests/adversarial.rs
Admin d2130e6550 libs: model, mesh_edit, csg, scene, sim, render, xr
Squash of 25 work commits (Sep 1–12):
  237da90  render: the sprite lane hands the screen draw back the way it found it
  895b9a7  particles: an emitter can ride a body's own frame
  914471f  ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
  3fcccf3  sim: the whole world is implicitly editable, and one seam says where the ground is
  5692fab  sim: the landform world proves itself — walker through the tunnel included
  f4af6df  sim: terrain knows who changed it — a plan layer over player history
  adbb078  render: a water volume can be physics without a picture
  c17480f  render: a non-rigid body may carry its own orientation
  acad401  sim: an agent with no route holds and retries instead of walking into the wall
  22b2bf9  sim + chat: the composed world surface takes a map floor; the chat gets plan tools
  faf8112  web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
  d3472eb  tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
  4946c9e  sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
  4317f58  sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
  9791279  render: support rigged models and custom materials across viewers
  3ce2792  sim: use deck geometry for collision and sensing
  c8b79ff  Add portable PBR, rig and soft-body authoring support
  108d423  Add transactional polygon modeling and editable asset documents
  38f4d3b  Refine editable modeling and firm yarn character behavior
  08a2637  sim: add entity-owned lights and vehicle headlights
  00d96a7  render: add clustered lights, local shadows and incremental GI
  cf916af  render: import glTF asset extensions and wire clustered GI
  c6d2cca  model: cut transaction memory and raise capacity limits
  41af47a  raytrace: add a CPU probe-ray BVH budget example
  c963d0a  libs: the game sim splits into makepad-scene and makepad-soft-body; render, model, fab and the asset importer retarget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00

742 lines
24 KiB
Rust

//! Adversarial checks of document transactions, history, codec and the
//! strict service protocol. Foundation operations only; rig/texture coverage
//! is limited to protocol rejection and rollback through existing apply.
use makepad_model::*;
use makepad_model::json::{self, Value};
use std::cell::Cell;
fn cube(object: &str) -> Operation {
Operation::Cube {
object: object.into(),
size: [1., 1., 1.],
}
}
fn plane(object: &str) -> Operation {
Operation::Plane {
object: object.into(),
size: [2., 2.],
}
}
fn apply(doc: &mut Document, id: &str, operations: Vec<Operation>) -> Applied {
doc.apply(
Transaction {
request_id: id.into(),
expected: doc.head(),
operations,
},
None,
)
.unwrap()
}
fn bytes(doc: &Document) -> Vec<u8> {
doc.to_bytes(None).unwrap()
}
fn arr<const N: usize>(v: [f64; N]) -> Value {
Value::Arr(v.into_iter().map(Value::F64).collect())
}
fn sid(v: u64) -> Value {
json::s(v.to_string())
}
#[test]
fn failed_second_operation_rolls_back_the_whole_transaction() {
let mut doc = Document::new(Limits::default()).unwrap();
apply(&mut doc, "body", vec![cube("body")]);
let snapshot = bytes(&doc);
let head = doc.head();
let err = doc
.apply(
Transaction {
request_id: "partial".into(),
expected: head,
operations: vec![
plane("ground"),
Operation::DeleteObject {
object: "absent".into(),
},
],
},
None,
)
.unwrap_err();
assert!(matches!(err, Error::MissingObject(_)), "{err:?}");
assert_eq!(bytes(&doc), snapshot);
assert_eq!(doc.head(), head);
assert!(doc.object("ground").is_none());
assert!(doc.object("body").is_some());
}
#[test]
fn cancellation_midway_does_not_commit() {
let mut doc = Document::new(Limits::default()).unwrap();
apply(&mut doc, "body", vec![cube("body")]);
let snapshot = bytes(&doc);
let head = doc.head();
let calls = Cell::new(0u32);
let cancel = || {
let n = calls.get() + 1;
calls.set(n);
n > 4
};
let err = doc
.apply(
Transaction {
request_id: "cancelled".into(),
expected: head,
operations: vec![plane("ground"), cube("other")],
},
Some(&cancel),
)
.unwrap_err();
assert!(
matches!(err, Error::Mesh(mesh::MeshError::Cancelled)),
"{err:?}"
);
assert_eq!(bytes(&doc), snapshot);
assert_eq!(doc.head(), head);
}
#[test]
fn undo_restores_content_hash_but_generation_is_monotonic() {
let mut doc = Document::new(Limits::default()).unwrap();
let empty = doc.head();
assert_eq!(empty.generation, 0);
let first = apply(&mut doc, "body", vec![cube("body")]);
assert_eq!(first.committed.generation, 1);
assert_ne!(first.committed.content, empty.content);
let undone = doc.undo(doc.head(), None).unwrap();
assert_eq!(undone.content, empty.content);
assert!(undone.generation > first.committed.generation);
assert_ne!(undone.generation, empty.generation);
// ABA: a writer holding the original empty head must not land on the
// restored content.
let stale = doc
.apply(
Transaction {
request_id: "stale-empty".into(),
expected: empty,
operations: vec![plane("ground")],
},
None,
)
.unwrap_err();
assert!(
matches!(stale, Error::StaleHead { expected, actual } if expected == empty && actual == undone),
"{stale:?}"
);
let redone = doc.redo(undone, None).unwrap();
assert_eq!(redone.content, first.committed.content);
assert!(redone.generation > undone.generation);
}
#[test]
fn history_branch_discards_redo_and_keeps_old_receipt() {
let mut doc = Document::new(Limits::default()).unwrap();
let tx = Transaction {
request_id: "body".into(),
expected: doc.head(),
operations: vec![cube("body")],
};
let first = doc.apply(tx.clone(), None).unwrap();
let after_cube = bytes(&doc);
let undone = doc.undo(doc.head(), None).unwrap();
apply(&mut doc, "branch", vec![plane("ground")]);
assert!(doc.object("body").is_none());
assert!(doc.object("ground").is_some());
assert!(doc.redo(doc.head(), None).is_err());
let replay = doc.apply(tx, None).unwrap();
assert!(replay.replayed);
assert_eq!(replay.committed, first.committed);
assert_eq!(replay.current, doc.head());
assert_ne!(replay.current, first.committed);
assert_ne!(bytes(&doc), after_cube);
assert_eq!(undone.content, Document::new(Limits::default()).unwrap().head().content);
}
#[test]
fn request_retry_is_idempotent_across_canonical_roundtrip() {
let mut doc = Document::new(Limits::default()).unwrap();
let tx = Transaction {
request_id: "retry-me".into(),
expected: doc.head(),
operations: vec![cube("body")],
};
let first = doc.apply(tx.clone(), None).unwrap();
let encoded = bytes(&doc);
let mut reopened = Document::from_bytes(&encoded, Limits::default(), None).unwrap();
assert_eq!(bytes(&reopened), encoded);
let replay = reopened.apply(tx.clone(), None).unwrap();
assert!(replay.replayed);
assert_eq!(replay.committed, first.committed);
assert_eq!(bytes(&reopened), encoded);
let mut different = tx.clone();
different.operations = vec![plane("ground")];
assert_eq!(
reopened.apply(different, None).unwrap_err(),
Error::RequestIdReused
);
assert_eq!(bytes(&reopened), encoded);
}
#[test]
fn same_request_id_with_moved_expected_is_not_a_retry() {
let mut doc = Document::new(Limits::default()).unwrap();
let tx = Transaction {
request_id: "same".into(),
expected: doc.head(),
operations: vec![cube("body")],
};
doc.apply(tx.clone(), None).unwrap();
let mut moved = tx;
moved.expected = doc.head();
assert_eq!(doc.apply(moved, None).unwrap_err(), Error::RequestIdReused);
}
#[test]
fn request_id_is_not_recyclable_after_receipt_eviction() {
let mut limits = Limits::default();
limits.max_receipts = 1;
let mut doc = Document::new(limits).unwrap();
apply(&mut doc, "r1", vec![cube("body")]);
apply(&mut doc, "r2", vec![plane("ground")]);
let snapshot = bytes(&doc);
let err = doc.apply(
Transaction {
request_id: "r1".into(),
expected: doc.head(),
operations: vec![cube("other")],
},
None,
);
// Idempotency receipts are a bounded window. Recycling an evicted request
// ID would duplicate an edit under a previously acknowledged identity.
assert!(
matches!(err, Err(Error::RequestIdReused)),
"evicted request id was recyclable: {err:?}"
);
assert_eq!(bytes(&doc), snapshot);
}
#[test]
fn checkpoint_preserves_receipts_and_clears_undo() {
let mut doc = Document::new(Limits::default()).unwrap();
let tx = Transaction {
request_id: "body".into(),
expected: doc.head(),
operations: vec![cube("body")],
};
let first = doc.apply(tx.clone(), None).unwrap();
doc.checkpoint(doc.head()).unwrap();
assert_eq!(doc.history_position(), (0, 0));
assert!(doc.undo(doc.head(), None).is_err());
let replay = doc.apply(tx, None).unwrap();
assert!(replay.replayed);
assert_eq!(replay.committed, first.committed);
}
#[test]
fn import_of_truncated_mesh_rolls_back() {
let mut doc = Document::new(Limits::default()).unwrap();
apply(&mut doc, "body", vec![cube("body")]);
let good = doc.object("body").unwrap().to_bytes(&mut mesh::Context::default()).unwrap();
let snapshot = bytes(&doc);
let err = doc
.apply(
Transaction {
request_id: "bad-import".into(),
expected: doc.head(),
operations: vec![Operation::ImportMesh {
object: "broken".into(),
source: good[..good.len() / 2].to_vec(),
}],
},
None,
)
.unwrap_err();
assert!(
matches!(err, Error::Mesh(mesh::MeshError::CorruptData(_)) | Error::Mesh(mesh::MeshError::Budget { .. })),
"{err:?}"
);
assert_eq!(bytes(&doc), snapshot);
assert!(doc.object("broken").is_none());
}
#[test]
fn hostile_and_truncated_document_sources_are_refused() {
let mut doc = Document::new(Limits::default()).unwrap();
apply(&mut doc, "body", vec![cube("body")]);
apply(&mut doc, "ground", vec![plane("ground")]);
let encoded = bytes(&doc);
let limits = Limits::default();
for n in [0usize, 1, 8, 12, encoded.len() / 3, encoded.len() - 1] {
assert!(
Document::from_bytes(&encoded[..n], limits.clone(), None).is_err(),
"accepted truncated document length {n}"
);
}
let mut trailing = encoded.clone();
trailing.push(0);
assert!(Document::from_bytes(&trailing, limits.clone(), None).is_err());
let mut bad_magic = encoded.clone();
bad_magic[0] ^= 0x5a;
assert!(Document::from_bytes(&bad_magic, limits.clone(), None).is_err());
let mut tiny = limits.clone();
tiny.max_source_bytes = 16;
assert!(matches!(
Document::from_bytes(&encoded, tiny, None),
Err(Error::Budget("source bytes"))
));
// Swap the two object records so the name order is no longer canonical.
let mut shuffled = encoded.clone();
if let Some(a) = find_ascii(&shuffled, b"body") {
if let Some(b) = find_ascii(&shuffled, b"ground") {
if a != b {
shuffled[a] = b'z';
assert!(Document::from_bytes(&shuffled, limits, None).is_err());
}
}
}
}
fn find_ascii(bytes: &[u8], needle: &[u8]) -> Option<usize> {
bytes.windows(needle.len()).position(|w| w == needle)
}
#[test]
fn engine_rejects_unknown_and_duplicate_fields() {
let mut engine = Engine::new(Limits::default());
engine.open("doc", None, None).unwrap();
let expected = head_json(engine.document("doc").unwrap().head());
let extra = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("r")),
("expected", expected.clone()),
("operations", Value::Arr(vec![json::obj(vec![
("op", json::s("cube")),
("object", json::s("body")),
("size", arr([1., 1., 1.])),
])])),
("surprise", Value::Bool(true)),
]);
assert!(engine.execute("model.apply", &extra, None).is_err());
let duplicate = Value::Obj(vec![
("document".into(), json::s("doc")),
("request_id".into(), json::s("r")),
("expected".into(), expected.clone()),
("operations".into(), Value::Arr(vec![json::obj(vec![
("op", json::s("cube")),
("object", json::s("body")),
("size", arr([1., 1., 1.])),
])])),
("document".into(), json::s("doc")),
]);
assert!(engine.execute("model.apply", &duplicate, None).is_err());
let parsed = json::parse(
br#"{"document":"doc","request_id":"r","expected":{"generation":"0","content":"00"},"extra":1}"#,
);
assert!(parsed.is_err() || engine.execute("model.apply", &parsed.unwrap(), None).is_err());
}
#[test]
fn engine_rejects_unknown_operation_and_nested_unknown_fields() {
let mut engine = Engine::new(Limits::default());
engine.open("doc", None, None).unwrap();
let expected = head_json(engine.document("doc").unwrap().head());
let unknown_op = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("r")),
("expected", expected.clone()),
("operations", Value::Arr(vec![json::obj(vec![
("op", json::s("bevel")),
("object", json::s("body")),
])])),
]);
assert!(engine.execute("model.apply", &unknown_op, None).is_err());
let nested = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("r")),
("expected", expected),
("operations", Value::Arr(vec![json::obj(vec![
("op", json::s("cube")),
("object", json::s("body")),
("size", arr([1., 1., 1.])),
("color", arr([1., 0., 0.])),
])])),
]);
assert!(engine.execute("model.apply", &nested, None).is_err());
}
#[test]
fn stable_ids_and_generations_are_precise_decimal_strings() {
let mut engine = Engine::new(Limits::default());
engine.open("doc", None, None).unwrap();
let expected = head_json(engine.document("doc").unwrap().head());
engine
.execute(
"model.apply",
&json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("make")),
("expected", expected),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("cube")),
("object", json::s("body")),
("size", arr([1., 1., 1.])),
])]),
),
]),
None,
)
.unwrap();
let head = engine.document("doc").unwrap().head();
assert!(head_json(head).get("generation").unwrap().as_str().is_some());
let face = engine.document("doc").unwrap().object("body").unwrap().faces()[0]
.id
.0;
// JSON numbers are refused even when they fit in i64.
let numeric_id = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("numeric")),
("expected", head_json(head)),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("extrude")),
("object", json::s("body")),
("face", Value::Int(face as i64)),
("offset", arr([0., 0., 1.])),
])]),
),
]);
assert!(engine.execute("model.apply", &numeric_id, None).is_err());
let leading_zero = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("zero")),
("expected", head_json(head)),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("extrude")),
("object", json::s("body")),
("face", json::s(format!("0{face}"))),
("offset", arr([0., 0., 1.])),
])]),
),
]);
assert!(engine.execute("model.apply", &leading_zero, None).is_err());
// Values above JSON number precision must round-trip as the exact u64.
let past_mantissa = 9007199254740993u64; // 2^53 + 1
let large = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("large")),
("expected", head_json(head)),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("extrude")),
("object", json::s("body")),
("face", sid(past_mantissa)),
("offset", arr([0., 0., 1.])),
])]),
),
]);
match engine.execute("model.apply", &large, None) {
Err(Error::Mesh(mesh::MeshError::UnknownElement(mesh::ElementId::Face(id)))) => {
assert_eq!(id.0, past_mantissa);
}
other => panic!("large face id lost precision or changed error: {other:?}"),
}
let overflow = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("overflow")),
("expected", head_json(head)),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("extrude")),
("object", json::s("body")),
("face", json::s("18446744073709551616")),
("offset", arr([0., 0., 1.])),
])]),
),
]);
assert!(engine.execute("model.apply", &overflow, None).is_err());
let max = json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("umax")),
("expected", head_json(head)),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("extrude")),
("object", json::s("body")),
("face", sid(u64::MAX)),
("offset", arr([0., 0., 1.])),
])]),
),
]);
match engine.execute("model.apply", &max, None) {
Err(Error::Mesh(mesh::MeshError::UnknownElement(mesh::ElementId::Face(id)))) => {
assert_eq!(id.0, u64::MAX);
}
other => panic!("u64::MAX face id was not preserved: {other:?}"),
}
let numeric_generation = json::obj(vec![
("generation", Value::Int(1)),
(
"content",
json::s(head.content.iter().map(|b| format!("{b:02x}")).collect::<String>()),
),
]);
assert!(parse_head(&numeric_generation).is_err());
}
#[test]
fn inspect_paginates_and_caps_page_size() {
let mut engine = Engine::new(Limits::default());
engine.open("doc", None, None).unwrap();
let expected = head_json(engine.document("doc").unwrap().head());
engine
.execute(
"model.apply",
&json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("make")),
("expected", expected),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("cube")),
("object", json::s("body")),
("size", arr([1., 1., 1.])),
])]),
),
]),
None,
)
.unwrap();
let page = engine
.execute(
"model.inspect",
&json::obj(vec![
("document", json::s("doc")),
("object", json::s("body")),
("domain", json::s("vertices")),
("offset", Value::Int(0)),
("limit", Value::Int(3)),
]),
None,
)
.unwrap();
assert_eq!(page.get("total").and_then(Value::as_i64), Some(8));
assert_eq!(page.get("offset").and_then(Value::as_i64), Some(0));
assert_eq!(page.get("next_offset").and_then(Value::as_i64), Some(3));
let items = page.get("items").and_then(Value::as_arr).unwrap();
assert_eq!(items.len(), 3);
assert!(items[0].get("id").and_then(Value::as_str).is_some());
let last = engine
.execute(
"model.inspect",
&json::obj(vec![
("document", json::s("doc")),
("object", json::s("body")),
("domain", json::s("vertices")),
("offset", Value::Int(6)),
("limit", Value::Int(3)),
]),
None,
)
.unwrap();
let last_items = last.get("items").and_then(Value::as_arr).unwrap();
assert_eq!(last_items.len(), 2);
assert!(last.get("next_offset").unwrap().is_null());
let past = engine
.execute(
"model.inspect",
&json::obj(vec![
("document", json::s("doc")),
("object", json::s("body")),
("domain", json::s("faces")),
("offset", Value::Int(50)),
("limit", Value::Int(4)),
]),
None,
)
.unwrap();
assert_eq!(past.get("items").and_then(Value::as_arr).unwrap().len(), 0);
assert!(past.get("next_offset").unwrap().is_null());
let too_big = json::obj(vec![
("document", json::s("doc")),
("object", json::s("body")),
("domain", json::s("vertices")),
("offset", Value::Int(0)),
("limit", Value::Int((MAX_INSPECTION_PAGE as i64) + 1)),
]);
assert!(engine.execute("model.inspect", &too_big, None).is_err());
let zero = json::obj(vec![
("document", json::s("doc")),
("object", json::s("body")),
("domain", json::s("vertices")),
("offset", Value::Int(0)),
("limit", Value::Int(0)),
]);
assert!(engine.execute("model.inspect", &zero, None).is_err());
let edges = engine
.execute(
"model.inspect",
&json::obj(vec![
("document", json::s("doc")),
("object", json::s("body")),
("domain", json::s("edges")),
("offset", Value::Int(0)),
("limit", Value::Int(5)),
]),
None,
)
.unwrap();
assert_eq!(edges.get("total").and_then(Value::as_i64), Some(12));
assert_eq!(edges.get("items").and_then(Value::as_arr).unwrap().len(), 5);
assert_eq!(edges.get("next_offset").and_then(Value::as_i64), Some(5));
let overview = engine
.execute(
"model.inspect",
&json::obj(vec![
("document", json::s("doc")),
("object", json::s("body")),
("domain", json::s("overview")),
("offset", Value::Int(0)),
("limit", Value::Int(8)),
]),
None,
)
.unwrap();
let validation = overview.get("validation").unwrap();
assert_eq!(
validation
.get("self_intersections")
.and_then(Value::as_str),
Some("not_checked")
);
}
#[test]
fn engine_open_is_bounded_and_names_are_strict() {
let mut engine = Engine::new(Limits::default());
for i in 0..MAX_OPEN_DOCUMENTS {
engine.open(&format!("d{i}"), None, None).unwrap();
}
assert!(matches!(
engine.open("overflow", None, None),
Err(Error::Budget("open documents"))
));
let mut other = Engine::new(Limits::default());
assert!(other.open("bad\nname", None, None).is_err());
assert!(other.open("", None, None).is_err());
}
#[test]
fn inspect_ids_stay_strings_after_extrude() {
let mut engine = Engine::new(Limits::default());
engine.open("doc", None, None).unwrap();
let expected = head_json(engine.document("doc").unwrap().head());
let created = engine
.execute(
"model.apply",
&json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("make")),
("expected", expected),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("cube")),
("object", json::s("body")),
("size", arr([1., 1., 1.])),
])]),
),
]),
None,
)
.unwrap();
let face = created
.get("results")
.and_then(Value::as_arr)
.unwrap()[0]
.get("faces")
.and_then(Value::as_arr)
.unwrap()[0]
.as_str()
.unwrap()
.to_string();
let expected = created.get("head").unwrap().clone();
let extruded = engine
.execute(
"model.apply",
&json::obj(vec![
("document", json::s("doc")),
("request_id", json::s("ex")),
("expected", expected),
(
"operations",
Value::Arr(vec![json::obj(vec![
("op", json::s("extrude")),
("object", json::s("body")),
("face", json::s(face)),
("offset", arr([0., 0., 1.])),
])]),
),
]),
None,
)
.unwrap();
assert!(!extruded.get("replayed").unwrap().as_bool().unwrap());
let faces = extruded
.get("results")
.and_then(Value::as_arr)
.unwrap()[0]
.get("faces")
.and_then(Value::as_arr)
.unwrap();
assert!(faces.iter().all(|v| v.as_str().is_some()));
}
#[test]
fn empty_transaction_and_unknown_tool_are_refused() {
let mut doc = Document::new(Limits::default()).unwrap();
assert!(matches!(
doc.apply(
Transaction {
request_id: "empty".into(),
expected: doc.head(),
operations: vec![],
},
None
),
Err(Error::Invalid("empty transaction"))
));
let mut engine = Engine::new(Limits::default());
engine.open("doc", None, None).unwrap();
assert!(engine
.execute(
"model.explode",
&json::obj(vec![("document", json::s("doc"))]),
None
)
.is_err());
}