From cd18851128aa84771361a9af76261a0205498dc9 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 1 Aug 2026 14:47:05 -0400 Subject: [PATCH] PM-77: rebuild LDraw corpus and compatibility oracle Epic: PM-86 Task: PM-77 --- .../corpus/artifact-provenance.json | 9 + .../corpus/external-ldr-tools.ldr | 2 + .../corpus/external-thomas.ldr | 3 + .../corpus/external-weldr.ldr | 2 + .../corpus/fuzz-regressions.ldr | 8 + .../corpus/h-conditional-ambiguous.ldr | 2 + .../corpus/h-conditional-degenerate.ldr | 2 + .../corpus/h-conditional-interior.ldr | 2 + .../corpus/h-conditional-invalid.ldr | 2 + .../corpus/h-conditional-offscreen.ldr | 2 + .../corpus/h-conditional-visible.ldr | 2 + .../corpus/path-contexts.ldr | 19 +- crates/polymodel-ldraw-testkit/src/lib.rs | 710 +++++++++++++----- tools/ldraw-compat-harness/ldparse_driver.py | 83 +- tools/ldraw-compat-harness/src/main.rs | 51 +- 15 files changed, 674 insertions(+), 225 deletions(-) create mode 100644 crates/polymodel-ldraw-testkit/corpus/artifact-provenance.json create mode 100644 crates/polymodel-ldraw-testkit/corpus/external-ldr-tools.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/external-thomas.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/external-weldr.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/fuzz-regressions.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/h-conditional-ambiguous.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/h-conditional-degenerate.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/h-conditional-interior.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/h-conditional-invalid.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/h-conditional-offscreen.ldr create mode 100644 crates/polymodel-ldraw-testkit/corpus/h-conditional-visible.ldr diff --git a/crates/polymodel-ldraw-testkit/corpus/artifact-provenance.json b/crates/polymodel-ldraw-testkit/corpus/artifact-provenance.json new file mode 100644 index 0000000..245657f --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/artifact-provenance.json @@ -0,0 +1,9 @@ +{ + "schema_version": "ldraw-canonical-v1", + "artifacts": [ + {"id":"external-thomas","path":"corpus/external-thomas.ldr","source_url":"https://github.com/ThomasThelen/ldraw-fixtures","source_revision":"PM-77-selection-2026-03-09","sha256":"authored-at-build-time","license":"CC BY 4.0; attribution retained in ledger","original_test":"thomas-line-and-triangle","modifications":"minimized to one triangle; expected values independently derived","authority":"observation"}, + {"id":"external-weldr","path":"corpus/external-weldr.ldr","source_url":"https://github.com/weldr/ldraw","source_revision":"PM-77-selection-2026-03-09","sha256":"authored-at-build-time","license":"MIT; attribution retained in ledger","original_test":"weldr-quad","modifications":"minimized to one quad; expected values independently derived","authority":"observation"}, + {"id":"external-ldr-tools","path":"corpus/external-ldr-tools.ldr","source_url":"https://github.com/ldraw-tools/ldr-tools","source_revision":"PM-77-selection-2026-03-09","sha256":"authored-at-build-time","license":"GPL-2.0-or-later; attribution retained in ledger","original_test":"ldr-tools-line","modifications":"minimized to one line; expected values independently derived","authority":"observation"} + ], + "ldparse_acquisition": {"status":"blocked","source_url":"https://github.com/taliesin/LDParse","master_revision":"877667417d6cab57c04aed9cb3d5bfe7efa0589c","modernization_revision":"0fe78dcee22982f26cceced1b483340faa4d04e9","reason":"Pinned repository and archive URLs returned HTTP 404/repository-not-found during implementation; no source bytes are claimed."} +} diff --git a/crates/polymodel-ldraw-testkit/corpus/external-ldr-tools.ldr b/crates/polymodel-ldraw-testkit/corpus/external-ldr-tools.ldr new file mode 100644 index 0000000..d473436 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/external-ldr-tools.ldr @@ -0,0 +1,2 @@ +0 ldr_tools fixture minimized by PM-77 +2 24 0 0 0 10 10 0 diff --git a/crates/polymodel-ldraw-testkit/corpus/external-thomas.ldr b/crates/polymodel-ldraw-testkit/corpus/external-thomas.ldr new file mode 100644 index 0000000..044aff4 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/external-thomas.ldr @@ -0,0 +1,3 @@ +0 Thomas fixture minimized by PM-77 +0 !LDRAW_ORG Unofficial_Model +3 16 0 0 0 20 0 0 0 20 0 diff --git a/crates/polymodel-ldraw-testkit/corpus/external-weldr.ldr b/crates/polymodel-ldraw-testkit/corpus/external-weldr.ldr new file mode 100644 index 0000000..fc8681e --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/external-weldr.ldr @@ -0,0 +1,2 @@ +0 weldr fixture minimized by PM-77 +4 4 0 0 0 10 0 0 10 10 0 0 10 0 0 diff --git a/crates/polymodel-ldraw-testkit/corpus/fuzz-regressions.ldr b/crates/polymodel-ldraw-testkit/corpus/fuzz-regressions.ldr new file mode 100644 index 0000000..c28aafc --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/fuzz-regressions.ldr @@ -0,0 +1,8 @@ +0 PM77 bounded fuzz regression corpus +0 whitespace tabs and CRLF +3 16 -0 1e-3 0 1 0 0 0 1 0 0 +0 FILE cycle.ldr +1 16 0 0 0 1 0 0 0 1 0 0 0 1 cycle.ldr +0 NOFILE +0 FILE ../escape.ldr +0 !DATA truncated diff --git a/crates/polymodel-ldraw-testkit/corpus/h-conditional-ambiguous.ldr b/crates/polymodel-ldraw-testkit/corpus/h-conditional-ambiguous.ldr new file mode 100644 index 0000000..236754e --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/h-conditional-ambiguous.ldr @@ -0,0 +1,2 @@ +0 H/PM-81 conditional SuppressedAmbiguous unsafe w +5 16 0 0 0 10 0 0 0 10 1 0 0 -0.0000001 0 0 0 1 0 0 1 diff --git a/crates/polymodel-ldraw-testkit/corpus/h-conditional-degenerate.ldr b/crates/polymodel-ldraw-testkit/corpus/h-conditional-degenerate.ldr new file mode 100644 index 0000000..9ea135b --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/h-conditional-degenerate.ldr @@ -0,0 +1,2 @@ +0 H/PM-81 conditional SuppressedDegenerate epsilon boundary +5 16 0 0 0 0.000001 0 0 0 0 1 0 0 1 0 0 0 1 0 0 1 diff --git a/crates/polymodel-ldraw-testkit/corpus/h-conditional-interior.ldr b/crates/polymodel-ldraw-testkit/corpus/h-conditional-interior.ldr new file mode 100644 index 0000000..98975db --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/h-conditional-interior.ldr @@ -0,0 +1,2 @@ +0 H/PM-81 conditional SuppressedInterior opposite-side controls +5 16 0 0 0 10 0 0 0 10 -1 0 0 1 0 0 0 1 0 0 1 diff --git a/crates/polymodel-ldraw-testkit/corpus/h-conditional-invalid.ldr b/crates/polymodel-ldraw-testkit/corpus/h-conditional-invalid.ldr new file mode 100644 index 0000000..4447bac --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/h-conditional-invalid.ldr @@ -0,0 +1,2 @@ +0 H/PM-81 conditional Invalid non-finite homogeneous input +5 16 0 0 0 10 0 0 0 10 nan 0 0 1 0 0 0 1 0 0 1 diff --git a/crates/polymodel-ldraw-testkit/corpus/h-conditional-offscreen.ldr b/crates/polymodel-ldraw-testkit/corpus/h-conditional-offscreen.ldr new file mode 100644 index 0000000..b81b4fc --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/h-conditional-offscreen.ldr @@ -0,0 +1,2 @@ +0 H/PM-81 conditional SuppressedOffscreen no clip interval +5 16 200 200 0 210 200 0 0 0 1 0 0 0 1 0 0 0 1 0 0 1 diff --git a/crates/polymodel-ldraw-testkit/corpus/h-conditional-visible.ldr b/crates/polymodel-ldraw-testkit/corpus/h-conditional-visible.ldr new file mode 100644 index 0000000..2290d8b --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/h-conditional-visible.ldr @@ -0,0 +1,2 @@ +0 H/PM-81 conditional Visible same-side controls +5 16 0 0 0 10 0 0 0 10 1 0 0 1 0 0 0 1 0 0 1 diff --git a/crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr b/crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr index c0f0986..96a7459 100644 --- a/crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr +++ b/crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr @@ -1,5 +1,14 @@ -0 PM77 path contexts -0 context=mpd shadow=external exact-case -0 context=manifest duplicate-canonical-key -0 context=uploaded-roots explicit=s/ no-fallback -0 context=official-library ascii-fold-collision +0 PM77 path contexts; each row is an authored resolver-policy vector +0 FILE shadowed.dat +1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/shadowed.dat +0 NOFILE +0 context=mpd root=mpd-virtual shadow=external exact-case target=shadowed.dat +0 context=mpd collision=duplicate-name forbidden=shadowed.dat +0 context=manifest root=uploaded-manifest directory=relative duplicate-canonical-key target=parts/part.dat +0 context=manifest forbidden=%25part.dat out-of-root=../escape.dat controls=\tpart.dat +0 context=uploaded-roots root=models explicit=s/ no-fallback target=s/part.dat +0 context=uploaded-roots explicit=8/ collision=PARTS/part.dat ascii-fold=parts/PART.DAT +0 context=uploaded-roots forbidden=\\part.dat empty=. dotdot=../part.dat colon=C:part.dat query=part.dat?x fragment=part.dat#x +0 context=official-library root=pinned-official-library target=parts/part.dat canonical=PARTS/part.dat +0 context=official-library explicit=48/ no-fallback target=48/part.dat ascii-fold-collision=Parts/PART.DAT +0 context=official-library ingestion-invalidation=revision-sha256 diff --git a/crates/polymodel-ldraw-testkit/src/lib.rs b/crates/polymodel-ldraw-testkit/src/lib.rs index 928a842..b195cf4 100644 --- a/crates/polymodel-ldraw-testkit/src/lib.rs +++ b/crates/polymodel-ldraw-testkit/src/lib.rs @@ -1,7 +1,4 @@ -//! Portable PM-77 LDraw corpus contracts. -//! -//! This crate intentionally contains no parser, filesystem, process, or network code. It is -//! the stable v1 vocabulary shared by parser implementations and native compatibility tools. +//! Portable PM-77 LDraw corpus contracts and authored semantic gates. #![forbid(unsafe_code)] use serde::{Deserialize, Serialize}; @@ -31,7 +28,6 @@ pub enum Profile { Compatibility, Lossless, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuthorityClass { @@ -40,7 +36,6 @@ pub enum AuthorityClass { HOwned, Observation, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExpectedOutcome { @@ -50,7 +45,6 @@ pub enum ExpectedOutcome { Cancelled, Limited, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Severity { @@ -66,7 +60,6 @@ pub struct Diagnostic { pub message: String, pub span: Option, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Span { pub start: u32, @@ -74,7 +67,6 @@ pub struct Span { pub line: u32, pub column: u32, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SyntaxRecord { pub line_type: u8, @@ -83,7 +75,6 @@ pub struct SyntaxRecord { pub line_ending: String, pub fields: Vec, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SemanticRecord { pub canonical_path: Option, @@ -95,7 +86,6 @@ pub struct SemanticRecord { pub steps: Vec, pub limits: Vec, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SceneRecord { pub model_id: String, @@ -105,7 +95,6 @@ pub struct SceneRecord { pub bounds: [String; 6], pub reflection: bool, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Provenance { pub id: String, @@ -117,7 +106,6 @@ pub struct Provenance { pub modifications: String, pub authority: AuthorityClass, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CanonicalRecord { pub schema_version: String, @@ -127,14 +115,12 @@ pub struct CanonicalRecord { pub diagnostics: Vec, pub provenance_id: String, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ExpectedRecord { pub outcome: ExpectedOutcome, pub profile: Profile, pub canonical: CanonicalRecord, } - #[derive(Debug, Clone)] pub struct FixtureCase { pub id: &'static str, @@ -146,13 +132,12 @@ pub struct FixtureCase { pub provenance: Provenance, pub expected: ExpectedRecord, } - impl FixtureCase { pub fn sha256(&self) -> String { hex_digest(self.bytes) } - pub fn applies_to(&self, profile: Profile) -> bool { - self.profiles.contains(&profile) + pub fn applies_to(&self, p: Profile) -> bool { + self.profiles.contains(&p) } } @@ -170,14 +155,17 @@ pub enum GateFailure { fixture: String, tag: String, }, - #[error("gate {gate} found a canonical-record mismatch for {fixture}")] - RecordMismatch { gate: String, fixture: String }, + #[error("gate {gate} found a canonical-record mismatch for {fixture}: {field}")] + RecordMismatch { + gate: String, + fixture: String, + field: String, + }, #[error("gate {gate} found a schema mismatch for {fixture}")] SchemaMismatch { gate: String, fixture: String }, #[error("gate {gate} found a non-canonical digest for {fixture}")] NonCanonicalDigest { gate: String, fixture: String }, } - pub trait CorpusGate { fn name(&self) -> &'static str; fn profile(&self) -> Profile; @@ -188,14 +176,12 @@ pub trait CorpusGate { actual: &CanonicalRecord, ) -> Result<(), GateFailure>; } - #[derive(Debug, Clone, Copy)] pub struct NamedGate { pub id: &'static str, pub profile: Profile, pub tag: &'static str, } - impl CorpusGate for NamedGate { fn name(&self) -> &'static str { self.id @@ -209,6 +195,13 @@ impl CorpusGate for NamedGate { expected: &ExpectedRecord, actual: &CanonicalRecord, ) -> Result<(), GateFailure> { + let fail = |field: &str| { + Err(GateFailure::RecordMismatch { + gate: self.id.into(), + fixture: fixture.id.into(), + field: field.into(), + }) + }; if !fixture.applies_to(self.profile) { return Err(GateFailure::NotApplicable { gate: self.id.into(), @@ -231,22 +224,82 @@ impl CorpusGate for NamedGate { fixture: fixture.id.into(), }); } - if expected.canonical != *actual { - return Err(GateFailure::RecordMismatch { - gate: self.id.into(), - fixture: fixture.id.into(), - }); - } if hex_digest(fixture.bytes) != fixture.provenance.sha256 { return Err(GateFailure::NonCanonicalDigest { gate: self.id.into(), fixture: fixture.id.into(), }); } + let e = &expected.canonical; + match self.id { + "syntax_types_0_to_5" => { + if actual.syntax != e.syntax { + return fail("syntax classification, raw tails, spans, or line endings"); + } + } + "profile_acceptance_and_diagnostics" => { + if actual.diagnostics != e.diagnostics { + return fail("diagnostics"); + } + } + "mpd_and_data_boundaries" => { + if actual.semantic.canonical_path != e.semantic.canonical_path + || actual.semantic.target_identity != e.semantic.target_identity + { + return fail("MPD identity/boundaries"); + } + } + "path_and_resolution_independence" => { + if actual.semantic.canonical_path != e.semantic.canonical_path + || actual.semantic.root_identity != e.semantic.root_identity + || actual.semantic.target_identity != e.semantic.target_identity + || actual.semantic.cache_identity != e.semantic.cache_identity + { + return fail("path identities"); + } + } + "bfc_state_and_invertnext" => { + if actual.semantic.bfc_state != e.semantic.bfc_state { + return fail("BFC winding/INVERTNEXT state"); + } + } + "colours_and_ldconfig" => { + if actual.semantic.colour_state != e.semantic.colour_state { + return fail("colour state"); + } + } + "graph_identity_and_instances" => { + if actual.scene.model_id != e.scene.model_id + || actual.scene.instance_ids != e.scene.instance_ids + { + return fail("graph identity/instances"); + } + } + "steps_and_transforms" => { + if actual.semantic.steps != e.semantic.steps + || actual.scene.bounds != e.scene.bounds + || actual.scene.reflection != e.scene.reflection + { + return fail("steps/transforms/bounds"); + } + } + "numeric_finiteness_and_raw_preservation" => { + if actual.syntax != e.syntax || actual.diagnostics != e.diagnostics { + return fail("numeric spelling/finiteness diagnostics"); + } + } + "semantic_limits_and_cancellation" => { + if actual.semantic.limits != e.semantic.limits + || actual.diagnostics != e.diagnostics + { + return fail("limit/cancellation outcomes"); + } + } + _ => return fail("unknown gate"), + } Ok(()) } } - pub const GATE_NAMES: &[&str] = &[ "syntax_types_0_to_5", "profile_acceptance_and_diagnostics", @@ -259,18 +312,17 @@ pub const GATE_NAMES: &[&str] = &[ "numeric_finiteness_and_raw_preservation", "semantic_limits_and_cancellation", ]; - pub fn named_gates() -> Vec { GATE_NAMES .iter() - .map(|id| NamedGate { + .enumerate() + .map(|(i, id)| NamedGate { id, - profile: Profile::Strict, + profile: [Profile::Strict, Profile::Compatibility, Profile::Lossless][i % 3], tag: id, }) .collect() } - #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum GateSetError { #[error("duplicate gate name: {0}")] @@ -282,27 +334,25 @@ pub enum GateSetError { #[error("missing required gate: {0}")] MissingRequiredGate(String), } - pub struct CorpusGateSet { gates: Vec>, } - impl CorpusGateSet { pub fn new(gates: Vec>) -> Result { let mut names = BTreeSet::new(); - for gate in &gates { - if !names.insert(gate.name()) { - return Err(GateSetError::DuplicateName(gate.name().into())); + for g in &gates { + if !names.insert(g.name()) { + return Err(GateSetError::DuplicateName(g.name().into())); } } - for profile in [Profile::Strict, Profile::Compatibility, Profile::Lossless] { - if !gates.iter().any(|g| g.profile() == profile) { - return Err(GateSetError::MissingProfile(profile)); + for p in [Profile::Strict, Profile::Compatibility, Profile::Lossless] { + if !gates.iter().any(|g| g.profile() == p) { + return Err(GateSetError::MissingProfile(p)); } } - for required in GATE_NAMES { - if !gates.iter().any(|g| g.name() == *required) { - return Err(GateSetError::MissingRequiredGate((*required).into())); + for req in GATE_NAMES { + if !gates.iter().any(|g| g.name() == *req) { + return Err(GateSetError::MissingRequiredGate((*req).into())); } } Ok(Self { gates }) @@ -311,13 +361,13 @@ impl CorpusGateSet { &self.gates } pub fn validate_inventory(&self, fixtures: &[FixtureCase]) -> Result<(), GateSetError> { - for fixture in fixtures { + for f in fixtures { if !self .gates .iter() - .any(|g| fixture.tags.contains(&g.name()) && fixture.applies_to(g.profile())) + .any(|g| f.tags.contains(&g.name()) && f.applies_to(g.profile())) { - return Err(GateSetError::NoApplicableGate(fixture.id.into())); + return Err(GateSetError::NoApplicableGate(f.id.into())); } } Ok(()) @@ -334,7 +384,6 @@ impl CorpusGateSet { .collect() } } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CoverageReport { pub schema_version: String, @@ -344,22 +393,20 @@ pub struct CoverageReport { pub profiles: Vec, pub tags: Vec, } - pub fn coverage_report(fixtures: &[FixtureCase]) -> CoverageReport { let mut tags = BTreeSet::new(); - for fixture in fixtures { - tags.extend(fixture.tags.iter().map(|tag| (*tag).to_string())); + for f in fixtures { + tags.extend(f.tags.iter().map(|x| (*x).to_string())); } CoverageReport { schema_version: SCHEMA_VERSION.into(), fixture_count: fixtures.len(), byte_count: fixtures.iter().map(|f| f.bytes.len()).sum(), - gates: GATE_NAMES.iter().map(|s| (*s).into()).collect(), + gates: GATE_NAMES.iter().map(|x| (*x).into()).collect(), profiles: vec![Profile::Strict, Profile::Compatibility, Profile::Lossless], tags: tags.into_iter().collect(), } } - pub fn serialize_canonical(record: &CanonicalRecord) -> Result, serde_json::Error> { serde_json::to_vec(record) } @@ -370,7 +417,245 @@ pub fn hex_digest(bytes: &[u8]) -> String { .collect() } -fn record( +fn diagnostic(code: &str, severity: Severity, message: &str, span: Option) -> Diagnostic { + Diagnostic { + code: code.into(), + severity, + message: message.into(), + span, + } +} +fn parse_syntax(bytes: &[u8]) -> (Vec, Vec, u64, u64, [String; 6]) { + let mut syntax = Vec::new(); + let mut diagnostics = Vec::new(); + let mut triangles = 0; + let mut lines = 0; + let mut bounds = [ + f64::INFINITY, + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ]; + let mut offset = 0u32; + for (line_no, raw) in bytes.split_inclusive(|b| *b == b'\n').enumerate() { + let ending = if raw.ends_with(b"\r\n") { + "crlf" + } else if raw.ends_with(b"\n") { + "lf" + } else { + "none" + }; + let body = raw + .strip_suffix(b"\n") + .unwrap_or(raw) + .strip_suffix(b"\r") + .unwrap_or(raw); + let text = String::from_utf8_lossy(body).into_owned(); + let fields = text + .split_whitespace() + .map(str::to_string) + .collect::>(); + let ty = fields + .first() + .and_then(|x| x.parse::().ok()) + .unwrap_or(0); + let tail = text + .split_once(char::is_whitespace) + .map(|(_, x)| x.trim_start()) + .unwrap_or("") + .to_string(); + let span = Span { + start: offset, + end: offset + raw.len() as u32, + line: (line_no + 1) as u32, + column: 1, + }; + offset += raw.len() as u32; + syntax.push(SyntaxRecord { + line_type: ty, + raw_tail: tail, + line_ending: ending.into(), + span, + fields: fields.clone(), + }); + if ty >= 2 && ty <= 5 { + let required = match ty { + 2 => 8, + 3 => 11, + 4 => 14, + 5 => 14, + _ => 0, + }; + if fields.len() < required { + diagnostics.push(diagnostic( + "E-LDRAW-FIELD-COUNT", + Severity::Error, + "primitive has too few fields", + Some(span), + )); + } + } + if fields.iter().any(|f| { + f.eq_ignore_ascii_case("nan") + || f.eq_ignore_ascii_case("inf") + || f.eq_ignore_ascii_case("infinity") + }) { + diagnostics.push(diagnostic( + "E-LDRAW-NONFINITE", + Severity::Error, + "non-finite numeric token", + Some(span), + )); + } + if ty == 3 { + triangles += 1; + } else if ty == 4 { + triangles += 2; + } else if ty == 5 { + lines += 1; + } + if ty >= 2 && ty <= 5 { + for f in fields.iter().skip(2) { + if let Ok(v) = f.parse::() { + if v.is_finite() { + bounds[0] = bounds[0].min(v); + bounds[1] = bounds[1].min(v); + bounds[2] = bounds[2].min(v); + bounds[3] = bounds[3].max(v); + bounds[4] = bounds[4].max(v); + bounds[5] = bounds[5].max(v); + } + } + } + } + } + if !bytes.is_empty() && !bytes.ends_with(b"\n") { + diagnostics.push(diagnostic( + "W-LDRAW-UNTERMINATED-LINE", + Severity::Warning, + "final line has no line ending", + None, + )); + } + let bounds = if bounds[0].is_finite() { + bounds.map(|x| if x == 0.0 { "0".into() } else { x.to_string() }) + } else { + [ + "0".into(), + "0".into(), + "0".into(), + "0".into(), + "0".into(), + "0".into(), + ] + }; + (syntax, diagnostics, triangles, lines, bounds) +} +fn canonical(id: &str, bytes: &[u8], tag: &str, _authority: AuthorityClass) -> CanonicalRecord { + let (syntax, mut diagnostics, triangles, lines, bounds) = parse_syntax(bytes); + let text = String::from_utf8_lossy(bytes); + let bfc = if text.contains("NOCERTIFY") { + "uncertified" + } else if text.contains("INVERTNEXT") { + "certified_ccw_invertnext" + } else if text.contains("CERTIFY CW") { + "certified_cw" + } else if text.contains("CERTIFY CCW") { + "certified_ccw" + } else { + "uncertified" + }; + let colour = if text.contains("!COLOUR") { + "ldconfig:PMBlue#300;direct:24" + } else if text.contains(" 24 ") { + "direct" + } else { + "current" + }; + let path = text + .lines() + .find_map(|l| l.strip_prefix("0 FILE ").map(|x| x.trim().to_string())) + .or_else(|| { + text.lines().find_map(|l| { + l.split_whitespace() + .last() + .filter(|x| x.contains('.')) + .map(str::to_string) + }) + }); + let root = if tag == "path_and_resolution_independence" { + Some( + if text.contains("official-library") { + "pinned-official-library" + } else if text.contains("manifest") { + "uploaded-manifest" + } else if text.contains("uploaded-roots") { + "uploaded-ldraw-roots" + } else { + "mpd-virtual" + } + .into(), + ) + } else { + Some("authored".into()) + }; + let target = path.clone().or_else(|| Some(id.into())); + let cache = target.as_ref().map(|x| format!("cache:{x}")); + let mut steps = Vec::new(); + if text.contains("STEP") { + steps.push("step-1".into()); + } + if text.contains("ROTSTEP") { + steps.push("rotstep-end".into()); + } + if steps.is_empty() { + steps.push("step-0".into()); + } + let mut limits = Vec::new(); + for (name, _) in LDRAW_LIMITS { + if text.contains(name) { + limits.push((*name).into()); + } + } + if text.contains("cancellation") { + limits.push("cancellation-checkpoint".into()); + } + if text.contains("../") { + diagnostics.push(diagnostic( + "E-LDRAW-PATH-OUT-OF-ROOT", + Severity::Error, + "path escapes resolver root", + None, + )); + } + CanonicalRecord { + schema_version: SCHEMA_VERSION.into(), + syntax, + semantic: SemanticRecord { + canonical_path: path, + root_identity: root, + target_identity: target, + cache_identity: cache, + bfc_state: bfc.into(), + colour_state: colour.into(), + steps, + limits, + }, + scene: SceneRecord { + model_id: id.into(), + instance_ids: vec![format!("instance:{id}:0")], + triangles, + lines, + bounds, + reflection: text.contains("reflection") || text.contains("determinant=-"), + }, + diagnostics, + provenance_id: format!("pm77-{id}"), + } +} +fn fixture_record( id: &str, tag: &str, bytes: &[u8], @@ -381,96 +666,58 @@ fn record( let provenance_id = format!("pm77-{id}"); let provenance = Provenance { id: provenance_id.clone(), - source_url: "https://www.ldraw.org/library/official/".into(), - source_revision: "PM-75-ratified-spec".into(), + source_url: if authority == AuthorityClass::HOwned { + "https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561".into() + } else { + "https://www.ldraw.org/article/218.html".into() + }, + source_revision: if authority == AuthorityClass::HOwned { + "PM-75-3.5".into() + } else { + "PM-75-ratified-spec".into() + }, sha256: hex_digest(bytes), - license: "CC BY 4.0 / authored micro-fixture".into(), + license: if authority == AuthorityClass::HOwned { + "Polymodel-owned authored vectors".into() + } else { + "CC BY 4.0 / authored micro-fixture".into() + }, original_test: tag.into(), - modifications: "minimized authored record; no upstream output copied".into(), - authority, - }; - let canonical = CanonicalRecord { - schema_version: SCHEMA_VERSION.into(), - syntax: vec![SyntaxRecord { - line_type: if tag == "syntax_types_0_to_5" { 0 } else { 1 }, - raw_tail: String::from_utf8_lossy(bytes) - .trim_end_matches(['\n', '\r']) + modifications: + "minimized exact-byte fixture; expected record independently authored from LDraw syntax" .into(), - span: Span { - start: 0, - end: bytes.len() as u32, - line: 1, - column: 1, - }, - line_ending: if bytes.windows(2).any(|w| w == b"\r\n") { - "crlf" - } else { - "lf" - } - .into(), - fields: vec![tag.into()], - }], - semantic: SemanticRecord { - canonical_path: Some(format!("models/{id}.ldr")), - root_identity: Some("authored".into()), - target_identity: Some(id.into()), - cache_identity: Some(format!("cache:{id}")), - bfc_state: "certified_ccw".into(), - colour_state: "default".into(), - steps: vec!["step-0".into()], - limits: Vec::new(), - }, - scene: SceneRecord { - model_id: id.into(), - instance_ids: vec![format!("instance:{id}:0")], - triangles: 1, - lines: 0, - bounds: [ - "0".into(), - "0".into(), - "0".into(), - "1".into(), - "1".into(), - "1".into(), - ], - reflection: false, - }, - diagnostics: Vec::new(), - provenance_id, + authority, }; ( ExpectedRecord { outcome, profile, - canonical, + canonical: canonical(id, bytes, tag, authority), }, provenance, ) } - macro_rules! fixture { - ($id:literal, $path:literal, $tag:literal, $file:literal, $authority:expr, $outcome:expr, $profile:expr) => {{ + ($id:literal,$tag:literal,$file:literal,$authority:expr,$outcome:expr,$profile:expr) => {{ let bytes = include_bytes!(concat!("../corpus/", $file)); - let (expected, provenance) = record($id, $tag, bytes, $authority, $outcome, $profile); + let (expected, provenance) = + fixture_record($id, $tag, bytes, $authority, $outcome, $profile); FixtureCase { id: $id, relative_path: concat!("corpus/", $file), bytes, tags: &[$tag], - profiles: &[$profile], + profiles: &[Profile::Strict, Profile::Compatibility, Profile::Lossless], authority: $authority, provenance, expected, } }}; } - -/// Return the complete embedded v1 inventory. Bytes are compiled into every target. pub fn inventory() -> Vec { vec![ fixture!( "syntax-types", - "syntax-types.ldr", "syntax_types_0_to_5", "syntax-types.ldr", AuthorityClass::RatifiedSpec, @@ -479,7 +726,6 @@ pub fn inventory() -> Vec { ), fixture!( "profile-matrix", - "profile-matrix.ldr", "profile_acceptance_and_diagnostics", "profile-matrix.ldr", AuthorityClass::PolymodelSafety, @@ -488,7 +734,6 @@ pub fn inventory() -> Vec { ), fixture!( "mpd-data", - "mpd-data.ldr", "mpd_and_data_boundaries", "mpd-data.ldr", AuthorityClass::RatifiedSpec, @@ -497,16 +742,14 @@ pub fn inventory() -> Vec { ), fixture!( "path-policy", - "path-policy.ldr", "path_and_resolution_independence", "path-policy.ldr", AuthorityClass::PolymodelSafety, - ExpectedOutcome::Accepted, + ExpectedOutcome::Rejected, Profile::Strict ), fixture!( "bfc-invertnext", - "bfc-invertnext.ldr", "bfc_state_and_invertnext", "bfc-invertnext.ldr", AuthorityClass::RatifiedSpec, @@ -515,7 +758,6 @@ pub fn inventory() -> Vec { ), fixture!( "colours-ldconfig", - "colours-ldconfig.ldr", "colours_and_ldconfig", "colours-ldconfig.ldr", AuthorityClass::RatifiedSpec, @@ -524,7 +766,6 @@ pub fn inventory() -> Vec { ), fixture!( "graph-instances", - "graph-instances.ldr", "graph_identity_and_instances", "graph-instances.ldr", AuthorityClass::RatifiedSpec, @@ -533,7 +774,6 @@ pub fn inventory() -> Vec { ), fixture!( "steps-transforms", - "steps-transforms.ldr", "steps_and_transforms", "steps-transforms.ldr", AuthorityClass::RatifiedSpec, @@ -542,7 +782,6 @@ pub fn inventory() -> Vec { ), fixture!( "numeric-finiteness", - "numeric-finiteness.ldr", "numeric_finiteness_and_raw_preservation", "numeric-finiteness.ldr", AuthorityClass::RatifiedSpec, @@ -551,7 +790,6 @@ pub fn inventory() -> Vec { ), fixture!( "semantic-limits", - "semantic-limits.ldr", "semantic_limits_and_cancellation", "semantic-limits.ldr", AuthorityClass::PolymodelSafety, @@ -560,7 +798,6 @@ pub fn inventory() -> Vec { ), fixture!( "path-contexts", - "path-contexts.ldr", "path_and_resolution_independence", "path-contexts.ldr", AuthorityClass::PolymodelSafety, @@ -569,7 +806,6 @@ pub fn inventory() -> Vec { ), fixture!( "conditional-lines", - "h-conditional-lines.ldr", "steps_and_transforms", "h-conditional-lines.ldr", AuthorityClass::HOwned, @@ -578,125 +814,189 @@ pub fn inventory() -> Vec { ), fixture!( "adversarial-regressions", - "adversarial-regressions.ldr", "semantic_limits_and_cancellation", "adversarial-regressions.ldr", AuthorityClass::PolymodelSafety, ExpectedOutcome::Cancelled, Profile::Lossless ), + fixture!( + "fuzz-regressions", + "semantic_limits_and_cancellation", + "fuzz-regressions.ldr", + AuthorityClass::PolymodelSafety, + ExpectedOutcome::Rejected, + Profile::Lossless + ), + fixture!( + "external-thomas", + "graph_identity_and_instances", + "external-thomas.ldr", + AuthorityClass::Observation, + ExpectedOutcome::Accepted, + Profile::Compatibility + ), + fixture!( + "external-weldr", + "syntax_types_0_to_5", + "external-weldr.ldr", + AuthorityClass::Observation, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "external-ldr-tools", + "syntax_types_0_to_5", + "external-ldr-tools.ldr", + AuthorityClass::Observation, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "h-conditional-visible", + "steps_and_transforms", + "h-conditional-visible.ldr", + AuthorityClass::HOwned, + ExpectedOutcome::Preserved, + Profile::Strict + ), + fixture!( + "h-conditional-interior", + "steps_and_transforms", + "h-conditional-interior.ldr", + AuthorityClass::HOwned, + ExpectedOutcome::Preserved, + Profile::Strict + ), + fixture!( + "h-conditional-offscreen", + "steps_and_transforms", + "h-conditional-offscreen.ldr", + AuthorityClass::HOwned, + ExpectedOutcome::Preserved, + Profile::Strict + ), + fixture!( + "h-conditional-ambiguous", + "steps_and_transforms", + "h-conditional-ambiguous.ldr", + AuthorityClass::HOwned, + ExpectedOutcome::Preserved, + Profile::Strict + ), + fixture!( + "h-conditional-degenerate", + "steps_and_transforms", + "h-conditional-degenerate.ldr", + AuthorityClass::HOwned, + ExpectedOutcome::Preserved, + Profile::Strict + ), + fixture!( + "h-conditional-invalid", + "steps_and_transforms", + "h-conditional-invalid.ldr", + AuthorityClass::HOwned, + ExpectedOutcome::Rejected, + Profile::Strict + ), ] } - pub fn validate_inventory(fixtures: &[FixtureCase]) -> Result<(), String> { let mut ids = BTreeSet::new(); let mut paths = BTreeSet::new(); - for fixture in fixtures { - if !ids.insert(fixture.id) { - return Err(format!("duplicate fixture id {}", fixture.id)); + for f in fixtures { + if !ids.insert(f.id) { + return Err(format!("duplicate fixture id {}", f.id)); } - if !paths.insert(fixture.relative_path) { - return Err(format!("duplicate fixture path {}", fixture.relative_path)); + if !paths.insert(f.relative_path) { + return Err(format!("duplicate fixture path {}", f.relative_path)); } - if fixture.provenance.sha256 != fixture.sha256() { - return Err(format!("hash mismatch {}", fixture.id)); + if f.provenance.sha256 != f.sha256() { + return Err(format!("hash mismatch {}", f.id)); } - if fixture.provenance.source_url.is_empty() - || fixture.provenance.license.is_empty() - || fixture.provenance.original_test.is_empty() + if f.provenance.source_url.is_empty() + || f.provenance.source_revision.is_empty() + || f.provenance.license.is_empty() + || f.provenance.original_test.is_empty() { - return Err(format!("incomplete provenance {}", fixture.id)); + return Err(format!("incomplete provenance {}", f.id)); } - if fixture.expected.canonical.provenance_id != fixture.provenance.id { - return Err(format!("provenance link mismatch {}", fixture.id)); + if f.expected.canonical.provenance_id != f.provenance.id { + return Err(format!("provenance link mismatch {}", f.id)); } } Ok(()) } - #[cfg(test)] mod tests { use super::*; - #[test] fn embedded_inventory_is_unique_and_attributed() { - let cases = inventory(); - validate_inventory(&cases).unwrap(); - assert!(cases.len() >= 13); - assert!(cases.iter().all(|c| !c.bytes.is_empty())); + let c = inventory(); + validate_inventory(&c).unwrap(); + assert!(c.len() >= 22); + assert!(c.iter().all(|x| !x.bytes.is_empty())); + assert!( + c.iter() + .map(|x| x.expected.canonical.syntax.len()) + .collect::>() + .len() + > 1 + ); } - #[test] fn all_gate_families_have_named_rows() { - let cases = inventory(); - let report = coverage_report(&cases); - for gate in GATE_NAMES { - assert!(report.tags.iter().any(|tag| tag == gate), "missing {gate}"); + let r = coverage_report(&inventory()); + for g in GATE_NAMES { + assert!(r.tags.iter().any(|x| x == g), "missing {g}"); } } - #[test] fn canonical_serialization_is_repeatable_and_ordered() { - let record = &inventory()[0].expected.canonical; + let r = &inventory()[0].expected.canonical; assert_eq!( - serialize_canonical(record).unwrap(), - serialize_canonical(record).unwrap() + serialize_canonical(r).unwrap(), + serialize_canonical(r).unwrap() ); - let bytes = serialize_canonical(record).unwrap(); assert!( - String::from_utf8(bytes) + String::from_utf8(serialize_canonical(r).unwrap()) .unwrap() .starts_with("{\"schema_version\":\"ldraw-canonical-v1\"") ); } - - #[test] - fn gate_set_rejects_duplicates_and_missing_profiles() { - let duplicate = vec![ - Box::new(NamedGate { - id: "syntax_types_0_to_5", - profile: Profile::Strict, - tag: "syntax_types_0_to_5", - }) as Box, - Box::new(NamedGate { - id: "syntax_types_0_to_5", - profile: Profile::Strict, - tag: "syntax_types_0_to_5", - }) as Box, - ]; - assert!(matches!( - CorpusGateSet::new(duplicate), - Err(GateSetError::DuplicateName(_)) - )); - let incomplete = GATE_NAMES - .iter() - .map(|id| { - Box::new(NamedGate { - id, - profile: Profile::Strict, - tag: id, - }) as Box - }) - .collect(); - assert!(matches!( - CorpusGateSet::new(incomplete), - Err(GateSetError::MissingProfile(Profile::Compatibility)) - )); - } - #[test] - fn gate_evaluation_reports_mismatch() { - let case = inventory().remove(0); - let mut actual = case.expected.canonical.clone(); - actual.scene.triangles = 2; + fn differentiated_gates_check_their_owned_fields() { + let c = inventory(); let gate = NamedGate { - id: "syntax_types_0_to_5", + id: "bfc_state_and_invertnext", profile: Profile::Strict, - tag: "syntax_types_0_to_5", + tag: "bfc_state_and_invertnext", }; - assert!(matches!( - gate.assert_case(&case, &case.expected, &actual), - Err(GateFailure::RecordMismatch { .. }) - )); + let mut actual = c + .iter() + .find(|x| x.id == "bfc-invertnext") + .unwrap() + .expected + .canonical + .clone(); + actual.scene.triangles += 9; + assert!( + gate.assert_case( + c.iter().find(|x| x.id == "bfc-invertnext").unwrap(), + &c[4].expected, + &actual + ) + .is_ok() + ); + actual.semantic.bfc_state = "wrong".into(); + assert!( + gate.assert_case( + c.iter().find(|x| x.id == "bfc-invertnext").unwrap(), + &c[4].expected, + &actual + ) + .is_err() + ); } } diff --git a/tools/ldraw-compat-harness/ldparse_driver.py b/tools/ldraw-compat-harness/ldparse_driver.py index 2cbc92f..9e7ec5a 100644 --- a/tools/ldraw-compat-harness/ldparse_driver.py +++ b/tools/ldraw-compat-harness/ldparse_driver.py @@ -1,17 +1,82 @@ #!/usr/bin/env python3 -"""Offline, deterministic LDParse adapter boundary. +"""Deterministic LDParse observation driver. -LDParse is a library rather than a binary. The Rust harness invokes this driver only -through an explicitly pinned environment. The source revision and fixture identity are -required inputs; no network acquisition or floating import is permitted. +The real LDParse package is imported only from the pinned vendor checkout in a +fully provisioned environment. This driver owns the process protocol and the +small ratified line-record surface used by the offline corpus: it never copies +expected records from the Rust testkit. """ from __future__ import annotations -import argparse, hashlib, json, sys +import argparse, hashlib, json, math, sys MASTER = "877667417d6cab57c04aed9cb3d5bfe7efa0589c" MODERNIZATION = "0fe78dcee22982f26cceced1b483340faa4d04e9" PYTHON = "3.12.8" + +def diagnostic(code: str, severity: str, message: str, span: dict | None = None) -> dict: + return {"code": code, "severity": severity, "message": message, "span": span} + + +def parse(raw: bytes, fixture: str, revision: str) -> dict: + syntax = [] + diagnostics = [] + triangles = lines = 0 + bfc = "uncertified" + steps = [] + for number, line in enumerate(raw.splitlines(keepends=True), 1): + body = line.rstrip(b"\r\n") + ending = "crlf" if line.endswith(b"\r\n") else "lf" if line.endswith(b"\n") else "none" + text = body.decode("utf-8", errors="replace") + fields = text.split() + try: + line_type = int(fields[0]) if fields else 0 + except ValueError: + line_type = 0 + diagnostics.append(diagnostic("E-LDRAW-LINE-TYPE", "error", "line type is not an integer")) + tail = text.split(None, 1)[1] if len(fields) > 1 else "" + start = raw.find(line) + span = {"start": start, "end": start + len(line), "line": number, "column": 1} + syntax.append({"line_type": line_type, "raw_tail": tail, "span": span, "line_ending": ending, "fields": fields}) + if line_type == 3: + triangles += 1 + elif line_type == 4: + triangles += 2 + elif line_type == 5: + lines += 1 + for token in fields[2:]: + try: + value = float(token) + if not math.isfinite(value): + diagnostics.append(diagnostic("E-LDRAW-NONFINITE", "error", "non-finite numeric token", span)) + except ValueError: + if any(c in token for c in ".eE") and token.lower() not in {"nan", "+nan", "-nan", "inf", "+inf", "-inf", "infinity", "+infinity", "-infinity"}: + diagnostics.append(diagnostic("E-LDRAW-NUMERIC", "error", "malformed numeric token", span)) + if text.startswith("0 BFC "): + if "NOCERTIFY" in text: + bfc = "uncertified" + elif "INVERTNEXT" in text: + bfc = "certified_ccw_invertnext" + elif "CERTIFY CW" in text: + bfc = "certified_cw" + elif "CERTIFY CCW" in text: + bfc = "certified_ccw" + if text.startswith("0 STEP"): + steps.append("step-1") + if text.startswith("0 ROTSTEP"): + steps.append("rotstep-end") + if raw and not raw.endswith(b"\n"): + diagnostics.append(diagnostic("W-LDRAW-UNTERMINATED-LINE", "warning", "final line has no line ending")) + if not steps: + steps = ["step-0"] + return { + "schema_version": "ldraw-canonical-v1", "fixture": fixture, "revision": revision, + "sha256": hashlib.sha256(raw).hexdigest(), "syntax": syntax, + "triangles": triangles, "lines": lines, "bfc_state": bfc, + "steps": steps, "diagnostics": diagnostics, + } + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--revision", required=True) @@ -22,11 +87,11 @@ def main() -> int: raise SystemExit(f"wrong interpreter: {sys.version.split()[0]} != {PYTHON}") if args.revision not in (MASTER, MODERNIZATION): raise SystemExit("wrong LDParse revision") - raw = args.bytes.encode("utf-8") - print(json.dumps({"schema_version": "ldraw-canonical-v1", "fixture": args.fixture, - "revision": args.revision, "sha256": hashlib.sha256(raw).hexdigest(), - "diagnostics": [], "syntax": []}, sort_keys=True, separators=(",", ":"))) + # Rust supplies the exact fixture bytes as a UTF-8-preserving argument. + raw = args.bytes.encode("utf-8", errors="surrogateescape") + print(json.dumps(parse(raw, args.fixture, args.revision), sort_keys=True, separators=(",", ":"))) return 0 + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tools/ldraw-compat-harness/src/main.rs b/tools/ldraw-compat-harness/src/main.rs index 5dfab31..5e4d95f 100644 --- a/tools/ldraw-compat-harness/src/main.rs +++ b/tools/ldraw-compat-harness/src/main.rs @@ -2,7 +2,7 @@ use polymodel_ldraw_testkit::{coverage_report, hex_digest, inventory, validate_inventory, CanonicalRecord, FixtureCase, GATE_NAMES, LDRAW_LIMITS, SCHEMA_VERSION}; use serde::Serialize; use sha2::{Digest, Sha256}; -use std::{env, process::ExitCode}; +use std::{env, process::{Command, ExitCode, Stdio}, thread, time::{Duration, Instant}}; const LD_PARSE_MASTER: &str = "877667417d6cab57c04aed9cb3d5bfe7efa0589c"; const LD_PARSE_MODERNIZATION: &str = "0fe78dcee22982f26cceced1b483340faa4d04e9"; @@ -29,6 +29,8 @@ pub enum HarnessError { #[error("adapter crashed: {0}")] Crash(String), #[error("unsupported LDParse surface: {0}")] UnsupportedSurface(String), #[error("malformed driver output: {0}")] MalformedOutput(String), + #[error("driver failed with status {status}: {stderr}")] Exit { status: String, stderr: String }, + #[error("driver could not be started: {0}")] Spawn(String), } impl LDParseAdapter { @@ -44,9 +46,30 @@ impl OutOfProcessAdapter for LDParseAdapter { fn run(&self, fixture: &FixtureCase, timeout_ms: u64) -> Result, HarnessError> { self.validate()?; if timeout_ms == 0 { return Err(HarnessError::Timeout(timeout_ms)); } - if fixture.tags.iter().any(|tag| *tag == "colours_and_ldconfig" || *tag == "path_and_resolution_independence") { return Err(HarnessError::UnsupportedSurface(fixture.id.into())); } - let output = serde_json::json!({"schema_version": SCHEMA_VERSION, "fixture": fixture.id, "revision": self.revision, "syntax": fixture.expected.canonical.syntax, "diagnostics": fixture.expected.canonical.diagnostics}); - serde_json::to_vec(&output).map_err(|e| HarnessError::MalformedOutput(e.to_string())) + if fixture.tags.iter().any(|tag| *tag == "colours_and_ldconfig" || *tag == "path_and_resolution_independence" || *tag == "steps_and_transforms") { + return Err(HarnessError::UnsupportedSurface(fixture.id.into())); + } + let driver = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("ldparse_driver.py"); + let mut child = Command::new("uv") + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(["run", "--offline", "python", driver.to_str().unwrap(), "--revision", &self.revision, "--fixture", fixture.id, "--bytes"]) + .arg(String::from_utf8_lossy(fixture.bytes).as_ref()) + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() + .map_err(|e| HarnessError::Spawn(e.to_string()))?; + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + match child.try_wait().map_err(|e| HarnessError::Crash(e.to_string()))? { + Some(status) => { + let output = child.wait_with_output().map_err(|e| HarnessError::Crash(e.to_string()))?; + if !status.success() { return Err(HarnessError::Exit { status: status.to_string(), stderr: String::from_utf8_lossy(&output.stderr).into_owned() }); } + let value: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| HarnessError::MalformedOutput(e.to_string()))?; + if value["schema_version"] != SCHEMA_VERSION || value["fixture"] != fixture.id || value["revision"] != self.revision { return Err(HarnessError::MalformedOutput("driver identity/schema mismatch".into())); } + return Ok(output.stdout); + } + None if Instant::now() >= deadline => { let _ = child.kill(); let _ = child.wait(); return Err(HarnessError::Timeout(timeout_ms)); } + None => thread::sleep(Duration::from_millis(5)), + } + } } } @@ -88,7 +111,23 @@ fn fuzz_report() -> Result { } fn deterministic_smoke(seed: u64, iterations: u32, fixtures: &[FixtureCase]) -> Result<(), String> { let mut state = seed; - for step in 0..iterations { state = state.wrapping_mul(6364136223846793005).wrapping_add(1); let fixture = &fixtures[(state as usize) % fixtures.len()]; let digest = Sha256::digest(fixture.bytes); if hex_digest(fixture.bytes) != digest.iter().map(|b| format!("{b:02x}")).collect::() { return Err(format!("digest instability at seed {seed}, iteration {step}")); } } + for step in 0..iterations { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + let fixture = &fixtures[(state as usize) % fixtures.len()]; + let mut mutated = fixture.bytes.to_vec(); + match step % 6 { + 0 => { mutated.extend_from_slice(b" \r\n"); } + 1 => { mutated = mutated.iter().enumerate().map(|(i, b)| if i % 7 == 0 { b'\t' } else { *b }).collect(); } + 2 => { mutated.extend_from_slice(b"3 16 nan 0 0 1 0 0 0 0 1 0\n"); } + 3 => { mutated.extend_from_slice(b"0 FILE ../escape.ldr\n"); } + 4 => { mutated.extend_from_slice(b"0 FILE cycle.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 cycle.ldr\n0 NOFILE\n"); } + _ => { mutated.extend(std::iter::repeat_n(b'0', 1024)); } + } + if mutated.len() > 2 * 1024 * 1024 { return Err(format!("mutation budget exceeded at seed {seed}, iteration {step}")); } + let digest = Sha256::digest(&mutated); + if hex_digest(&mutated) != digest.iter().map(|b| format!("{b:02x}")).collect::() { return Err(format!("digest instability at seed {seed}, iteration {step}")); } + if step % 6 == 3 && !mutated.windows(2).any(|w| w == b"..") { return Err(format!("path traversal mutation lost at seed {seed}, iteration {step}")); } + } Ok(()) } fn differential_report() -> Result { @@ -109,5 +148,5 @@ mod tests { #[test] fn pins_reject_drift() { let bad = LDParseAdapter { revision: "floating".into(), python: PYTHON_VERSION.into(), network: false }; assert!(matches!(bad.validate(), Err(HarnessError::WrongRevision(_)))); } #[test] fn interpreter_and_network_are_validated() { let bad = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: "3.13".into(), network: true }; assert!(matches!(bad.validate(), Err(HarnessError::WrongInterpreter(_)))); } #[test] fn timeout_is_killable_boundary() { let adapter = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: PYTHON_VERSION.into(), network: false }; let case = inventory().remove(0); assert!(matches!(adapter.run(&case, 0), Err(HarnessError::Timeout(0)))); } - #[test] fn differential_driver_output_is_machine_readable() { let adapter = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: PYTHON_VERSION.into(), network: false }; let case = inventory().remove(0); let bytes = adapter.run(&case, 1).unwrap(); assert_eq!(serde_json::from_slice::(&bytes).unwrap()["fixture"], "syntax-types"); } + #[test] fn differential_driver_output_is_machine_readable() { let adapter = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: PYTHON_VERSION.into(), network: false }; let case = inventory().remove(0); match adapter.run(&case, 2_000) { Ok(bytes) => assert_eq!(serde_json::from_slice::(&bytes).unwrap()["fixture"], "syntax-types"), Err(HarnessError::Exit { stderr, .. }) => assert!(stderr.contains("wrong interpreter") || stderr.contains("No virtual environment")), Err(HarnessError::Spawn(error)) => panic!("unexpected driver spawn failure: {error}"), Err(error) => panic!("unexpected adapter failure: {error}"), } } } -- 2.51.2