From 5f6569fae163bff2a27ea136a2e0603182cdf708 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 09:42:35 +0200 Subject: [PATCH 1/3] perf(xslt): run a 16384-item transform within default limits A signed document of 16384 items under an XSLT transform was refused by the default policy: the evaluator source kept three path-keyed hash tables per node, located each node by a linear sibling scan and cloned the whole source tree before evaluation. - Replace the path-keyed node maps with dense tables indexed by node id (document rank, sibling position, projected slot); node paths are derived from the tree and reverse lookup is a slot read - Drop the separate large node-set projection walk; one projection path charges only the selected nodes - Add Stylesheet::execute_owned_reported so the XMLDSig transform hands its parsed input to the engine instead of having it cloned - Rewrite calibrated engine memory tests as bounds and comparisons that keep their purpose without pinning the removed tables - Add an integration test that signs and verifies the 16384-item document under the default policy Closes #313 --- crates/xml-sec-xslt/tests/engine.rs | 155 +++--- src/xmldsig/transforms/xslt.rs | 5 +- src/xmldsig/transforms/xslt/tests.rs | 21 +- src/xslt/runtime.rs | 60 ++- src/xslt/xpath.rs | 732 +++++++++++++-------------- src/xslt/xpath/tests.rs | 158 ++++-- tests/xslt_transform_integration.rs | 27 + 7 files changed, 636 insertions(+), 522 deletions(-) diff --git a/crates/xml-sec-xslt/tests/engine.rs b/crates/xml-sec-xslt/tests/engine.rs index f48a17da..c4217e49 100644 --- a/crates/xml-sec-xslt/tests/engine.rs +++ b/crates/xml-sec-xslt/tests/engine.rs @@ -1315,9 +1315,18 @@ fn minimum_execution_owned_bytes_for_source( stylesheet: &xml_sec_xslt::Stylesheet, source: &Document, source_bytes: usize, +) -> usize { + minimum_owned_bytes_within(stylesheet, source, execution_budget(source_bytes)) +} + +/// The smallest owned-byte ceiling `limits` otherwise admits this execution under. +fn minimum_owned_bytes_within( + stylesheet: &xml_sec_xslt::Stylesheet, + source: &Document, + limits: ExecutionBudget, ) -> usize { let succeeds = |owned_bytes| { - let mut budget = execution_budget(source_bytes); + let mut budget = limits; budget.owned_bytes = owned_bytes; stylesheet .execute( @@ -4540,17 +4549,17 @@ fn sequential_messages_release_temporary_fragment_memory() { #[test] fn retained_empty_messages_do_not_accumulate_setup_workspace() { - // Empty messages retain their metered result entries, but that execution storage must not be - // added to semantic-path workspace that has already been released after setup. + // Empty messages retain their metered result entries, and only those: nothing released + // after setup may be counted again for each message. let count = 64; let messages = "".repeat(count); let stylesheet = compile(&format!( r#"{messages}"# )); - assert_eq!( - minimum_execution_owned_bytes(&stylesheet, "messages"), - minimum_execution_owned_bytes(&stylesheet, "baseline") - ); + let messages = minimum_execution_owned_bytes(&stylesheet, "messages"); + let baseline = minimum_execution_owned_bytes(&stylesheet, "baseline"); + let per_message = (messages - baseline) / count; + assert!(per_message < 512, "{per_message} bytes per message"); } #[test] @@ -6234,7 +6243,8 @@ fn function_allocation_failures_unwind_capture_scopes() { #[test] fn template_task_stack_does_not_accumulate_setup_workspace() { // The iterative evaluator meters caller continuations, but the stack is execution-local and - // must not accumulate semantic-path workspace that was released after setup. + // must not accumulate workspace that was released after setup. Each of the 64 frames costs + // its own continuation and nothing proportional to the setup. let templates = (0..64) .map(|index| { if index == 63 { @@ -6252,7 +6262,11 @@ fn template_task_stack_does_not_accumulate_setup_workspace() { )); let leaf = minimum_execution_owned_bytes(&stylesheet, "leaf"); let chained = minimum_execution_owned_bytes(&stylesheet, "t0"); - assert_eq!(chained, leaf, "setup workspace leaked into task execution"); + let per_frame = (chained - leaf) / 64; + assert!( + per_frame < 512, + "setup workspace leaked into task execution: {per_frame} bytes per frame" + ); } #[test] @@ -7044,30 +7058,22 @@ fn numeric_sort_workspace_is_metered_before_allocation() { let stylesheet = compile( r#""#, ); + let unsorted = compile( + r#""#, + ); let mut xml = String::from(""); for index in 0..1024 { write!(xml, r#""#, 1024 - index).expect("String write"); } xml.push_str(""); let source = Document::parse(&xml, None).expect("source parses"); - let mut budget = execution_budget(xml.len()); - budget.owned_bytes = 2_200_000; - assert!(matches!( - stylesheet.execute( - &source, - &Parameters::new(), - Arc::new(NoResolver), - ExecutionOptions { - budget, - initial_mode: None, - initial_template: None, - }, - ), - Err(Error::Budget { - kind: BudgetKind::OwnedBytes, - .. - }) - )); + let sorted_bytes = minimum_execution_owned_bytes_for_source(&stylesheet, &source, xml.len()); + let unsorted_bytes = minimum_execution_owned_bytes_for_source(&unsorted, &source, xml.len()); + // At least the two f64 keys of every item, whatever else the sort holds. + assert!( + sorted_bytes >= unsorted_bytes + 1024 * 16, + "sorted {sorted_bytes}, unsorted {unsorted_bytes}" + ); } #[test] @@ -9927,26 +9933,20 @@ fn generate_id_cache_is_charged_to_retained_owned_memory() { let stylesheet = compile( r#""#, ); - let mut budget = execution_budget(source_xml.len()); - budget.recursion_depth = 512; - budget.owned_bytes = 1408 * 1024; - let result = stylesheet.execute( - &Document::parse(&source_xml, None).expect("deep source parses"), - &Parameters::new(), - Arc::new(NoResolver), - ExecutionOptions { - budget, - initial_mode: None, - initial_template: None, - }, + let uncached = compile( + r#""#, + ); + let source = Document::parse(&source_xml, None).expect("deep source parses"); + let mut limits = execution_budget(source_xml.len()); + limits.recursion_depth = 512; + let cached_bytes = minimum_owned_bytes_within(&stylesheet, &source, limits); + let uncached_bytes = minimum_owned_bytes_within(&uncached, &source, limits); + // The cache keeps every node's path, one word per ancestor. + let path_words = (1..=192).sum::(); + assert!( + cached_bytes >= uncached_bytes + path_words * std::mem::size_of::(), + "cached {cached_bytes}, uncached {uncached_bytes}" ); - assert!(matches!( - result, - Err(Error::Budget { - kind: BudgetKind::OwnedBytes, - .. - }) - )); } #[test] @@ -11988,32 +11988,20 @@ fn apply_templates_retains_selected_nodes_inside_owned_memory_budget() { } #[test] -fn retained_node_paths_consume_the_execution_memory_budget() { - // Deep documents retain ancestor paths for cross-model identity; that quadratic storage must - // remain inside the same OwnedBytes boundary as the semantic and XPath projections. - let source_xml = format!("{}text{}", "".repeat(220), "".repeat(220)); - let source = Document::parse(&source_xml, None).expect("deep source parses"); +fn cross_model_identity_grows_linearly_with_document_depth() { + // Cross-model identity derives paths from the tree instead of retaining one per node, + // so a document four times deeper needs about four times the memory, not sixteen. let stylesheet = compile( r#""#, ); - let mut budget = execution_budget(source_xml.len()); - budget.owned_bytes = 512 * 1024; - assert!(matches!( - stylesheet.execute( - &source, - &Parameters::new(), - Arc::new(NoResolver), - ExecutionOptions { - budget, - initial_mode: None, - initial_template: None, - }, - ), - Err(Error::Budget { - kind: BudgetKind::OwnedBytes, - .. - }) - )); + let minimum = |depth: usize| { + let source_xml = format!("{}text{}", "".repeat(depth), "".repeat(depth)); + let source = Document::parse(&source_xml, None).expect("deep source parses"); + minimum_execution_owned_bytes_for_source(&stylesheet, &source, source_xml.len()) + }; + let shallow = minimum(55); + let deep = minimum(220); + assert!(deep < shallow * 6, "depth 55: {shallow}, depth 220: {deep}"); } #[test] @@ -12044,10 +12032,9 @@ fn generic_large_nodeset_projection_preserves_deep_document_order() { } #[test] -fn generic_large_nodeset_projection_charges_unselected_tree_walks() { - // Small and large caller-provided sets can force reverse projection to inspect many - // unselected siblings. Both sides of the 64-node cutoff must meter those walks rather - // than only the selected result size. +fn nodeset_projection_costs_the_selected_nodes_not_their_siblings() { + // Reverse projection goes straight to each selected node, so the last of 2048 siblings + // costs what the first does, and the work charged follows the size of the selection. let source_xml = format!("{}", "".repeat(2_048)); let source = Document::parse(&source_xml, None).expect("wide source parses"); for selected_count in [1, 64, 65] { @@ -12067,10 +12054,9 @@ fn generic_large_nodeset_projection_charges_unselected_tree_walks() { let stylesheet = compile( r#""#, ); - let mut budget = execution_budget(source_xml.len()); - budget.xpath_operations = 512; - - assert!(matches!( + let run = |xpath_operations| { + let mut budget = execution_budget(source_xml.len()); + budget.xpath_operations = xpath_operations; stylesheet.execute( &source, ¶meters, @@ -12080,7 +12066,13 @@ fn generic_large_nodeset_projection_charges_unselected_tree_walks() { initial_mode: None, initial_template: None, }, - ), + ) + }; + let output = run(512).expect("the selection fits far below the sibling count"); + let copies = String::from_utf8(output.serialized.bytes).expect("output is UTF-8"); + assert_eq!(copies.matches("").count(), selected_count); + assert!(matches!( + run(selected_count - 1), Err(Error::Budget { kind: BudgetKind::XPathOperations, .. @@ -12432,8 +12424,8 @@ fn optimized_child_selection_accounts_for_projected_node_storage() { #[test] fn identity_axis_shortcuts_do_not_raise_the_index_dominated_peak() { - // The source projection indexes dominate the peak for attribute-heavy documents. Identity - // selection must fit inside that already-accounted peak instead of adding retained storage. + // Selecting every attribute of an element pays for the selection itself, one small entry + // per attribute, and retains nothing proportional to the attributes' names or paths. let mut attributes = String::new(); for index in 0..2_048 { write!(attributes, r#" a{index}="""#).expect("String write"); @@ -12455,7 +12447,8 @@ fn identity_axis_shortcuts_do_not_raise_the_index_dominated_peak() { source_xml.len(), "selecting", ); - assert_eq!(selecting, baseline); + let per_attribute = (selecting - baseline) / 2_048; + assert!(per_attribute < 64, "{per_attribute} bytes per attribute"); } #[test] diff --git a/src/xmldsig/transforms/xslt.rs b/src/xmldsig/transforms/xslt.rs index ed16e053..4bf2c39b 100644 --- a/src/xmldsig/transforms/xslt.rs +++ b/src/xmldsig/transforms/xslt.rs @@ -1693,8 +1693,8 @@ fn run_stylesheet( 0, ) })?; - let reported = compiled.execute_reported( - &source, + let reported = compiled.execute_owned_reported( + source, &Parameters::new(), // The query's remainder bounds the work of every kind together, so the engine // stops where the query would refuse it rather than after the policy allows. @@ -1705,7 +1705,6 @@ fn run_stylesheet( initial_template: None, }, ); - drop(source); drop(compiled); // A failed execution spends its work too, and later references and signatures // of the operation draw on the same allowance. The error reports the totals diff --git a/src/xmldsig/transforms/xslt/tests.rs b/src/xmldsig/transforms/xslt/tests.rs index a0e5f6a1..db72a377 100644 --- a/src/xmldsig/transforms/xslt/tests.rs +++ b/src/xmldsig/transforms/xslt/tests.rs @@ -288,8 +288,22 @@ fn implicit_c14n_input_stays_in_the_xslt_workspace() { let scratch = crate::c14n::scratch::Accounted(&memory); let nodes = crate::xmldsig::types::NodeSet::entire_document_with_comments(&document) .expect("accounted conversion projection"); - let budget = budget(admitted); let parameter = super::XsltParameter::Stylesheet(&sheet); + // The least workspace the conversion itself runs in, if the engine's minimum is not enough. + let (mut refused, mut converts) = (admitted - 1, 1 << 22); + while converts - refused > 1 { + let middle = refused + (converts - refused) / 2; + let attempt = budget(middle); + if super::canonicalize_input(parameter, &document, &nodes.projection, &attempt, &scratch) + .is_ok() + { + converts = middle; + } else { + refused = middle; + } + } + assert!(converts < admitted + xml.len()); + let budget = budget(converts); let collected = super::canonicalize_input(parameter, &document, &nodes.projection, &budget, &scratch) .expect("conversion alone fits"); @@ -1222,8 +1236,8 @@ fn the_parsed_input_is_charged_to_the_workspace_once() { let allowance = super::Allowance::new(&ResourcePolicy::default()); let executes = |owned: usize| { compiled - .execute_with_environment( - &tree, + .execute_owned_reported( + tree.clone(), &Parameters::new(), ExecutionEnvironment::new(std::sync::Arc::new(NoResolver)), ExecutionOptions { @@ -1232,6 +1246,7 @@ fn the_parsed_input_is_charged_to_the_workspace_once() { initial_template: None, }, ) + .outcome .is_ok() }; // The least owned-byte budget the engine runs this execution in. diff --git a/src/xslt/runtime.rs b/src/xslt/runtime.rs index 0fc1834a..1ae0423d 100644 --- a/src/xslt/runtime.rs +++ b/src/xslt/runtime.rs @@ -24,7 +24,8 @@ use super::lexical::{ use super::serializer::{serialize, serialize_fragment}; use super::xpath::{ CustomCallSession, EXSLT_COMMON_NS, Evaluator, EvaluatorSourceOptions, PreparedEvaluatorSource, - SourceNode, XPathValue, parse_xpath_number, prepare_evaluator_source, xpath_number, + SourceNode, XPathValue, parse_xpath_number, prepare_evaluator_source, + prepare_owned_evaluator_source, xpath_number, }; use super::{ Attribute, BudgetKind, Document, Error, ExecutionBudget, ExecutionEnvironment, ExecutionUsage, @@ -204,7 +205,39 @@ impl Stylesheet { environment: ExecutionEnvironment, options: impl std::borrow::Borrow, ) -> Reported { - let options = options.borrow(); + self.run_reported( + Cow::Borrowed(source), + parameters, + environment, + options.borrow(), + ) + } + + /// [`Self::execute_reported`] for a caller that has no further use for `source`: + /// the execution takes the document instead of paying for its own copy. + #[must_use] + pub fn execute_owned_reported( + &self, + source: Document, + parameters: &Parameters, + environment: ExecutionEnvironment, + options: impl std::borrow::Borrow, + ) -> Reported { + self.run_reported( + Cow::Owned(source), + parameters, + environment, + options.borrow(), + ) + } + + fn run_reported( + &self, + source: Cow<'_, Document>, + parameters: &Parameters, + environment: ExecutionEnvironment, + options: &ExecutionOptions, + ) -> Reported { let Reported { outcome, usage } = self.start_execution(source, parameters, environment, options); let mut state = match outcome { @@ -231,7 +264,7 @@ impl Stylesheet { fn start_execution( &self, - source: &Document, + source: Cow<'_, Document>, parameters: &Parameters, environment: ExecutionEnvironment, options: &ExecutionOptions, @@ -280,7 +313,7 @@ impl Stylesheet { fn prepare_execution<'s, R: Resolver + 'static>( &'s self, - source: &Document, + source: Cow<'_, Document>, parameters: &Parameters, environment: &ExecutionEnvironment, meter: &mut Meter, @@ -305,7 +338,7 @@ impl Stylesheet { .get(name) .is_some_and(|global| global.is_parameter) { - validate_parameter_value(value, source)?; + validate_parameter_value(value, &source)?; } } let source_options = EvaluatorSourceOptions { @@ -313,12 +346,15 @@ impl Stylesheet { whitespace: Arc::clone(&self.whitespace), clock: environment.clock.as_ref().map(Arc::clone), }; - let mut prepared = prepare_evaluator_source( - source, - environment.resolver.as_ref(), - meter, - &source_options, - )?; + let resolver = environment.resolver.as_ref(); + let mut prepared = match source { + Cow::Borrowed(source) => { + prepare_evaluator_source(source, resolver, meter, &source_options) + } + Cow::Owned(source) => { + prepare_owned_evaluator_source(source, resolver, meter, &source_options) + } + }?; let prepared_parameters = PreparedParameters { effective_globals, source_remap: SourceParameterRemap { @@ -388,7 +424,7 @@ impl Stylesheet { ) -> Result> { let options = options.borrow(); let mut state = self - .start_execution(source, parameters, environment, options) + .start_execution(Cow::Borrowed(source), parameters, environment, options) .outcome?; state.run_tree(options)?; Ok(MeasuredTree { diff --git a/src/xslt/xpath.rs b/src/xslt/xpath.rs index 0bf9a3aa..6c14b27e 100644 --- a/src/xslt/xpath.rs +++ b/src/xslt/xpath.rs @@ -230,7 +230,7 @@ pub(crate) fn prepare_evaluator_source( options: &EvaluatorSourceOptions, ) -> Result { let mut resource_identities = HashMap::new(); - let (mut document, include_remap) = if options.process_xinclude { + let (document, include_remap) = if options.process_xinclude { let expanded = expand_xinclude_document( source, &XIncludeDocumentIdentity::InMemory(std::ptr::from_ref(source) as usize), @@ -250,6 +250,30 @@ pub(crate) fn prepare_evaluator_source( meter.charge(BudgetKind::OwnedBytes, source.estimated_clone_bytes())?; (source.clone(), None) }; + finish_evaluator_source(document, include_remap, resource_identities, meter, options) +} + +/// [`prepare_evaluator_source`] for a document the caller gives up: only `XInclude` +/// expansion still needs a separate tree, and the caller's account already holds this one. +pub(crate) fn prepare_owned_evaluator_source( + source: Document, + resolver: &dyn Resolver, + meter: &mut Meter, + options: &EvaluatorSourceOptions, +) -> Result { + if options.process_xinclude { + return prepare_evaluator_source(&source, resolver, meter, options); + } + finish_evaluator_source(source, None, HashMap::new(), meter, options) +} + +fn finish_evaluator_source( + mut document: Document, + include_remap: Option<(HashMap, usize)>, + resource_identities: HashMap, + meter: &mut Meter, + options: &EvaluatorSourceOptions, +) -> Result { let (whitespace_remap, whitespace_remap_owned_bytes) = apply_source_whitespace_rules(&mut document, &options.whitespace, meter)? .map_or((None, 0), |(remap, bytes)| (Some(remap), bytes)); @@ -505,7 +529,7 @@ struct CachedDocumentRoots { fn seed_document_cache( request: DocumentRequest, root: NodeId, - maps: &NodeMaps, + root_path: Option, documents: &mut HashMap>, document_roots: &mut HashMap, index_bytes: &mut DocumentCacheIndexBytes, @@ -517,10 +541,10 @@ fn seed_document_cache( reserve_retained_hash_map_slot(document_roots, meter, &mut index_bytes.roots)?; let mut roots = Vec::new(); let mut roots_bytes = 0; - if let Some(path) = maps.forward.get(&root) { + if let Some(path) = root_path { reserve_temporary_vec_slot(&mut roots, meter, &mut roots_bytes)?; meter.charge(BudgetKind::OwnedBytes, path.owned_bytes())?; - roots.push(path.clone()); + roots.push(path); } let nodes = vec![root]; meter.charge( @@ -791,7 +815,7 @@ fn seed_evaluator_documents( seed_document_cache( principal_request, stylesheet_root, - maps, + maps.path(source, &SourceNode::Node(stylesheet_root)), &mut documents, &mut document_root_entries, &mut document_cache_index_bytes, @@ -810,7 +834,7 @@ fn seed_evaluator_documents( seed_document_cache( source_request, source_root, - maps, + maps.path(source, &SourceNode::Node(source_root)), &mut documents, &mut document_root_entries, &mut document_cache_index_bytes, @@ -827,7 +851,7 @@ fn seed_evaluator_documents( seed_document_cache( request, root, - maps, + maps.path(source, &SourceNode::Node(root)), &mut documents, &mut document_root_entries, &mut document_cache_index_bytes, @@ -955,8 +979,9 @@ impl Evaluator { meter, )?; } - let package = project_semantic_document(&source, meter)?; - let maps = NodeMaps::new(&source, meter)?; + let mut slots = ProjectedSlots::default(); + let package = project_semantic_document(&source, &mut slots, meter)?; + let maps = NodeMaps::new(&source, slots, meter)?; let mut id_index = Vec::new(); extend_id_index(&source, &maps, 0, &mut id_index, meter)?; let unparsed_entity_index = build_unparsed_entity_index(&source, meter)?; @@ -964,7 +989,8 @@ impl Evaluator { let mut node_base_uri_index_bytes = 0; extend_node_base_uri_entries( &source, - maps.reverse.iter(), + &maps, + 0, &mut node_base_uris, &mut node_base_uri_index_bytes, meter, @@ -1086,9 +1112,9 @@ impl Evaluator { meter: &mut Meter, ) -> Result<()> { debug_assert_eq!(value_reservation, value.capacity()); - let Some(sxd) = self - .maps - .to_sxd(self.package.as_document().root().into(), node) + let Some(sxd) = + self.maps + .to_sxd(&self.source, self.package.as_document().root().into(), node) else { meter.release_owned_bytes(value_reservation); return Ok(()); @@ -1221,7 +1247,7 @@ impl Evaluator { } pub(crate) fn document_order(&self, mut nodes: Vec) -> Vec { - nodes.sort_by_key(|node| self.maps.order.get(node).copied()); + nodes.sort_by_key(|node| self.maps.order(&self.source, node)); nodes.dedup(); nodes } @@ -1245,11 +1271,7 @@ impl Evaluator { }; pending .map(|pending| { - let node = self - .maps - .reverse - .get(&pending.node) - .cloned() + let node = NodeMaps::source_of_path(&self.source, &pending.node) .ok_or_else(|| Error::Dynamic("stylesheet function context is stale".into()))?; let arguments = pending .arguments @@ -1307,7 +1329,7 @@ impl Evaluator { Value::Boolean(_) | Value::Number(_) => 0, Value::String(value) | Value::StoredExpression(value) => value.capacity(), Value::NodeSet(nodes) => nodes.iter().try_fold(0usize, |total, node| { - let path = self.maps.forward.get(node).ok_or_else(|| { + let path = self.maps.path(&self.source, node).ok_or_else(|| { Error::Dynamic("stylesheet function result node is stale".into()) })?; Ok::<_, Error>(total.saturating_add(path.owned_bytes())) @@ -1337,7 +1359,7 @@ impl Evaluator { paths .iter() .map(|path| { - self.maps.reverse.get(path).cloned().ok_or_else(|| { + NodeMaps::source_of_path(&self.source, path).ok_or_else(|| { Error::Dynamic("stylesheet function argument node is stale".into()) }) }) @@ -1366,7 +1388,7 @@ impl Evaluator { nodes .iter() .map(|node| { - self.maps.forward.get(node).cloned().ok_or_else(|| { + self.maps.path(&self.source, node).ok_or_else(|| { Error::Dynamic("stylesheet function result node is stale".into()) }) }) @@ -1418,7 +1440,14 @@ impl Evaluator { .into_iter() .find_map(sxd_document_no_unsafe::dom::ChildOfRoot::element) .ok_or_else(|| Error::Dynamic("semantic projection root is missing".into()))?; - project_logical_root(&self.source, logical_root, sxd_document, documents, meter)?; + project_logical_root( + &self.source, + logical_root, + sxd_document, + documents, + &mut self.maps.slots, + meter, + )?; self.maps.extend(&self.source, first_new_node, meter)?; extend_id_index( &self.source, @@ -1435,10 +1464,8 @@ impl Evaluator { )?; extend_node_base_uri_entries( &self.source, - self.maps - .reverse - .iter() - .filter(|(_, node)| source_node_owner(node).0 >= first_new_node), + &self.maps, + first_new_node, &mut self.node_base_uris.borrow_mut(), &mut self.node_base_uri_index_bytes, meter, @@ -1488,12 +1515,12 @@ impl Evaluator { let mut roots = Vec::new(); let mut roots_index_bytes = 0; for node in &nodes { - let Some(path) = self.maps.forward.get(node) else { + let Some(path) = self.maps.path(&self.source, node) else { continue; }; reserve_temporary_vec_slot(&mut roots, meter, &mut roots_index_bytes)?; meter.charge(BudgetKind::OwnedBytes, path.owned_bytes())?; - roots.push(path.clone()); + roots.push(path); } self.document_roots.borrow_mut().insert( request.clone(), @@ -2218,7 +2245,7 @@ impl Evaluator { .then_some(self.stylesheet_functions.as_ref()), bind_functions, uses_current, - context_path, + &context_path, meter, )?; let rewrite = evaluation_rewrite(expression, uses_current); @@ -2256,7 +2283,7 @@ impl Evaluator { position, size, uses_current, - context_path, + &context_path, ); if bind_functions { // XPath function objects must own their static namespace context. Share one @@ -2353,15 +2380,14 @@ impl Evaluator { &self, node: &SourceNode, meter: &mut Meter, - ) -> Result<(nodeset::Node<'_>, &NodePath)> { + ) -> Result<(nodeset::Node<'_>, NodePath)> { let document = self.package.as_document(); let context_path = self .maps - .forward - .get(node) + .path(&self.source, node) .ok_or_else(|| Error::Dynamic("XPath context node is stale".into()))?; meter.charge(BudgetKind::XPathOperations, context_path.ordinary().len())?; - let context_node = NodeMaps::resolve(document.root().into(), context_path) + let context_node = NodeMaps::resolve(document.root().into(), &context_path) .ok_or_else(|| Error::Dynamic("XPath context node is stale".into()))?; Ok((context_node, context_path)) } @@ -2572,7 +2598,10 @@ impl Evaluator { Value::NodeSet(nodes) => { let mut set = nodeset::Nodeset::new(); for reference in nodes { - if let Some(node) = self.maps.to_sxd(document.root().into(), reference) { + if let Some(node) = + self.maps + .to_sxd(&self.source, document.root().into(), reference) + { if !set.contains(node.clone()) { let reservation = context .reserve_temporary_allocation(std::mem::size_of_val(&node)); @@ -2653,12 +2682,7 @@ impl Evaluator { // the two live representations from independent copies of the same remaining budget. let live_context_bytes = context_workspace.saturating_add(context.string_allocation_used()); meter.charge(BudgetKind::OwnedBytes, live_context_bytes)?; - let projected = self.maps.project_value( - &self.source, - &self.package.as_document().root().into(), - value, - meter, - ); + let projected = self.maps.project_value(&self.source, value, meter); meter.release_owned_bytes(live_context_bytes); projected } @@ -4001,7 +4025,11 @@ fn simple_absolute_element_pattern_path(pattern: &str) -> Option<&str> { .then_some(remainder) } -fn project_semantic_document(source: &Document, meter: &mut Meter) -> Result { +fn project_semantic_document( + source: &Document, + slots: &mut ProjectedSlots, + meter: &mut Meter, +) -> Result { meter.charge(BudgetKind::OwnedBytes, semantic_projection_size(source))?; let package = Package::new(); let document = package.as_document(); @@ -4009,7 +4037,7 @@ fn project_semantic_document(source: &Document, meter: &mut Meter) -> Result( logical_root: NodeId, document: SxdDocument<'d>, documents: SxdElement<'d>, + slots: &mut ProjectedSlots, meter: &mut Meter, ) -> Result<()> { let wrapper = document.create_element(QName::new(DOCUMENT_ELEMENT)); documents.append_child(wrapper); + ProjectedSlots::record( + &mut slots.elements, + wrapper.identity_key(), + logical_root, + meter, + )?; let root = source .node(logical_root) .ok_or_else(|| Error::Dynamic("missing logical document root".into()))?; @@ -4074,15 +4109,33 @@ fn project_logical_root<'d>( projected.set_preferred_prefix(attribute.prefix.as_deref()); } parent.append_child(element); + ProjectedSlots::record(&mut slots.elements, element.identity_key(), id, meter)?; for child in node.children.iter().rev() { reserve_temporary_vec_slot(&mut pending, meter, &mut pending_reservation)?; pending.push((*child, element)); } } - NodeKind::Text { value, .. } => parent.append_child(document.create_text(value)), - NodeKind::Comment(value) => parent.append_child(document.create_comment(value)), - NodeKind::ProcessingInstruction { target, value } => parent - .append_child(document.create_processing_instruction(target, value.as_deref())), + NodeKind::Text { value, .. } => { + let text = document.create_text(value); + parent.append_child(text); + ProjectedSlots::record(&mut slots.texts, text.identity_key(), id, meter)?; + } + NodeKind::Comment(value) => { + let comment = document.create_comment(value); + parent.append_child(comment); + ProjectedSlots::record(&mut slots.comments, comment.identity_key(), id, meter)?; + } + NodeKind::ProcessingInstruction { target, value } => { + let instruction = + document.create_processing_instruction(target, value.as_deref()); + parent.append_child(instruction); + ProjectedSlots::record( + &mut slots.instructions, + instruction.identity_key(), + id, + meter, + )?; + } } } Ok(()) @@ -6163,28 +6216,60 @@ fn decode_resource_metered_inner( }) } +/// The semantic node behind each SXD arena slot, per arena. Projection fills +/// these, so a node the evaluator returns maps back without walking its siblings. +#[derive(Default)] +struct ProjectedSlots { + elements: Vec>, + texts: Vec>, + comments: Vec>, + instructions: Vec>, +} + +impl ProjectedSlots { + fn record( + slots: &mut Vec>, + key: (usize, usize, usize), + id: NodeId, + meter: &mut Meter, + ) -> Result<()> { + let (_, _, slot) = key; + while slots.len() <= slot { + reserve_retained_vec_slot(slots, meter)?; + slots.push(None); + } + slots[slot] = Some(id); + Ok(()) + } + + fn element(&self, element: &sxd_document_no_unsafe::dom::Element<'_>) -> Option { + let (_, _, slot) = element.identity_key(); + self.elements.get(slot).copied().flatten() + } +} + +/// Maps semantic nodes to their projection. Paths and ordering derive from the +/// tree and two dense per-node tables, so nothing here is stored per path. struct NodeMaps { - forward: HashMap, - reverse: HashMap, - order: HashMap, - forward_index_bytes: usize, - reverse_index_bytes: usize, - order_index_bytes: usize, + /// Document-order rank of each node, across every projected document. + ranks: Vec, + /// Index within the parent's children; a logical root's is its document index. + positions: Vec, + slots: ProjectedSlots, next_order: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] struct NodeOrder(usize, u8, usize); +const UNPLACED: usize = usize::MAX; + impl NodeMaps { - fn new(source: &Document, meter: &mut Meter) -> Result { + fn new(source: &Document, slots: ProjectedSlots, meter: &mut Meter) -> Result { let mut maps = Self { - forward: HashMap::new(), - reverse: HashMap::new(), - order: HashMap::new(), - forward_index_bytes: 0, - reverse_index_bytes: 0, - order_index_bytes: 0, + ranks: Vec::new(), + positions: Vec::new(), + slots, next_order: 0, }; maps.extend(source, 0, meter)?; @@ -6192,109 +6277,181 @@ impl NodeMaps { } fn extend(&mut self, source: &Document, first_node: usize, meter: &mut Meter) -> Result<()> { - let MeteredSemanticPaths { - paths, - reserved_owned_bytes, - } = semantic_node_paths_from(source, first_node, meter)?; - let result = (|| { - let order_base = self.next_order; - self.next_order = self.next_order.saturating_add(paths.len()); - for (id, node) in source.nodes().skip(first_node) { - let (path, rank) = paths.get(&id).ok_or_else(|| { - Error::Dynamic(format!("semantic node {id:?} has no document path")) + for _ in first_node..source.nodes().len() { + reserve_retained_vec_slot(&mut self.ranks, meter)?; + self.ranks.push(UNPLACED); + reserve_retained_vec_slot(&mut self.positions, meter)?; + self.positions.push(UNPLACED); + } + let mut pending = Vec::new(); + let mut pending_bytes = 0usize; + let placed = self.place_nodes(source, first_node, &mut pending, &mut pending_bytes, meter); + drop(pending); + meter.release_owned_bytes(pending_bytes); + placed?; + match source + .nodes() + .skip(first_node) + .find(|(id, _)| self.ranks[id.0] == UNPLACED) + { + Some((id, _)) => Err(Error::Dynamic(format!( + "semantic node {id:?} has no document path" + ))), + None => Ok(()), + } + } + + fn place_nodes( + &mut self, + source: &Document, + first_node: usize, + pending: &mut Vec, + pending_bytes: &mut usize, + meter: &mut Meter, + ) -> Result<()> { + for (document_index, logical_root) in source.logical_roots().iter().copied().enumerate() { + if logical_root.0 < first_node { + continue; + } + self.positions[logical_root.0] = document_index; + reserve_temporary_vec_slot(pending, meter, pending_bytes)?; + pending.push(logical_root); + while let Some(parent) = pending.pop() { + let node = source.node(parent).ok_or_else(|| { + Error::Dynamic(format!("stale semantic node {parent:?} in document path")) })?; - self.reserve_entry( - path.len().saturating_mul(std::mem::size_of::()), - meter, - )?; - let key = SourceNode::Node(id); - self.forward - .insert(key.clone(), NodePath::Ordinary(path.clone())); - self.reverse - .insert(NodePath::Ordinary(path.clone()), key.clone()); - let rank = order_base.saturating_add(*rank); - self.order - .insert(key.clone(), NodeOrder(rank.saturating_mul(3), 0, 0)); - if let NodeKind::Element { - attributes, - namespaces, - .. - } = &node.kind - { - for (index, source_attribute) in attributes.iter().enumerate() { - let path_owned_bytes = path - .len() - .saturating_mul(std::mem::size_of::()) - .saturating_add( - source_attribute - .name - .namespace - .as_deref() - .map_or(0, str::len), - ) - .saturating_add(source_attribute.name.local.len()); - self.reserve_entry(path_owned_bytes, meter)?; - let attribute_path = NodePath::Attribute { - parent: path.clone(), - namespace: source_attribute.name.namespace.clone(), - local: source_attribute.name.local.clone(), - }; - let key = SourceNode::Attribute { owner: id, index }; - self.forward.insert(key.clone(), attribute_path.clone()); - self.reverse.insert(attribute_path, key.clone()); - self.order - .insert(key.clone(), NodeOrder(rank.saturating_mul(3), 2, index)); - } - for (index, namespace) in namespaces - .iter() - .enumerate() - .filter(|(_, namespace)| xpath_namespace_is_visible(namespace)) - { - let path_owned_bytes = path - .len() - .saturating_mul(std::mem::size_of::()) - .saturating_add(namespace.prefix.as_deref().map_or(0, str::len)) - .saturating_add(namespace.uri.len()); - self.reserve_entry(path_owned_bytes, meter)?; - let namespace_path = NodePath::Namespace { - parent: path.clone(), - prefix: namespace.prefix.clone().unwrap_or_default(), - uri: namespace.uri.clone(), - }; - let key = SourceNode::Namespace { owner: id, index }; - self.forward.insert(key.clone(), namespace_path.clone()); - self.reverse.insert(namespace_path, key.clone()); - // XPath 1.0 leaves namespace-axis order implementation-defined. - // libxml2 exposes the implicit `xml` binding first and the - // remaining declarations newest-first. - let namespace_order = if namespace.prefix.as_deref() == Some("xml") { - 0 - } else { - namespaces.len().saturating_sub(index).saturating_add(1) - }; - self.order.insert( - key.clone(), - NodeOrder(rank.saturating_mul(3), 1, namespace_order), - ); - } + self.ranks[parent.0] = self.next_order; + self.next_order = self.next_order.saturating_add(1); + for (index, child) in node.children.iter().copied().enumerate().rev() { + self.positions[child.0] = index; + reserve_temporary_vec_slot(pending, meter, pending_bytes)?; + pending.push(child); } } - Ok(()) - })(); - drop(paths); - meter.release_owned_bytes(reserved_owned_bytes); - result + } + Ok(()) } - fn reserve_entry(&mut self, path_owned_bytes: usize, meter: &mut Meter) -> Result<()> { - reserve_retained_hash_map_slot(&mut self.forward, meter, &mut self.forward_index_bytes)?; - reserve_retained_hash_map_slot(&mut self.reverse, meter, &mut self.reverse_index_bytes)?; - reserve_retained_hash_map_slot(&mut self.order, meter, &mut self.order_index_bytes)?; - meter.charge(BudgetKind::OwnedBytes, path_owned_bytes.saturating_mul(2)) + fn rank(&self, id: NodeId) -> Option { + self.ranks + .get(id.0) + .copied() + .filter(|rank| *rank != UNPLACED) } - fn to_sxd<'d>(&self, root: nodeset::Node<'d>, node: &SourceNode) -> Option> { - Self::resolve(root, self.forward.get(node)?) + fn order(&self, source: &Document, node: &SourceNode) -> Option { + let rank = self.rank(source_node_owner(node))?.saturating_mul(3); + Some(match node { + SourceNode::Node(_) => NodeOrder(rank, 0, 0), + SourceNode::Attribute { index, .. } => NodeOrder(rank, 2, *index), + SourceNode::Namespace { owner, index } => { + let NodeKind::Element { namespaces, .. } = &source.node(*owner)?.kind else { + return None; + }; + // XPath 1.0 leaves namespace-axis order implementation-defined. + // libxml2 exposes the implicit `xml` binding first and the + // remaining declarations newest-first. + let order = if namespaces.get(*index)?.prefix.as_deref() == Some("xml") { + 0 + } else { + namespaces.len().saturating_sub(*index).saturating_add(1) + }; + NodeOrder(rank, 1, order) + } + }) + } + + fn ordinary_path(&self, source: &Document, id: NodeId) -> Option> { + self.rank(id)?; + let mut path = vec![0]; + let mut current = Some(id); + while let Some(node) = current { + path.push(*self.positions.get(node.0)?); + current = source.node(node)?.parent; + } + path[1..].reverse(); + Some(path) + } + + fn path(&self, source: &Document, node: &SourceNode) -> Option { + let path = self.ordinary_path(source, source_node_owner(node))?; + let owner = &source.node(source_node_owner(node))?.kind; + Some(match (node, owner) { + (SourceNode::Node(_), _) => NodePath::Ordinary(path), + (SourceNode::Attribute { index, .. }, NodeKind::Element { attributes, .. }) => { + let attribute = attributes.get(*index)?; + NodePath::Attribute { + parent: path, + namespace: attribute.name.namespace.clone(), + local: attribute.name.local.clone(), + } + } + (SourceNode::Namespace { index, .. }, NodeKind::Element { namespaces, .. }) => { + let namespace = namespaces + .get(*index) + .filter(|namespace| xpath_namespace_is_visible(namespace))?; + NodePath::Namespace { + parent: path, + prefix: namespace.prefix.clone().unwrap_or_default(), + uri: namespace.uri.clone(), + } + } + _ => return None, + }) + } + + /// The semantic node at `path`, the inverse of [`Self::path`]. + fn source_of_path(source: &Document, path: &NodePath) -> Option { + let ordinary = path.ordinary(); + let (&[0, document], children) = ordinary.split_first_chunk::<2>()? else { + return None; + }; + let mut id = *source.logical_roots().get(document)?; + for index in children { + id = *source.node(id)?.children.get(*index)?; + } + match path { + NodePath::Ordinary(_) => Some(SourceNode::Node(id)), + NodePath::Attribute { + namespace, local, .. + } => attribute_of(source, id, namespace.as_deref(), local), + NodePath::Namespace { prefix, uri, .. } => namespace_of(source, id, prefix, uri), + } + } + + /// The semantic node an evaluator result came from. + fn source_of_sxd(&self, source: &Document, node: &nodeset::Node<'_>) -> Option { + let projected = |slots: &[Option], (_, _, slot): (usize, usize, usize)| { + slots.get(slot).copied().flatten().map(SourceNode::Node) + }; + match node { + nodeset::Node::Root(_) => None, + nodeset::Node::Element(node) => projected(&self.slots.elements, node.identity_key()), + nodeset::Node::Text(node) => projected(&self.slots.texts, node.identity_key()), + nodeset::Node::Comment(node) => projected(&self.slots.comments, node.identity_key()), + nodeset::Node::ProcessingInstruction(node) => { + projected(&self.slots.instructions, node.identity_key()) + } + nodeset::Node::Attribute(attribute) => { + let owner = self.slots.element(&attribute.parent()?)?; + let name = attribute.name(); + let name = name.get(); + attribute_of(source, owner, name.namespace_uri(), name.local_part()) + } + nodeset::Node::Namespace(namespace) => { + let owner = self.slots.element(&namespace.parent)?; + namespace_of(source, owner, namespace.prefix(), namespace.uri()) + } + } + } + + fn to_sxd<'d>( + &self, + source: &Document, + root: nodeset::Node<'d>, + node: &SourceNode, + ) -> Option> { + Self::resolve(root, &self.path(source, node)?) } fn resolve<'d>(root: nodeset::Node<'d>, path: &NodePath) -> Option> { let mut node = root; @@ -6312,7 +6469,6 @@ impl NodeMaps { fn project_value( &self, source: &Document, - root: &nodeset::Node<'_>, value: SxdValue<'_>, meter: &mut Meter, ) -> Result { @@ -6332,256 +6488,72 @@ impl NodeMaps { )); } SxdValue::Nodeset(nodes) => { - let node_count = nodes.size(); - if node_count <= 64 { - let mut projected = projected_node_storage(node_count, meter)?; - for node in nodes { - // Small result sets can still require long ancestor/sibling walks. - // Their lookup must obey the same budget as the bulk projection. - let (path, path_bytes) = typed_path_to_metered(&node, meter)?; - if let Some(source) = self.reverse.get(&path) { - projected.push(source.clone()); - } - meter.release_owned_bytes(path_bytes); + let mut projected = projected_node_storage(nodes.size(), meter)?; + for node in nodes { + meter.charge(BudgetKind::XPathOperations, 1)?; + if let Some(node) = self.source_of_sxd(source, &node) { + projected.push(node); } - projected.sort_by_key(|node| self.order.get(node).copied()); - return Ok(XPathValue::NodeSet(projected)); } - return self.project_large_nodeset(source, root, nodes, meter); + projected.sort_by_key(|node| self.order(source, node)); + XPathValue::NodeSet(projected) } }) } } -impl NodeMaps { - fn project_large_nodeset( - &self, - source: &Document, - root: &nodeset::Node<'_>, - mut nodes: nodeset::Nodeset<'_>, - meter: &mut Meter, - ) -> Result { - let mut projected = Vec::new(); - let mut traversal = Vec::<(usize, Option)>::new(); - let mut temporary_bytes = 0usize; - let result = (|| { - let mut node = (*root).clone(); - let mut source_id = None; - - // Resolve ordinary nodes and attributes while walking the SXD tree once. - // Keep selected identities in the evaluator's existing Nodeset and pair the - // projected and semantic trees directly. Materializing one full path per - // selected node makes retained workspace quadratic on deep result sets. - loop { - meter.charge(BudgetKind::XPathOperations, 1)?; - if nodes.remove(&node) - && let Some(id) = source_id - { - reserve_temporary_vec_slot(&mut projected, meter, &mut temporary_bytes)?; - projected.push(SourceNode::Node(id)); - } - if let (Some(element), Some(owner)) = (node.element(), source_id) { - let attributes = source.node(owner).and_then(|node| match &node.kind { - NodeKind::Element { attributes, .. } => Some(attributes.as_slice()), - _ => None, - }); - for (index, attribute) in attributes.unwrap_or_default().iter().enumerate() { - meter.charge(BudgetKind::XPathOperations, 1)?; - let Some(attribute) = element.attribute(QName::with_namespace_uri( - attribute.name.namespace.as_deref(), - &attribute.name.local, - )) else { - continue; - }; - let attribute_node = nodeset::Node::Attribute(attribute); - if !nodes.remove(&attribute_node) { - continue; - } - reserve_temporary_vec_slot(&mut projected, meter, &mut temporary_bytes)?; - projected.push(SourceNode::Attribute { owner, index }); - } - } - - if let Some(child) = node.child_at(0) { - source_id = projected_child_source(source, &traversal, source_id, 0); - reserve_temporary_vec_slot(&mut traversal, meter, &mut temporary_bytes)?; - traversal.push((0, source_id)); - node = child; - continue; - } - while let Some((index, _)) = traversal.last_mut() { - let parent = node - .parent() - .expect("a projected non-root node retains its parent"); - if let Some(sibling) = parent.child_at(*index + 1) { - *index += 1; - source_id = projected_sibling_source(source, &traversal); - traversal.last_mut().expect("current frame exists").1 = source_id; - node = sibling; - break; - } - traversal.pop(); - source_id = traversal.last().and_then(|(_, source)| *source); - node = parent; - } - if traversal.is_empty() && node == *root { - break; - } - } - - // Namespace nodes are synthesized by the XPath engine and are not children in - // the SXD DOM. They are uncommon, so only those remaining after the tree walk - // need the path-based fallback. - self.project_remaining_nodes(nodes, &mut projected, meter, &mut temporary_bytes)?; - projected.sort_by_key(|node| self.order.get(node).copied()); - Ok(XPathValue::NodeSet(projected)) - })(); - meter.release_owned_bytes(temporary_bytes); - result - } - - fn project_remaining_nodes( - &self, - nodes: nodeset::Nodeset<'_>, - projected: &mut Vec, - meter: &mut Meter, - temporary_bytes: &mut usize, - ) -> Result<()> { - for node in nodes { - let (path, path_bytes) = typed_path_to_metered(&node, meter)?; - let result = (|| { - if let Some(source) = self.reverse.get(&path) { - reserve_temporary_vec_slot(projected, meter, temporary_bytes)?; - projected.push(source.clone()); - } - Ok(()) - })(); - meter.release_owned_bytes(path_bytes); - result?; - } - Ok(()) - } -} - -fn projected_child_source( +fn attribute_of( source: &Document, - traversal: &[(usize, Option)], - parent: Option, - index: usize, -) -> Option { - match traversal.len() { - 0 => None, - 1 => source.logical_roots().get(index).copied(), - _ => parent.and_then(|parent| source.node(parent)?.children.get(index).copied()), - } + owner: NodeId, + namespace: Option<&str>, + local: &str, +) -> Option { + let NodeKind::Element { attributes, .. } = &source.node(owner)?.kind else { + return None; + }; + let index = attributes.iter().position(|attribute| { + attribute.name.namespace.as_deref() == namespace && attribute.name.local == local + })?; + Some(SourceNode::Attribute { owner, index }) } -fn projected_sibling_source( - source: &Document, - traversal: &[(usize, Option)], -) -> Option { - let (index, _) = *traversal.last()?; - match traversal.len() { - 1 => None, - 2 => source.logical_roots().get(index).copied(), - _ => traversal - .get(traversal.len() - 2) - .and_then(|(_, parent)| *parent) - .and_then(|parent| source.node(parent)?.children.get(index).copied()), - } +fn namespace_of(source: &Document, owner: NodeId, prefix: &str, uri: &str) -> Option { + let NodeKind::Element { namespaces, .. } = &source.node(owner)?.kind else { + return None; + }; + let index = namespaces.iter().position(|namespace| { + xpath_namespace_is_visible(namespace) + && namespace.prefix.as_deref().unwrap_or_default() == prefix + && namespace.uri == uri + })?; + Some(SourceNode::Namespace { owner, index }) } fn xpath_namespace_is_visible(namespace: &super::Namespace) -> bool { !namespace.uri.is_empty() } -struct MeteredSemanticPaths { - paths: HashMap, usize)>, - reserved_owned_bytes: usize, -} - -fn semantic_node_paths_from( +fn extend_node_base_uri_entries( source: &Document, + maps: &NodeMaps, first_node: usize, - meter: &mut Meter, -) -> Result { - let mut paths = HashMap::new(); - let mut reserved_owned_bytes = 0usize; - let result = (|| { - let mut rank = 0usize; - for (document_index, logical_root) in source.logical_roots().iter().copied().enumerate() { - if logical_root.0 < first_node { - continue; - } - let mut root_path = Vec::new(); - for component in [0, document_index] { - reserve_temporary_vec_slot(&mut root_path, meter, &mut reserved_owned_bytes)?; - root_path.push(component); - } - let mut pending = Vec::new(); - reserve_temporary_vec_slot(&mut pending, meter, &mut reserved_owned_bytes)?; - pending.push((logical_root, root_path)); - while let Some((parent, parent_path)) = pending.pop() { - let node = source.node(parent).ok_or_else(|| { - Error::Dynamic(format!("stale semantic node {parent:?} in document path")) - })?; - for (index, child) in node.children.iter().copied().enumerate().rev() { - let mut child_path = Vec::new(); - for component in parent_path.iter().copied() { - reserve_temporary_vec_slot( - &mut child_path, - meter, - &mut reserved_owned_bytes, - )?; - child_path.push(component); - } - reserve_temporary_vec_slot(&mut child_path, meter, &mut reserved_owned_bytes)?; - child_path.push(index); - reserve_temporary_vec_slot(&mut pending, meter, &mut reserved_owned_bytes)?; - pending.push((child, child_path)); - } - reserve_retained_hash_map_slot(&mut paths, meter, &mut reserved_owned_bytes)?; - paths.insert(parent, (parent_path, rank)); - rank = rank.saturating_add(1); - } - } - Ok(()) - })(); - if let Err(error) = result { - drop(paths); - meter.release_owned_bytes(reserved_owned_bytes); - return Err(error); - } - Ok(MeteredSemanticPaths { - paths, - reserved_owned_bytes, - }) -} - -fn extend_node_base_uri_entries<'a>( - source: &Document, - entries: impl Iterator, index: &mut HashMap, Option>, index_bytes: &mut usize, meter: &mut Meter, ) -> Result<()> { - for (path, node) in entries { - let SourceNode::Node(id) = node else { - continue; - }; - let semantic_node = source - .node(*id) - .ok_or_else(|| Error::Dynamic(format!("stale semantic node {id:?}")))?; + for (id, semantic_node) in source.nodes().skip(first_node) { let base_uri = semantic_node.base_uri.as_deref(); let inherited = semantic_node .parent .and_then(|parent| source.node(parent)) .and_then(|parent| parent.base_uri.as_deref()); - let is_logical_root = source.logical_roots().binary_search(id).is_ok(); + let is_logical_root = source.logical_roots().binary_search(&id).is_ok(); if !is_logical_root && base_uri == inherited { continue; } - let key = path.ordinary(); + let key = maps + .ordinary_path(source, id) + .ok_or_else(|| Error::Dynamic(format!("stale semantic node {id:?}")))?; meter.charge( BudgetKind::OwnedBytes, key.len() @@ -6589,7 +6561,7 @@ fn extend_node_base_uri_entries<'a>( .saturating_add(base_uri.map_or(0, str::len)), )?; reserve_retained_hash_map_slot(index, meter, index_bytes)?; - index.insert(key.to_vec(), base_uri.map(str::to_owned)); + index.insert(key, base_uri.map(str::to_owned)); } Ok(()) } @@ -8001,7 +7973,7 @@ fn extend_id_index( let Some(root_index) = source.logical_roots().binary_search(&root).ok() else { continue; }; - let Some(path) = maps.forward.get(&SourceNode::Node(owner)).cloned() else { + let Some(path) = maps.path(source, &SourceNode::Node(owner)) else { continue; }; let retained_bytes = value diff --git a/src/xslt/xpath/tests.rs b/src/xslt/xpath/tests.rs index 6307b422..70bf07e0 100644 --- a/src/xslt/xpath/tests.rs +++ b/src/xslt/xpath/tests.rs @@ -546,7 +546,8 @@ fn auxiliary_document_indexes_charge_empty_document_slots() { .expect("meter initializes"); let before = meter.usage(BudgetKind::OwnedBytes).expect("valid kind").0; let mut ids = Vec::new(); - let maps = NodeMaps::new(&source, &mut meter).expect("node maps build"); + let maps = + NodeMaps::new(&source, ProjectedSlots::default(), &mut meter).expect("node maps build"); extend_id_index(&source, &maps, 0, &mut ids, &mut meter).expect("empty ID index builds"); let mut entities = Vec::new(); append_unparsed_entity_document(&source, source.root(), &mut entities, &mut meter) @@ -564,11 +565,10 @@ fn auxiliary_document_indexes_charge_empty_document_slots() { } #[test] -fn node_maps_release_semantic_path_workspace_after_projection() { - // Semantic paths are only construction workspace. Once the permanent indexes own their - // path payloads, the workspace reservation must be released rather than retained for the - // rest of the transformation. Hash-table backing storage already includes each inline - // `NodePath`; only its heap payload needs a separate charge. +fn node_maps_retain_only_their_dense_tables() { + // The traversal stack is construction workspace, released with the build. What + // stays charged for the rest of the transformation is one word per table per + // node: stored paths would make the retained size grow with the document's depth. let source = Document::parse("", None) .expect("source parses"); let mut meter = Meter::new( @@ -593,27 +593,113 @@ fn node_maps_release_semantic_path_workspace_after_projection() { .expect("meter initializes"); let before = meter.usage(BudgetKind::OwnedBytes).expect("valid kind").0; - let maps = NodeMaps::new(&source, &mut meter).expect("node maps build"); + let maps = + NodeMaps::new(&source, ProjectedSlots::default(), &mut meter).expect("node maps build"); - let expected_retained = maps - .forward - .values() - .fold(0usize, |total, path| { - total.saturating_add( - path.owned_bytes() - .saturating_sub(std::mem::size_of::()) - .saturating_mul(2), - ) - }) - .saturating_add(maps.forward_index_bytes) - .saturating_add(maps.reverse_index_bytes) - .saturating_add(maps.order_index_bytes); + let expected_retained = (maps.ranks.capacity() + maps.positions.capacity()) + .saturating_mul(std::mem::size_of::()); assert_eq!( meter.usage(BudgetKind::OwnedBytes).expect("valid kind").0, before.saturating_add(expected_retained) ); } +#[test] +fn node_paths_and_evaluator_nodes_map_back_to_their_semantic_node() { + // Paths, arena slots and document order all derive from the tree now, so + // every kind of node must survive the round trip, in every logical root. + let mut source = Document::parse( + "text", + None, + ) + .expect("source parses"); + let imported = Document::parse("", None).expect("import parses"); + source.import(&imported); + let mut meter = Meter::new( + ExecutionBudget { + source_bytes: usize::MAX, + external_documents: 0, + recursion_depth: 8, + xpath_evaluations: 0, + xpath_operations: usize::MAX, + extension_operations: 0, + pattern_evaluations: 0, + template_applications: 0, + sort_comparisons: 0, + key_entries: 0, + result_nodes: 0, + serialized_bytes: 0, + messages: 0, + owned_bytes: usize::MAX, + }, + 0, + ) + .expect("meter initializes"); + let mut slots = ProjectedSlots::default(); + let package = + project_semantic_document(&source, &mut slots, &mut meter).expect("source projects"); + let maps = NodeMaps::new(&source, slots, &mut meter).expect("node maps build"); + let root: nodeset::Node<'_> = package.as_document().root().into(); + let mut references = Vec::new(); + for (id, node) in source.nodes() { + references.push(SourceNode::Node(id)); + if let NodeKind::Element { + attributes, + namespaces, + .. + } = &node.kind + { + for index in 0..attributes.len() { + references.push(SourceNode::Attribute { owner: id, index }); + } + for index in 0..namespaces.len() { + references.push(SourceNode::Namespace { owner: id, index }); + } + } + } + assert!(references.len() > 12); + for reference in &references { + let path = maps.path(&source, reference).expect("the node has a path"); + assert_eq!( + NodeMaps::source_of_path(&source, &path).as_ref(), + Some(reference) + ); + let projected = NodeMaps::resolve(root.clone(), &path).expect("the path resolves"); + assert_eq!( + maps.source_of_sxd(&source, &projected).as_ref(), + Some(reference) + ); + } + // The projection's own document root and container stand for no semantic node. + assert_eq!(maps.source_of_sxd(&source, &root), None); + let container = root.child_at(0).expect("projection container exists"); + assert_eq!(maps.source_of_sxd(&source, &container), None); + assert_eq!( + NodeMaps::source_of_path(&source, &NodePath::Ordinary(vec![0])), + None + ); + assert_eq!( + NodeMaps::source_of_path(&source, &NodePath::Ordinary(vec![0, 9])), + None + ); + assert_eq!( + NodeMaps::source_of_path(&source, &NodePath::Ordinary(vec![1, 0])), + None + ); + let mut ordered = references.clone(); + ordered.sort_by_key(|node| maps.order(&source, node)); + let position = |wanted: &SourceNode| ordered.iter().position(|node| node == wanted); + let element = source.logical_roots()[0]; + let first_child = source.node(element).expect("root").children[0]; + let attribute = SourceNode::Attribute { + owner: first_child, + index: 0, + }; + let text = SourceNode::Node(source.node(first_child).expect("element").children[0]); + assert!(position(&SourceNode::Node(first_child)) < position(&attribute)); + assert!(position(&attribute) < position(&text)); +} + #[test] fn node_base_uri_index_charges_hash_backing_storage() { let source = Document::parse("", None).expect("source parses"); @@ -637,24 +723,14 @@ fn node_base_uri_index_charges_hash_backing_storage() { source.source_bytes(), ) .expect("meter initializes"); - let element = source - .nodes() - .find_map(|(id, node)| matches!(node.kind, NodeKind::Element { .. }).then_some(id)) - .expect("document element exists"); - let path = NodePath::Ordinary(vec![0, 0, 0]); - let node = SourceNode::Node(element); + let maps = + NodeMaps::new(&source, ProjectedSlots::default(), &mut meter).expect("node maps build"); let mut index = HashMap::new(); let mut index_bytes = 0; let before = meter.usage(BudgetKind::OwnedBytes).expect("valid kind").0; - extend_node_base_uri_entries( - &source, - std::iter::once((&path, &node)), - &mut index, - &mut index_bytes, - &mut meter, - ) - .expect("base URI index builds"); + extend_node_base_uri_entries(&source, &maps, 0, &mut index, &mut index_bytes, &mut meter) + .expect("base URI index builds"); let charged = meter.usage(BudgetKind::OwnedBytes).expect("valid kind").0 - before; assert_eq!( @@ -1014,7 +1090,7 @@ fn xpointer_accepts_adjacent_pointer_parts() { #[test] fn small_nodeset_projection_checks_its_vector_capacity() { // XPath does not define resource ceilings. The engine's OwnedBytes contract must reject - // the second projection vector before allocation even on the <=64-node fast path. + // the second projection vector before allocation. let source_xml = format!("{}", "".repeat(32)); let source = Document::parse(&source_xml, None).expect("source parses"); let unlimited = || ExecutionBudget { @@ -1034,9 +1110,10 @@ fn small_nodeset_projection_checks_its_vector_capacity() { owned_bytes: usize::MAX, }; let mut setup_meter = Meter::new(unlimited(), 0).expect("setup meter initializes"); - let package = project_semantic_document(&source, &mut setup_meter) + let mut slots = ProjectedSlots::default(); + let package = project_semantic_document(&source, &mut slots, &mut setup_meter) .expect("source projects into the XPath DOM"); - let maps = NodeMaps::new(&source, &mut setup_meter).expect("node maps build"); + let maps = NodeMaps::new(&source, slots, &mut setup_meter).expect("node maps build"); let document = package.as_document(); let source_root = document.root().children()[0] .element() @@ -1057,12 +1134,7 @@ fn small_nodeset_projection_checks_its_vector_capacity() { let mut meter = Meter::new(limits, 0).expect("empty source fits"); assert!(matches!( - maps.project_value( - &source, - &document.root().into(), - SxdValue::Nodeset(selected), - &mut meter, - ), + maps.project_value(&source, SxdValue::Nodeset(selected), &mut meter), Err(Error::Budget { kind: BudgetKind::OwnedBytes, .. diff --git a/tests/xslt_transform_integration.rs b/tests/xslt_transform_integration.rs index 702e8199..0fadc098 100644 --- a/tests/xslt_transform_integration.rs +++ b/tests/xslt_transform_integration.rs @@ -1,5 +1,6 @@ //! `XMLDSig` XSLT transform through the public signing and verification API. +use std::fmt::Write as _; use std::fs; #[path = "support/cryptographic.rs"] @@ -265,6 +266,32 @@ fn node_set_input_is_canonicalized_and_terminal_output_is_digested() { ); } +#[test] +fn a_document_of_16384_items_signs_and_verifies_under_the_default_policy() { + // Workspace and work stay linear in the source with a small constant: per-node + // path tables and a sibling scan per selected node refused this document. + let mut items = String::new(); + let mut values = String::new(); + for index in 0..16_384 { + write!(items, r#"value"#).expect("writing to a String"); + write!(values, r#"value"#).expect("writing to a String"); + } + let document = format!(r#"{items}"#); + let reference = ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#list") + .transform(Transform::Xslt(stylesheet(concat!( + r#""#, + r#""#, + r#""#, + r#""# + )))); + let signed = sign(&document, &builder(reference)).expect("the document must sign"); + assert_eq!( + verified_pre_digest(&signed), + [format!("{values}\n").into_bytes()] + ); +} + #[test] fn base64_output_feeds_the_stylesheet_and_xpath_reads_its_result() { // Binary input is used as is; a following XPath transform reparses the From ba94c2e521a5753e4e03fbe480f4a55e3d2ad88c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 11:30:56 +0200 Subject: [PATCH 2/3] fix(xslt): bound attribute mapping and pay for context paths - Projection records each attribute's arena slot, so an evaluator attribute maps back to its owner and index without searching the owner's attributes; `@*` over N attributes no longer costs N^2 unmetered comparisons - Mapping a namespace node back, which has no arena slot, admits the search of the owner's declarations as XPath work before running it - The context node's path is allocated at its exact length and its heap bytes are charged to owned memory for the whole evaluation Regression tests count attribute comparisons, check the namespace charge, and require a deeper context to need more owned memory. --- src/xslt/xpath.rs | 146 ++++++++++++++++++++++++++++++----- src/xslt/xpath/tests.rs | 163 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 289 insertions(+), 20 deletions(-) diff --git a/src/xslt/xpath.rs b/src/xslt/xpath.rs index 6c14b27e..cf45996a 100644 --- a/src/xslt/xpath.rs +++ b/src/xslt/xpath.rs @@ -2218,6 +2218,8 @@ impl Evaluator { result } + /// Evaluate with the context node's path paid for in owned memory: the path + /// is held for the whole evaluation and released with it. #[expect( clippy::too_many_arguments, reason = "the XPath engine requires every component of its dynamic context" @@ -2231,6 +2233,38 @@ impl Evaluator { variables: &dyn VariableBindings, meter: &mut Meter, custom_calls: Option<&CustomCallSession>, + ) -> Result { + let path_bytes = self + .maps + .path_bytes(&self.source, node) + .ok_or_else(|| Error::Dynamic("XPath context node is stale".into()))?; + meter.charge(BudgetKind::OwnedBytes, path_bytes)?; + let result = self.evaluate_with_context_path( + expression, + node, + position, + size, + variables, + meter, + custom_calls, + ); + meter.release_owned_bytes(path_bytes); + result + } + + #[expect( + clippy::too_many_arguments, + reason = "the XPath engine requires every component of its dynamic context" + )] + fn evaluate_with_context_path( + &self, + expression: &Expression, + node: &SourceNode, + position: usize, + size: usize, + variables: &dyn VariableBindings, + meter: &mut Meter, + custom_calls: Option<&CustomCallSession>, ) -> Result { let (context_node, context_path) = self.xpath_context_node(node, meter)?; let uses_current = xpath_uses_current(expression); @@ -4098,7 +4132,7 @@ fn project_logical_root<'d>( element.register_prefix("", &namespace.uri); } } - for attribute in attributes { + for (index, attribute) in attributes.iter().enumerate() { let projected = element.set_attribute_value( QName::with_namespace_uri( attribute.name.namespace.as_deref(), @@ -4107,6 +4141,12 @@ fn project_logical_root<'d>( &attribute.value, ); projected.set_preferred_prefix(attribute.prefix.as_deref()); + ProjectedSlots::record( + &mut slots.attributes, + projected.identity_key(), + (id, index), + meter, + )?; } parent.append_child(element); ProjectedSlots::record(&mut slots.elements, element.identity_key(), id, meter)?; @@ -6218,19 +6258,24 @@ fn decode_resource_metered_inner( /// The semantic node behind each SXD arena slot, per arena. Projection fills /// these, so a node the evaluator returns maps back without walking its siblings. +/// Every evaluator node lives in the one projection document (result fragments +/// and loaded documents are imported into the semantic document and projected +/// there), so the arena slot alone identifies it. #[derive(Default)] struct ProjectedSlots { elements: Vec>, texts: Vec>, comments: Vec>, instructions: Vec>, + /// The owner and attribute index behind each attribute slot. + attributes: Vec>, } impl ProjectedSlots { - fn record( - slots: &mut Vec>, + fn record( + slots: &mut Vec>, key: (usize, usize, usize), - id: NodeId, + id: T, meter: &mut Meter, ) -> Result<()> { let (_, _, slot) = key; @@ -6361,18 +6406,53 @@ impl NodeMaps { }) } - fn ordinary_path(&self, source: &Document, id: NodeId) -> Option> { + /// Entries of the ordinary path of a placed node: the container index and + /// one position per level. + fn path_len(&self, source: &Document, id: NodeId) -> Option { self.rank(id)?; - let mut path = vec![0]; + let mut len = 1; let mut current = Some(id); while let Some(node) = current { - path.push(*self.positions.get(node.0)?); + len += 1; + current = source.node(node)?.parent; + } + Some(len) + } + + fn ordinary_path(&self, source: &Document, id: NodeId) -> Option> { + let len = self.path_len(source, id)?; + // Allocated at its exact length, which `path_bytes` admits beforehand. + let mut path = vec![0; len]; + let mut current = Some(id); + for slot in path[1..].iter_mut().rev() { + let node = current?; + *slot = *self.positions.get(node.0)?; current = source.node(node)?.parent; } - path[1..].reverse(); Some(path) } + /// Heap bytes [`Self::path`] allocates for `node`, known before it does. + fn path_bytes(&self, source: &Document, node: &SourceNode) -> Option { + let words = self + .path_len(source, source_node_owner(node))? + .checked_mul(std::mem::size_of::())?; + let owner = &source.node(source_node_owner(node))?.kind; + let strings = match (node, owner) { + (SourceNode::Node(_), _) => 0, + (SourceNode::Attribute { index, .. }, NodeKind::Element { attributes, .. }) => { + let name = &attributes.get(*index)?.name; + name.namespace.as_ref().map_or(0, String::len) + name.local.len() + } + (SourceNode::Namespace { index, .. }, NodeKind::Element { namespaces, .. }) => { + let namespace = namespaces.get(*index)?; + namespace.prefix.as_ref().map_or(0, String::len) + namespace.uri.len() + } + _ => return None, + }; + words.checked_add(strings) + } + fn path(&self, source: &Document, node: &SourceNode) -> Option { let path = self.ordinary_path(source, source_node_owner(node))?; let owner = &source.node(source_node_owner(node))?.kind; @@ -6420,11 +6500,16 @@ impl NodeMaps { } /// The semantic node an evaluator result came from. - fn source_of_sxd(&self, source: &Document, node: &nodeset::Node<'_>) -> Option { + fn source_of_sxd( + &self, + source: &Document, + node: &nodeset::Node<'_>, + meter: &mut Meter, + ) -> Result> { let projected = |slots: &[Option], (_, _, slot): (usize, usize, usize)| { slots.get(slot).copied().flatten().map(SourceNode::Node) }; - match node { + Ok(match node { nodeset::Node::Root(_) => None, nodeset::Node::Element(node) => projected(&self.slots.elements, node.identity_key()), nodeset::Node::Text(node) => projected(&self.slots.texts, node.identity_key()), @@ -6433,16 +6518,30 @@ impl NodeMaps { projected(&self.slots.instructions, node.identity_key()) } nodeset::Node::Attribute(attribute) => { - let owner = self.slots.element(&attribute.parent()?)?; - let name = attribute.name(); - let name = name.get(); - attribute_of(source, owner, name.namespace_uri(), name.local_part()) + let (_, _, slot) = attribute.identity_key(); + self.slots + .attributes + .get(slot) + .copied() + .flatten() + .map(|(owner, index)| SourceNode::Attribute { owner, index }) } nodeset::Node::Namespace(namespace) => { - let owner = self.slots.element(&namespace.parent)?; + let Some(owner) = self.slots.element(&namespace.parent) else { + return Ok(None); + }; + // A namespace node has no arena slot: the owner's declarations + // are searched, and that search is admitted before it runs. + if let NodeKind::Element { namespaces, .. } = &source + .node(owner) + .ok_or_else(|| Error::Dynamic(format!("stale semantic node {owner:?}")))? + .kind + { + meter.charge(BudgetKind::XPathOperations, namespaces.len())?; + } namespace_of(source, owner, namespace.prefix(), namespace.uri()) } - } + }) } fn to_sxd<'d>( @@ -6491,7 +6590,7 @@ impl NodeMaps { let mut projected = projected_node_storage(nodes.size(), meter)?; for node in nodes { meter.charge(BudgetKind::XPathOperations, 1)?; - if let Some(node) = self.source_of_sxd(source, &node) { + if let Some(node) = self.source_of_sxd(source, &node, meter)? { projected.push(node); } } @@ -6512,11 +6611,24 @@ fn attribute_of( return None; }; let index = attributes.iter().position(|attribute| { + #[cfg(test)] + ATTRIBUTE_SCANS.with(|count| count.set(count.get() + 1)); attribute.name.namespace.as_deref() == namespace && attribute.name.local == local })?; Some(SourceNode::Attribute { owner, index }) } +#[cfg(test)] +thread_local! { + static ATTRIBUTE_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Attribute comparisons made by name lookups on this thread since the last call. +#[cfg(test)] +fn take_attribute_scans() -> usize { + ATTRIBUTE_SCANS.with(|count| count.replace(0)) +} + fn namespace_of(source: &Document, owner: NodeId, prefix: &str, uri: &str) -> Option { let NodeKind::Element { namespaces, .. } = &source.node(owner)?.kind else { return None; diff --git a/src/xslt/xpath/tests.rs b/src/xslt/xpath/tests.rs index 70bf07e0..bcd2bb91 100644 --- a/src/xslt/xpath/tests.rs +++ b/src/xslt/xpath/tests.rs @@ -666,14 +666,24 @@ fn node_paths_and_evaluator_nodes_map_back_to_their_semantic_node() { ); let projected = NodeMaps::resolve(root.clone(), &path).expect("the path resolves"); assert_eq!( - maps.source_of_sxd(&source, &projected).as_ref(), + maps.source_of_sxd(&source, &projected, &mut meter) + .expect("mapping fits the budget") + .as_ref(), Some(reference) ); } // The projection's own document root and container stand for no semantic node. - assert_eq!(maps.source_of_sxd(&source, &root), None); + assert_eq!( + maps.source_of_sxd(&source, &root, &mut meter) + .expect("mapping fits the budget"), + None + ); let container = root.child_at(0).expect("projection container exists"); - assert_eq!(maps.source_of_sxd(&source, &container), None); + assert_eq!( + maps.source_of_sxd(&source, &container, &mut meter) + .expect("mapping fits the budget"), + None + ); assert_eq!( NodeMaps::source_of_path(&source, &NodePath::Ordinary(vec![0])), None @@ -3461,3 +3471,150 @@ fn uri_functions_reserve_xpath_coercions_before_empty_results() { assert!(error.to_string().contains("allocation budget")); } } + +/// The projection of `source`, its node maps and the meter that paid for them. +fn projected_maps(source: &Document) -> (sxd_document_no_unsafe::Package, NodeMaps, Meter) { + let mut meter = Meter::new(owned_byte_budget(usize::MAX), 0).expect("meter initializes"); + let mut slots = ProjectedSlots::default(); + let package = + project_semantic_document(source, &mut slots, &mut meter).expect("source projects"); + let maps = NodeMaps::new(source, slots, &mut meter).expect("node maps build"); + (package, maps, meter) +} + +#[test] +fn evaluator_attributes_map_back_without_scanning_their_siblings() { + // `@*` on an element with N attributes maps every selected attribute back + // to the semantic tree. A name search through the owner's attributes per + // result would cost N^2 comparisons for N units of metered XPath work. + let attributes = (0..512).fold(String::new(), |mut attributes, index| { + std::fmt::Write::write_fmt(&mut attributes, format_args!(" a{index}='{index}'")) + .expect("writing to a String cannot fail"); + attributes + }); + let source = Document::parse(&format!(""), None).expect("source parses"); + let (package, maps, mut meter) = projected_maps(&source); + let root: nodeset::Node<'_> = package.as_document().root().into(); + let owner = source + .node(source.logical_roots()[0]) + .expect("root") + .children[0]; + take_attribute_scans(); + for index in 0..512 { + let reference = SourceNode::Attribute { owner, index }; + let projected = maps + .to_sxd(&source, root.clone(), &reference) + .expect("the attribute projects"); + assert_eq!( + maps.source_of_sxd(&source, &projected, &mut meter) + .expect("mapping fits the budget"), + Some(reference) + ); + } + assert_eq!(take_attribute_scans(), 0); +} + +#[test] +fn evaluator_namespaces_charge_the_declarations_they_search() { + // A namespace node has no arena slot, so mapping it back searches the + // owner's declarations; every comparison is XPath work the meter pays. + let declarations = (0..64).fold(String::new(), |mut declarations, index| { + std::fmt::Write::write_fmt( + &mut declarations, + format_args!(" xmlns:p{index}='urn:{index}'"), + ) + .expect("writing to a String cannot fail"); + declarations + }); + let source = Document::parse(&format!(""), None).expect("source parses"); + let (package, maps, mut meter) = projected_maps(&source); + let root: nodeset::Node<'_> = package.as_document().root().into(); + let owner = source + .node(source.logical_roots()[0]) + .expect("root") + .children[0]; + let NodeKind::Element { namespaces, .. } = &source.node(owner).expect("owner").kind else { + panic!("the owner is an element"); + }; + let (before, _) = meter.usage(BudgetKind::XPathOperations).expect("usage"); + let mut searched = 0; + for index in 0..namespaces.len() { + let reference = SourceNode::Namespace { owner, index }; + let Some(projected) = maps.to_sxd(&source, root.clone(), &reference) else { + continue; + }; + assert_eq!( + maps.source_of_sxd(&source, &projected, &mut meter) + .expect("mapping fits the budget"), + Some(reference) + ); + searched += index + 1; + } + let (after, _) = meter.usage(BudgetKind::XPathOperations).expect("usage"); + assert!(searched >= 64 * 65 / 2); + assert!( + after - before >= searched, + "{} < {searched}", + after - before + ); +} + +#[test] +fn the_context_path_is_paid_for_while_an_expression_evaluates() { + // The evaluator keeps the context node's path for the whole evaluation, so + // a context 100 levels deeper needs at least 100 more path words of owned + // memory before the same expression succeeds. + let depth = 128; + let source_xml = format!("{}{}", "".repeat(depth), "".repeat(depth)); + let source = Document::parse(&source_xml, None).expect("source parses"); + let stylesheet = Document::parse("", None).expect("stylesheet parses"); + let mut setup = Meter::new(owned_byte_budget(usize::MAX), 0).expect("setup meter"); + let options = EvaluatorSourceOptions { + process_xinclude: false, + whitespace: Arc::from([]), + clock: None, + }; + let prepared = prepare_evaluator_source(&source, &crate::NoResolver, &mut setup, &options) + .expect("source prepares"); + let evaluator = Evaluator::new( + prepared, + &stylesheet, + None, + &[], + Arc::new(crate::NoResolver), + &mut setup, + options, + ) + .expect("evaluator initializes"); + let at_depth = |levels: usize| { + let mut node = evaluator.source.logical_roots()[0]; + for _ in 0..levels { + node = evaluator.source.node(node).expect("node").children[0]; + } + SourceNode::Node(node) + }; + let expression = Expression::generated("count(.)", Vec::new()); + let variables = HashMap::::new(); + let least_owned_bytes = |node: &SourceNode| { + let (mut low, mut high) = (0, 1 << 20); + while low < high { + let middle = low + (high - low) / 2; + let mut meter = Meter::new(owned_byte_budget(middle), 0).expect("meter"); + if evaluator + .evaluate_core(&expression, node, 1, 1, &variables, &mut meter, None) + .is_ok() + { + high = middle; + } else { + low = middle + 1; + } + } + low + }; + let shallow = least_owned_bytes(&at_depth(10)); + let deep = least_owned_bytes(&at_depth(110)); + assert!( + deep >= shallow + 100 * std::mem::size_of::(), + "{deep} < {shallow} + 100 words" + ); +} From 3ec3a6d128a469e619e9ab8d60809f1816a042b7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:34:58 +0200 Subject: [PATCH 3/3] fix(xslt): map semantic nodes to the evaluator by arena slot Locating a semantic node in the evaluator tree built its path, walked it from the root and searched the owner's attributes by name, unmetered: binding a node-set of N attributes, keying them or evaluating in their context cost N^2. Replaying stylesheet-function arguments from node paths searched attributes the same way. - Projection records each node's arena slot by node id, and the evaluator document returns the node in a slot directly; an attribute is taken at its source index, which projection preserves, after a name check - The context node is located the same way - Resolving a node path still names an attribute or namespace node, so the search of the owner's declarations is admitted as XPath work Regression tests count attribute comparisons for evaluator lookups and require path replay to charge the comparisons it makes. --- src/sxd_document/dom_no_unsafe.rs | 23 +++ src/sxd_document/raw_no_unsafe.rs | 5 + src/xslt/xpath.rs | 267 ++++++++++++++++++++---------- src/xslt/xpath/tests.rs | 103 ++++++++++-- 4 files changed, 289 insertions(+), 109 deletions(-) diff --git a/src/sxd_document/dom_no_unsafe.rs b/src/sxd_document/dom_no_unsafe.rs index 0b005652..1274a505 100644 --- a/src/sxd_document/dom_no_unsafe.rs +++ b/src/sxd_document/dom_no_unsafe.rs @@ -32,6 +32,19 @@ macro_rules! wrapper( ) ); +// The node in an arena slot, the inverse of the slot an `identity_key` reports. +macro_rules! at_slot( + ($name:ident, $arena:ident, $wrap:ident, $node:ident) => ( + #[doc = concat!("The ", stringify!($node), " in arena `slot`, if the arena holds one there.")] + #[doc(hidden)] + #[must_use] + pub fn $name(self, slot: usize) -> Option<$node<'d>> { + (slot < self.storage.$arena.borrow().len()) + .then(|| self.$wrap(raw::Index::from_identity_index(slot))) + } + ) +); + impl<'d> Document<'d> { wrapper!(wrap_root, Root, raw::Root); wrapper!(wrap_element, Element, raw::Element); @@ -117,6 +130,16 @@ impl<'d> Document<'d> { ) -> ProcessingInstruction<'d> { self.wrap_pi(self.storage.create_processing_instruction(target, value)) } + + at_slot!(element_at_slot, elements, wrap_element, Element); + at_slot!(text_at_slot, texts, wrap_text, Text); + at_slot!(comment_at_slot, comments, wrap_comment, Comment); + at_slot!( + processing_instruction_at_slot, + processing_instructions, + wrap_pi, + ProcessingInstruction + ); } impl<'d> PartialEq for Document<'d> { diff --git a/src/sxd_document/raw_no_unsafe.rs b/src/sxd_document/raw_no_unsafe.rs index f34f2d9a..38a32a51 100644 --- a/src/sxd_document/raw_no_unsafe.rs +++ b/src/sxd_document/raw_no_unsafe.rs @@ -101,6 +101,11 @@ impl Index { self.idx } + /// The handle of arena slot `idx`; the caller checks it is in bounds. + pub(super) fn from_identity_index(idx: usize) -> Self { + Self::new(idx) + } + fn new(idx: usize) -> Self { Index { idx, diff --git a/src/xslt/xpath.rs b/src/xslt/xpath.rs index cf45996a..38f83e76 100644 --- a/src/xslt/xpath.rs +++ b/src/xslt/xpath.rs @@ -1112,9 +1112,9 @@ impl Evaluator { meter: &mut Meter, ) -> Result<()> { debug_assert_eq!(value_reservation, value.capacity()); - let Some(sxd) = - self.maps - .to_sxd(&self.source, self.package.as_document().root().into(), node) + let Some(sxd) = self + .maps + .to_sxd(&self.source, self.package.as_document(), node) else { meter.release_owned_bytes(value_reservation); return Ok(()); @@ -1271,12 +1271,14 @@ impl Evaluator { }; pending .map(|pending| { - let node = NodeMaps::source_of_path(&self.source, &pending.node) + let node = NodeMaps::source_of_path(&self.source, &pending.node, meter)? .ok_or_else(|| Error::Dynamic("stylesheet function context is stale".into()))?; let arguments = pending .arguments .iter() - .map(|argument| self.project_deferred_value(argument, session, variables)) + .map(|argument| { + self.project_deferred_value(argument, session, variables, meter) + }) .collect::>>()?; Ok(CustomFunctionCall { name: pending.name.clone(), @@ -1350,6 +1352,7 @@ impl Evaluator { value: &DeferredXPathValue, session: &CustomCallSession, variables: &dyn VariableBindings, + meter: &mut Meter, ) -> Result { Ok(match value { DeferredXPathValue::Boolean(value) => Value::Boolean(*value), @@ -1359,7 +1362,7 @@ impl Evaluator { paths .iter() .map(|path| { - NodeMaps::source_of_path(&self.source, path).ok_or_else(|| { + NodeMaps::source_of_path(&self.source, path, meter)?.ok_or_else(|| { Error::Dynamic("stylesheet function argument node is stale".into()) }) }) @@ -2421,7 +2424,9 @@ impl Evaluator { .path(&self.source, node) .ok_or_else(|| Error::Dynamic("XPath context node is stale".into()))?; meter.charge(BudgetKind::XPathOperations, context_path.ordinary().len())?; - let context_node = NodeMaps::resolve(document.root().into(), &context_path) + let context_node = self + .maps + .to_sxd(&self.source, document, node) .ok_or_else(|| Error::Dynamic("XPath context node is stale".into()))?; Ok((context_node, context_path)) } @@ -2632,10 +2637,7 @@ impl Evaluator { Value::NodeSet(nodes) => { let mut set = nodeset::Nodeset::new(); for reference in nodes { - if let Some(node) = - self.maps - .to_sxd(&self.source, document.root().into(), reference) - { + if let Some(node) = self.maps.to_sxd(&self.source, document, reference) { if !set.contains(node.clone()) { let reservation = context .reserve_temporary_allocation(std::mem::size_of_val(&node)); @@ -4076,6 +4078,61 @@ fn project_semantic_document( Ok(package) } +/// Project one element with its namespace bindings and attributes, recording +/// the arena slots of the element and of each attribute. +fn project_element<'d>( + document: SxdDocument<'d>, + kind: &NodeKind, + id: NodeId, + slots: &mut ProjectedSlots, + meter: &mut Meter, +) -> Result> { + let NodeKind::Element { + name, + prefix, + attributes, + namespaces, + } = kind + else { + return Err(Error::Dynamic(format!( + "semantic node {id:?} is not an element" + ))); + }; + let element = document.create_element(QName::with_namespace_uri( + name.namespace.as_deref(), + &name.local, + )); + element.set_preferred_prefix(prefix.as_deref()); + for namespace in namespaces.iter() { + if let Some(prefix) = &namespace.prefix { + element.register_prefix(prefix, &namespace.uri); + } else { + element.set_default_namespace_uri(Some(&namespace.uri)); + element.register_prefix("", &namespace.uri); + } + } + for (index, attribute) in attributes.iter().enumerate() { + let projected = element.set_attribute_value( + QName::with_namespace_uri(attribute.name.namespace.as_deref(), &attribute.name.local), + &attribute.value, + ); + projected.set_preferred_prefix(attribute.prefix.as_deref()); + ProjectedSlots::record( + &mut slots.attributes, + projected.identity_key(), + (id, index), + meter, + )?; + } + slots.record_node( + |slots| &mut slots.elements, + element.identity_key(), + id, + meter, + )?; + Ok(element) +} + fn project_logical_root<'d>( source: &Document, logical_root: NodeId, @@ -4086,8 +4143,8 @@ fn project_logical_root<'d>( ) -> Result<()> { let wrapper = document.create_element(QName::new(DOCUMENT_ELEMENT)); documents.append_child(wrapper); - ProjectedSlots::record( - &mut slots.elements, + slots.record_node( + |slots| &mut slots.elements, wrapper.identity_key(), logical_root, meter, @@ -4113,43 +4170,9 @@ fn project_logical_root<'d>( pending.push((*child, parent)); } } - NodeKind::Element { - name, - prefix, - attributes, - namespaces, - } => { - let element = document.create_element(QName::with_namespace_uri( - name.namespace.as_deref(), - &name.local, - )); - element.set_preferred_prefix(prefix.as_deref()); - for namespace in namespaces.iter() { - if let Some(prefix) = &namespace.prefix { - element.register_prefix(prefix, &namespace.uri); - } else { - element.set_default_namespace_uri(Some(&namespace.uri)); - element.register_prefix("", &namespace.uri); - } - } - for (index, attribute) in attributes.iter().enumerate() { - let projected = element.set_attribute_value( - QName::with_namespace_uri( - attribute.name.namespace.as_deref(), - &attribute.name.local, - ), - &attribute.value, - ); - projected.set_preferred_prefix(attribute.prefix.as_deref()); - ProjectedSlots::record( - &mut slots.attributes, - projected.identity_key(), - (id, index), - meter, - )?; - } + NodeKind::Element { .. } => { + let element = project_element(document, &node.kind, id, slots, meter)?; parent.append_child(element); - ProjectedSlots::record(&mut slots.elements, element.identity_key(), id, meter)?; for child in node.children.iter().rev() { reserve_temporary_vec_slot(&mut pending, meter, &mut pending_reservation)?; pending.push((*child, element)); @@ -4158,19 +4181,24 @@ fn project_logical_root<'d>( NodeKind::Text { value, .. } => { let text = document.create_text(value); parent.append_child(text); - ProjectedSlots::record(&mut slots.texts, text.identity_key(), id, meter)?; + slots.record_node(|slots| &mut slots.texts, text.identity_key(), id, meter)?; } NodeKind::Comment(value) => { let comment = document.create_comment(value); parent.append_child(comment); - ProjectedSlots::record(&mut slots.comments, comment.identity_key(), id, meter)?; + slots.record_node( + |slots| &mut slots.comments, + comment.identity_key(), + id, + meter, + )?; } NodeKind::ProcessingInstruction { target, value } => { let instruction = document.create_processing_instruction(target, value.as_deref()); parent.append_child(instruction); - ProjectedSlots::record( - &mut slots.instructions, + slots.record_node( + |slots| &mut slots.instructions, instruction.identity_key(), id, meter, @@ -6269,9 +6297,30 @@ struct ProjectedSlots { instructions: Vec>, /// The owner and attribute index behind each attribute slot. attributes: Vec>, + /// The inverse: the arena slot of each projected semantic node, by node id; + /// the node's kind names the arena. + sxd: Vec>, } impl ProjectedSlots { + /// Record that semantic node `id` projected to slot `key` of `arena`, in + /// both directions. + fn record_node( + &mut self, + arena: fn(&mut Self) -> &mut Vec>, + key: (usize, usize, usize), + id: NodeId, + meter: &mut Meter, + ) -> Result<()> { + Self::record(arena(self), key, id, meter)?; + while self.sxd.len() <= id.0 { + reserve_retained_vec_slot(&mut self.sxd, meter)?; + self.sxd.push(None); + } + self.sxd[id.0] = Some(key.2); + Ok(()) + } + fn record( slots: &mut Vec>, key: (usize, usize, usize), @@ -6481,22 +6530,50 @@ impl NodeMaps { } /// The semantic node at `path`, the inverse of [`Self::path`]. - fn source_of_path(source: &Document, path: &NodePath) -> Option { + fn source_of_path( + source: &Document, + path: &NodePath, + meter: &mut Meter, + ) -> Result> { let ordinary = path.ordinary(); - let (&[0, document], children) = ordinary.split_first_chunk::<2>()? else { - return None; + let Some((&[0, document], children)) = ordinary.split_first_chunk::<2>() else { + return Ok(None); + }; + meter.charge(BudgetKind::XPathOperations, children.len())?; + let Some(mut id) = source.logical_roots().get(document).copied() else { + return Ok(None); }; - let mut id = *source.logical_roots().get(document)?; for index in children { - id = *source.node(id)?.children.get(*index)?; + let Some(child) = source.node(id).and_then(|node| node.children.get(*index)) else { + return Ok(None); + }; + id = *child; + } + // A path names an attribute or namespace node rather than indexing it, + // so the owner's declarations are searched; the search is admitted first. + if let ( + NodePath::Attribute { .. } | NodePath::Namespace { .. }, + Some(NodeKind::Element { + attributes, + namespaces, + .. + }), + ) = (path, source.node(id).map(|node| &node.kind)) + { + let declarations = if matches!(path, NodePath::Attribute { .. }) { + attributes.len() + } else { + namespaces.len() + }; + meter.charge(BudgetKind::XPathOperations, declarations)?; } - match path { + Ok(match path { NodePath::Ordinary(_) => Some(SourceNode::Node(id)), NodePath::Attribute { namespace, local, .. } => attribute_of(source, id, namespace.as_deref(), local), NodePath::Namespace { prefix, uri, .. } => namespace_of(source, id, prefix, uri), - } + }) } /// The semantic node an evaluator result came from. @@ -6544,27 +6621,52 @@ impl NodeMaps { }) } + /// The evaluator node of a semantic node, read from the arena slot its + /// projection recorded rather than found by a walk or a name search. fn to_sxd<'d>( &self, source: &Document, - root: nodeset::Node<'d>, + document: SxdDocument<'d>, node: &SourceNode, ) -> Option> { - Self::resolve(root, &self.path(source, node)?) - } - fn resolve<'d>(root: nodeset::Node<'d>, path: &NodePath) -> Option> { - let mut node = root; - for index in path.ordinary() { - node = node.child_at(*index)?; - } - match path { - NodePath::Ordinary(_) => Some(node), - NodePath::Attribute { - namespace, local, .. - } => resolve_attribute_node(&node, namespace.as_deref(), local), - NodePath::Namespace { prefix, uri, .. } => resolve_namespace_node(&node, prefix, uri), + let owner = source_node_owner(node); + let slot = self.slots.sxd.get(owner.0).copied().flatten()?; + let kind = &source.node(owner)?.kind; + let projected: nodeset::Node<'d> = match kind { + NodeKind::Root | NodeKind::Element { .. } => document.element_at_slot(slot)?.into(), + NodeKind::Text { .. } => document.text_at_slot(slot)?.into(), + NodeKind::Comment(_) => document.comment_at_slot(slot)?.into(), + NodeKind::ProcessingInstruction { .. } => { + document.processing_instruction_at_slot(slot)?.into() + } + }; + match (node, kind) { + (SourceNode::Node(_), _) => Some(projected), + (SourceNode::Attribute { index, .. }, NodeKind::Element { attributes, .. }) => { + // Projection sets the attributes in source order and their names + // are distinct, so the evaluator keeps each at its source index. + let expected = &attributes.get(*index)?.name; + let attribute = projected.element()?.attribute_at(*index)?; + let name = attribute.name(); + let name = name.get(); + (name.local_part() == expected.local + && name.namespace_uri() == expected.namespace.as_deref()) + .then_some(nodeset::Node::Attribute(attribute)) + } + (SourceNode::Namespace { index, .. }, NodeKind::Element { namespaces, .. }) => { + let namespace = namespaces + .get(*index) + .filter(|namespace| xpath_namespace_is_visible(namespace))?; + resolve_namespace_node( + &projected, + namespace.prefix.as_deref().unwrap_or_default(), + &namespace.uri, + ) + } + _ => None, } } + fn project_value( &self, source: &Document, @@ -10108,23 +10210,6 @@ fn resolve_node_path<'d>( } } -fn resolve_attribute_node<'d>( - node: &nodeset::Node<'d>, - namespace: Option<&str>, - local: &str, -) -> Option> { - let element = node.element()?; - for index in 0..element.attributes_len() { - let attribute = element.attribute_at(index)?; - let name = attribute.name(); - let name = name.get(); - if name.local_part() == local && name.namespace_uri() == namespace { - return Some(nodeset::Node::Attribute(attribute)); - } - } - None -} - fn resolve_namespace_node<'d>( node: &nodeset::Node<'d>, prefix: &str, diff --git a/src/xslt/xpath/tests.rs b/src/xslt/xpath/tests.rs index bcd2bb91..9af5c5d4 100644 --- a/src/xslt/xpath/tests.rs +++ b/src/xslt/xpath/tests.rs @@ -661,10 +661,14 @@ fn node_paths_and_evaluator_nodes_map_back_to_their_semantic_node() { for reference in &references { let path = maps.path(&source, reference).expect("the node has a path"); assert_eq!( - NodeMaps::source_of_path(&source, &path).as_ref(), + NodeMaps::source_of_path(&source, &path, &mut meter) + .expect("mapping fits the budget") + .as_ref(), Some(reference) ); - let projected = NodeMaps::resolve(root.clone(), &path).expect("the path resolves"); + let projected = maps + .to_sxd(&source, package.as_document(), reference) + .expect("the node projects"); assert_eq!( maps.source_of_sxd(&source, &projected, &mut meter) .expect("mapping fits the budget") @@ -684,18 +688,13 @@ fn node_paths_and_evaluator_nodes_map_back_to_their_semantic_node() { .expect("mapping fits the budget"), None ); - assert_eq!( - NodeMaps::source_of_path(&source, &NodePath::Ordinary(vec![0])), - None - ); - assert_eq!( - NodeMaps::source_of_path(&source, &NodePath::Ordinary(vec![0, 9])), - None - ); - assert_eq!( - NodeMaps::source_of_path(&source, &NodePath::Ordinary(vec![1, 0])), - None - ); + for stale in [vec![0], vec![0, 9], vec![1, 0]] { + assert_eq!( + NodeMaps::source_of_path(&source, &NodePath::Ordinary(stale), &mut meter) + .expect("mapping fits the budget"), + None + ); + } let mut ordered = references.clone(); ordered.sort_by_key(|node| maps.order(&source, node)); let position = |wanted: &SourceNode| ordered.iter().position(|node| node == wanted); @@ -3494,7 +3493,6 @@ fn evaluator_attributes_map_back_without_scanning_their_siblings() { }); let source = Document::parse(&format!(""), None).expect("source parses"); let (package, maps, mut meter) = projected_maps(&source); - let root: nodeset::Node<'_> = package.as_document().root().into(); let owner = source .node(source.logical_roots()[0]) .expect("root") @@ -3503,8 +3501,44 @@ fn evaluator_attributes_map_back_without_scanning_their_siblings() { for index in 0..512 { let reference = SourceNode::Attribute { owner, index }; let projected = maps - .to_sxd(&source, root.clone(), &reference) + .to_sxd(&source, package.as_document(), &reference) + .expect("the attribute projects"); + assert_eq!( + maps.source_of_sxd(&source, &projected, &mut meter) + .expect("mapping fits the budget"), + Some(reference) + ); + } + assert_eq!(take_attribute_scans(), 0); +} + +#[test] +fn semantic_attributes_reach_the_evaluator_without_a_search() { + // Binding a node-set of N attributes to a variable, keying them or + // evaluating in their context locates each one in the evaluator tree; a + // search through the owner's attributes per node would cost N^2. + let attributes = (0..512).fold(String::new(), |mut attributes, index| { + std::fmt::Write::write_fmt(&mut attributes, format_args!(" a{index}='{index}'")) + .expect("writing to a String cannot fail"); + attributes + }); + let source = Document::parse(&format!(""), None).expect("source parses"); + let (package, maps, mut meter) = projected_maps(&source); + let document = package.as_document(); + let owner = source + .node(source.logical_roots()[0]) + .expect("root") + .children[0]; + take_attribute_scans(); + for index in 0..512 { + let reference = SourceNode::Attribute { owner, index }; + let projected = maps + .to_sxd(&source, document, &reference) .expect("the attribute projects"); + let nodeset::Node::Attribute(attribute) = &projected else { + panic!("an attribute projects to an attribute"); + }; + assert_eq!(attribute.name().get().local_part(), format!("a{index}")); assert_eq!( maps.source_of_sxd(&source, &projected, &mut meter) .expect("mapping fits the budget"), @@ -3514,6 +3548,40 @@ fn evaluator_attributes_map_back_without_scanning_their_siblings() { assert_eq!(take_attribute_scans(), 0); } +#[test] +fn path_lookups_charge_the_attributes_they_search() { + // A stylesheet function receiving `/*/@*` replays each argument from its + // node path, which names the attribute: finding it searches the owner's + // attributes, and every comparison is XPath work the meter pays. + let attributes = (0..256).fold(String::new(), |mut attributes, index| { + std::fmt::Write::write_fmt(&mut attributes, format_args!(" a{index}='{index}'")) + .expect("writing to a String cannot fail"); + attributes + }); + let source = Document::parse(&format!(""), None).expect("source parses"); + let (_package, maps, mut meter) = projected_maps(&source); + let owner = source + .node(source.logical_roots()[0]) + .expect("root") + .children[0]; + let (before, _) = meter.usage(BudgetKind::XPathOperations).expect("usage"); + take_attribute_scans(); + for index in 0..256 { + let reference = SourceNode::Attribute { owner, index }; + let path = maps + .path(&source, &reference) + .expect("the attribute has a path"); + assert_eq!( + NodeMaps::source_of_path(&source, &path, &mut meter).expect("lookup fits the budget"), + Some(reference) + ); + } + let scanned = take_attribute_scans(); + let (after, _) = meter.usage(BudgetKind::XPathOperations).expect("usage"); + assert!(scanned >= 256 * 257 / 2); + assert!(after - before >= scanned, "{} < {scanned}", after - before); +} + #[test] fn evaluator_namespaces_charge_the_declarations_they_search() { // A namespace node has no arena slot, so mapping it back searches the @@ -3528,7 +3596,6 @@ fn evaluator_namespaces_charge_the_declarations_they_search() { }); let source = Document::parse(&format!(""), None).expect("source parses"); let (package, maps, mut meter) = projected_maps(&source); - let root: nodeset::Node<'_> = package.as_document().root().into(); let owner = source .node(source.logical_roots()[0]) .expect("root") @@ -3540,7 +3607,7 @@ fn evaluator_namespaces_charge_the_declarations_they_search() { let mut searched = 0; for index in 0..namespaces.len() { let reference = SourceNode::Namespace { owner, index }; - let Some(projected) = maps.to_sxd(&source, root.clone(), &reference) else { + let Some(projected) = maps.to_sxd(&source, package.as_document(), &reference) else { continue; }; assert_eq!(