From 90fae14818f684bf778640be2e613f34e3c7e999 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 13 Sep 2026 05:27:02 -0700 Subject: [PATCH] Make typed source index paths iterative and strengthen adversarial controls --- .../src/language/analysis_source/index.rs | 69 +++- .../language/analysis_source/index/tests.rs | 336 +++++++++++++++++- 2 files changed, 386 insertions(+), 19 deletions(-) diff --git a/crates/oa-core/src/language/analysis_source/index.rs b/crates/oa-core/src/language/analysis_source/index.rs index fcef16f3..f22e4f0b 100644 --- a/crates/oa-core/src/language/analysis_source/index.rs +++ b/crates/oa-core/src/language/analysis_source/index.rs @@ -1,23 +1,83 @@ //! Temporary typed source witnesses. No arena index becomes a model identity. //! The staged entry point parses each supplied typed file once. A future bundle -//! parser can pass its already parsed units/tokens to the private builder. +//! parser must retain the same opaque parse/lower provenance. Occurrence +//! completeness trusts that lowerer; the private builder does not prove the +//! presence of every syntax reference in a caller-fabricated Lowered value. use super::cursor::Cursor; use super::lower::{self, Child, Field, Lowered, Role, Target, Unit}; use super::{Budget, Error, Span, declarations, error, lex}; use crate::{Id, Project, analysis as a}; +use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::sync::Arc; /// Persistent structural path: cloning shares ancestors, and equality/order /// compare the complete edge sequence, never allocation or arena identities. /// Depth is checked against the inherited frame limit before allocation. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)] +/// Comparison and Debug walk leaf-to-root without allocating or recursing; +/// this private total order need not be root-first lexicographic order. +#[derive(Clone, Default)] pub(super) struct Path(Option>); -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] struct Step { parent: Path, edge: Child, } +impl PartialEq for Path { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} +impl Eq for Path {} +impl PartialOrd for Path { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Path { + fn cmp(&self, other: &Self) -> Ordering { + let (mut left, mut right) = (self, other); + loop { + match (left.0.as_deref(), right.0.as_deref()) { + (Some(a), Some(b)) => { + let order = a.edge.cmp(&b.edge); + if order != Ordering::Equal { + return order; + } + (left, right) = (&a.parent, &b.parent); + } + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + } + } + } +} +impl fmt::Debug for Path { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + // Debug intentionally shows the same leaf-to-root order as comparison. + let mut list = formatter.debug_list(); + let mut path = self; + while let Some(step) = &path.0 { + list.entry(&step.edge); + path = &step.parent; + } + list.finish() + } +} +impl Drop for Path { + fn drop(&mut self) { + let mut next = self.0.take(); + while let Some(link) = next { + // Shared ancestors remain owned elsewhere. The final owner peels + // unique links here, taking the parent before Step is dropped. + let Some(mut step) = Arc::into_inner(link) else { + break; + }; + next = step.parent.0.take(); + } + } +} impl Path { pub fn edges(&self) -> Vec { let mut result = Vec::new(); @@ -119,6 +179,9 @@ impl<'s> Index<'s> { Ok((index, lowered)) } + // Same-parse/lower internal seam only. Checks below reject damaged supplied + // sites but occurrence completeness is an invariant of our own lowerer. + // Do not expose a constructor accepting independently fabricated Lowered. fn build( ordinary: &Project, sources: &'s BTreeMap, diff --git a/crates/oa-core/src/language/analysis_source/index/tests.rs b/crates/oa-core/src/language/analysis_source/index/tests.rs index 44f0f569..3a4e7b08 100644 --- a/crates/oa-core/src/language/analysis_source/index/tests.rs +++ b/crates/oa-core/src/language/analysis_source/index/tests.rs @@ -206,9 +206,30 @@ fn arena_rejects_forward_cycles_and_cross_owner_field_file_parents() { #[test] fn arena_rejects_duplicate_sites_occurrences_and_wrong_callee_labels() { for damage in 0..4 { - let sources = sources(); + let mut sources = sources(); + // The other callee is fully valid and reuses both parameter identities + // and spellings. Catalog/token validation must not explain rejection. + sources.insert( + 31, + r#"calculation other "Other" id "00000000-0000-0000-0000-00000000cafe" { + input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration); + input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration); + result Quantity(@duration); body $a + $b; +}"# + .into(), + ); let mut work = budget(); let (units, witnesses, mut lowered) = raw(&sources, &mut work); + let valid = Index::build( + &ordinary(), + &sources, + &units, + &witnesses, + &lowered, + &mut work, + ) + .unwrap(); + let mut expected_mismatch_span = None; match damage { 0 => { let site = &lowered.expressions[0]; @@ -238,9 +259,21 @@ fn arena_rejects_duplicate_sites_occurrences_and_wrong_callee_labels() { .iter_mut() .find(|row| row.role == Role::ArgumentLabel) .unwrap(); - if let Target::Parameter { calculation, .. } = &mut label.target { - *calculation = ordinary().id; - } + let original = label.target; + let Target::Parameter { + calculation, + parameter, + } = &mut label.target + else { + panic!("argument label must target a parameter"); + }; + let other = Target::Parameter { + calculation: Id::from_u128(0xcafe), + parameter: *parameter, + }; + assert_eq!(valid.names[&original], valid.names[&other]); + *calculation = Id::from_u128(0xcafe); + expected_mismatch_span = Some(label.span); } _ => { let row = lowered @@ -251,18 +284,22 @@ fn arena_rejects_duplicate_sites_occurrences_and_wrong_callee_labels() { row.field = Field::Override(ordinary().id); } } - assert!( - Index::build( - &ordinary(), - &sources, - &units, - &witnesses, - &lowered, - &mut work - ) - .is_err(), - "damage {damage}" - ); + let failure = Index::build( + &ordinary(), + &sources, + &units, + &witnesses, + &lowered, + &mut work, + ) + .unwrap_err(); + if let Some(span) = expected_mismatch_span { + assert_eq!( + failure.message, + "Argument label crosses its called calculation." + ); + assert_eq!(failure.span, span); + } } } @@ -324,3 +361,270 @@ fn index_has_shared_budget_and_never_stabilizes_missing_ids() { assert!(Index::parse(&ordinary(), &sources(), &mut work).is_err()); assert!(work.active().is_err()); } + +fn on_small_stack(work: impl FnOnce() + Send + 'static) { + // Match the established core language_nesting_tests worker-stack request. + // This is a requested platform stack size, not a measured peak-byte claim. + std::thread::Builder::new() + .name("analysis-index-128k".into()) + .stack_size(128 * 1024) + .spawn(work) + .unwrap() + .join() + .unwrap(); +} + +fn chain(first: Child, edges: usize) -> Path { + let mut path = Path::default(); + for position in 0..edges { + path = Path(Some(Arc::new(Step { + parent: path, + edge: if position == 0 { first } else { Child::Operand }, + }))); + } + path +} + +#[test] +fn maximum_index_paths_compare_lookup_debug_and_drop_on_128k_stack() { + on_small_stack(|| { + let frames = super::super::budget::Limits::source8(ParseLimits::UNLIMITED).frames; + assert_eq!(frames, 256); + let edges = frames - 1; // root has frame depth one + let mut work = budget(); + work.frame(frames, Span::default()).unwrap(); + let left = chain(Child::Left, edges); + let equal = chain(Child::Left, edges); // independent allocations + let right = chain(Child::Right, edges); + assert!(!Arc::ptr_eq( + left.0.as_ref().unwrap(), + equal.0.as_ref().unwrap() + )); + assert_eq!(left, equal); + assert_eq!(left.partial_cmp(&equal), Some(Ordering::Equal)); + // The difference is at the root-most edge: leaf-first walks every link. + assert_eq!(left.cmp(&right), Child::Left.cmp(&Child::Right)); + assert_eq!(right.cmp(&left), left.cmp(&right).reverse()); + assert!(Path::default() < left); + assert!(chain(Child::Operand, edges - 1) < chain(Child::Operand, edges)); + let shared = left.clone(); + assert!(Arc::ptr_eq( + left.0.as_ref().unwrap(), + shared.0.as_ref().unwrap() + )); + let leaf = Arc::downgrade(left.0.as_ref().unwrap()); + let key = |path| ExpressionKey { + owner: Id::from_u128(1), + field: Field::Body, + path, + }; + let mut expressions = BTreeMap::new(); + assert_eq!(expressions.insert(key(left.clone()), 1), None); + assert_eq!(expressions.insert(key(equal.clone()), 2), Some(1)); + assert_eq!(expressions.insert(key(right.clone()), 3), None); + assert_eq!(expressions.get(&key(equal.clone())), Some(&2)); + assert_eq!(expressions.get(&key(chain(Child::Right, edges))), Some(&3)); + assert_eq!(expressions.len(), 2); + let occurrence = |path| OccurrenceKey { + owner: Id::from_u128(1), + field: Some(Field::Body), + path: Some(path), + role: TokenRole::Use(Role::ParameterUse), + target: Target::Parameter { + calculation: Id::from_u128(1), + parameter: a::ParameterId(Id::from_u128(2)), + }, + }; + let mut occurrences = BTreeSet::new(); + assert!(occurrences.insert(occurrence(left.clone()))); + assert!(!occurrences.insert(occurrence(equal.clone()))); + assert!(occurrences.contains(&occurrence(chain(Child::Left, edges)))); + let debug = format!("{left:?}"); + assert_eq!(debug.matches("Operand").count(), edges - 1); + assert!(debug.ends_with("Left]")); + assert!(!format!("{expressions:?} {occurrences:?}").is_empty()); + drop(expressions); + drop(occurrences); + drop(shared); + drop(left); + assert!(leaf.upgrade().is_none(), "maps/clones release their chain"); + drop(equal); + drop(right); + // Dropping a child stops at a shared ancestor, which remains usable. + let parent = chain(Child::Left, edges - 1); + let ancestor = Arc::downgrade(parent.0.as_ref().unwrap()); + let child = Path(Some(Arc::new(Step { + parent: parent.clone(), + edge: Child::Right, + }))); + drop(child); + assert_eq!(parent.edges().len(), edges - 1); + drop(parent); + assert!(ancestor.upgrade().is_none()); + // No other strong owners: final destruction must peel the full chain. + let unique = chain(Child::Left, edges); + let final_leaf = Arc::downgrade(unique.0.as_ref().unwrap()); + drop(unique); + assert!(final_leaf.upgrade().is_none()); + }); +} + +#[test] +fn deep_parse_index_plan_and_failed_build_cleanup_on_128k_stack() { + on_small_stack(|| { + use super::super::update::Plan; + let deep = format!("{}$a{}", "abs(".repeat(127), ")".repeat(127)); + let source = format!( + r#"calculation f "Deep 雪" id "00000000-0000-0000-0000-000000000081" {{ + input a id "00000000-0000-0000-0000-000000000083" : Number; + result Number; body {deep}; +}} +calculation caller "Caller" id "00000000-0000-0000-0000-000000000082" {{ + result Number; body @f(a: 1); +}} +"# + ); + let sources = BTreeMap::from([(47, source.clone())]); + let skeleton = ordinary(); + let mut work = budget(); + let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap(); + assert_eq!(work.usage.bytes, source.len()); + assert_eq!(index.expressions.len(), 130); // 128 unary/leaf + call/argument + assert_eq!( + index.expressions.keys().map(|k| k.path.edges().len()).max(), + Some(127) + ); + let renames = BTreeMap::from([ + (Target::Global(Id::from_u128(0x81)), "renamed_deep".into()), + ( + Target::Parameter { + calculation: Id::from_u128(0x81), + parameter: a::ParameterId(Id::from_u128(0x83)), + }, + "renamed_arg".into(), + ), + ]); + let usage = work.usage; + let plan = Plan::renames(&index, &renames, &work).unwrap(); + assert_eq!(plan.edit_count(), 5); // declaration/call + input/use/label + let output = plan.apply(&sources, &work).unwrap(); + assert_eq!(work.usage, usage); + // Independent complete original-token oracle; no index-derived offsets. + let expected_source = source + .replace("calculation f ", "calculation renamed_deep ") + .replace("input a id", "input renamed_arg id") + .replace("$a", "$renamed_arg") + .replace("@f(a: 1)", "@renamed_deep(renamed_arg: 1)"); + assert_eq!(output, BTreeMap::from([(47, expected_source)])); + let (renamed_index, renamed) = Index::parse(&skeleton, &output, &mut budget()).unwrap(); + assert_eq!( + index.occurrences.keys().collect::>(), + renamed_index.occurrences.keys().collect::>() + ); + // Compare complete independently constructed models, including deepest + // parameter identity and the call's parameter-keyed argument map. + for (lowered, renamed) in [(lowered, false), (renamed, true)] { + let mut expected = skeleton.clone(); + let parameter = a::ParameterId(Id::from_u128(0x83)); + let mut expression = a::Expr::Parameter(parameter); + for _ in 0..127 { + expression = a::Expr::Unary { + op: a::UnaryOp::Absolute, + operand: Box::new(expression), + }; + } + for (id, alias, name, parameters, expression) in [ + ( + 0x81, + if renamed { "renamed_deep" } else { "f" }, + "Deep 雪", + vec![a::Parameter { + id: parameter, + name: if renamed { "renamed_arg" } else { "a" }.into(), + value_type: a::AnalysisType::Scalar(a::ScalarType::Number), + }], + expression, + ), + ( + 0x82, + "caller", + "Caller", + Vec::new(), + a::Expr::Call { + definition: a::DefinitionRef::Local(Id::from_u128(0x81)), + arguments: BTreeMap::from([( + parameter, + a::Expr::Number(crate::analysis_numeric::Exact::from_integer(1)), + )]), + }, + ), + ] { + let id = Id::from_u128(id); + expected.definitions.insert( + id, + a::Definition { + id, + alias: alias.into(), + name: name.into(), + description: String::new(), + body: a::DefinitionBody::Calculation(a::CalculationDefinition { + parameters, + result: a::AnalysisType::Scalar(a::ScalarType::Number), + body: a::Binding::Expression(expression), + }), + }, + ); + } + let mut actual = skeleton.clone(); + actual.definitions.extend(lowered.definitions); + actual.elements.extend(lowered.elements); + assert_eq!(actual, expected); + a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap(); + drop(actual); + drop(expected); + } + drop(plan); + drop(renamed_index); + drop(index); + // Failure after the deepest keys have been allocated must clean up on + // the same stack; no modified Lowered escapes this private test seam. + let mut work = budget(); + let (units, witnesses, mut lowered) = raw(&sources, &mut work); + let deepest = lowered + .expressions + .iter() + .find(|site| &sources[&site.file][site.span.start..site.span.end] == "$a") + .unwrap(); + lowered.expressions.push(lower::ExpressionSite { + file: deepest.file, + owner: deepest.owner, + field: deepest.field, + parent: deepest.parent, + span: deepest.span, + }); + let failure = + Index::build(&skeleton, &sources, &units, &witnesses, &lowered, &mut work).unwrap_err(); + assert_eq!(failure.message, "Duplicate qualified expression path."); + lowered.expressions.pop(); + let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED); + limits.frames = 127; + let mut bounded = Budget::new(limits).unwrap(); + let failure = Index::build( + &skeleton, + &sources, + &units, + &witnesses, + &lowered, + &mut bounded, + ) + .unwrap_err(); + assert!(failure.message.contains("syntax frames")); + assert!(bounded.active().is_err()); + drop(lowered); + drop(witnesses); + drop(units); + let rejected = BTreeMap::from([(47, source.replace(&deep, &format!("abs({deep})")))]); + let failure = Index::parse(&skeleton, &rejected, &mut budget()).unwrap_err(); + assert!(failure.error.message.contains("logical depth")); + }); +}