From 9505e68d9e72e33e93a29d23d8cfd0ab5d93880f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 17:02:59 +0200 Subject: [PATCH 1/2] perf(xslt): read node paths from the projection inside XPath functions XPath functions that name a node by its path (generate-id(), key(), id(), document(), unparsed-entity-uri(), stylesheet function calls and the key index) built that path by scanning each ancestor's children until the node was found, so naming every child of a wide parent cost quadratic XPath work. - The functions borrow the projection's node maps for the evaluation and read a node's path from the recorded per-node positions: one charged step per level. Attribute and namespace leaves keep their names from the evaluator node, including the implicit xml binding. - key(), id() and unparsed-entity-uri() read only the logical document index, walking to the logical root without building a path. - The key index takes its entry paths from the semantic node directly. - The sibling-scanning builder is removed. An engine test doubles the children of a wide parent and requires the XPath work to stay below triple; unit tests check that every node kind's path resolves back to the same node and that a late child's path fits a depth-sized allowance. Closes #371 --- crates/xml-sec-xslt/tests/engine.rs | 44 ++++ src/xslt/xpath.rs | 349 ++++++++++++++---------- src/xslt/xpath/tests.rs | 394 +++++++++++++++++++--------- 3 files changed, 518 insertions(+), 269 deletions(-) diff --git a/crates/xml-sec-xslt/tests/engine.rs b/crates/xml-sec-xslt/tests/engine.rs index c4217e49..4e536d5f 100644 --- a/crates/xml-sec-xslt/tests/engine.rs +++ b/crates/xml-sec-xslt/tests/engine.rs @@ -15480,3 +15480,47 @@ fn imported_stylesheet_document_roots_preserve_module_order() { .expect("each module resolves its own document root"); assert_eq!(result.serialized.bytes, b"first|second"); } + +#[test] +fn node_identity_functions_cost_linear_work_in_a_wide_document() { + // generate-id(), key() and the key index name each child of one wide + // parent by its path. Finding a child's position by scanning the siblings + // before it would make doubling the children quadruple the XPath work; + // reading the projected position keeps it proportional. + let stylesheet = compile( + r#" + + + + "#, + ); + let work = |children: usize| { + let xml = format!( + "{}", + (0..children).fold(String::new(), |mut xml, index| { + write!(xml, "").expect("writing to a String cannot fail"); + xml + }) + ); + let source = Document::parse(&xml, None).expect("source parses"); + let reported = stylesheet.execute_reported( + &source, + &Parameters::new(), + ExecutionEnvironment::new(Arc::new(NoResolver)), + ExecutionOptions { + budget: ExecutionBudget { + template_applications: 1_000_000, + key_entries: 1_000_000, + result_nodes: 1_000_000, + ..execution_budget(xml.len()) + }, + initial_mode: None, + initial_template: None, + }, + ); + reported.outcome.expect("transform succeeds"); + reported.usage.xpath_operations + }; + let (narrow, wide) = (work(1024), work(2048)); + assert!(wide < narrow * 3, "{narrow} -> {wide}"); +} diff --git a/src/xslt/xpath.rs b/src/xslt/xpath.rs index 38f83e76..170120fd 100644 --- a/src/xslt/xpath.rs +++ b/src/xslt/xpath.rs @@ -675,12 +675,13 @@ impl CustomCallSession { } } -struct DeferredStylesheetFunction { +struct DeferredStylesheetFunction<'a> { + paths: ProjectedPaths<'a>, name: ExpandedName, session: Rc>, } -impl function::Function for DeferredStylesheetFunction { +impl function::Function for DeferredStylesheetFunction<'_> { fn evaluate<'d>( &self, context: &sxd_xpath_no_unsafe::context::Evaluation<'_, 'd>, @@ -689,12 +690,12 @@ impl function::Function for DeferredStylesheetFunction { context.reserve_string_allocation(deferred_call_size(&self.name, &arguments))?; let call = DeferredCustomCall { name: self.name.clone(), - node: typed_path_to_with_context(&context.node, context)?, + node: self.paths.path(&context.node, context)?, position: context.position, size: context.size, arguments: arguments .into_iter() - .map(|value| defer_sxd_value(value, context)) + .map(|value| defer_sxd_value(value, self.paths, context)) .collect::, _>>()?, }; let mut state = self.session.borrow_mut(); @@ -741,6 +742,7 @@ fn deferred_sxd_value_size(value: &SxdValue<'_>) -> usize { fn defer_sxd_value( value: SxdValue<'_>, + paths: ProjectedPaths<'_>, context: &sxd_xpath_no_unsafe::context::Evaluation<'_, '_>, ) -> std::result::Result { Ok(match value { @@ -756,7 +758,7 @@ fn defer_sxd_value( nodes .document_order_with_context(context)? .iter() - .map(|node| typed_path_to_with_context(node, context)) + .map(|node| paths.path(node, context)) .collect::, _>>()?, ), }) @@ -1112,13 +1114,15 @@ impl Evaluator { meter: &mut Meter, ) -> Result<()> { debug_assert_eq!(value_reservation, value.capacity()); - let Some(sxd) = self + // A node the evaluator cannot see is never a key result. + if self .maps .to_sxd(&self.source, self.package.as_document(), node) - else { + .is_none() + { meter.release_owned_bytes(value_reservation); return Ok(()); - }; + } let document_index = self .source .logical_root_for(node) @@ -1136,7 +1140,7 @@ impl Evaluator { return Err(error); } }; - let (path, path_reservation) = match typed_path_to_metered(&sxd, meter) { + let (path, path_reservation) = match self.key_entry_path(node, meter) { Ok(path) => path, Err(error) => { meter.release_owned_bytes(value_reservation); @@ -1171,6 +1175,25 @@ impl Evaluator { result } + /// The retained path of a key entry, its bytes charged before it exists. + fn key_entry_path(&self, node: &SourceNode, meter: &mut Meter) -> Result<(NodePath, usize)> { + let stale = || Error::Dynamic("key node has no document path".into()); + let bytes = self.maps.path_bytes(&self.source, node).ok_or_else(stale)?; + let words = self + .maps + .path_len(&self.source, source_node_owner(node)) + .ok_or_else(stale)?; + meter.charge(BudgetKind::XPathOperations, words)?; + meter.charge(BudgetKind::OwnedBytes, bytes)?; + match self.maps.path(&self.source, node) { + Some(path) => Ok((path, bytes)), + None => { + meter.release_owned_bytes(bytes); + Err(stale()) + } + } + } + #[expect( clippy::too_many_arguments, reason = "XPath dynamic context and its budget/continuation state are independent inputs" @@ -2463,15 +2486,25 @@ impl Evaluator { Ok(()) } - fn bind_xpath_functions( - &self, - context: &mut sxd_xpath_no_unsafe::context::ScopedContext<'_, '_>, + /// The projection this evaluation's functions read node paths from. + fn projected_paths(&self) -> ProjectedPaths<'_> { + ProjectedPaths { + source: &self.source, + maps: &self.maps, + } + } + + fn bind_xpath_functions<'function>( + &'function self, + context: &mut sxd_xpath_no_unsafe::context::ScopedContext<'function, '_>, expression: &Expression, ) { + let paths = self.projected_paths(); let function_namespaces = Arc::clone(&expression.namespaces); context.set_function( "key", KeyFunction { + paths, index: Rc::clone(&self.key_index), namespaces: Arc::clone(&function_namespaces), declarations: Arc::clone(&self.key_declarations), @@ -2490,12 +2523,14 @@ impl Evaluator { context.set_function( "generate-id", GenerateId { + paths, assigned: self.generated_ids.clone(), }, ); context.set_function( "id", IdFunction { + paths, nodes_by_document: Rc::clone(&self.id_index), }, ); @@ -2517,6 +2552,7 @@ impl Evaluator { context.set_function( "unparsed-entity-uri", UnparsedEntityUriFunction { + paths, documents: Rc::clone(&self.unparsed_entity_index), }, ); @@ -2541,6 +2577,7 @@ impl Evaluator { context.set_function( "document", DocumentFunction { + paths: self.projected_paths(), resolver: self.resolver.as_ref(), access_error: document_access_error, static_base_uri: expression.static_base_uri.clone(), @@ -2559,6 +2596,7 @@ impl Evaluator { context.set_function( qname, DeferredStylesheetFunction { + paths: self.projected_paths(), name: name.clone(), session: Rc::clone(&custom_calls.state), }, @@ -6703,6 +6741,142 @@ impl NodeMaps { } } +/// The projection an XPath evaluation runs over, lent to the functions that +/// name evaluator nodes by path for the duration of that evaluation. +#[derive(Clone, Copy)] +struct ProjectedPaths<'a> { + source: &'a Document, + maps: &'a NodeMaps, +} + +/// Where an evaluator node's path starts in the projected package. +enum PathOwner { + /// The package root, whose path is empty. + Root, + /// The container of the logical documents, the root's only child. + Documents, + Projected(NodeId), +} + +impl ProjectedPaths<'_> { + fn owner(&self, node: &nodeset::Node<'_>) -> Option { + let slots = &self.maps.slots; + let projected = |table: &[Option], (_, _, slot): (usize, usize, usize)| { + table.get(slot).copied().flatten().map(PathOwner::Projected) + }; + match node { + nodeset::Node::Root(_) => Some(PathOwner::Root), + nodeset::Node::Element(element) => slots + .element(element) + .map(PathOwner::Projected) + .or_else(|| { + matches!(node.parent(), Some(nodeset::Node::Root(_))) + .then_some(PathOwner::Documents) + }), + nodeset::Node::Text(text) => projected(&slots.texts, text.identity_key()), + nodeset::Node::Comment(comment) => projected(&slots.comments, comment.identity_key()), + nodeset::Node::ProcessingInstruction(instruction) => { + projected(&slots.instructions, instruction.identity_key()) + } + nodeset::Node::Attribute(attribute) => { + self.owner(&nodeset::Node::Element(attribute.parent()?)) + } + nodeset::Node::Namespace(namespace) => { + self.owner(&nodeset::Node::Element(namespace.parent)) + } + } + } + + /// The path of `node`, read from the per-node positions the projection + /// recorded: one charged step per level, never a scan of siblings. + /// Attribute and namespace leaves take their names from the evaluator + /// node, which also exposes the implicit `xml` binding. + fn path( + &self, + node: &nodeset::Node<'_>, + context: &sxd_xpath_no_unsafe::context::Evaluation<'_, '_>, + ) -> std::result::Result { + let owner = self.owner(node).ok_or_else(outside_projection)?; + let words = match owner { + PathOwner::Root => 0, + PathOwner::Documents => 1, + PathOwner::Projected(id) => self + .maps + .path_len(self.source, id) + .ok_or_else(outside_projection)?, + }; + context.charge_work(words.max(1))?; + let leaf = match node { + nodeset::Node::Attribute(attribute) => { + let name = attribute.name(); + let name = name.get(); + name.namespace_uri().map_or(0, str::len) + name.local_part().len() + } + nodeset::Node::Namespace(namespace) => namespace.prefix().len() + namespace.uri().len(), + _ => 0, + }; + context.reserve_temporary_allocation(words * std::mem::size_of::() + leaf)?; + let parent = match owner { + PathOwner::Root => Vec::new(), + PathOwner::Documents => vec![0], + PathOwner::Projected(id) => self + .maps + .ordinary_path(self.source, id) + .ok_or_else(outside_projection)?, + }; + Ok(match node { + nodeset::Node::Attribute(attribute) => { + let name = attribute.name(); + let name = name.get(); + NodePath::Attribute { + parent, + namespace: name.namespace_uri().map(str::to_owned), + local: name.local_part().to_owned(), + } + } + nodeset::Node::Namespace(namespace) => NodePath::Namespace { + parent, + prefix: namespace.prefix().to_owned(), + uri: namespace.uri().to_owned(), + }, + _ => NodePath::Ordinary(parent), + }) + } + + /// The logical document holding `node`, the second entry of its path, + /// found by walking to its logical root without building the path. The + /// package root and the documents container belong to none. + fn document_index( + &self, + node: &nodeset::Node<'_>, + context: &sxd_xpath_no_unsafe::context::Evaluation<'_, '_>, + ) -> std::result::Result, function::Error> { + let mut id = match self.owner(node).ok_or_else(outside_projection)? { + PathOwner::Root | PathOwner::Documents => return Ok(None), + PathOwner::Projected(id) => id, + }; + loop { + context.charge_work(1)?; + match self.source.node(id).ok_or_else(outside_projection)?.parent { + Some(parent) => id = parent, + None => break, + } + } + Ok(self + .maps + .positions + .get(id.0) + .copied() + .filter(|position| *position != UNPLACED)) + } +} + +fn outside_projection() -> function::Error { + function::Error::Other { + what: "node is outside the projected documents".into(), + } +} + fn attribute_of( source: &Document, owner: NodeId, @@ -7786,10 +7960,11 @@ struct GeneratedNodeIdentity { path: NodePath, } -struct GenerateId { +struct GenerateId<'a> { + paths: ProjectedPaths<'a>, assigned: Rc>, } -impl function::Function for GenerateId { +impl function::Function for GenerateId<'_> { fn evaluate<'d>( &self, context: &sxd_xpath_no_unsafe::context::Evaluation<'_, 'd>, @@ -7813,7 +7988,7 @@ impl function::Function for GenerateId { &self.assigned, GeneratedNodeIdentity { document: node.document().identity(), - path: typed_path_to_with_context(&node, context)?, + path: self.paths.path(&node, context)?, }, )?; let digits = id.checked_ilog10().unwrap_or(0) as usize + 1; @@ -8104,11 +8279,12 @@ fn language_tag_matches(language: &str, requested: &str) -> bool { type IdIndex = Vec>; -struct IdFunction { +struct IdFunction<'a> { + paths: ProjectedPaths<'a>, nodes_by_document: Rc>, } -impl function::Function for IdFunction { +impl function::Function for IdFunction<'_> { fn evaluate<'d>( &self, context: &sxd_xpath_no_unsafe::context::Evaluation<'_, 'd>, @@ -8122,13 +8298,9 @@ impl function::Function for IdFunction { // XPath 1.0 section 4.1 binds id() to the document containing the dynamic context node. // Every projected logical document occupies root/containers/document[index]. // https://www.w3.org/TR/1999/REC-xpath-19991116/#function-id - let context_path = typed_path_to_with_context(&context.node, context)?; + let document_index = self.paths.document_index(&context.node, context)?; let nodes_by_document = self.nodes_by_document.borrow(); - let Some(nodes) = context_path - .ordinary() - .get(1) - .and_then(|index| nodes_by_document.get(*index)) - else { + let Some(nodes) = document_index.and_then(|index| nodes_by_document.get(index)) else { return Ok(SxdValue::Nodeset(nodeset::Nodeset::new())); }; let mut result = nodeset::Nodeset::new(); @@ -8325,11 +8497,12 @@ fn unparsed_entity_table_bytes(capacity: usize) -> usize { .saturating_mul(2) } -struct UnparsedEntityUriFunction { +struct UnparsedEntityUriFunction<'a> { + paths: ProjectedPaths<'a>, documents: Rc>>>, } -impl function::Function for UnparsedEntityUriFunction { +impl function::Function for UnparsedEntityUriFunction<'_> { fn evaluate<'d>( &self, context: &sxd_xpath_no_unsafe::context::Evaluation<'_, 'd>, @@ -8340,10 +8513,9 @@ impl function::Function for UnparsedEntityUriFunction { what: "unparsed-entity-uri() requires one argument".into(), }); } - let document_index = typed_path_to_with_context(&context.node, context)? - .ordinary() - .get(1) - .copied() + let document_index = self + .paths + .document_index(&context.node, context)? .ok_or_else(|| function::Error::Other { what: "unparsed-entity-uri() context has no logical document".into(), })?; @@ -8359,6 +8531,7 @@ impl function::Function for UnparsedEntityUriFunction { } struct DocumentFunction<'a, R: Resolver + ?Sized> { + paths: ProjectedPaths<'a>, resolver: &'a R, access_error: &'a RefCell>, roots: Rc>>, @@ -9338,7 +9511,7 @@ impl function::Function for DocumentFunction<'_, R> { else { return Ok(SxdValue::Nodeset(nodeset::Nodeset::new())); }; - let path = typed_path_to_with_context(&node, context)?; + let path = self.paths.path(&node, context)?; let base_uris = self.node_base_uris.borrow(); DocumentBaseSelection::Explicit { base_uri: clone_metered_optional_string( @@ -9356,7 +9529,7 @@ impl function::Function for DocumentFunction<'_, R> { match &args[0] { SxdValue::Nodeset(nodes) => { for node in nodes { - let path = typed_path_to_with_context(&node, context)?; + let path = self.paths.path(&node, context)?; let (base_uri, logical_document) = match &base_selection { DocumentBaseSelection::Explicit { base_uri, @@ -9872,113 +10045,12 @@ impl NodePath { } } -#[cfg(test)] -fn typed_path_to(node: &nodeset::Node<'_>) -> NodePath { - build_node_path(node, &mut |_, _| Ok::<_, std::convert::Infallible>(())) - .expect("unlimited fixture path") -} - -fn typed_path_to_metered(node: &nodeset::Node<'_>, meter: &mut Meter) -> Result<(NodePath, usize)> { - let mut reserved = 0usize; - let result = build_node_path(node, &mut |kind, amount| { - meter.charge(kind, amount)?; - if kind == BudgetKind::OwnedBytes { - reserved += amount; - } - Ok(()) - }); - match result { - Ok(path) => Ok((path, reserved)), - Err(error) => { - meter.release_owned_bytes(reserved); - Err(error) - } - } -} - -fn typed_path_to_with_context( - node: &nodeset::Node<'_>, - context: &sxd_xpath_no_unsafe::context::Evaluation<'_, '_>, -) -> std::result::Result { - build_node_path(node, &mut |kind, amount| match kind { - BudgetKind::OwnedBytes => context.reserve_temporary_allocation(amount), - _ => context.charge_work(amount), - }) -} - -// Both runtime budget domains gate the same builder. Count depth without allocation, reserve -// exactly one path buffer, then use indexed children rather than materializing sibling vectors. -fn build_node_path( - node: &nodeset::Node<'_>, - charge: &mut impl FnMut(BudgetKind, usize) -> std::result::Result<(), E>, -) -> std::result::Result { - let owner = match node { - nodeset::Node::Attribute(attribute) => attribute.parent().map(nodeset::Node::Element), - nodeset::Node::Namespace(namespace) => Some(nodeset::Node::Element(namespace.parent)), - node => Some(node.clone()), - }; - let mut depth = 0; - let mut current = owner.clone(); - while let Some(node) = current { - charge(BudgetKind::XPathOperations, 1)?; - current = node.parent(); - if current.is_some() { - depth += 1; - } - } - charge(BudgetKind::OwnedBytes, depth * std::mem::size_of::())?; - let mut path = Vec::with_capacity(depth); - let mut current = owner; - while let Some(node) = current { - let Some(parent) = node.parent() else { - break; - }; - let mut index = 0; - loop { - charge(BudgetKind::XPathOperations, 1)?; - if parent.child_at(index).as_ref() == Some(&node) { - break; - } - index += 1; - } - path.push(index); - current = Some(parent); - } - path.reverse(); - Ok(match node { - nodeset::Node::Attribute(attribute) => { - let name = attribute.name(); - let name = name.get(); - charge( - BudgetKind::OwnedBytes, - name.namespace_uri().map_or(0, str::len) + name.local_part().len(), - )?; - NodePath::Attribute { - parent: path, - namespace: name.namespace_uri().map(str::to_owned), - local: name.local_part().to_owned(), - } - } - nodeset::Node::Namespace(namespace) => { - charge( - BudgetKind::OwnedBytes, - namespace.prefix().len() + namespace.uri().len(), - )?; - NodePath::Namespace { - parent: path, - prefix: namespace.prefix().to_owned(), - uri: namespace.uri().to_owned(), - } - } - _ => NodePath::Ordinary(path), - }) -} - // Compiled key slots already identify expanded names. Grouping by slot/document lets // lookups borrow scalar values instead of cloning a QName/string tuple per candidate. type KeyIndex = HashMap<(usize, usize), HashMap>>; -struct KeyFunction { +struct KeyFunction<'a> { + paths: ProjectedPaths<'a>, index: Rc>, namespaces: Arc>, declarations: Arc<[KeyDeclaration]>, @@ -9986,7 +10058,7 @@ struct KeyFunction { ready: Rc>>, pending: Rc>>, } -impl function::Function for KeyFunction { +impl function::Function for KeyFunction<'_> { fn evaluate<'d>( &self, context: &sxd_xpath_no_unsafe::context::Evaluation<'_, 'd>, @@ -9998,10 +10070,9 @@ impl function::Function for KeyFunction { }); } let name = qname_argument(context, &args[0], &self.namespaces)?; - let document_index = typed_path_to_with_context(&context.node, context)? - .ordinary() - .get(1) - .copied() + let document_index = self + .paths + .document_index(&context.node, context)? .ok_or_else(|| function::Error::Other { what: "key() context has no logical document".into(), })?; diff --git a/src/xslt/xpath/tests.rs b/src/xslt/xpath/tests.rs index 9af5c5d4..218acb74 100644 --- a/src/xslt/xpath/tests.rs +++ b/src/xslt/xpath/tests.rs @@ -98,17 +98,21 @@ fn xinclude_line_normalization_replaces_accounted_storage() { fn generated_id_result_crosses_the_allocation_gate_before_formatting() { // The cached identity itself requires no new storage here, leaving only the rendered // generate-id() result to exercise the XPath result-allocation gate. - let package = Package::new(); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); let document = package.as_document(); let root = document.root(); - let path = typed_path_to(&nodeset::Node::Root(root)); let identity = GeneratedNodeIdentity { document: document.identity(), - path, + path: NodePath::Ordinary(Vec::new()), }; let mut assigned = HashMap::new(); assigned.insert(identity, 1); let function = GenerateId { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, assigned: Rc::new(RefCell::new(GeneratedIdCache { assigned, owned_bytes: 0, @@ -432,7 +436,8 @@ fn string_consumers_gate_nodeset_work_before_allocation() { #[test] fn key_name_coercion_rejects_work_before_lookup() { // key() must share QName coercion with availability/property queries, before index work. - let package = sxd_document_no_unsafe::parser::parse("payload").expect("test XML"); + let source = Document::parse("payload", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); let document = package.as_document(); let mut context = Context::new(); context.set_evaluation_work_limit(0); @@ -442,6 +447,10 @@ fn key_name_coercion_rejects_work_before_lookup() { let mut nodes = nodeset::Nodeset::new(); nodes.add(document.root()); let key = KeyFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, index: Rc::new(RefCell::new(HashMap::new())), namespaces: Arc::new(Vec::new()), declarations: Arc::from([]), @@ -495,14 +504,17 @@ fn live_result_tree_fragment_handles_do_not_alias() { #[test] fn request_base_uri_is_rejected_before_clone_when_budget_is_exhausted() { - let package = Package::new(); - let document = package.as_document(); - let root = document.create_element("root"); - document.root().append_child(root); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let root = package_node(&package, &[0, 0, 0]); let mut context = Context::new(); context.set_string_allocation_limit(0); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, root.into()); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, root); let function = DocumentFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, resolver: &crate::NoResolver, access_error: &RefCell::new(None), roots: Rc::new(RefCell::new(HashMap::new())), @@ -1882,25 +1894,23 @@ fn lang_ancestor_search_obeys_work_budget() { fn id_result_nodes_cross_the_xpath_allocation_gate() { // A wide id() result is temporary XPath-owned storage even when its caller consumes only // the first node. Every unique result insertion must therefore consume the shared gate. - let package = Package::new(); - let document = package.as_document(); - let documents = document.create_element("documents"); - let logical_document = document.create_element("document"); - let target = document.create_element("target"); - document.root().append_child(documents); - documents.append_child(logical_document); - logical_document.append_child(target); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let logical_document = package_node(&package, &[0, 0]); let index = vec![HashMap::from([( "target".into(), NodePath::Ordinary(vec![0, 0, 0]), )])]; let function = IdFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, nodes_by_document: Rc::new(RefCell::new(index)), }; let mut context = Context::new(); context.set_string_allocation_limit(0); - let evaluation = - sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical_document.into()); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical_document); let error = function .evaluate(&evaluation, vec![SxdValue::String("target".into())]) @@ -1912,19 +1922,19 @@ fn id_result_nodes_cross_the_xpath_allocation_gate() { fn id_string_tokenization_obeys_work_budget() { // Existing String values avoid coercion, but splitting and hashing their ID tokens is // still attacker-controlled XPath work even when every lookup misses. - let package = Package::new(); - let document = package.as_document(); - let documents = document.create_element("documents"); - let logical_document = document.create_element("document"); - document.root().append_child(documents); - documents.append_child(logical_document); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let logical_document = package_node(&package, &[0, 0]); let function = IdFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, nodes_by_document: Rc::new(RefCell::new(vec![HashMap::new()])), }; let mut context = Context::new(); context.set_evaluation_work_limit(8); - let evaluation = - sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical_document.into()); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical_document); let error = function .evaluate(&evaluation, vec![SxdValue::String("x ".repeat(128))]) @@ -1957,21 +1967,23 @@ fn scalar_math_extension_obeys_extension_work_budget() { fn runtime_node_paths_obey_work_budget() { // id() needs document identity even for an empty lookup; finding it must not scan // arbitrarily many preceding siblings outside the evaluation work allowance. - let package = Package::new(); - let document = package.as_document(); - let root = document.create_element("root"); - document.root().append_child(root); - for _ in 0..128 { - root.append_child(document.create_element("sibling")); - } - let target = document.create_element("target"); - root.append_child(target); + let source = Document::parse( + &format!("{}", "".repeat(128)), + None, + ) + .expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let target = package_node(&package, &[0, 0, 0, 128]); let function = IdFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, nodes_by_document: Rc::new(RefCell::new(Vec::new())), }; let mut context = Context::new(); context.set_evaluation_work_limit(0); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, target.into()); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, target); assert!( function .evaluate(&evaluation, vec![SxdValue::String(String::new())]) @@ -1979,14 +1991,114 @@ fn runtime_node_paths_obey_work_budget() { ); } +#[test] +fn a_node_path_costs_its_depth_not_its_preceding_siblings() { + // The last of 4096 children names its path and its document with work + // proportional to its depth. A scan of the preceding siblings would + // spend 4096 units on each and exceed this allowance. + let source = Document::parse( + &format!("{}", "".repeat(4096)), + None, + ) + .expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let paths = ProjectedPaths { + source: &source, + maps: &maps, + }; + let target = package_element(&package, &[0, 0, 0, 4096]); + let attribute = nodeset::Node::Attribute(target.attribute("a").expect("attribute")); + let mut context = Context::new(); + context.set_evaluation_work_limit(16); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, target.into()); + assert_eq!( + paths.path(&target.into(), &evaluation).expect("depth fits"), + NodePath::Ordinary(vec![0, 0, 0, 4096]) + ); + assert_eq!( + paths.path(&attribute, &evaluation).expect("depth fits"), + NodePath::Attribute { + parent: vec![0, 0, 0, 4096], + namespace: None, + local: "a".into(), + } + ); + assert_eq!( + paths + .document_index(&target.into(), &evaluation) + .expect("depth fits"), + Some(0) + ); +} + +#[test] +fn projected_paths_match_the_tree_for_every_node_kind() { + // Every evaluator node's path, read from the projection's positions, + // names that same node when followed from the package root: elements, + // text, comments, instructions, attributes, declared and implicit + // namespaces, the documents container, the package root, and nodes of a + // second logical document. + let mut source = Document::parse( + "tu", + None, + ) + .expect("source parses"); + source.import(&Document::parse("", None).expect("second parses")); + let (package, maps, _) = projected_maps(&source); + let paths = ProjectedPaths { + source: &source, + maps: &maps, + }; + let context = Context::new(); + let root: nodeset::Node<'_> = package.as_document().root().into(); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, root.clone()); + let mut pending = vec![root]; + let mut checked = 0; + while let Some(node) = pending.pop() { + let mut nodes = vec![node.clone()]; + if let Some(element) = node.element() { + nodes.extend((0..element.attributes_len()).map(|index| { + nodeset::Node::Attribute(element.attribute_at(index).expect("in range")) + })); + for (prefix, uri) in [ + ("p", "urn:p"), + ("xml", "http://www.w3.org/XML/1998/namespace"), + ] { + nodes.extend(resolve_namespace_node(&node, prefix, uri)); + } + } + for node in nodes { + let path = paths.path(&node, &evaluation).expect("unlimited path"); + assert_eq!( + resolve_node_path(&evaluation, &path).expect("unlimited resolution"), + Some(node), + "{path:?}" + ); + checked += 1; + } + pending.extend((0..).map_while(|index| node.child_at(index))); + } + assert!(checked > 20, "{checked}"); + let leaf = package_node(&package, &[0, 1, 0, 0]); + assert_eq!( + paths.document_index(&leaf, &evaluation).expect("unlimited"), + Some(1) + ); + assert_eq!( + paths + .document_index(&package.as_document().root().into(), &evaluation) + .expect("unlimited"), + None + ); +} + #[test] fn restored_function_results_obey_evaluation_budgets() { // Owned strings/node projections require replay storage; immutable RTF projections // must instead preserve shared ownership without copying their retained payload. - let package = Package::new(); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); let document = package.as_document(); - let root = document.create_element("root"); - document.root().append_child(root); let results = [ DeferredXPathValue::String("x".repeat(4096)), DeferredXPathValue::ResultTreeFragment { @@ -2012,6 +2124,10 @@ fn restored_function_results_obey_evaluation_budgets() { arguments: vec![], }; let function = DeferredStylesheetFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, name, session: Rc::new(RefCell::new(CustomCallState { completed: vec![CompletedCustomCall { @@ -2048,32 +2164,37 @@ fn restored_function_results_obey_evaluation_budgets() { fn restored_nodes_preserve_identity_and_enforce_both_budgets() { // Replay must retain ordinary, attribute and namespace identities without // enumerating the entire namespace axis; stale paths remain errors. - let package = Package::new(); + let source = Document::parse( + "", + None, + ) + .expect("source parses"); + let (package, maps, _) = projected_maps(&source); let document = package.as_document(); - let root = document.create_element("root"); - document.root().append_child(root); - root.set_attribute_value("id", "value"); - root.register_prefix("p", "urn:p"); - root.set_default_namespace_uri(Some("urn:default")); + let projected = ProjectedPaths { + source: &source, + maps: &maps, + }; + let root = vec![0, 0, 0]; let paths = vec![ - NodePath::Ordinary(vec![0]), + NodePath::Ordinary(root.clone()), NodePath::Attribute { - parent: vec![0], + parent: root.clone(), namespace: None, local: "id".into(), }, NodePath::Namespace { - parent: vec![0], + parent: root.clone(), prefix: "p".into(), uri: "urn:p".into(), }, NodePath::Namespace { - parent: vec![0], + parent: root.clone(), prefix: String::new(), uri: "urn:default".into(), }, NodePath::Namespace { - parent: vec![0], + parent: root, prefix: "xml".into(), uri: "http://www.w3.org/XML/1998/namespace".into(), }, @@ -2100,7 +2221,12 @@ fn restored_nodes_preserve_identity_and_enforce_both_budgets() { }; assert_eq!(nodes.size(), 1); assert_eq!( - typed_path_to(&nodes.iter().next().expect("one restored node")), + projected + .path( + &nodes.iter().next().expect("one restored node"), + &evaluation + ) + .expect("unlimited path"), path ); } @@ -2146,24 +2272,25 @@ fn exslt_set_result_nodes_cross_the_xpath_allocation_gate() { fn key_and_document_nodeset_arguments_obey_the_allocation_budget() { // XSLT node-set arguments can contain many large string-values. key() and document() // must not retain an unmetered batch before performing their lookups. - let package = Package::new(); - let document = package.as_document(); - let documents = document.create_element("documents"); - let logical = document.create_element("logical"); - document.root().append_child(documents); - documents.append_child(logical); + let item = format!("{}", "x".repeat(1_024)); + let source = Document::parse(&format!("{}", item.repeat(8)), None) + .expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let paths = ProjectedPaths { + source: &source, + maps: &maps, + }; + let logical = package_node(&package, &[0, 0]); let mut nodes = nodeset::Nodeset::new(); - for _ in 0..8 { - let item = document.create_element("item"); - item.append_child(document.create_text(&"x".repeat(1_024))); - logical.append_child(item); - nodes.add(item); + for index in 0..8 { + nodes.add(package_node(&package, &[0, 0, 0, index])); } let mut xpath_context = Context::new(); xpath_context.set_string_allocation_limit(128); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&xpath_context, logical.into()); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&xpath_context, logical); let key = KeyFunction { + paths, index: Rc::new(RefCell::new(HashMap::new())), namespaces: Arc::new(Vec::new()), declarations: Arc::from([]), @@ -2180,6 +2307,7 @@ fn key_and_document_nodeset_arguments_obey_the_allocation_budget() { )); let document_function = DocumentFunction { + paths, resolver: &crate::NoResolver, access_error: &RefCell::new(None), roots: Rc::new(RefCell::new(HashMap::new())), @@ -2199,14 +2327,17 @@ fn key_and_document_nodeset_arguments_obey_the_allocation_budget() { fn document_scalar_uri_obeys_the_xpath_work_budget() { // An existing scalar avoids string coercion allocation, but URI materialization and the // document-cache probe must still charge the caller-controlled URI bytes. - let package = Package::new(); - let document = package.as_document(); - let logical = document.create_element("logical"); - document.root().append_child(logical); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let logical = package_node(&package, &[0, 0]); let mut context = Context::new(); context.set_evaluation_work_limit(0); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical.into()); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical); let document_function = DocumentFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, resolver: &crate::NoResolver, access_error: &RefCell::new(None), roots: Rc::new(RefCell::new(HashMap::new())), @@ -2226,16 +2357,17 @@ fn document_scalar_uri_obeys_the_xpath_work_budget() { fn key_lookup_borrows_scalar_payload() { // An already-owned scalar argument needs no payload copy for a hash lookup. // Keep enough room for the context path/QName, but not for another large string. - let package = Package::new(); - let document = package.as_document(); - let documents = document.create_element("documents"); - let logical = document.create_element("logical"); - document.root().append_child(documents); - documents.append_child(logical); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let logical = package_node(&package, &[0, 0]); let mut context = Context::new(); context.set_string_allocation_limit(512); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical.into()); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical); let key = KeyFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, index: Rc::new(RefCell::new(HashMap::new())), namespaces: Arc::new(Vec::new()), declarations: Arc::from([]), @@ -2816,34 +2948,23 @@ fn set_distinct_charges_descendant_string_traversal_as_extension_work() { fn id_nodeset_arguments_stream_through_the_allocation_gate() { // id() needs every node's string-value but does not retain those values. It must meter // each temporary while avoiding simultaneous retention in an unbounded Vec. - let package = Package::new(); - let document = package.as_document(); - let container = document.create_element("container"); - let logical_document = document.create_element("document"); - let target = document.create_element("target"); - let argument = document.create_element("ids"); - argument.append_child(document.create_text("target")); - logical_document.append_child(target); - logical_document.append_child(argument); - container.append_child(logical_document); - document.root().append_child(container); - + let source = Document::parse("target", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let logical_document = package_node(&package, &[0, 0]); let mut indexed = HashMap::new(); - indexed.insert( - "target".into(), - typed_path_to(&nodeset::Node::Element(target)), - ); + indexed.insert("target".into(), NodePath::Ordinary(vec![0, 0, 0, 0])); let function = IdFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, nodes_by_document: Rc::new(RefCell::new(vec![indexed])), }; let mut nodes = nodeset::Nodeset::new(); - nodes.add(argument); + nodes.add(package_node(&package, &[0, 0, 0, 1])); let mut context = Context::new(); context.set_string_allocation_limit("target".len() - 1); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new( - &context, - nodeset::Node::Element(logical_document), - ); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical_document); let error = function .evaluate(&evaluation, vec![SxdValue::Nodeset(nodes)]) @@ -2856,22 +2977,17 @@ fn unparsed_entity_uri_uses_the_dynamic_context_document() { // XSLT 1.0 section 12.4 resolves the entity in the document containing the context node, // not in the principal document captured when the expression was compiled. // https://www.w3.org/TR/1999/REC-xslt-19991116#misc-func - let package = Package::new(); - let document = package.as_document(); - let documents = document.create_element("documents"); - document.root().append_child(documents); - let first = document.create_element("document"); - let second = document.create_element("document"); - documents.append_child(first); - documents.append_child(second); - let context_node = document.create_element("external"); - second.append_child(context_node); + let mut source = Document::parse("", None).expect("source parses"); + source.import(&Document::parse("", None).expect("second parses")); + let (package, maps, _) = projected_maps(&source); + let context_node = package_node(&package, &[0, 1, 0]); let context = Context::new(); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new( - &context, - nodeset::Node::Element(context_node), - ); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, context_node); let function = UnparsedEntityUriFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, documents: Rc::new(RefCell::new(vec![ HashMap::from([("logo".into(), "principal.png".into())]), HashMap::from([("logo".into(), "external.png".into())]), @@ -2890,14 +3006,15 @@ fn unparsed_entity_uri_uses_the_dynamic_context_document() { fn unparsed_entity_uri_reserves_the_returned_uri() { // The retained entity index owns one URI copy. Returning the XPath string creates a // second allocation, which must cross the operation allocation gate first. - let package = Package::new(); - let document = package.as_document(); - let documents = document.create_element("documents"); - let logical_document = document.create_element("document"); - document.root().append_child(documents); - documents.append_child(logical_document); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let logical_document = package_node(&package, &[0, 0]); let uri = "memory:entity/".repeat(32); let function = UnparsedEntityUriFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, documents: Rc::new(RefCell::new(vec![HashMap::from([( "logo".into(), uri.clone(), @@ -2905,10 +3022,7 @@ fn unparsed_entity_uri_reserves_the_returned_uri() { }; let mut context = Context::new(); context.set_string_allocation_limit(uri.len() - 1); - let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new( - &context, - nodeset::Node::Element(logical_document), - ); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, logical_document); let error = function .evaluate(&evaluation, vec![SxdValue::String("logo".into())]) @@ -2920,15 +3034,17 @@ fn unparsed_entity_uri_reserves_the_returned_uri() { fn document_function_reserves_explicit_base_selection_before_cloning() { // The second argument's effective base is retained for the complete document() call. // Reserve that first clone independently of each request constructed later. - let package = Package::new(); - let document = package.as_document(); - let root = document.create_element("root"); - let base = document.create_element("base"); - root.append_child(base); - document.root().append_child(root); - let path = typed_path_to(&nodeset::Node::Element(base)); + let source = Document::parse("", None).expect("source parses"); + let (package, maps, _) = projected_maps(&source); + let root = package_node(&package, &[0, 0, 0]); + let base = package_node(&package, &[0, 0, 0, 0]); + let path = NodePath::Ordinary(vec![0, 0, 0, 0]); let base_uri = "memory:base/".repeat(32); let function = DocumentFunction { + paths: ProjectedPaths { + source: &source, + maps: &maps, + }, resolver: &crate::NoResolver, access_error: &RefCell::new(None), roots: Rc::new(RefCell::new(HashMap::new())), @@ -2944,8 +3060,7 @@ fn document_function_reserves_explicit_base_selection_before_cloning() { nodes.add(base); let mut context = Context::new(); context.set_string_allocation_limit(base_uri.len() - 1); - let evaluation = - sxd_xpath_no_unsafe::context::Evaluation::new(&context, nodeset::Node::Element(root)); + let evaluation = sxd_xpath_no_unsafe::context::Evaluation::new(&context, root); let error = function .evaluate( @@ -3481,6 +3596,25 @@ fn projected_maps(source: &Document) -> (sxd_document_no_unsafe::Package, NodeMa (package, maps, meter) } +/// The evaluator node at `path` of a projected package: `[0]` is the +/// documents container and `[0, d]` the wrapper of logical document `d`. +fn package_node<'d>( + package: &'d sxd_document_no_unsafe::Package, + path: &[usize], +) -> nodeset::Node<'d> { + follow_path(package.as_document().root().into(), path).expect("fixture path exists") +} + +/// The element at `path` of a projected package. +fn package_element<'d>( + package: &'d sxd_document_no_unsafe::Package, + path: &[usize], +) -> sxd_document_no_unsafe::dom::Element<'d> { + package_node(package, path) + .element() + .expect("fixture path names an element") +} + #[test] fn evaluator_attributes_map_back_without_scanning_their_siblings() { // `@*` on an element with N attributes maps every selected attribute back From ede12a5b124e1dc8bd2a9acb7bc505c7179c76b2 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 17:13:03 +0200 Subject: [PATCH 2/2] perf(xslt): walk a node's ancestors once to admit and once to fill its path The path length read to admit a path's allocation is passed to the fill instead of being counted again, so a function-side path takes two ancestor walks instead of three and a key entry two instead of four. The base-URI index now admits each path key's bytes before allocating it. --- src/xslt/xpath.rs | 57 +++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/xslt/xpath.rs b/src/xslt/xpath.rs index 170120fd..61d4c428 100644 --- a/src/xslt/xpath.rs +++ b/src/xslt/xpath.rs @@ -1178,19 +1178,18 @@ impl Evaluator { /// The retained path of a key entry, its bytes charged before it exists. fn key_entry_path(&self, node: &SourceNode, meter: &mut Meter) -> Result<(NodePath, usize)> { let stale = || Error::Dynamic("key node has no document path".into()); - let bytes = self.maps.path_bytes(&self.source, node).ok_or_else(stale)?; - let words = self + let len = self .maps .path_len(&self.source, source_node_owner(node)) .ok_or_else(stale)?; - meter.charge(BudgetKind::XPathOperations, words)?; + let bytes = NodeMaps::path_bytes_of(&self.source, node, len).ok_or_else(stale)?; + meter.charge(BudgetKind::XPathOperations, len)?; meter.charge(BudgetKind::OwnedBytes, bytes)?; - match self.maps.path(&self.source, node) { - Some(path) => Ok((path, bytes)), - None => { - meter.release_owned_bytes(bytes); - Err(stale()) - } + if let Some(path) = self.maps.path_of(&self.source, node, len) { + Ok((path, bytes)) + } else { + meter.release_owned_bytes(bytes); + Err(stale()) } } @@ -6506,12 +6505,13 @@ impl NodeMaps { 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. + /// The ordinary path of `id`, whose [`Self::path_len`] the caller already + /// read to admit its allocation. + fn ordinary_path(&self, source: &Document, id: NodeId, len: usize) -> Option> { + // Allocated at its exact length, which the caller admits beforehand. let mut path = vec![0; len]; let mut current = Some(id); - for slot in path[1..].iter_mut().rev() { + for slot in path.get_mut(1..)?.iter_mut().rev() { let node = current?; *slot = *self.positions.get(node.0)?; current = source.node(node)?.parent; @@ -6521,9 +6521,13 @@ impl NodeMaps { /// 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 len = self.path_len(source, source_node_owner(node))?; + Self::path_bytes_of(source, node, len) + } + + /// [`Self::path_bytes`] for an owner whose path length is already known. + fn path_bytes_of(source: &Document, node: &SourceNode, len: usize) -> Option { + let words = len.checked_mul(std::mem::size_of::())?; let owner = &source.node(source_node_owner(node))?.kind; let strings = match (node, owner) { (SourceNode::Node(_), _) => 0, @@ -6541,7 +6545,13 @@ impl NodeMaps { } fn path(&self, source: &Document, node: &SourceNode) -> Option { - let path = self.ordinary_path(source, source_node_owner(node))?; + let len = self.path_len(source, source_node_owner(node))?; + self.path_of(source, node, len) + } + + /// [`Self::path`] for an owner whose path length is already known. + fn path_of(&self, source: &Document, node: &SourceNode, len: usize) -> Option { + let path = self.ordinary_path(source, source_node_owner(node), len)?; let owner = &source.node(source_node_owner(node))?.kind; Some(match (node, owner) { (SourceNode::Node(_), _) => NodePath::Ordinary(path), @@ -6741,7 +6751,7 @@ impl NodeMaps { } } -/// The projection an XPath evaluation runs over, lent to the functions that +/// The projection an `XPath` evaluation runs over, lent to the functions that /// name evaluator nodes by path for the duration of that evaluation. #[derive(Clone, Copy)] struct ProjectedPaths<'a> { @@ -6821,7 +6831,7 @@ impl ProjectedPaths<'_> { PathOwner::Documents => vec![0], PathOwner::Projected(id) => self .maps - .ordinary_path(self.source, id) + .ordinary_path(self.source, id, words) .ok_or_else(outside_projection)?, }; Ok(match node { @@ -6939,15 +6949,14 @@ fn extend_node_base_uri_entries( if !is_logical_root && base_uri == inherited { continue; } - let key = maps - .ordinary_path(source, id) - .ok_or_else(|| Error::Dynamic(format!("stale semantic node {id:?}")))?; + let stale = || Error::Dynamic(format!("stale semantic node {id:?}")); + let len = maps.path_len(source, id).ok_or_else(stale)?; meter.charge( BudgetKind::OwnedBytes, - key.len() - .saturating_mul(std::mem::size_of::()) + len.saturating_mul(std::mem::size_of::()) .saturating_add(base_uri.map_or(0, str::len)), )?; + let key = maps.ordinary_path(source, id, len).ok_or_else(stale)?; reserve_retained_hash_map_slot(index, meter, index_bytes)?; index.insert(key, base_uri.map(str::to_owned)); }