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/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/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..38f83e76 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, @@ -1088,7 +1114,7 @@ impl Evaluator { debug_assert_eq!(value_reservation, value.capacity()); let Some(sxd) = self .maps - .to_sxd(self.package.as_document().root().into(), node) + .to_sxd(&self.source, self.package.as_document(), 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,16 +1271,14 @@ impl Evaluator { }; pending .map(|pending| { - let node = self - .maps - .reverse - .get(&pending.node) - .cloned() + 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(), @@ -1307,7 +1331,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())) @@ -1328,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), @@ -1337,7 +1362,7 @@ impl Evaluator { paths .iter() .map(|path| { - self.maps.reverse.get(path).cloned().ok_or_else(|| { + NodeMaps::source_of_path(&self.source, path, meter)?.ok_or_else(|| { Error::Dynamic("stylesheet function argument node is stale".into()) }) }) @@ -1366,7 +1391,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 +1443,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 +1467,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 +1518,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(), @@ -2191,6 +2221,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" @@ -2204,6 +2236,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); @@ -2218,7 +2282,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 +2320,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 +2417,16 @@ 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 = self + .maps + .to_sxd(&self.source, document, node) .ok_or_else(|| Error::Dynamic("XPath context node is stale".into()))?; Ok((context_node, context_path)) } @@ -2572,7 +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(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)); @@ -2653,12 +2718,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 +4061,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,20 +4073,82 @@ fn project_semantic_document(source: &Document, meter: &mut Meter) -> Result( + 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, 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); + slots.record_node( + |slots| &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()))?; @@ -4044,45 +4170,40 @@ 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 attribute in attributes { - 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()); - } + NodeKind::Element { .. } => { + let element = project_element(document, &node.kind, id, slots, meter)?; parent.append_child(element); 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); + 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); + 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); + slots.record_node( + |slots| &mut slots.instructions, + instruction.identity_key(), + id, + meter, + )?; + } } } Ok(()) @@ -6163,28 +6284,86 @@ 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>, + /// 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), + id: T, + 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,127 +6371,305 @@ 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 resolve<'d>(root: nodeset::Node<'d>, path: &NodePath) -> Option> { - let mut node = root; - for index in path.ordinary() { - node = node.child_at(*index)?; + + /// 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 len = 1; + let mut current = Some(id); + while let Some(node) = current { + 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; + } + 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; + 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, + meter: &mut Meter, + ) -> Result> { + let ordinary = path.ordinary(); + 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); + }; + for index in children { + let Some(child) = source.node(id).and_then(|node| node.children.get(*index)) else { + return Ok(None); + }; + id = *child; } - match path { - NodePath::Ordinary(_) => Some(node), + // 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)?; + } + Ok(match path { + NodePath::Ordinary(_) => Some(SourceNode::Node(id)), NodePath::Attribute { namespace, local, .. - } => resolve_attribute_node(&node, namespace.as_deref(), local), - NodePath::Namespace { prefix, uri, .. } => resolve_namespace_node(&node, prefix, uri), + } => 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<'_>, + meter: &mut Meter, + ) -> Result> { + let projected = |slots: &[Option], (_, _, slot): (usize, usize, usize)| { + slots.get(slot).copied().flatten().map(SourceNode::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()), + 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 (_, _, slot) = attribute.identity_key(); + self.slots + .attributes + .get(slot) + .copied() + .flatten() + .map(|(owner, index)| SourceNode::Attribute { owner, index }) + } + nodeset::Node::Namespace(namespace) => { + 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()) + } + }) + } + + /// 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, + document: SxdDocument<'d>, + node: &SourceNode, + ) -> Option> { + 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, - root: &nodeset::Node<'_>, value: SxdValue<'_>, meter: &mut Meter, ) -> Result { @@ -6332,256 +6689,85 @@ 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, meter)? { + 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 attribute_of( + source: &Document, + 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| { + #[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 }) +} - 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(()) - } +#[cfg(test)] +thread_local! { + static ATTRIBUTE_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -fn projected_child_source( - 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()), - } +/// 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 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 +6775,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 +8187,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 @@ -10024,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 6307b422..9af5c5d4 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,122 @@ 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, &mut meter) + .expect("mapping fits the budget") + .as_ref(), + Some(reference) + ); + 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") + .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, &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, &mut meter) + .expect("mapping fits the budget"), + 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); + 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 +732,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 +1099,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 +1119,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 +1143,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, .. @@ -3389,3 +3470,218 @@ 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 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, 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"), + Some(reference) + ); + } + 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 + // 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 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, package.as_document(), &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" + ); +} 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