A construction plane built from model geometry resolves each of its sides through the document again. That hop restarted the recursion depth at 0, so a plane that is its own side never exceeded depth 1 and the 32-level cap never fired: a ~600-byte design file recursed until the stack overflowed and the process aborted, which no panic guard can intercept. Carry the depth on the construction context so the geometry arms continue the caller's count instead of restarting it. Fixes #143 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 4bca1d49f27dfe9ef0140132746e1c7415daba8e)
245 lines
10 KiB
Rust
245 lines
10 KiB
Rust
//! Designs saved by older SolveCraft builds (tests/corpus, see its README) open and evaluate as
|
|
//! they did when they were saved; damaged and hostile design files never panic.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{Value, json};
|
|
use solvecraft_engine::Session;
|
|
|
|
fn corpus() -> Vec<PathBuf> {
|
|
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
|
let mut v: Vec<PathBuf> =
|
|
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solvecraft")).collect();
|
|
v.sort();
|
|
assert!(v.len() >= 7, "corpus files in {}", dir.display());
|
|
v
|
|
}
|
|
|
|
fn open(path: &Path) -> Session {
|
|
let mut s = Session::default();
|
|
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
|
s
|
|
}
|
|
|
|
/// Differences that are deliberate behaviour changes made before the file format had versions
|
|
/// (no migration can tell those files apart): (file, check, why).
|
|
const KNOWN: [(&str, &str, &str); 2] = [
|
|
(
|
|
"m0-revolve-primitives",
|
|
"Body1 bbox",
|
|
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
|
|
),
|
|
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
|
];
|
|
|
|
fn known(file: &str, check: &str) -> bool {
|
|
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
|
|
}
|
|
|
|
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
|
|
/// become exact since): the same part within this relative tolerance (the oracle's).
|
|
const REL: f64 = 1e-3;
|
|
|
|
fn close(a: f64, b: f64, rel: f64) -> bool {
|
|
(a - b).abs() <= rel * b.abs().max(1.0)
|
|
}
|
|
|
|
#[test]
|
|
fn old_designs_open_and_evaluate_as_saved() {
|
|
let mut problems = Vec::new();
|
|
for p in corpus() {
|
|
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
|
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
|
|
let mut s = open(&p);
|
|
for r in &s.model.results {
|
|
if let Some(e) = &r.error {
|
|
problems.push(format!("{name}: feature {}: {e}", r.id));
|
|
}
|
|
}
|
|
assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded");
|
|
let got = s.execute("inspect.measure", &json!({})).unwrap();
|
|
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
|
|
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
|
|
}
|
|
for w in want["bodies"].as_array().into_iter().flatten() {
|
|
let bname = w["name"].as_str().unwrap_or_default();
|
|
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
|
|
problems.push(format!("{name}: body {bname} missing"));
|
|
continue;
|
|
};
|
|
for k in ["volume_mm3", "area_mm2"] {
|
|
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
|
|
if !close(a, b, REL) {
|
|
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
|
|
}
|
|
}
|
|
if known(&name, &format!("{bname} bbox")) {
|
|
continue;
|
|
}
|
|
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
|
|
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
|
|
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
|
|
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
|
|
}
|
|
}
|
|
}
|
|
// Saved again, it reads back the same.
|
|
let again = solvecraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
|
assert_eq!(again, *s.doc, "{name}: round trip");
|
|
}
|
|
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
|
}
|
|
|
|
/// A small deterministic generator (xorshift).
|
|
struct Rng(u64);
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 ^= self.0 << 13;
|
|
self.0 ^= self.0 >> 7;
|
|
self.0 ^= self.0 << 17;
|
|
self.0
|
|
}
|
|
fn below(&mut self, n: usize) -> usize {
|
|
(self.next() % n.max(1) as u64) as usize
|
|
}
|
|
}
|
|
|
|
const HOSTILE: [&str; 14] =
|
|
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
|
|
|
|
/// Every JSON value in a document, as a path of keys and indices.
|
|
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
|
|
out.push(at.clone());
|
|
match v {
|
|
Value::Object(m) => {
|
|
for (k, x) in m {
|
|
at.push(json!(k));
|
|
paths(x, at, out);
|
|
at.pop();
|
|
}
|
|
}
|
|
Value::Array(a) => {
|
|
for (i, x) in a.iter().enumerate() {
|
|
at.push(json!(i));
|
|
paths(x, at, out);
|
|
at.pop();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
|
|
let mut cur = v;
|
|
for k in path {
|
|
cur = match k {
|
|
Value::String(s) => cur.get_mut(s.as_str())?,
|
|
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
|
|
_ => return None,
|
|
};
|
|
}
|
|
Some(cur)
|
|
}
|
|
|
|
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
|
|
fn try_bytes(bytes: &[u8]) {
|
|
if let Ok(doc) = solvecraft_engine::io::read_design(bytes) {
|
|
let mut s = Session::new(doc);
|
|
let _ = s.execute("inspect.measure", &json!({}));
|
|
let _ = s.execute("document.inspect", &json!({}));
|
|
}
|
|
}
|
|
|
|
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
|
|
/// cycle, which resolve through the model and back into the document.
|
|
fn generated() -> Vec<(String, Vec<u8>)> {
|
|
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
|
|
let plane = |id: u64, name: &str, side: Value| {
|
|
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
|
|
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
|
|
};
|
|
let design = |name: &str, features: Value| {
|
|
json!({"format": "solvecraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
|
|
};
|
|
vec![
|
|
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
|
|
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_and_hostile_design_files_never_panic() {
|
|
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
|
// The cases come from one seeded sequence; trying them is independent, so threads share it.
|
|
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
|
|
for p in corpus() {
|
|
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
|
let bytes = std::fs::read(&p).unwrap();
|
|
let doc: Value = serde_json::from_slice(&bytes).unwrap();
|
|
let mut all = Vec::new();
|
|
paths(&doc, &mut Vec::new(), &mut all);
|
|
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
|
|
// Truncated and bit-flipped files.
|
|
for _ in 0..8 {
|
|
let cut = rng.below(bytes.len());
|
|
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
|
|
let mut b = bytes.clone();
|
|
let i = rng.below(b.len());
|
|
b[i] ^= 1 << rng.below(8);
|
|
cases.push((format!("bit flip at {i}"), b));
|
|
}
|
|
// Values replaced by hostile ones, and keys removed.
|
|
for _ in 0..25 {
|
|
let path = &all[rng.below(all.len())];
|
|
let mut v = doc.clone();
|
|
let h = HOSTILE[rng.below(HOSTILE.len())];
|
|
if let Some(slot) = at_mut(&mut v, path) {
|
|
*slot = serde_json::from_str(h).unwrap();
|
|
}
|
|
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
|
|
let mut v = doc.clone();
|
|
if let Some((last, parent)) = path.split_last()
|
|
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
|
|
{
|
|
m.remove(k);
|
|
}
|
|
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
|
|
}
|
|
// Features repeated many times; deep nesting; not JSON at all.
|
|
let mut v = doc.clone();
|
|
if let Some(Value::Array(fs)) = v.get_mut("features") {
|
|
let copy = fs.clone();
|
|
for _ in 0..2 {
|
|
fs.extend(copy.iter().cloned());
|
|
}
|
|
}
|
|
cases.push(("features repeated".into(), v.to_string().into_bytes()));
|
|
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
|
|
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
|
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
|
|
}
|
|
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
|
|
let next = std::sync::atomic::AtomicUsize::new(0);
|
|
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
|
let failures: Vec<String> = std::thread::scope(|scope| {
|
|
let workers: Vec<_> = (0..threads)
|
|
.map(|_| {
|
|
scope.spawn(|| {
|
|
let mut failures = Vec::new();
|
|
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
|
if std::env::var_os("CORPUS_TRACE").is_some() {
|
|
eprintln!("{name}: {what}");
|
|
}
|
|
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
|
|
failures.push(format!("{name}: {what}"));
|
|
}
|
|
}
|
|
failures
|
|
})
|
|
})
|
|
.collect();
|
|
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
|
});
|
|
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
|
|
}
|