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..61d4c428 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,24 @@ 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 len = self
+ .maps
+ .path_len(&self.source, source_node_owner(node))
+ .ok_or_else(stale)?;
+ let bytes = NodeMaps::path_bytes_of(&self.source, node, len).ok_or_else(stale)?;
+ meter.charge(BudgetKind::XPathOperations, len)?;
+ meter.charge(BudgetKind::OwnedBytes, bytes)?;
+ if let Some(path) = self.maps.path_of(&self.source, node, len) {
+ Ok((path, bytes))
+ } else {
+ 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 +2485,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 +2522,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 +2551,7 @@ impl Evaluator {
context.set_function(
"unparsed-entity-uri",
UnparsedEntityUriFunction {
+ paths,
documents: Rc::clone(&self.unparsed_entity_index),
},
);
@@ -2541,6 +2576,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 +2595,7 @@ impl Evaluator {
context.set_function(
qname,
DeferredStylesheetFunction {
+ paths: self.projected_paths(),
name: name.clone(),
session: Rc::clone(&custom_calls.state),
},
@@ -6468,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;
@@ -6483,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,
@@ -6503,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),
@@ -6703,6 +6751,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, words)
+ .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