1use std::cell::Cell;
23use std::collections::{BTreeMap, HashSet};
24
25use base64::{Engine as _, engine::general_purpose::STANDARD};
26use roxmltree::{Document, Node, NodeId, NodeType};
27use sha2::{Digest as _, Sha256};
28
29use super::parse::XMLDSIG_NS;
30use super::types::{
31 NodeSetMaterializationBudget, TransformData, TransformError, transform_resource_limit,
32};
33use super::whitespace::is_xml_whitespace_only;
34use super::xpath::{
35 XPathDocumentRelation, XPathWorkBudget, apply_xpath_filter_with_semantics_and_budget,
36 apply_xpath_filter2_with_semantics_and_budget, is_xpath_whitespace,
37 xpath_may_read_mutable_character_data,
38};
39use crate::c14n::xml_base::XmlBaseResolutionBudget;
40use crate::c14n::{self, C14nAlgorithm};
41use crate::document::{
42 DocumentParseSettings, XmlDocumentError, XmlParseWorkBudget,
43 parse_borrowed_with_settings_and_budget,
44};
45#[cfg(test)]
46use crate::hard_limits::XML_DOCUMENT_NODE_CEILING;
47
48pub const ENVELOPED_SIGNATURE_URI: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
50pub const BASE64_TRANSFORM_URI: &str = "http://www.w3.org/2000/09/xmldsig#base64";
52pub const XPATH_TRANSFORM_URI: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
54pub const XPATH_FILTER2_TRANSFORM_URI: &str = "http://www.w3.org/2002/06/xmldsig-filter2";
56pub const DEFAULT_IMPLICIT_C14N_URI: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
59pub const MAX_TRANSFORMS_PER_REFERENCE: usize = crate::hard_limits::REFERENCE_TRANSFORM_CEILING;
65pub(super) const ENVELOPED_SIGNATURE_XPATH_PREFIX: &str = "dsig";
68pub(super) const ENVELOPED_SIGNATURE_XPATH_EXPR: &str = "not(ancestor-or-self::dsig:Signature)";
69pub(super) const MAX_XPATH_EXPRESSION_BYTES: usize =
70 crate::hard_limits::XPATH_EXPRESSION_BYTE_CEILING;
71pub(super) const MAX_XPATH_FILTERS: usize = crate::hard_limits::XPATH_FILTER_COUNT_CEILING;
72pub(super) const MAX_XPATH_EXPRESSIONS_PER_SIGNATURE: usize =
77 crate::hard_limits::XPATH_EXPRESSION_COUNT_CEILING;
78const MAX_XPATH_NAMESPACE_BINDINGS: usize = crate::hard_limits::XPATH_NAMESPACE_BINDING_CEILING;
79const MAX_XPATH_NAMESPACE_BYTES: usize = crate::hard_limits::XPATH_NAMESPACE_BYTE_CEILING;
80const MAX_BASE64_TRANSFORM_INPUT_BYTES: usize =
81 crate::hard_limits::BASE64_TRANSFORM_INPUT_BYTE_CEILING;
82const MAX_BASE64_TRANSFORM_OUTPUT_BYTES: usize =
83 crate::hard_limits::BASE64_TRANSFORM_OUTPUT_BYTE_CEILING;
84const MAX_C14N_OUTPUT_BYTES: usize = crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING;
85const MAX_NODE_SET_FILTER_WORK: usize = crate::hard_limits::NODE_SET_FILTER_WORK_CEILING;
87
88const EXCLUSIVE_C14N_NS_URI: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
90
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum XPathHereSemantics {
94 #[default]
97 Specification,
98 XmlSecLegacy,
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
107pub(crate) struct TransformOptions {
108 xpath_here_semantics: XPathHereSemantics,
109 allow_internal_dtd: bool,
110}
111
112pub(crate) struct TransformExecutionBudget {
113 xpath: XPathWorkBudget,
114 base64: Base64WorkBudget,
115 c14n: C14nOutputBudget,
116 node_filter: NodeFilterWorkBudget,
117 node_set_materialization: NodeSetMaterializationBudget,
118 xml_base_resolution: XmlBaseResolutionBudget,
119 xml_parse_work: XmlParseWorkBudget,
120 xml_parse_settings: DocumentParseSettings,
121}
122
123impl Default for TransformExecutionBudget {
124 fn default() -> Self {
125 Self::from_resources(&crate::policy::ResourcePolicy::default())
126 }
127}
128
129pub(super) struct NodeFilterWorkBudget {
130 remaining: Cell<usize>,
131 maximum: usize,
132}
133
134impl Default for NodeFilterWorkBudget {
135 fn default() -> Self {
136 Self {
137 remaining: Cell::new(MAX_NODE_SET_FILTER_WORK),
138 maximum: MAX_NODE_SET_FILTER_WORK,
139 }
140 }
141}
142
143impl NodeFilterWorkBudget {
144 pub(super) fn charge(&self, entries: usize) -> Result<(), TransformError> {
145 let consumed = self.maximum.saturating_sub(self.remaining.get());
146 if !charge_byte_budget(&self.remaining, entries) {
147 return Err(transform_resource_limit(
148 crate::policy::resource_name::NODE_SET_FILTER_WORK,
149 self.maximum,
150 consumed.saturating_add(entries),
151 ));
152 }
153 Ok(())
154 }
155}
156
157struct Base64WorkBudget {
158 remaining_input_bytes: Cell<usize>,
159 remaining_output_bytes: Cell<usize>,
160 max_input_bytes: usize,
161 max_output_bytes: usize,
162}
163
164struct C14nOutputBudget {
165 remaining: Cell<usize>,
166 max_bytes: usize,
167}
168
169fn charge_byte_budget(remaining: &Cell<usize>, bytes: usize) -> bool {
170 let Some(next) = remaining.get().checked_sub(bytes) else {
171 remaining.set(0);
172 return false;
173 };
174 remaining.set(next);
175 true
176}
177
178impl Default for C14nOutputBudget {
179 fn default() -> Self {
180 Self {
181 remaining: Cell::new(MAX_C14N_OUTPUT_BYTES),
182 max_bytes: MAX_C14N_OUTPUT_BYTES,
183 }
184 }
185}
186
187impl C14nOutputBudget {
188 fn with_limit(max_bytes: usize) -> Self {
189 Self {
190 remaining: Cell::new(max_bytes),
191 max_bytes,
192 }
193 }
194
195 fn remaining(&self) -> usize {
196 self.remaining.get()
197 }
198
199 fn charge(&self, bytes: usize) -> Result<(), crate::policy::PolicyViolation> {
200 let consumed = self.max_bytes.saturating_sub(self.remaining.get());
201 if !charge_byte_budget(&self.remaining, bytes) {
202 return Err(crate::policy::PolicyViolation::ResourceLimit {
203 resource: crate::policy::resource_name::CANONICALIZED_BYTES,
204 maximum: self.max_bytes,
205 actual: consumed.saturating_add(bytes),
206 });
207 }
208 Ok(())
209 }
210
211 fn exhaust(&self) {
212 self.remaining.set(0);
213 }
214}
215
216#[cfg(test)]
217mod c14n_budget_regression_tests {
218 use super::*;
219 use crate::c14n::C14nMode;
220 use crate::xmldsig::types::NodeSet;
221 use roxmltree::Document;
222
223 #[test]
224 fn bounded_c14n_failure_exhausts_the_shared_budget() {
225 let document = Document::parse("<root><payload>more than eight bytes</payload></root>")
226 .expect("test XML must parse");
227 let budget = TransformExecutionBudget::with_c14n_limit(8);
228
229 let error = execute_transforms_with_options_and_budget(
230 document.root_element(),
231 TransformData::NodeSet(
232 NodeSet::entire_document_without_comments(&document)
233 .expect("test document must fit the node-set ceiling"),
234 ),
235 &[Transform::C14n(C14nAlgorithm::new(
236 C14nMode::Inclusive1_0,
237 false,
238 ))],
239 TransformOptions::default(),
240 &budget,
241 )
242 .expect_err("canonicalized output must exceed the shared budget");
243
244 assert!(matches!(
245 error,
246 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
247 resource: crate::policy::resource_name::CANONICALIZED_BYTES,
248 maximum: 8,
249 ..
250 })
251 ));
252 assert_eq!(
253 budget.remaining_c14n_output(),
254 0,
255 "a failed bounded render must not leave the same allowance reusable"
256 );
257 }
258}
259
260impl Default for Base64WorkBudget {
261 fn default() -> Self {
262 Self {
263 remaining_input_bytes: Cell::new(MAX_BASE64_TRANSFORM_INPUT_BYTES),
264 remaining_output_bytes: Cell::new(MAX_BASE64_TRANSFORM_OUTPUT_BYTES),
265 max_input_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES,
266 max_output_bytes: MAX_BASE64_TRANSFORM_OUTPUT_BYTES,
267 }
268 }
269}
270
271impl Base64WorkBudget {
272 fn charge_input(&self, bytes: usize) -> Result<(), TransformError> {
273 let consumed = self
274 .max_input_bytes
275 .saturating_sub(self.remaining_input_bytes.get());
276 if !charge_byte_budget(&self.remaining_input_bytes, bytes) {
277 return Err(transform_resource_limit(
278 crate::policy::resource_name::BASE64_TRANSFORM_INPUT_BYTES,
279 self.max_input_bytes,
280 consumed.saturating_add(bytes),
281 ));
282 }
283 Ok(())
284 }
285
286 fn ensure_output_capacity(&self, bytes: usize) -> Result<(), TransformError> {
287 let consumed = self
288 .max_output_bytes
289 .saturating_sub(self.remaining_output_bytes.get());
290 if bytes > self.remaining_output_bytes.get() {
291 return Err(transform_resource_limit(
292 crate::policy::resource_name::BASE64_TRANSFORM_OUTPUT_BYTES,
293 self.max_output_bytes,
294 consumed.saturating_add(bytes),
295 ));
296 }
297 Ok(())
298 }
299
300 fn charge_output(&self, bytes: usize) -> Result<(), TransformError> {
301 self.ensure_output_capacity(bytes)?;
302 let charged = charge_byte_budget(&self.remaining_output_bytes, bytes);
303 debug_assert!(charged, "preflighted Base64 output charge must fit");
304 Ok(())
305 }
306}
307
308#[cfg(test)]
309impl TransformExecutionBudget {
310 pub(crate) fn with_xpath_limit(limit: usize) -> Self {
311 Self {
312 xpath: XPathWorkBudget::with_limit(limit),
313 base64: Base64WorkBudget::default(),
314 c14n: C14nOutputBudget::default(),
315 node_filter: NodeFilterWorkBudget::default(),
316 node_set_materialization: NodeSetMaterializationBudget::default(),
317 xml_base_resolution: XmlBaseResolutionBudget::default(),
318 xml_parse_work: XmlParseWorkBudget::from_resources(
319 &crate::policy::ResourcePolicy::default(),
320 ),
321 xml_parse_settings: DocumentParseSettings::default(),
322 }
323 }
324
325 fn with_node_filter_limit(limit: usize) -> Self {
326 Self {
327 xpath: XPathWorkBudget::default(),
328 base64: Base64WorkBudget::default(),
329 c14n: C14nOutputBudget::default(),
330 node_filter: NodeFilterWorkBudget {
331 remaining: Cell::new(limit),
332 maximum: limit,
333 },
334 node_set_materialization: NodeSetMaterializationBudget::default(),
335 xml_base_resolution: XmlBaseResolutionBudget::default(),
336 xml_parse_work: XmlParseWorkBudget::from_resources(
337 &crate::policy::ResourcePolicy::default(),
338 ),
339 xml_parse_settings: DocumentParseSettings::default(),
340 }
341 }
342
343 pub(crate) fn with_node_set_materialization_limit(limit: usize) -> Self {
344 Self {
345 xpath: XPathWorkBudget::default(),
346 base64: Base64WorkBudget::default(),
347 c14n: C14nOutputBudget::default(),
348 node_filter: NodeFilterWorkBudget::default(),
349 node_set_materialization: NodeSetMaterializationBudget::with_limit(limit),
350 xml_base_resolution: XmlBaseResolutionBudget::default(),
351 xml_parse_work: XmlParseWorkBudget::from_resources(
352 &crate::policy::ResourcePolicy::default(),
353 ),
354 xml_parse_settings: DocumentParseSettings::default(),
355 }
356 }
357
358 pub(crate) fn with_c14n_limit(max_bytes: usize) -> Self {
359 Self {
360 c14n: C14nOutputBudget::with_limit(max_bytes),
361 ..Self::default()
362 }
363 }
364}
365
366impl TransformExecutionBudget {
367 pub(crate) fn from_resources(resources: &crate::policy::ResourcePolicy) -> Self {
368 Self {
369 xpath: XPathWorkBudget::with_limits(resources),
370 base64: Base64WorkBudget {
371 remaining_input_bytes: Cell::new(resources.max_base64_transform_input_bytes),
372 remaining_output_bytes: Cell::new(resources.max_base64_transform_output_bytes),
373 max_input_bytes: resources.max_base64_transform_input_bytes,
374 max_output_bytes: resources.max_base64_transform_output_bytes,
375 },
376 c14n: C14nOutputBudget::with_limit(resources.effective_canonicalized_bytes()),
377 node_filter: NodeFilterWorkBudget {
378 remaining: Cell::new(resources.max_node_set_filter_work),
379 maximum: resources.max_node_set_filter_work,
380 },
381 node_set_materialization: NodeSetMaterializationBudget::with_limits(
382 resources.max_node_set_entries,
383 resources.max_node_set_owned_string_bytes,
384 resources.max_node_set_cumulative_owned_string_bytes,
385 ),
386 xml_base_resolution: XmlBaseResolutionBudget::with_limits(
387 resources.effective_xml_base_components(),
388 resources.effective_xml_base_resolution_bytes(),
389 ),
390 xml_parse_work: XmlParseWorkBudget::from_resources(resources),
391 xml_parse_settings: DocumentParseSettings::new_with_depth(
392 false,
393 resources.effective_xml_nodes(),
394 resources.max_xml_depth,
395 resources.max_xml_document_bytes,
396 ),
397 }
398 }
399
400 pub(crate) fn charge_c14n_output(&self, bytes: usize) -> Result<(), TransformError> {
401 self.c14n.charge(bytes).map_err(TransformError::from)
402 }
403
404 pub(crate) fn charge_c14n_output_policy(
405 &self,
406 bytes: usize,
407 ) -> Result<(), crate::policy::PolicyViolation> {
408 self.c14n.charge(bytes)
409 }
410
411 pub(crate) fn remaining_c14n_output(&self) -> usize {
412 self.c14n.remaining()
413 }
414
415 pub(crate) fn c14n_output_limit(&self) -> usize {
416 self.c14n.max_bytes
417 }
418
419 pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget {
420 &self.node_set_materialization
421 }
422
423 pub(crate) fn xml_base_resolution(&self) -> &XmlBaseResolutionBudget {
424 &self.xml_base_resolution
425 }
426
427 pub(crate) fn xml_parse_work(&self) -> &XmlParseWorkBudget {
428 &self.xml_parse_work
429 }
430
431 pub(crate) fn charge_xpath_work(&self, work: usize) -> Result<(), TransformError> {
432 self.xpath.charge(work)
433 }
434
435 pub(crate) fn validate_xpath_context_evaluations(
436 &self,
437 actual: usize,
438 ) -> Result<(), TransformError> {
439 self.xpath.validate_context_evaluations(actual)
440 }
441
442 pub(crate) fn charge_node_filter_work(&self, nodes: usize) -> Result<(), TransformError> {
443 self.node_filter.charge(nodes)
444 }
445}
446
447impl TransformOptions {
448 #[must_use]
450 pub(crate) fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self {
451 self.xpath_here_semantics = semantics;
452 self
453 }
454
455 #[must_use]
458 pub(crate) fn allow_internal_dtd(mut self, enabled: bool) -> Self {
459 self.allow_internal_dtd = enabled;
460 self
461 }
462
463 pub(crate) fn here_semantics(self) -> XPathHereSemantics {
464 self.xpath_here_semantics
465 }
466
467 pub(crate) fn internal_dtd_allowed(self) -> bool {
468 self.allow_internal_dtd
469 }
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473struct XPathHereNodes {
474 specification_xpath_element: roxmltree::NodeId,
475 xmlsec_legacy_transform_element: roxmltree::NodeId,
476 document: XPathDocumentIdentity,
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480struct XPathDocumentIdentity([u8; 32]);
481
482impl XPathDocumentIdentity {
483 fn from_document(document: &Document<'_>) -> Self {
484 #[cfg(test)]
485 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(count.get() + 1));
486 Self(Sha256::digest(document.input_text().as_bytes()).into())
487 }
488}
489
490#[cfg(test)]
491thread_local! {
492 static XPATH_DOCUMENT_IDENTITY_COMPUTATIONS: Cell<usize> = const { Cell::new(0) };
493}
494
495#[derive(Default)]
496struct TransformChainState {
497 xpath_document_identity: Cell<Option<CachedXPathDocumentIdentity>>,
498}
499
500#[derive(Clone, Copy)]
501struct CachedXPathDocumentIdentity {
502 document: *const (),
503 identity: XPathDocumentIdentity,
504}
505
506impl TransformChainState {
507 fn xpath_document_identity(&self, document: &Document<'_>) -> XPathDocumentIdentity {
508 let document_key = std::ptr::from_ref(document).cast::<()>();
509 if let Some(cached) = self.xpath_document_identity.get()
510 && cached.document == document_key
511 {
512 return cached.identity;
513 }
514 let identity = XPathDocumentIdentity::from_document(document);
515 self.xpath_document_identity
516 .set(Some(CachedXPathDocumentIdentity {
517 document: document_key,
518 identity,
519 }));
520 identity
521 }
522
523 fn document_reparsed(&self) {
524 self.xpath_document_identity.set(None);
525 }
526}
527
528struct TransformExecutionContext<'a> {
529 options: TransformOptions,
530 budget: &'a TransformExecutionBudget,
531 state: &'a TransformChainState,
532}
533
534#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct XPathExpression {
537 expression: String,
538 namespaces: BTreeMap<String, String>,
539 here_nodes: Option<XPathHereNodes>,
540}
541
542impl XPathExpression {
543 pub fn new(expression: impl Into<String>) -> Self {
545 Self {
546 expression: expression.into(),
547 namespaces: BTreeMap::new(),
548 here_nodes: None,
549 }
550 }
551
552 pub fn with_namespace(mut self, prefix: impl Into<String>, uri: impl Into<String>) -> Self {
554 self.namespaces.insert(prefix.into(), uri.into());
555 self
556 }
557
558 pub fn expression(&self) -> &str {
560 &self.expression
561 }
562
563 pub fn namespaces(&self) -> &BTreeMap<String, String> {
565 &self.namespaces
566 }
567
568 pub(crate) fn here_context_node(
569 &self,
570 semantics: XPathHereSemantics,
571 ) -> Option<roxmltree::NodeId> {
572 self.here_nodes.map(|nodes| match semantics {
573 XPathHereSemantics::Specification => nodes.specification_xpath_element,
574 XPathHereSemantics::XmlSecLegacy => nodes.xmlsec_legacy_transform_element,
575 })
576 }
577
578 fn parsed_document_identity(&self) -> Option<XPathDocumentIdentity> {
579 self.here_nodes.map(|nodes| nodes.document)
580 }
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub enum XPathFilterOperation {
586 Intersect,
588 Subtract,
590 Union,
592}
593
594impl XPathFilterOperation {
595 pub(crate) fn as_str(self) -> &'static str {
596 match self {
597 Self::Intersect => "intersect",
598 Self::Subtract => "subtract",
599 Self::Union => "union",
600 }
601 }
602}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
606pub struct XPathFilter {
607 operation: XPathFilterOperation,
608 xpath: XPathExpression,
609}
610
611impl XPathFilter {
612 pub fn new(operation: XPathFilterOperation, xpath: XPathExpression) -> Self {
614 Self { operation, xpath }
615 }
616
617 pub fn operation(&self) -> XPathFilterOperation {
619 self.operation
620 }
621
622 pub fn xpath(&self) -> &XPathExpression {
624 &self.xpath
625 }
626}
627
628#[derive(Debug, Clone)]
630pub enum Transform {
631 Enveloped,
636
637 XpathExcludeAllSignatures,
643
644 XPath(XPathExpression),
646
647 XPathFilter2(Vec<XPathFilter>),
649
650 C14n(C14nAlgorithm),
654
655 Base64Decode,
663}
664
665impl Transform {
666 pub(crate) fn algorithm_uri(&self) -> &'static str {
667 match self {
668 Self::Enveloped => ENVELOPED_SIGNATURE_URI,
669 Self::XpathExcludeAllSignatures | Self::XPath(_) => XPATH_TRANSFORM_URI,
670 Self::XPathFilter2(_) => XPATH_FILTER2_TRANSFORM_URI,
671 Self::C14n(algorithm) => algorithm.uri(),
672 Self::Base64Decode => BASE64_TRANSFORM_URI,
673 }
674 }
675}
676
677#[cfg(test)]
685pub(crate) fn apply_transform<'a>(
686 signature_node: Node<'a, 'a>,
687 transform: &Transform,
688 input: TransformData<'a>,
689) -> Result<TransformData<'a>, TransformError> {
690 let budget = TransformExecutionBudget::default();
691 let state = TransformChainState::default();
692 apply_transform_with_options_and_state(
693 signature_node,
694 transform,
695 input,
696 TransformOptions::default(),
697 &budget,
698 &state,
699 )
700}
701
702#[cfg(test)]
703pub(super) fn apply_transform_with_options<'s, 'd>(
704 signature_node: Node<'s, 's>,
705 transform: &Transform,
706 input: TransformData<'d>,
707 options: TransformOptions,
708 budget: &TransformExecutionBudget,
709) -> Result<TransformData<'d>, TransformError> {
710 let state = TransformChainState::default();
711 apply_transform_with_options_and_state(
712 signature_node,
713 transform,
714 input,
715 options,
716 budget,
717 &state,
718 )
719}
720
721fn apply_transform_with_options_and_state<'s, 'd>(
722 signature_node: Node<'s, 's>,
723 transform: &Transform,
724 input: TransformData<'d>,
725 options: TransformOptions,
726 budget: &TransformExecutionBudget,
727 state: &TransformChainState,
728) -> Result<TransformData<'d>, TransformError> {
729 match transform {
730 Transform::Enveloped => {
731 let mut nodes = input.into_node_set()?;
732 if !std::ptr::eq(signature_node.document(), nodes.document()) {
741 return Err(TransformError::CrossDocumentSignatureNode);
742 }
743 budget.node_filter.charge(nodes.len())?;
744 nodes.exclude_subtree(signature_node);
745 Ok(TransformData::NodeSet(nodes))
746 }
747 Transform::XpathExcludeAllSignatures => {
748 let mut nodes = input.into_node_set()?;
749 let doc = nodes.document();
750
751 budget.xpath.validate_context_evaluations(nodes.len())?;
756 budget.xpath.charge(doc.descendants().count())?;
757
758 for node in doc.descendants().filter(|node| {
759 node.is_element()
760 && node.tag_name().name() == "Signature"
761 && node.tag_name().namespace() == Some(XMLDSIG_NS)
762 }) {
763 budget.node_filter.charge(nodes.len())?;
764 nodes.exclude_subtree(node);
765 }
766
767 Ok(TransformData::NodeSet(nodes))
768 }
769 Transform::XPath(xpath) => {
770 let nodes = input.into_node_set()?;
771 let document_relation = xpath_document_relation(
772 signature_node.document(),
773 nodes.document(),
774 std::iter::once(xpath),
775 state,
776 );
777 Ok(TransformData::NodeSet(
778 apply_xpath_filter_with_semantics_and_budget(
779 nodes,
780 xpath,
781 options.here_semantics(),
782 document_relation,
783 &budget.xpath,
784 &budget.node_filter,
785 &budget.node_set_materialization,
786 )?,
787 ))
788 }
789 Transform::XPathFilter2(filters) => {
790 let nodes = input.into_node_set()?;
791 let document_relation = xpath_document_relation(
792 signature_node.document(),
793 nodes.document(),
794 filters.iter().map(XPathFilter::xpath),
795 state,
796 );
797 Ok(TransformData::NodeSet(
798 apply_xpath_filter2_with_semantics_and_budget(
799 nodes,
800 filters,
801 options.here_semantics(),
802 document_relation,
803 &budget.xpath,
804 &budget.node_filter,
805 &budget.node_set_materialization,
806 )?,
807 ))
808 }
809 Transform::C14n(algo) => {
810 let nodes = input.into_node_set()?;
811 let mut output = Vec::new();
812 c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
813 nodes.document(),
814 Some(&nodes),
815 algo,
816 None,
817 budget.c14n.remaining(),
818 budget.xml_base_resolution(),
819 &mut output,
820 )
821 .map_err(|error| map_c14n_limit_error(error, &budget.c14n))?;
822 budget.c14n.charge(output.len())?;
823 Ok(TransformData::Binary(output))
824 }
825 Transform::Base64Decode => {
826 let mut normalized = Vec::new();
827 match input {
828 TransformData::Binary(bytes) => {
829 append_normalized_base64(&bytes, &mut normalized, &budget.base64)?;
830 }
831 TransformData::NodeSet(nodes) => {
832 for node in nodes.document().descendants() {
833 if nodes.contains(node) && node.is_text() {
834 append_normalized_base64(
835 node.text().unwrap_or_default().as_bytes(),
836 &mut normalized,
837 &budget.base64,
838 )?;
839 }
840 }
841 }
842 }
843 Ok(TransformData::Binary(decode_base64_transform(
844 &normalized,
845 &budget.base64,
846 )?))
847 }
848 }
849}
850
851fn xpath_document_relation<'a>(
852 signature_document: &Document<'_>,
853 input_document: &Document<'_>,
854 expressions: impl IntoIterator<Item = &'a XPathExpression>,
855 state: &TransformChainState,
856) -> XPathDocumentRelation {
857 if matches!(
858 XPathDocumentRelation::between(signature_document, input_document),
859 XPathDocumentRelation::CrossDocument
860 ) {
861 return XPathDocumentRelation::CrossDocument;
862 }
863
864 let mut parsed_identities = expressions
865 .into_iter()
866 .filter_map(XPathExpression::parsed_document_identity);
867 let Some(first) = parsed_identities.next() else {
868 return XPathDocumentRelation::SameDocument;
869 };
870 let input_identity = state.xpath_document_identity(input_document);
871 if first == input_identity && parsed_identities.all(|identity| identity == input_identity) {
872 XPathDocumentRelation::SameDocument
873 } else {
874 XPathDocumentRelation::CrossDocument
875 }
876}
877
878fn append_normalized_base64(
884 encoded: &[u8],
885 normalized: &mut Vec<u8>,
886 budget: &Base64WorkBudget,
887) -> Result<(), TransformError> {
888 budget.charge_input(encoded.len())?;
889
890 let additional = encoded
891 .iter()
892 .filter(|byte| is_rfc2045_base64_byte(**byte))
893 .count();
894 normalized.reserve(additional);
895 normalized.extend(
896 encoded
897 .iter()
898 .copied()
899 .filter(|byte| is_rfc2045_base64_byte(*byte)),
900 );
901 Ok(())
902}
903
904fn is_rfc2045_base64_byte(byte: u8) -> bool {
905 byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
906}
907
908fn decode_base64_transform(
909 normalized: &[u8],
910 budget: &Base64WorkBudget,
911) -> Result<Vec<u8>, TransformError> {
912 let padding = normalized
913 .iter()
914 .rev()
915 .take_while(|byte| **byte == b'=')
916 .count();
917 let decoded_len = base64::decoded_len_estimate(normalized.len()).saturating_sub(padding);
918 budget.ensure_output_capacity(decoded_len)?;
919
920 let mut decoded = vec![0_u8; decoded_len];
921 let written = STANDARD
922 .decode_slice(normalized, &mut decoded)
923 .map_err(|error| TransformError::Base64(error.to_string()))?;
924 budget.charge_output(written)?;
925 decoded.truncate(written);
926 Ok(decoded)
927}
928
929pub fn execute_transforms<'a>(
938 signature_node: Node<'a, 'a>,
939 initial_data: TransformData<'a>,
940 transforms: &[Transform],
941) -> Result<Vec<u8>, TransformError> {
942 execute_transforms_with_options(
943 signature_node,
944 initial_data,
945 transforms,
946 TransformOptions::default(),
947 )
948}
949
950pub(crate) fn execute_transforms_with_options<'a>(
952 signature_node: Node<'a, 'a>,
953 initial_data: TransformData<'a>,
954 transforms: &[Transform],
955 options: TransformOptions,
956) -> Result<Vec<u8>, TransformError> {
957 let budget = TransformExecutionBudget::default();
958 execute_transforms_with_options_and_budget(
959 signature_node,
960 initial_data,
961 transforms,
962 options,
963 &budget,
964 )
965}
966
967pub(crate) fn execute_transforms_with_options_and_budget<'a>(
968 signature_node: Node<'a, 'a>,
969 initial_data: TransformData<'a>,
970 transforms: &[Transform],
971 options: TransformOptions,
972 budget: &TransformExecutionBudget,
973) -> Result<Vec<u8>, TransformError> {
974 ensure_transform_count(transforms.len())?;
975 let state = TransformChainState::default();
976 let context = TransformExecutionContext {
977 options,
978 budget,
979 state: &state,
980 };
981 execute_transform_chain(
982 signature_node,
983 Some(signature_node),
984 initial_data,
985 transforms,
986 None,
987 None,
988 &context,
989 )
990 .map(|output| output.bytes)
991}
992
993pub(crate) struct TransformDependencyOutput {
995 pub(crate) dependencies: HashSet<usize>,
996}
997
998struct TransformChainOutput {
999 bytes: Vec<u8>,
1000 dependencies: HashSet<usize>,
1001}
1002
1003struct DependencyTracking {
1004 active_nodes: Vec<TrackedDependencyNode>,
1007 dormant_indexes: HashSet<usize>,
1012 opaque_dependencies: HashSet<usize>,
1015 canonical_positions: Option<Vec<CanonicalDependencyPosition>>,
1016}
1017
1018struct TrackedDependencyNode {
1019 index: usize,
1020 node_id: NodeId,
1021 node_type: NodeType,
1022}
1023
1024struct CanonicalDependencyPosition {
1025 index: usize,
1026 position: usize,
1027 node_type: NodeType,
1028}
1029
1030pub(crate) fn execute_transforms_with_dependency_nodes<'a>(
1036 signature_node: Node<'a, 'a>,
1037 initial_data: TransformData<'a>,
1038 transforms: &[Transform],
1039 options: TransformOptions,
1040 budget: &TransformExecutionBudget,
1041 tracked_nodes: Vec<(usize, NodeId)>,
1042) -> Result<TransformDependencyOutput, TransformError> {
1043 ensure_transform_count(transforms.len())?;
1044 let mut active_nodes = Vec::with_capacity(tracked_nodes.len());
1045 let mut opaque_dependencies = HashSet::new();
1046 let mut dormant_indexes = HashSet::new();
1047 for (index, node_id) in tracked_nodes {
1048 if let Some(node) = signature_node.document().get_node(node_id) {
1049 let belongs_to_input = match &initial_data {
1050 TransformData::NodeSet(nodes) => nodes.contains(node),
1051 TransformData::Binary(_) => false,
1052 };
1053 if belongs_to_input {
1054 active_nodes.push(TrackedDependencyNode {
1055 index,
1056 node_id,
1057 node_type: node.node_type(),
1058 });
1059 } else {
1060 dormant_indexes.insert(index);
1061 }
1062 } else {
1063 opaque_dependencies.insert(index);
1064 }
1065 }
1066 let state = TransformChainState::default();
1067 let context = TransformExecutionContext {
1068 options,
1069 budget,
1070 state: &state,
1071 };
1072 let output = execute_transform_chain(
1073 signature_node,
1074 Some(signature_node),
1075 initial_data,
1076 transforms,
1077 None,
1078 Some(DependencyTracking {
1079 active_nodes,
1080 dormant_indexes,
1081 opaque_dependencies,
1082 canonical_positions: None,
1083 }),
1084 &context,
1085 )?;
1086 Ok(TransformDependencyOutput {
1087 dependencies: output.dependencies,
1088 })
1089}
1090
1091fn ensure_transform_count(count: usize) -> Result<(), TransformError> {
1092 if count > MAX_TRANSFORMS_PER_REFERENCE {
1093 return Err(transform_resource_limit(
1094 crate::policy::resource_name::REFERENCE_TRANSFORMS,
1095 MAX_TRANSFORMS_PER_REFERENCE,
1096 count,
1097 ));
1098 }
1099 Ok(())
1100}
1101
1102fn execute_transform_chain<'s, 'e, 'd>(
1103 source_signature: Node<'s, 's>,
1104 enveloped_signature: Option<Node<'e, 'e>>,
1105 data: TransformData<'d>,
1106 transforms: &[Transform],
1107 canonical_signature_position: Option<Option<usize>>,
1108 mut dependency_tracking: Option<DependencyTracking>,
1109 context: &TransformExecutionContext<'_>,
1110) -> Result<TransformChainOutput, TransformError> {
1111 let Some((transform, remaining)) = transforms.split_first() else {
1112 if let (TransformData::NodeSet(nodes), Some(tracking)) = (&data, &mut dependency_tracking) {
1113 tracking.active_nodes.retain(|tracked| {
1114 nodes
1115 .document()
1116 .get_node(tracked.node_id)
1117 .is_some_and(|node| nodes.contains(node))
1118 });
1119 }
1120 let bytes = finalize_transform_data(data, context.budget)?;
1121 return Ok(TransformChainOutput {
1122 bytes,
1123 dependencies: dependency_indexes(dependency_tracking),
1124 });
1125 };
1126
1127 if transform_requires_node_set(transform)
1128 && let TransformData::Binary(bytes) = data
1129 {
1130 let xml = crate::encoding::decode_xml_octets(&bytes)
1136 .map_err(|error| TransformError::XmlParse(error.to_string()))?;
1137 let settings = DocumentParseSettings {
1138 allow_dtd: context.options.internal_dtd_allowed(),
1139 ..context.budget.xml_parse_settings
1140 };
1141 let document = parse_borrowed_with_settings_and_budget(
1142 &xml,
1143 settings,
1144 Some(&context.budget.xml_parse_work),
1145 )
1146 .map_err(|error| map_transform_xml_parse_error(error, settings))?;
1147 context.state.document_reparsed();
1148 let nodes = super::types::NodeSet::entire_document_with_comments_with_budget(
1149 &document,
1150 &context.budget.node_set_materialization,
1151 )?;
1152 if let Some(tracking) = &mut dependency_tracking
1153 && let Some(positions) = tracking.canonical_positions.take()
1154 {
1155 let mut remapped = Vec::with_capacity(positions.len());
1156 for tracked in positions {
1157 if let Some(node) = document.descendants().find(|node| {
1158 node.node_type() == tracked.node_type && node.range().start == tracked.position
1159 }) {
1160 remapped.push(TrackedDependencyNode {
1161 index: tracked.index,
1162 node_id: node.id(),
1163 node_type: tracked.node_type,
1164 });
1165 } else {
1166 tracking.opaque_dependencies.insert(tracked.index);
1169 }
1170 }
1171 tracking.active_nodes = remapped;
1172 }
1173 return match canonical_signature_position {
1174 Some(Some(position)) => {
1175 let remapped = document
1176 .descendants()
1177 .find(|node| node.is_element() && node.range().start == position)
1178 .filter(|node| {
1179 enveloped_signature
1180 .is_some_and(|source| node.tag_name() == source.tag_name())
1181 })
1182 .ok_or(TransformError::CrossDocumentSignatureNode)?;
1183 execute_transform_chain(
1184 source_signature,
1185 Some(remapped),
1186 TransformData::NodeSet(nodes),
1187 transforms,
1188 None,
1189 dependency_tracking,
1190 context,
1191 )
1192 }
1193 Some(None) => execute_transform_chain(
1194 source_signature,
1195 None,
1196 TransformData::NodeSet(nodes),
1197 transforms,
1198 None,
1199 dependency_tracking,
1200 context,
1201 ),
1202 None => execute_transform_chain(
1203 source_signature,
1204 None,
1208 TransformData::NodeSet(nodes),
1209 transforms,
1210 None,
1211 dependency_tracking,
1212 context,
1213 ),
1214 };
1215 }
1216
1217 if let Transform::C14n(algo) = transform
1218 && let TransformData::NodeSet(nodes) = &data
1219 {
1220 let tracked_element = enveloped_signature
1221 .filter(|signature| std::ptr::eq(signature.document(), nodes.document()))
1222 .filter(|signature| nodes.contains(*signature))
1223 .map(|signature| signature.id());
1224 let mut output = Vec::new();
1225 if let Some(tracking) = &mut dependency_tracking {
1226 tracking.active_nodes.retain(|tracked| {
1227 nodes
1228 .document()
1229 .get_node(tracked.node_id)
1230 .is_some_and(|node| nodes.contains(node))
1231 });
1232 tracking.dormant_indexes.clear();
1233 }
1234 let position = if let Some(tracking) = &mut dependency_tracking {
1235 let mut tracked_ids = tracking
1236 .active_nodes
1237 .iter()
1238 .map(|tracked| tracked.node_id)
1239 .collect::<Vec<_>>();
1240 if let Some(signature_id) = tracked_element
1241 && !tracked_ids.contains(&signature_id)
1242 {
1243 tracked_ids.push(signature_id);
1244 }
1245 let positions =
1246 c14n::canonicalize_with_visibility_and_positions_bounded_with_xml_base_budget(
1247 nodes.document(),
1248 Some(nodes),
1249 algo,
1250 &tracked_ids,
1251 context.budget.c14n.remaining(),
1252 context.budget.xml_base_resolution(),
1253 &mut output,
1254 )
1255 .map_err(|error| map_c14n_limit_error(error, &context.budget.c14n))?;
1256 let mut canonical_positions = Vec::with_capacity(tracking.active_nodes.len());
1257 for tracked in &tracking.active_nodes {
1258 if let Some((_, position)) = positions
1259 .iter()
1260 .find(|(tracked_id, _)| *tracked_id == tracked.node_id)
1261 {
1262 canonical_positions.push(CanonicalDependencyPosition {
1263 index: tracked.index,
1264 position: *position,
1265 node_type: tracked.node_type,
1266 });
1267 } else {
1268 tracking.opaque_dependencies.insert(tracked.index);
1269 }
1270 }
1271 tracking.canonical_positions = Some(canonical_positions);
1272 tracked_element.and_then(|signature_id| {
1273 positions
1274 .iter()
1275 .find(|(tracked_id, _)| *tracked_id == signature_id)
1276 .map(|(_, position)| *position)
1277 })
1278 } else {
1279 c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
1280 nodes.document(),
1281 Some(nodes),
1282 algo,
1283 tracked_element,
1284 context.budget.c14n.remaining(),
1285 context.budget.xml_base_resolution(),
1286 &mut output,
1287 )
1288 .map_err(|error| map_c14n_limit_error(error, &context.budget.c14n))?
1289 };
1290 context.budget.c14n.charge(output.len())?;
1291 return execute_transform_chain(
1292 source_signature,
1293 enveloped_signature,
1294 TransformData::Binary(output),
1295 remaining,
1296 Some(position),
1297 dependency_tracking,
1298 context,
1299 );
1300 }
1301
1302 if matches!(transform, Transform::Enveloped) {
1303 let Some(signature) = enveloped_signature else {
1304 return execute_transform_chain(
1305 source_signature,
1306 None,
1307 data,
1308 remaining,
1309 None,
1310 dependency_tracking,
1311 context,
1312 );
1313 };
1314 let data = apply_transform_with_options_and_state(
1315 signature,
1316 transform,
1317 data,
1318 context.options,
1319 context.budget,
1320 context.state,
1321 )?;
1322 return execute_transform_chain(
1323 source_signature,
1324 Some(signature),
1325 data,
1326 remaining,
1327 None,
1328 dependency_tracking,
1329 context,
1330 );
1331 }
1332
1333 let data = apply_transform_with_options_and_state(
1334 source_signature,
1335 transform,
1336 data,
1337 context.options,
1338 context.budget,
1339 context.state,
1340 )?;
1341 if let Some(tracking) = &mut dependency_tracking {
1342 match &data {
1343 TransformData::NodeSet(nodes) => {
1344 let preserve_excluded_as_opaque = match transform {
1345 Transform::XPath(expression) => {
1346 xpath_may_read_mutable_character_data(expression.expression())
1347 }
1348 Transform::XPathFilter2(filters) => filters.iter().any(|filter| {
1349 xpath_may_read_mutable_character_data(filter.xpath().expression())
1350 }),
1351 _ => false,
1352 };
1353 if preserve_excluded_as_opaque {
1354 tracking
1355 .opaque_dependencies
1356 .extend(tracking.dormant_indexes.drain());
1357 }
1358 let mut active_nodes = Vec::with_capacity(tracking.active_nodes.len());
1359 for tracked in tracking.active_nodes.drain(..) {
1360 let remains_visible = nodes
1361 .document()
1362 .get_node(tracked.node_id)
1363 .is_some_and(|node| nodes.contains(node));
1364 if remains_visible {
1365 active_nodes.push(tracked);
1366 } else if preserve_excluded_as_opaque {
1367 tracking.opaque_dependencies.insert(tracked.index);
1373 } else {
1374 tracking.dormant_indexes.insert(tracked.index);
1375 }
1376 }
1377 tracking.active_nodes = active_nodes;
1378 }
1379 TransformData::Binary(_) => {
1380 tracking
1381 .opaque_dependencies
1382 .extend(tracking.active_nodes.drain(..).map(|tracked| tracked.index));
1383 tracking.dormant_indexes.clear();
1384 tracking.canonical_positions = None;
1385 }
1386 }
1387 }
1388 execute_transform_chain(
1389 source_signature,
1390 enveloped_signature,
1391 data,
1392 remaining,
1393 None,
1394 dependency_tracking,
1395 context,
1396 )
1397}
1398
1399fn map_transform_xml_parse_error(
1400 error: XmlDocumentError,
1401 settings: DocumentParseSettings,
1402) -> TransformError {
1403 match error.into_policy_violation(settings) {
1404 Ok(error) => TransformError::Policy(error),
1405 Err(error) => TransformError::XmlParse(error.to_string()),
1406 }
1407}
1408
1409fn dependency_indexes(tracking: Option<DependencyTracking>) -> HashSet<usize> {
1410 let Some(tracking) = tracking else {
1411 return HashSet::new();
1412 };
1413 tracking
1414 .active_nodes
1415 .into_iter()
1416 .map(|tracked| tracked.index)
1417 .chain(tracking.opaque_dependencies)
1418 .collect()
1419}
1420
1421fn transform_requires_node_set(transform: &Transform) -> bool {
1422 !matches!(transform, Transform::Base64Decode)
1423}
1424
1425fn finalize_transform_data(
1426 data: TransformData<'_>,
1427 budget: &TransformExecutionBudget,
1428) -> Result<Vec<u8>, TransformError> {
1429 match data {
1432 TransformData::Binary(bytes) => Ok(bytes),
1433 TransformData::NodeSet(nodes) => {
1434 #[expect(clippy::expect_used, reason = "hardcoded URI is a known constant")]
1435 let algo = C14nAlgorithm::from_uri(DEFAULT_IMPLICIT_C14N_URI)
1436 .expect("default C14N algorithm URI must be supported by C14nAlgorithm::from_uri");
1437 let mut output = Vec::new();
1438 c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
1439 nodes.document(),
1440 Some(&nodes),
1441 &algo,
1442 None,
1443 budget.c14n.remaining(),
1444 budget.xml_base_resolution(),
1445 &mut output,
1446 )
1447 .map_err(|error| map_c14n_limit_error(error, &budget.c14n))?;
1448 budget.c14n.charge(output.len())?;
1449 Ok(output)
1450 }
1451 }
1452}
1453
1454fn map_c14n_limit_error(error: c14n::C14nError, budget: &C14nOutputBudget) -> TransformError {
1455 if c14n::is_output_limit_error(&error) {
1456 budget.exhaust();
1459 }
1460 if let Some(violation) = map_c14n_resource_policy_violation(
1461 &error,
1462 crate::policy::resource_name::CANONICALIZED_BYTES,
1463 budget.max_bytes,
1464 ) {
1465 TransformError::Policy(violation)
1466 } else {
1467 TransformError::C14n(error)
1468 }
1469}
1470
1471pub(crate) fn transform_chain_produces_binary(
1472 initial_binary: bool,
1473 transforms: &[Transform],
1474) -> bool {
1475 transforms.last().map_or(initial_binary, |transform| {
1476 matches!(transform, Transform::C14n(_) | Transform::Base64Decode)
1477 })
1478}
1479
1480pub(crate) fn map_c14n_resource_policy_violation(
1485 error: &c14n::C14nError,
1486 output_resource: &'static str,
1487 output_maximum: usize,
1488) -> Option<crate::policy::PolicyViolation> {
1489 match error {
1490 error if c14n::is_output_limit_error(error) => {
1491 Some(crate::policy::PolicyViolation::ResourceLimit {
1492 resource: output_resource,
1493 maximum: output_maximum,
1494 actual: output_maximum.saturating_add(1),
1497 })
1498 }
1499 c14n::C14nError::XmlBaseComponentsTooLarge { max, actual } => {
1500 Some(crate::policy::PolicyViolation::ResourceLimit {
1501 resource: crate::policy::resource_name::XML_BASE_COMPONENTS,
1502 maximum: *max,
1503 actual: *actual,
1504 })
1505 }
1506 c14n::C14nError::XmlBaseResolutionTooLarge { max_bytes, actual } => {
1507 Some(crate::policy::PolicyViolation::ResourceLimit {
1508 resource: crate::policy::resource_name::XML_BASE_RESOLUTION_BYTES,
1509 maximum: *max_bytes,
1510 actual: *actual,
1511 })
1512 }
1513 _ => None,
1514 }
1515}
1516
1517pub(crate) fn validate_signing_transform_policy(
1518 initial_binary: bool,
1519 transforms: &[Transform],
1520 allowed: Option<&HashSet<String>>,
1521) -> Result<(), crate::policy::PolicyViolation> {
1522 let Some(allowed) = allowed else {
1523 return Ok(());
1524 };
1525 for transform in transforms {
1526 let algorithm = transform.algorithm_uri();
1527 if !allowed.contains(algorithm) {
1528 return Err(crate::policy::PolicyViolation::Algorithm {
1529 operation: "signing transform",
1530 algorithm: algorithm.to_owned(),
1531 });
1532 }
1533 }
1534 if !transform_chain_produces_binary(initial_binary, transforms)
1535 && !allowed.contains(DEFAULT_IMPLICIT_C14N_URI)
1536 {
1537 return Err(crate::policy::PolicyViolation::Algorithm {
1538 operation: "signing transform",
1539 algorithm: DEFAULT_IMPLICIT_C14N_URI.to_owned(),
1540 });
1541 }
1542 Ok(())
1543}
1544
1545pub fn parse_transforms(transforms_node: Node) -> Result<Vec<Transform>, TransformError> {
1554 parse_transforms_with_budget(transforms_node, &mut XPathSignatureParseBudget::default())
1555}
1556
1557pub(crate) fn parse_transforms_with_budget(
1558 transforms_node: Node,
1559 signature_budget: &mut XPathSignatureParseBudget,
1560) -> Result<Vec<Transform>, TransformError> {
1561 if !transforms_node.is_element() {
1563 return Err(TransformError::UnsupportedTransform(
1564 "expected <Transforms> element but got non-element node".into(),
1565 ));
1566 }
1567 let transforms_tag = transforms_node.tag_name();
1568 if transforms_tag.name() != "Transforms" || transforms_tag.namespace() != Some(XMLDSIG_NS) {
1569 return Err(TransformError::UnsupportedTransform(
1570 "expected <ds:Transforms> element in XMLDSig namespace".into(),
1571 ));
1572 }
1573
1574 let mut chain = Vec::new();
1575 let mut xpath_state = XPathParseState::new(signature_budget);
1576
1577 for child in transforms_node.children() {
1578 if !child.is_element() {
1579 continue;
1580 }
1581 ensure_transform_count(chain.len() + 1)?;
1582
1583 let tag = child.tag_name();
1585 if tag.name() != "Transform" || tag.namespace() != Some(XMLDSIG_NS) {
1586 return Err(TransformError::UnsupportedTransform(
1587 "unexpected child element of <ds:Transforms>; only <ds:Transform> is allowed"
1588 .into(),
1589 ));
1590 }
1591 let uri = child.attribute("Algorithm").ok_or_else(|| {
1592 TransformError::UnsupportedTransform(
1593 "missing Algorithm attribute on <Transform>".into(),
1594 )
1595 })?;
1596
1597 let transform = if uri == ENVELOPED_SIGNATURE_URI {
1598 Transform::Enveloped
1599 } else if uri == BASE64_TRANSFORM_URI {
1600 validate_empty_transform(child, "Base64")?;
1601 Transform::Base64Decode
1602 } else if uri == XPATH_TRANSFORM_URI {
1603 parse_xpath_transform_with_state(child, &mut xpath_state)?
1604 } else if uri == XPATH_FILTER2_TRANSFORM_URI {
1605 parse_xpath_filter2_transform(child, &mut xpath_state)?
1606 } else if let Some(mut algo) = C14nAlgorithm::from_uri(uri) {
1607 if algo.mode() == c14n::C14nMode::Exclusive1_0
1609 && let Some(prefix_list) = parse_inclusive_prefixes(child)?
1610 {
1611 algo = algo.with_prefix_list(&prefix_list);
1612 }
1613 Transform::C14n(algo)
1614 } else {
1615 return Err(TransformError::UnsupportedTransform(uri.to_string()));
1616 };
1617 chain.push(transform);
1618 }
1619
1620 Ok(chain)
1621}
1622
1623fn validate_empty_transform(
1625 transform_node: Node,
1626 transform_name: &'static str,
1627) -> Result<(), TransformError> {
1628 for child in transform_node.children() {
1629 if child.is_element()
1630 || (child.is_text()
1631 && child
1632 .text()
1633 .is_some_and(|text| !is_xml_whitespace_only(text)))
1634 {
1635 return Err(TransformError::UnsupportedTransform(format!(
1636 "{transform_name} transform must not contain parameters"
1637 )));
1638 }
1639 }
1640 Ok(())
1641}
1642
1643#[cfg(test)]
1644pub(super) fn parse_xpath_transform(transform_node: Node) -> Result<Transform, TransformError> {
1645 parse_xpath_transform_with_state(
1646 transform_node,
1647 &mut XPathParseState::new(&mut XPathSignatureParseBudget::default()),
1648 )
1649}
1650
1651fn parse_xpath_transform_with_state(
1652 transform_node: Node,
1653 xpath_state: &mut XPathParseState,
1654) -> Result<Transform, TransformError> {
1655 let mut xpath_node = None;
1656
1657 for child in transform_node.children() {
1658 if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1659 continue;
1660 }
1661 if child.is_comment() || child.is_pi() {
1662 continue;
1663 }
1664 if !child.is_element() {
1665 return Err(TransformError::XPath(
1666 "XPath transform contains non-whitespace parameter content".into(),
1667 ));
1668 }
1669 let tag = child.tag_name();
1670 if tag.name() == "XPath" && tag.namespace() == Some(XMLDSIG_NS) {
1671 if xpath_node.is_some() {
1672 return Err(TransformError::XPath(
1673 "XPath transform must contain exactly one XMLDSig <XPath> child element".into(),
1674 ));
1675 }
1676 xpath_node = Some(child);
1677 } else {
1678 return Err(TransformError::XPath(
1679 "XPath transform allows only a single XMLDSig <XPath> child element".into(),
1680 ));
1681 }
1682 }
1683
1684 let xpath_node = xpath_node.ok_or_else(|| {
1685 TransformError::XPath(
1686 "XPath transform requires a single XMLDSig <XPath> child element".into(),
1687 )
1688 })?;
1689 if xpath_node.attributes().len() != 0 {
1690 return Err(TransformError::XPath(
1691 "XMLDSig <XPath> does not allow attributes".into(),
1692 ));
1693 }
1694 let xpath = parse_xpath_expression(xpath_node, transform_node.id(), xpath_state)?;
1695
1696 if xpath.expression() == ENVELOPED_SIGNATURE_XPATH_EXPR
1697 && xpath.namespaces().get("dsig").map(String::as_str) == Some(XMLDSIG_NS)
1698 {
1699 Ok(Transform::XpathExcludeAllSignatures)
1700 } else {
1701 Ok(Transform::XPath(xpath))
1702 }
1703}
1704
1705fn parse_xpath_filter2_transform(
1706 transform_node: Node,
1707 xpath_state: &mut XPathParseState,
1708) -> Result<Transform, TransformError> {
1709 let mut filters = Vec::new();
1710 for child in transform_node.children() {
1711 if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1712 continue;
1713 }
1714 if child.is_comment() || child.is_pi() {
1715 continue;
1716 }
1717 if !child.is_element()
1718 || child.tag_name().name() != "XPath"
1719 || child.tag_name().namespace() != Some(XPATH_FILTER2_TRANSFORM_URI)
1720 {
1721 return Err(TransformError::XPath(
1722 "XPath Filter 2.0 allows only filter-namespace <XPath> children".into(),
1723 ));
1724 }
1725 if filters.len() == xpath_state.signature_budget.max_filters {
1726 return Err(transform_resource_limit(
1727 crate::policy::resource_name::XPATH_FILTERS,
1728 xpath_state.signature_budget.max_filters,
1729 filters.len().saturating_add(1),
1730 ));
1731 }
1732 if child.attributes().len() != 1 || child.attribute("Filter").is_none() {
1733 return Err(TransformError::XPath(
1734 "XPath Filter 2.0 <XPath> requires only the unqualified Filter attribute".into(),
1735 ));
1736 }
1737 let operation = match child.attribute("Filter") {
1738 Some("intersect") => XPathFilterOperation::Intersect,
1739 Some("subtract") => XPathFilterOperation::Subtract,
1740 Some("union") => XPathFilterOperation::Union,
1741 Some(value) => {
1742 return Err(TransformError::XPath(format!(
1743 "unsupported XPath Filter 2.0 operation: {value}"
1744 )));
1745 }
1746 None => unreachable!("Filter presence was checked above"),
1747 };
1748 filters.push(XPathFilter::new(
1749 operation,
1750 parse_xpath_expression(child, transform_node.id(), xpath_state)?,
1751 ));
1752 }
1753 if filters.is_empty() {
1754 return Err(TransformError::XPath(
1755 "XPath Filter 2.0 requires at least one expression".into(),
1756 ));
1757 }
1758 Ok(Transform::XPathFilter2(filters))
1759}
1760
1761fn parse_xpath_expression(
1762 xpath_node: Node,
1763 transform_node: roxmltree::NodeId,
1764 xpath_state: &mut XPathParseState,
1765) -> Result<XPathExpression, TransformError> {
1766 let mut source = String::new();
1767 for child in xpath_node.children() {
1768 if child.is_text() {
1769 let text = child.text().unwrap_or_default();
1770 let attempted = source.len().saturating_add(text.len());
1771 if attempted > xpath_state.signature_budget.max_expression_bytes {
1772 return Err(transform_resource_limit(
1773 crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
1774 xpath_state.signature_budget.max_expression_bytes,
1775 attempted,
1776 ));
1777 }
1778 source.push_str(text);
1779 } else if child.is_element() {
1780 return Err(TransformError::XPath(
1781 "XPath expressions must contain text only".into(),
1782 ));
1783 }
1784 }
1785 let source = source.trim_matches(is_xpath_whitespace);
1786 if source.is_empty() {
1787 return Err(TransformError::XPath(
1788 "XPath expression must not be empty".into(),
1789 ));
1790 }
1791 xpath_state.signature_budget.charge()?;
1792 if source.len() > xpath_state.signature_budget.max_expression_bytes {
1793 return Err(transform_resource_limit(
1794 crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
1795 xpath_state.signature_budget.max_expression_bytes,
1796 source.len(),
1797 ));
1798 }
1799 let complexity = crate::xmldsig::xpath::xpath_expression_complexity(source);
1800 if complexity > xpath_state.signature_budget.max_expression_complexity {
1801 return Err(transform_resource_limit(
1802 crate::policy::resource_name::XPATH_EXPRESSION_COMPLEXITY,
1803 xpath_state.signature_budget.max_expression_complexity,
1804 complexity,
1805 ));
1806 }
1807 crate::xmldsig::xpath::compile_xpath_with_policy_limits(
1808 source,
1809 xpath_state.signature_budget.max_expression_bytes,
1810 xpath_state.signature_budget.max_expression_complexity,
1811 )
1812 .map_err(TransformError::XPath)?;
1813
1814 let namespaces = collect_xpath_namespaces_with_limits(
1815 xpath_node,
1816 xpath_state.signature_budget.max_namespace_bindings,
1817 xpath_state.signature_budget.max_namespace_bytes,
1818 )?;
1819 let xpath = XPathExpression {
1820 expression: source.to_owned(),
1821 namespaces,
1822 here_nodes: Some(XPathHereNodes {
1823 specification_xpath_element: xpath_node.id(),
1826 xmlsec_legacy_transform_element: transform_node,
1827 document: xpath_state.document_identity(xpath_node.document()),
1831 }),
1832 };
1833 Ok(xpath)
1834}
1835
1836struct XPathParseState<'a> {
1837 document_identity: Option<XPathDocumentIdentity>,
1838 signature_budget: &'a mut XPathSignatureParseBudget,
1839}
1840
1841impl<'a> XPathParseState<'a> {
1842 fn new(signature_budget: &'a mut XPathSignatureParseBudget) -> Self {
1843 Self {
1844 document_identity: None,
1845 signature_budget,
1846 }
1847 }
1848
1849 fn document_identity(&mut self, document: &Document<'_>) -> XPathDocumentIdentity {
1850 *self
1851 .document_identity
1852 .get_or_insert_with(|| XPathDocumentIdentity::from_document(document))
1853 }
1854}
1855
1856pub(crate) struct XPathSignatureParseBudget {
1859 expressions: usize,
1860 max_expressions: usize,
1861 max_expression_bytes: usize,
1862 max_expression_complexity: usize,
1863 max_namespace_bindings: usize,
1864 max_namespace_bytes: usize,
1865 max_filters: usize,
1866}
1867
1868impl Default for XPathSignatureParseBudget {
1869 fn default() -> Self {
1870 Self {
1871 expressions: 0,
1872 max_expressions: MAX_XPATH_EXPRESSIONS_PER_SIGNATURE,
1873 max_expression_bytes: MAX_XPATH_EXPRESSION_BYTES,
1874 max_expression_complexity: crate::hard_limits::XPATH_EXPRESSION_COMPLEXITY_CEILING,
1875 max_namespace_bindings: MAX_XPATH_NAMESPACE_BINDINGS,
1876 max_namespace_bytes: MAX_XPATH_NAMESPACE_BYTES,
1877 max_filters: MAX_XPATH_FILTERS,
1878 }
1879 }
1880}
1881
1882impl XPathSignatureParseBudget {
1883 pub(crate) fn from_resources(resources: &crate::policy::ResourcePolicy) -> Self {
1884 Self {
1885 expressions: 0,
1886 max_expressions: resources.max_xpath_expressions,
1887 max_expression_bytes: resources.max_xpath_expression_bytes,
1888 max_expression_complexity: resources.max_xpath_expression_complexity,
1889 max_namespace_bindings: resources.max_xpath_namespace_bindings,
1890 max_namespace_bytes: resources.max_xpath_namespace_bytes,
1891 max_filters: resources.max_xpath_filters,
1892 }
1893 }
1894
1895 pub(crate) fn charge(&mut self) -> Result<(), TransformError> {
1896 self.expressions = self
1897 .expressions
1898 .checked_add(1)
1899 .ok_or_else(|| self.error())?;
1900 if self.expressions > self.max_expressions {
1901 return Err(self.error());
1902 }
1903 Ok(())
1904 }
1905
1906 pub(crate) fn validate_expression(&mut self, source: &str) -> Result<(), TransformError> {
1907 if source.is_empty() {
1908 return Err(TransformError::XPath(
1909 "XPath expression must not be empty".into(),
1910 ));
1911 }
1912 self.charge()?;
1913 if source.len() > self.max_expression_bytes {
1914 return Err(transform_resource_limit(
1915 crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
1916 self.max_expression_bytes,
1917 source.len(),
1918 ));
1919 }
1920 let complexity = crate::xmldsig::xpath::xpath_expression_complexity(source);
1921 if complexity > self.max_expression_complexity {
1922 return Err(transform_resource_limit(
1923 crate::policy::resource_name::XPATH_EXPRESSION_COMPLEXITY,
1924 self.max_expression_complexity,
1925 complexity,
1926 ));
1927 }
1928 crate::xmldsig::xpath::compile_xpath_with_policy_limits(
1929 source,
1930 self.max_expression_bytes,
1931 self.max_expression_complexity,
1932 )
1933 .map(|_| ())
1934 .map_err(TransformError::XPath)
1935 }
1936
1937 pub(crate) fn validate_namespaces(
1938 &self,
1939 namespaces: &BTreeMap<String, String>,
1940 ) -> Result<(), TransformError> {
1941 let mut budget = XPathNamespaceBudget::with_limits(
1942 self.max_namespace_bindings,
1943 self.max_namespace_bytes,
1944 );
1945 for (prefix, uri) in namespaces {
1946 budget.charge(prefix, uri)?;
1947 }
1948 Ok(())
1949 }
1950
1951 fn error(&self) -> TransformError {
1952 transform_resource_limit(
1953 crate::policy::resource_name::XPATH_EXPRESSIONS,
1954 self.max_expressions,
1955 self.expressions.max(self.max_expressions.saturating_add(1)),
1956 )
1957 }
1958}
1959
1960struct XPathNamespaceBudget {
1961 bindings: usize,
1962 bytes: usize,
1963 max_bindings: usize,
1964 max_bytes: usize,
1965}
1966
1967impl Default for XPathNamespaceBudget {
1968 fn default() -> Self {
1969 Self::with_limits(MAX_XPATH_NAMESPACE_BINDINGS, MAX_XPATH_NAMESPACE_BYTES)
1970 }
1971}
1972
1973impl XPathNamespaceBudget {
1974 fn with_limits(max_bindings: usize, max_bytes: usize) -> Self {
1975 Self {
1976 bindings: 0,
1977 bytes: 0,
1978 max_bindings,
1979 max_bytes,
1980 }
1981 }
1982
1983 fn charge(&mut self, prefix: &str, uri: &str) -> Result<(), TransformError> {
1984 let bindings = self.bindings.saturating_add(1);
1985 let bytes = self
1986 .bytes
1987 .checked_add(prefix.len())
1988 .and_then(|bytes| bytes.checked_add(uri.len()))
1989 .unwrap_or(usize::MAX);
1990 if bindings > self.max_bindings {
1991 return Err(transform_resource_limit(
1992 crate::policy::resource_name::XPATH_NAMESPACE_BINDINGS,
1993 self.max_bindings,
1994 bindings,
1995 ));
1996 }
1997 if bytes > self.max_bytes {
1998 return Err(transform_resource_limit(
1999 crate::policy::resource_name::XPATH_NAMESPACE_BYTES,
2000 self.max_bytes,
2001 bytes,
2002 ));
2003 }
2004 self.bindings = bindings;
2005 self.bytes = bytes;
2006 Ok(())
2007 }
2008}
2009
2010pub(crate) fn collect_xpath_namespaces_with_resources(
2011 xpath_node: Node<'_, '_>,
2012 resources: &crate::policy::ResourcePolicy,
2013) -> Result<BTreeMap<String, String>, TransformError> {
2014 collect_xpath_namespaces_with_limits(
2015 xpath_node,
2016 resources.max_xpath_namespace_bindings,
2017 resources.max_xpath_namespace_bytes,
2018 )
2019}
2020
2021fn collect_xpath_namespaces_with_limits(
2022 xpath_node: Node<'_, '_>,
2023 max_bindings: usize,
2024 max_bytes: usize,
2025) -> Result<BTreeMap<String, String>, TransformError> {
2026 let mut budget = XPathNamespaceBudget::with_limits(max_bindings, max_bytes);
2027 for namespace in xpath_node.namespaces() {
2028 if let Some(prefix) = namespace.name() {
2029 budget.charge(prefix, namespace.uri())?;
2030 }
2031 }
2032 Ok(xpath_node
2033 .namespaces()
2034 .filter_map(|namespace| {
2035 namespace
2036 .name()
2037 .map(|prefix| (prefix.to_owned(), namespace.uri().to_owned()))
2038 })
2039 .collect())
2040}
2041
2042pub(crate) fn validate_xpath_namespace_budget_with_resources(
2043 transforms: &[Transform],
2044 inherited_namespace: Option<(&str, &str)>,
2045 resources: &crate::policy::ResourcePolicy,
2046) -> Result<(), TransformError> {
2047 validate_xpath_namespace_budget_with_limits(
2048 transforms,
2049 inherited_namespace,
2050 resources.max_xpath_namespace_bindings,
2051 resources.max_xpath_namespace_bytes,
2052 )
2053}
2054
2055fn validate_xpath_namespace_budget_with_limits(
2056 transforms: &[Transform],
2057 inherited_namespace: Option<(&str, &str)>,
2058 max_bindings: usize,
2059 max_bytes: usize,
2060) -> Result<(), TransformError> {
2061 let validate_expression = |xpath: &XPathExpression| {
2062 let mut budget = XPathNamespaceBudget::with_limits(max_bindings, max_bytes);
2063 for (prefix, uri) in xpath.namespaces() {
2064 budget.charge(prefix, uri)?;
2065 }
2066 if let Some((prefix, uri)) = inherited_namespace
2067 && !xpath.namespaces().contains_key(prefix)
2068 {
2069 budget.charge(prefix, uri)?;
2070 }
2071 Ok::<(), TransformError>(())
2072 };
2073 for transform in transforms {
2074 match transform {
2075 Transform::XpathExcludeAllSignatures => {
2076 let xpath = XPathExpression::new(ENVELOPED_SIGNATURE_XPATH_EXPR)
2077 .with_namespace(ENVELOPED_SIGNATURE_XPATH_PREFIX, XMLDSIG_NS);
2078 validate_expression(&xpath)?;
2079 }
2080 Transform::XPath(xpath) => validate_expression(xpath)?,
2081 Transform::XPathFilter2(filters) => {
2082 for filter in filters {
2083 validate_expression(filter.xpath())?;
2084 }
2085 }
2086 _ => {}
2087 }
2088 }
2089 Ok(())
2090}
2091
2092fn parse_inclusive_prefixes(transform_node: Node) -> Result<Option<String>, TransformError> {
2110 for child in transform_node.children() {
2111 if child.is_element() {
2112 let tag = child.tag_name();
2113 if tag.name() == "InclusiveNamespaces" && tag.namespace() == Some(EXCLUSIVE_C14N_NS_URI)
2114 {
2115 let prefix_list = child.attribute("PrefixList").ok_or_else(|| {
2116 TransformError::UnsupportedTransform(
2117 "missing PrefixList attribute on <InclusiveNamespaces>".into(),
2118 )
2119 })?;
2120 return Ok(Some(prefix_list.to_string()));
2121 }
2122 }
2123 }
2124 Ok(None)
2125}
2126
2127#[cfg(test)]
2128#[expect(clippy::unwrap_used, reason = "tests use trusted XML fixtures")]
2129mod tests {
2130 use super::*;
2131 use crate::xmldsig::NodeSet;
2132 use roxmltree::Document;
2133
2134 fn assert_resource_limit(error: &TransformError, expected_resource: &'static str) {
2135 assert!(
2136 matches!(
2137 error,
2138 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2139 resource,
2140 maximum,
2141 actual,
2142 }) if *resource == expected_resource && actual > maximum
2143 ),
2144 "unexpected error: {error:?}"
2145 );
2146 }
2147
2148 #[test]
2151 fn enveloped_excludes_signature_subtree() {
2152 let xml = r#"<root>
2154 <data>hello</data>
2155 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
2156 <SignedInfo><Reference URI=""/></SignedInfo>
2157 <SignatureValue>abc</SignatureValue>
2158 </Signature>
2159 </root>"#;
2160 let doc = Document::parse(xml).unwrap();
2161
2162 let sig_node = doc
2164 .descendants()
2165 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
2166 .unwrap();
2167
2168 let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
2170 let data = TransformData::NodeSet(node_set);
2171
2172 let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
2174 let node_set = result.into_node_set().unwrap();
2175
2176 assert!(node_set.contains(doc.root_element()));
2178 let data_elem = doc
2179 .descendants()
2180 .find(|n| n.is_element() && n.tag_name().name() == "data")
2181 .unwrap();
2182 assert!(node_set.contains(data_elem));
2183
2184 assert!(
2186 !node_set.contains(sig_node),
2187 "Signature element should be excluded"
2188 );
2189 let signed_info = doc
2190 .descendants()
2191 .find(|n| n.is_element() && n.tag_name().name() == "SignedInfo")
2192 .unwrap();
2193 assert!(
2194 !node_set.contains(signed_info),
2195 "SignedInfo (child of Signature) should be excluded"
2196 );
2197 }
2198
2199 #[test]
2200 fn enveloped_requires_node_set_input() {
2201 let xml = "<root/>";
2202 let doc = Document::parse(xml).unwrap();
2203 let data = TransformData::Binary(vec![1, 2, 3]);
2205 let result = apply_transform(doc.root_element(), &Transform::Enveloped, data);
2206 assert!(result.is_err());
2207 match result.unwrap_err() {
2208 TransformError::TypeMismatch { expected, got } => {
2209 assert_eq!(expected, "NodeSet");
2210 assert_eq!(got, "Binary");
2211 }
2212 other => panic!("expected TypeMismatch, got: {other:?}"),
2213 }
2214 }
2215
2216 #[test]
2217 fn enveloped_rejects_cross_document_signature_node() {
2218 let xml = r#"<Root><Signature Id="sig"/></Root>"#;
2221 let doc1 = Document::parse(xml).unwrap();
2222 let doc2 = Document::parse(xml).unwrap();
2223
2224 let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
2226 let input = TransformData::NodeSet(node_set);
2227 let sig_from_doc2 = doc2
2228 .descendants()
2229 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
2230 .unwrap();
2231
2232 let result = apply_transform(sig_from_doc2, &Transform::Enveloped, input);
2233 assert!(matches!(
2234 result,
2235 Err(TransformError::CrossDocumentSignatureNode)
2236 ));
2237 }
2238
2239 #[test]
2242 fn c14n_transform_produces_bytes() {
2243 let xml = r#"<root b="2" a="1"><child/></root>"#;
2244 let doc = Document::parse(xml).unwrap();
2245
2246 let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
2247 let data = TransformData::NodeSet(node_set);
2248
2249 let algo =
2250 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2251 let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data).unwrap();
2252
2253 let bytes = result.into_binary().unwrap();
2254 let output = String::from_utf8(bytes).unwrap();
2255 assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
2257 }
2258
2259 #[test]
2260 fn c14n_transform_requires_node_set() {
2261 let xml = "<root/>";
2262 let doc = Document::parse(xml).unwrap();
2263
2264 let algo =
2265 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2266 let data = TransformData::Binary(vec![1, 2, 3]);
2267 let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data);
2268
2269 assert!(result.is_err());
2270 assert!(matches!(
2271 result.unwrap_err(),
2272 TransformError::TypeMismatch { .. }
2273 ));
2274 }
2275
2276 #[test]
2277 fn c14n_1_1_uses_the_compiled_xml_base_policy() {
2278 let document = Document::parse(
2281 r#"<root xml:base="one/"><parent xml:base="two/"><leaf/></parent></root>"#,
2282 )
2283 .unwrap();
2284 let leaf = document
2285 .descendants()
2286 .find(|node| node.has_tag_name("leaf"))
2287 .unwrap();
2288 let resources = crate::policy::ResourcePolicy {
2289 max_xml_base_components: 1,
2290 ..crate::policy::ResourcePolicy::default()
2291 };
2292 let budget = TransformExecutionBudget::from_resources(&resources);
2293 let algorithm = C14nAlgorithm::new(crate::c14n::C14nMode::Inclusive1_1, false);
2294
2295 let error = execute_transforms_with_options_and_budget(
2296 document.root_element(),
2297 TransformData::NodeSet(NodeSet::subtree(leaf).unwrap()),
2298 &[Transform::C14n(algorithm)],
2299 TransformOptions::default(),
2300 &budget,
2301 )
2302 .expect_err("C14N must use the operation's XML Base component limit");
2303
2304 assert!(matches!(
2305 error,
2306 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2307 resource: crate::policy::resource_name::XML_BASE_COMPONENTS,
2308 maximum: 1,
2309 actual: 2
2310 })
2311 ));
2312 }
2313
2314 #[test]
2317 fn base64_transform_decodes_binary_with_xml_whitespace() {
2318 let doc = Document::parse("<root/>").unwrap();
2320 let input = TransformData::Binary(b" SGV\tsbG8=\r\n".to_vec());
2321
2322 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
2323
2324 assert_eq!(result.into_binary().unwrap(), b"Hello");
2325 }
2326
2327 #[test]
2328 fn base64_transform_concatenates_only_selected_text_nodes_in_document_order() {
2329 let xml = r#"<root><Data ID="payload">SGV<!-- split --><Part>sb</Part><?pi ignored?>G8=</Data></root>"#;
2332 let doc = Document::parse(xml).unwrap();
2333 let data = doc
2334 .descendants()
2335 .find(|node| node.attribute("ID") == Some("payload"))
2336 .unwrap();
2337 let input = TransformData::NodeSet(NodeSet::subtree(data).unwrap());
2338
2339 let result = apply_transform(data, &Transform::Base64Decode, input).unwrap();
2340
2341 assert_eq!(result.into_binary().unwrap(), b"Hello");
2342 }
2343
2344 #[test]
2345 fn base64_transform_omits_text_excluded_from_the_node_set() {
2346 let xml = "<root>SGV<Excluded>QUJD</Excluded>sbG8=</root>";
2349 let doc = Document::parse(xml).unwrap();
2350 let excluded = doc
2351 .descendants()
2352 .find(|node| node.has_tag_name("Excluded"))
2353 .unwrap();
2354 let mut nodes = NodeSet::subtree(doc.root_element()).unwrap();
2355 nodes.exclude_subtree(excluded);
2356
2357 let result = apply_transform(
2358 doc.root_element(),
2359 &Transform::Base64Decode,
2360 TransformData::NodeSet(nodes),
2361 )
2362 .unwrap();
2363
2364 assert_eq!(result.into_binary().unwrap(), b"Hello");
2365 }
2366
2367 #[test]
2368 fn base64_transform_ignores_rfc2045_non_alphabet_bytes() {
2369 let doc = Document::parse("<root/>").unwrap();
2370 let input = TransformData::Binary(b"SGVs!\xFFbG8=".to_vec());
2371
2372 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
2373
2374 assert_eq!(result.into_binary().unwrap(), b"Hello");
2375 }
2376
2377 #[test]
2378 fn base64_transform_rejects_invalid_padding() {
2379 let doc = Document::parse("<root/>").unwrap();
2380 let result = apply_transform(
2381 doc.root_element(),
2382 &Transform::Base64Decode,
2383 TransformData::Binary(b"SGVsbG8===".to_vec()),
2384 );
2385
2386 assert!(matches!(result, Err(TransformError::Base64(_))));
2387 }
2388
2389 #[test]
2390 fn base64_transform_accepts_empty_input() {
2391 let doc = Document::parse("<root/>").unwrap();
2392 let result = apply_transform(
2393 doc.root_element(),
2394 &Transform::Base64Decode,
2395 TransformData::Binary(Vec::new()),
2396 )
2397 .unwrap();
2398
2399 assert!(result.into_binary().unwrap().is_empty());
2400 }
2401
2402 #[test]
2403 fn base64_transform_rejects_oversized_raw_binary_before_normalization() {
2404 let doc = Document::parse("<root/>").unwrap();
2407 let input = TransformData::Binary(vec![b' '; MAX_BASE64_TRANSFORM_INPUT_BYTES + 1]);
2408
2409 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
2410
2411 assert!(matches!(
2412 result,
2413 Err(TransformError::Policy(
2414 crate::policy::PolicyViolation::ResourceLimit {
2415 resource: crate::policy::resource_name::BASE64_TRANSFORM_INPUT_BYTES,
2416 maximum: MAX_BASE64_TRANSFORM_INPUT_BYTES,
2417 ..
2418 }
2419 ))
2420 ));
2421 }
2422
2423 #[test]
2424 fn base64_transform_rejects_node_set_that_decodes_past_output_budget() {
2425 let encoded_len = MAX_BASE64_TRANSFORM_OUTPUT_BYTES.div_ceil(3) * 4 + 4;
2428 let xml = format!("<root>{}</root>", "A".repeat(encoded_len));
2429 let doc = Document::parse(&xml).unwrap();
2430 let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
2431
2432 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
2433
2434 assert!(matches!(
2435 result,
2436 Err(TransformError::Policy(
2437 crate::policy::PolicyViolation::ResourceLimit {
2438 resource: crate::policy::resource_name::BASE64_TRANSFORM_OUTPUT_BYTES,
2439 maximum: MAX_BASE64_TRANSFORM_OUTPUT_BYTES,
2440 ..
2441 }
2442 ))
2443 ));
2444 }
2445
2446 #[test]
2447 fn base64_transform_handles_highly_fragmented_node_set_input() {
2448 let expected = vec![0x42_u8; 3 * 1_024];
2452 let encoded = STANDARD.encode(&expected);
2453 let mut xml = String::from("<root>");
2454 for byte in encoded.bytes() {
2455 xml.push(char::from(byte));
2456 xml.push_str("<!-- split -->");
2457 }
2458 xml.push_str("</root>");
2459 let doc = Document::parse(&xml).unwrap();
2460 let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
2461
2462 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
2463
2464 assert_eq!(result.into_binary().unwrap(), expected);
2465 }
2466
2467 #[test]
2468 fn pipeline_rejects_cumulative_base64_input_past_budget() {
2469 let doc = Document::parse("<root/>").unwrap();
2472 let inner = vec![b'A'; MAX_BASE64_TRANSFORM_OUTPUT_BYTES];
2473 let outer = STANDARD.encode(&inner);
2474 let transforms = [Transform::Base64Decode, Transform::Base64Decode];
2475
2476 let result = execute_transforms(
2477 doc.root_element(),
2478 TransformData::Binary(outer.into_bytes()),
2479 &transforms,
2480 );
2481
2482 assert!(matches!(
2483 result,
2484 Err(TransformError::Policy(
2485 crate::policy::PolicyViolation::ResourceLimit {
2486 resource: crate::policy::resource_name::BASE64_TRANSFORM_INPUT_BYTES,
2487 maximum: MAX_BASE64_TRANSFORM_INPUT_BYTES,
2488 ..
2489 }
2490 ))
2491 ));
2492 }
2493
2494 #[test]
2495 fn operation_rejects_cumulative_base64_output_past_budget() {
2496 let doc = Document::parse("<root/>").unwrap();
2499 let resources = crate::policy::ResourcePolicy {
2500 max_base64_transform_input_bytes: 8,
2501 max_base64_transform_output_bytes: 1,
2502 ..crate::policy::ResourcePolicy::default()
2503 };
2504 let budget = TransformExecutionBudget::from_resources(&resources);
2505
2506 let first = apply_transform_with_options(
2507 doc.root_element(),
2508 &Transform::Base64Decode,
2509 TransformData::Binary(b"YQ==".to_vec()),
2510 TransformOptions::default(),
2511 &budget,
2512 )
2513 .expect("the first one-byte output must fit");
2514 assert_eq!(first.into_binary().unwrap(), b"a");
2515
2516 let error = apply_transform_with_options(
2517 doc.root_element(),
2518 &Transform::Base64Decode,
2519 TransformData::Binary(b"Yg==".to_vec()),
2520 TransformOptions::default(),
2521 &budget,
2522 )
2523 .expect_err("the second output must exceed the cumulative allowance");
2524
2525 assert!(matches!(
2526 error,
2527 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2528 resource: crate::policy::resource_name::BASE64_TRANSFORM_OUTPUT_BYTES,
2529 maximum: 1,
2530 actual: 2,
2531 })
2532 ));
2533 }
2534
2535 #[test]
2536 fn pipeline_rejects_unbounded_programmatic_transform_chain() {
2537 let doc = Document::parse("<root/>").unwrap();
2540 let transforms = vec![Transform::Base64Decode; 65];
2541
2542 let result = execute_transforms(
2543 doc.root_element(),
2544 TransformData::Binary(Vec::new()),
2545 &transforms,
2546 );
2547
2548 assert!(matches!(
2549 result,
2550 Err(TransformError::Policy(
2551 crate::policy::PolicyViolation::ResourceLimit {
2552 resource: crate::policy::resource_name::REFERENCE_TRANSFORMS,
2553 maximum: MAX_TRANSFORMS_PER_REFERENCE,
2554 ..
2555 }
2556 ))
2557 ));
2558 }
2559
2560 #[test]
2563 fn byte_budgets_remain_exhausted_after_overflow() {
2564 let c14n = C14nOutputBudget::default();
2567 assert!(c14n.charge(MAX_C14N_OUTPUT_BYTES + 1).is_err());
2568 assert!(c14n.charge(1).is_err());
2569
2570 let base64 = Base64WorkBudget::default();
2571 assert!(
2572 base64
2573 .charge_input(MAX_BASE64_TRANSFORM_INPUT_BYTES + 1)
2574 .is_err()
2575 );
2576 assert!(base64.charge_input(1).is_err());
2577 }
2578
2579 #[test]
2580 fn pipeline_rejects_cumulative_c14n_output() {
2581 let xml = format!("<root>{}</root>", "x".repeat(4_096));
2584 let document = Document::parse(&xml).unwrap();
2585 let algorithm =
2586 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2587 let transforms = vec![
2588 Transform::C14n(algorithm.clone()),
2589 Transform::C14n(algorithm),
2590 ];
2591 let one_output = execute_transforms(
2592 document.root_element(),
2593 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
2594 &transforms[..1],
2595 )
2596 .expect("one canonicalization must succeed")
2597 .len();
2598 let limit = one_output * 2 - 1;
2599
2600 let result = execute_transforms_with_options_and_budget(
2601 document.root_element(),
2602 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
2603 &transforms,
2604 TransformOptions::default(),
2605 &TransformExecutionBudget::with_c14n_limit(limit),
2606 );
2607
2608 assert!(matches!(
2609 result,
2610 Err(TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2611 resource: crate::policy::resource_name::CANONICALIZED_BYTES,
2612 maximum,
2613 ..
2614 })) if maximum == limit
2615 ));
2616 }
2617
2618 #[test]
2619 fn binary_to_node_set_adapter_uses_shared_materialization_budget() {
2620 let signature_document = Document::parse("<Signature/>").unwrap();
2624 let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
2625 let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2626
2627 let error = execute_transforms_with_options_and_budget(
2628 signature_document.root_element(),
2629 TransformData::Binary(b"<root xmlns:n=\"urn:namespace\"/>".to_vec()),
2630 &transforms,
2631 TransformOptions::default(),
2632 &budget,
2633 )
2634 .expect_err("the binary adapter must charge cloned namespace strings");
2635
2636 assert!(matches!(
2637 error,
2638 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2639 resource: crate::policy::resource_name::NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
2640 ..
2641 })
2642 ));
2643 }
2644
2645 #[test]
2646 fn recursive_binary_adapters_share_xml_parse_work() {
2647 let signature_document = Document::parse("<Signature/>").unwrap();
2651 let xml = b"<root/>";
2652 let parser_passes = 2 + usize::from(cfg!(feature = "xml-backend-xmloxide"));
2653 let resources = crate::policy::ResourcePolicy {
2654 max_xml_parse_work_bytes: xml.len() * parser_passes,
2655 ..crate::policy::ResourcePolicy::default()
2656 };
2657 let budget = TransformExecutionBudget::from_resources(&resources);
2658 let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2659
2660 execute_transforms_with_options_and_budget(
2661 signature_document.root_element(),
2662 TransformData::Binary(xml.to_vec()),
2663 &transforms,
2664 TransformOptions::default(),
2665 &budget,
2666 )
2667 .expect("the first adapter parse must consume the exact allowance");
2668 let error = execute_transforms_with_options_and_budget(
2669 signature_document.root_element(),
2670 TransformData::Binary(xml.to_vec()),
2671 &transforms,
2672 TransformOptions::default(),
2673 &budget,
2674 )
2675 .expect_err("the second adapter parse must inherit exhausted work");
2676
2677 assert!(matches!(
2678 error,
2679 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2680 resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
2681 maximum,
2682 actual,
2683 }) if maximum == xml.len() * parser_passes
2684 && actual == xml.len() * (parser_passes + 1)
2685 ));
2686 }
2687
2688 #[test]
2689 fn binary_to_node_set_adapter_bounds_external_xml_nodes_during_parse() {
2690 let signature_document = Document::parse("<Signature/>").unwrap();
2693 let xml = format!(
2694 "<root>{}</root>",
2695 "<n/>".repeat(XML_DOCUMENT_NODE_CEILING as usize + 1),
2696 );
2697 let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2698
2699 execute_transforms(
2700 signature_document.root_element(),
2701 TransformData::Binary(b"<root><n/></root>".to_vec()),
2702 &transforms,
2703 )
2704 .expect("external XML below the node ceiling must parse and transform");
2705
2706 let error = execute_transforms(
2707 signature_document.root_element(),
2708 TransformData::Binary(xml.into_bytes()),
2709 &transforms,
2710 )
2711 .expect_err("external XML exceeding the node ceiling must fail during parse");
2712
2713 assert!(matches!(
2714 error,
2715 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2716 resource: crate::policy::resource_name::XML_NODES,
2717 ..
2718 })
2719 ));
2720 }
2721
2722 #[test]
2723 fn binary_to_node_set_adapter_enforces_operation_depth() {
2724 let signature_document = Document::parse("<Signature/>").unwrap();
2727 let resources = crate::policy::ResourcePolicy {
2728 max_xml_depth: 2,
2729 ..crate::policy::ResourcePolicy::default()
2730 };
2731 let budget = TransformExecutionBudget::from_resources(&resources);
2732 let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2733
2734 let error = execute_transforms_with_options_and_budget(
2735 signature_document.root_element(),
2736 TransformData::Binary(b"<root><child><leaf/></child></root>".to_vec()),
2737 &transforms,
2738 TransformOptions::default(),
2739 &budget,
2740 )
2741 .expect_err("over-depth transform XML must be rejected before XPath");
2742
2743 assert!(matches!(
2744 error,
2745 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2746 resource: crate::policy::resource_name::XML_DEPTH,
2747 maximum: 2,
2748 actual: 3,
2749 })
2750 ));
2751 }
2752
2753 #[test]
2754 fn xpath_projection_uses_shared_materialization_budget() {
2755 let document = Document::parse("<root attribute=\"value\"/>").unwrap();
2759 let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
2760 let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2761
2762 let error = execute_transforms_with_options_and_budget(
2763 document.root_element(),
2764 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
2765 &transforms,
2766 TransformOptions::default(),
2767 &budget,
2768 )
2769 .expect_err("XPath projection must charge cloned attribute names");
2770
2771 assert!(matches!(
2772 error,
2773 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2774 resource: crate::policy::resource_name::NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
2775 ..
2776 })
2777 ));
2778 }
2779
2780 #[test]
2781 fn explicit_and_implicit_c14n_stop_at_the_execution_ceiling() {
2782 let xml = format!("<root>{}</root>", "x".repeat(4_096));
2785 let document = Document::parse(&xml).unwrap();
2786 let nodes = || {
2787 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
2788 };
2789 let algorithm =
2790 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2791
2792 for transforms in [&[][..], &[Transform::C14n(algorithm)][..]] {
2793 let error = execute_transforms_with_options_and_budget(
2794 document.root_element(),
2795 nodes(),
2796 transforms,
2797 TransformOptions::default(),
2798 &TransformExecutionBudget::with_c14n_limit(64),
2799 )
2800 .expect_err("canonicalization must stop at the execution ceiling");
2801
2802 assert!(matches!(
2803 error,
2804 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2805 resource: crate::policy::resource_name::CANONICALIZED_BYTES,
2806 maximum: 64,
2807 ..
2808 })
2809 ));
2810 }
2811 }
2812
2813 #[test]
2814 fn execution_budget_bounds_c14n_output_across_references() {
2815 let xml = format!("<root>{}</root>", "x".repeat(4_096));
2819 let document = Document::parse(&xml).unwrap();
2820 let algorithm =
2821 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2822 let transforms = [Transform::C14n(algorithm)];
2823 let input = || {
2824 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
2825 };
2826 let one_output = execute_transforms(document.root_element(), input(), &transforms)
2827 .expect("one canonicalization must succeed")
2828 .len();
2829 let limit = one_output * 2 - 1;
2830 let execution_budget = TransformExecutionBudget::with_c14n_limit(limit);
2831
2832 execute_transforms_with_options_and_budget(
2833 document.root_element(),
2834 input(),
2835 &transforms,
2836 TransformOptions::default(),
2837 &execution_budget,
2838 )
2839 .expect("the first Reference must fit the cumulative C14N output budget");
2840 let result = execute_transforms_with_options_and_budget(
2841 document.root_element(),
2842 input(),
2843 &transforms,
2844 TransformOptions::default(),
2845 &execution_budget,
2846 );
2847
2848 assert!(matches!(
2849 result,
2850 Err(TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2851 resource: crate::policy::resource_name::CANONICALIZED_BYTES,
2852 maximum,
2853 ..
2854 })) if maximum == limit
2855 ));
2856 }
2857
2858 #[test]
2859 fn execution_budget_bounds_repeated_node_set_exclusions() {
2860 let document =
2863 Document::parse("<root><payload/><Signature><Object/></Signature></root>").unwrap();
2864 let signature = document
2865 .descendants()
2866 .find(|node| node.has_tag_name("Signature"))
2867 .unwrap();
2868 let input = || NodeSet::entire_document_with_comments(&document).unwrap();
2869 let entries_per_exclusion = input().len();
2870 let budget = TransformExecutionBudget::with_node_filter_limit(
2871 entries_per_exclusion.saturating_mul(2).saturating_sub(1),
2872 );
2873
2874 execute_transforms_with_options_and_budget(
2875 signature,
2876 TransformData::NodeSet(input()),
2877 &[Transform::Enveloped],
2878 TransformOptions::default(),
2879 &budget,
2880 )
2881 .expect("the first reference exclusion must fit the shared budget");
2882 let result = execute_transforms_with_options_and_budget(
2883 signature,
2884 TransformData::NodeSet(input()),
2885 &[Transform::Enveloped],
2886 TransformOptions::default(),
2887 &budget,
2888 );
2889
2890 assert!(
2891 matches!(
2892 result,
2893 Err(TransformError::Policy(
2894 crate::policy::PolicyViolation::ResourceLimit {
2895 resource: crate::policy::resource_name::NODE_SET_FILTER_WORK,
2896 ..
2897 }
2898 ))
2899 ),
2900 "the second reference exclusion must exhaust the shared budget"
2901 );
2902 }
2903
2904 #[test]
2905 fn xpath_node_set_operations_consume_filter_work_budget() {
2906 let document = Document::parse("<root><keep/><drop/></root>").unwrap();
2910 let transforms = [
2911 Transform::XPath(XPathExpression::new("true()")),
2912 Transform::XPathFilter2(vec![XPathFilter::new(
2913 XPathFilterOperation::Intersect,
2914 XPathExpression::new("//*"),
2915 )]),
2916 ];
2917
2918 for transform in transforms {
2919 let resources = crate::policy::ResourcePolicy {
2920 max_node_set_filter_work: 0,
2921 ..crate::policy::ResourcePolicy::default()
2922 };
2923 let budget = TransformExecutionBudget::from_resources(&resources);
2924 let input = NodeSet::entire_document_without_comments(&document)
2925 .map(TransformData::NodeSet)
2926 .unwrap();
2927 let error = execute_transforms_with_options_and_budget(
2928 document.root_element(),
2929 input,
2930 &[transform],
2931 TransformOptions::default(),
2932 &budget,
2933 )
2934 .expect_err("zero filter-work policy must deny XPath node-set operations");
2935
2936 assert!(matches!(
2937 error,
2938 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2939 resource: crate::policy::resource_name::NODE_SET_FILTER_WORK,
2940 maximum: 0,
2941 ..
2942 })
2943 ));
2944 }
2945 }
2946
2947 #[test]
2948 fn optimized_exclusion_consumes_xpath_execution_budgets() {
2949 let document = Document::parse(
2952 r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><value/><ds:Signature/></root>"#,
2953 )
2954 .unwrap();
2955
2956 for resource in [
2957 crate::policy::resource_name::XPATH_CONTEXT_EVALUATIONS,
2958 crate::policy::resource_name::XPATH_EVALUATION_WORK,
2959 ] {
2960 let resources = if resource == crate::policy::resource_name::XPATH_CONTEXT_EVALUATIONS {
2961 crate::policy::ResourcePolicy {
2962 max_xpath_context_evaluations: 0,
2963 ..crate::policy::ResourcePolicy::default()
2964 }
2965 } else {
2966 crate::policy::ResourcePolicy {
2967 max_xpath_evaluation_work: 0,
2968 ..crate::policy::ResourcePolicy::default()
2969 }
2970 };
2971 let budget = TransformExecutionBudget::from_resources(&resources);
2972 let input = NodeSet::entire_document_without_comments(&document)
2973 .map(TransformData::NodeSet)
2974 .unwrap();
2975 let error = execute_transforms_with_options_and_budget(
2976 document.root_element(),
2977 input,
2978 &[Transform::XpathExcludeAllSignatures],
2979 TransformOptions::default(),
2980 &budget,
2981 )
2982 .expect_err("optimized XPath must obey execution budgets");
2983
2984 assert!(matches!(
2985 error,
2986 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2987 resource: actual,
2988 maximum: 0,
2989 ..
2990 }) if actual == resource
2991 ));
2992 }
2993 }
2994
2995 #[test]
2996 fn optimized_exclusion_charges_document_scan_and_each_filter_pass() {
2997 let document = Document::parse(
3002 r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><payload><value/></payload><padding/><ds:Signature/><ds:Signature/></root>"#,
3003 )
3004 .unwrap();
3005 let payload = document
3006 .descendants()
3007 .find(|node| node.has_tag_name("payload"))
3008 .unwrap();
3009 let input = || NodeSet::subtree(payload).unwrap();
3010 let fragment_entries = input().len();
3011
3012 for resource in [
3013 crate::policy::resource_name::XPATH_EVALUATION_WORK,
3014 crate::policy::resource_name::NODE_SET_FILTER_WORK,
3015 ] {
3016 let resources = if resource == crate::policy::resource_name::XPATH_EVALUATION_WORK {
3017 crate::policy::ResourcePolicy {
3018 max_xpath_evaluation_work: fragment_entries,
3019 ..crate::policy::ResourcePolicy::default()
3020 }
3021 } else {
3022 crate::policy::ResourcePolicy {
3023 max_node_set_filter_work: fragment_entries,
3024 ..crate::policy::ResourcePolicy::default()
3025 }
3026 };
3027 let budget = TransformExecutionBudget::from_resources(&resources);
3028 let error = execute_transforms_with_options_and_budget(
3029 document.root_element(),
3030 TransformData::NodeSet(input()),
3031 &[Transform::XpathExcludeAllSignatures],
3032 TransformOptions::default(),
3033 &budget,
3034 )
3035 .expect_err("document-sized optimized XPath work must exceed the fragment budget");
3036
3037 assert_resource_limit(&error, resource);
3038 }
3039 }
3040
3041 #[test]
3042 fn filter2_charges_document_sized_set_operations() {
3043 let document =
3047 Document::parse("<root><payload><value/></payload><outside/><outside/></root>")
3048 .unwrap();
3049 let payload = document
3050 .descendants()
3051 .find(|node| node.has_tag_name("payload"))
3052 .unwrap();
3053 let input = NodeSet::subtree(payload).unwrap();
3054 let resources = crate::policy::ResourcePolicy {
3055 max_node_set_filter_work: input.len(),
3056 ..crate::policy::ResourcePolicy::default()
3057 };
3058 let budget = TransformExecutionBudget::from_resources(&resources);
3059 let transform = Transform::XPathFilter2(vec![XPathFilter::new(
3060 XPathFilterOperation::Intersect,
3061 XPathExpression::new("//*"),
3062 )]);
3063
3064 let error = execute_transforms_with_options_and_budget(
3065 document.root_element(),
3066 TransformData::NodeSet(input),
3067 &[transform],
3068 TransformOptions::default(),
3069 &budget,
3070 )
3071 .expect_err("Filter 2 must charge document-sized set operations");
3072
3073 assert_resource_limit(&error, crate::policy::resource_name::NODE_SET_FILTER_WORK);
3074 }
3075
3076 #[test]
3077 fn execution_budget_bounds_implicit_c14n_across_references() {
3078 let xml = format!("<root>{}</root>", "x".repeat(4_096));
3082 let document = Document::parse(&xml).unwrap();
3083 let input = || {
3084 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
3085 };
3086 let one_output = execute_transforms(document.root_element(), input(), &[])
3087 .expect("one implicit canonicalization must succeed")
3088 .len();
3089 let limit = one_output * 3 - 1;
3090 let execution_budget = TransformExecutionBudget::with_c14n_limit(limit);
3091
3092 for _ in 0..2 {
3093 execute_transforms_with_options_and_budget(
3094 document.root_element(),
3095 input(),
3096 &[],
3097 TransformOptions::default(),
3098 &execution_budget,
3099 )
3100 .expect("two implicit C14N outputs must fit the shared budget");
3101 }
3102 let result = execute_transforms_with_options_and_budget(
3103 document.root_element(),
3104 input(),
3105 &[],
3106 TransformOptions::default(),
3107 &execution_budget,
3108 );
3109
3110 assert!(matches!(
3111 result,
3112 Err(TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3113 resource: crate::policy::resource_name::CANONICALIZED_BYTES,
3114 maximum,
3115 ..
3116 })) if maximum == limit
3117 ));
3118 }
3119
3120 #[test]
3121 fn pipeline_enveloped_then_c14n() {
3122 let xml = r#"<root xmlns:ns="http://example.com" b="2" a="1">
3124 <data>hello</data>
3125 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
3126 <SignedInfo/>
3127 <SignatureValue>abc</SignatureValue>
3128 </Signature>
3129 </root>"#;
3130 let doc = Document::parse(xml).unwrap();
3131
3132 let sig_node = doc
3133 .descendants()
3134 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
3135 .unwrap();
3136
3137 let initial =
3138 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
3139 let transforms = vec![
3140 Transform::Enveloped,
3141 Transform::C14n(
3142 C14nAlgorithm::from_uri("http://www.w3.org/2001/10/xml-exc-c14n#").unwrap(),
3143 ),
3144 ];
3145
3146 let result = execute_transforms(sig_node, initial, &transforms).unwrap();
3147
3148 let output = String::from_utf8(result).unwrap();
3149 assert!(!output.contains("Signature"));
3151 assert!(!output.contains("SignedInfo"));
3152 assert!(!output.contains("SignatureValue"));
3153 assert!(output.contains("<data>hello</data>"));
3154 }
3155
3156 #[test]
3157 fn pipeline_c14n_then_enveloped_remaps_the_exact_signature() {
3158 let xml = r#"<root>
3162 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
3163 <data>hello</data>
3164 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
3165 </root>"#;
3166 let document = Document::parse(xml).unwrap();
3167 let signature = document
3168 .descendants()
3169 .find(|node| node.attribute("Id") == Some("owner"))
3170 .unwrap();
3171 let initial =
3172 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
3173 let transforms = vec![
3174 Transform::C14n(
3175 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
3176 ),
3177 Transform::Enveloped,
3178 ];
3179
3180 let output = execute_transforms(signature, initial, &transforms).unwrap();
3181 let output = String::from_utf8(output).unwrap();
3182
3183 assert!(output.contains("Id=\"other\""));
3184 assert!(!output.contains("Id=\"owner\""));
3185 assert!(output.contains("<data>hello</data>"));
3186 }
3187
3188 #[test]
3189 fn pipeline_remaps_signature_after_xpath_removes_an_earlier_sibling() {
3190 let xml = r#"<root>
3193 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
3194 <discard/>
3195 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
3196 </root>"#;
3197 let document = Document::parse(xml).unwrap();
3198 let signature = document
3199 .descendants()
3200 .find(|node| node.attribute("Id") == Some("owner"))
3201 .unwrap();
3202 let initial =
3203 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
3204 let transforms = vec![
3205 Transform::XPath(XPathExpression::new("not(self::discard)")),
3206 Transform::C14n(
3207 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
3208 ),
3209 Transform::Enveloped,
3210 ];
3211
3212 let output = execute_transforms(signature, initial, &transforms).unwrap();
3213 let output = String::from_utf8(output).unwrap();
3214
3215 assert!(output.contains("Id=\"other\""));
3216 assert!(!output.contains("Id=\"owner\""));
3217 assert!(!output.contains("discard"));
3218 }
3219
3220 #[test]
3221 fn dependency_tracking_retains_structurally_excluded_nodes_for_later_xpath() {
3222 let document = Document::parse(
3225 r#"<root><Signature><Object><Manifest><DigestValue>pending</DigestValue></Manifest></Object></Signature></root>"#,
3226 )
3227 .unwrap();
3228 let signature = document
3229 .descendants()
3230 .find(|node| node.tag_name().name() == "Signature")
3231 .unwrap();
3232 let manifest = document
3233 .descendants()
3234 .find(|node| node.tag_name().name() == "Manifest")
3235 .unwrap();
3236 let digest_text = document
3237 .descendants()
3238 .find(|node| node.is_text() && node.text() == Some("pending"))
3239 .unwrap();
3240 let transforms = [
3241 Transform::XPath(XPathExpression::new("not(ancestor-or-self::DigestValue)")),
3242 Transform::XPath(XPathExpression::new(
3243 "string-length(string(//DigestValue)) >= 0",
3244 )),
3245 ];
3246
3247 let output = execute_transforms_with_dependency_nodes(
3248 signature,
3249 TransformData::NodeSet(NodeSet::subtree(manifest).unwrap()),
3250 &transforms,
3251 TransformOptions::default(),
3252 &TransformExecutionBudget::default(),
3253 vec![(7, digest_text.id())],
3254 )
3255 .unwrap();
3256
3257 assert_eq!(output.dependencies, HashSet::from([7]));
3258 }
3259
3260 #[test]
3261 fn dependency_tracking_discards_dormant_nodes_at_binary_boundary() {
3262 let document = Document::parse(
3265 r#"<root><DigestValue>external</DigestValue><Signature><payload>value</payload></Signature></root>"#,
3266 )
3267 .unwrap();
3268 let signature = document
3269 .descendants()
3270 .find(|node| node.tag_name().name() == "Signature")
3271 .unwrap();
3272 let digest_text = document
3273 .descendants()
3274 .find(|node| node.is_text() && node.text() == Some("external"))
3275 .unwrap();
3276 let transforms = [
3277 Transform::C14n(
3278 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
3279 ),
3280 Transform::XPath(XPathExpression::new(
3281 "string-length(string(//DigestValue)) >= 0",
3282 )),
3283 ];
3284
3285 let output = execute_transforms_with_dependency_nodes(
3286 signature,
3287 TransformData::NodeSet(NodeSet::subtree(signature).unwrap()),
3288 &transforms,
3289 TransformOptions::default(),
3290 &TransformExecutionBudget::default(),
3291 vec![(11, digest_text.id())],
3292 )
3293 .unwrap();
3294
3295 assert!(output.dependencies.is_empty());
3296 }
3297
3298 #[test]
3299 fn pipeline_enveloped_ignores_signature_absent_after_base64_adaptation() {
3300 let source = Document::parse(
3304 r#"<root><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"/></root>"#,
3305 )
3306 .unwrap();
3307 let signature = source
3308 .descendants()
3309 .find(|node| node.tag_name().name() == "Signature")
3310 .unwrap();
3311 let encoded = base64::engine::general_purpose::STANDARD.encode(b"<payload>ok</payload>");
3312 let transforms = vec![
3313 Transform::Base64Decode,
3314 Transform::XPath(XPathExpression::new("true()")),
3315 Transform::Enveloped,
3316 ];
3317
3318 let output = execute_transforms(
3319 signature,
3320 TransformData::Binary(encoded.into()),
3321 &transforms,
3322 )
3323 .unwrap();
3324
3325 assert_eq!(output, b"<payload>ok</payload>");
3326 }
3327
3328 #[test]
3329 fn pipeline_no_transforms_applies_default_c14n() {
3330 let xml = r#"<root b="2" a="1"><child/></root>"#;
3332 let doc = Document::parse(xml).unwrap();
3333
3334 let initial =
3335 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
3336 let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
3337
3338 let output = String::from_utf8(result).unwrap();
3339 assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
3340 }
3341
3342 #[test]
3343 fn pipeline_binary_passthrough() {
3344 let xml = "<root/>";
3347 let doc = Document::parse(xml).unwrap();
3348
3349 let initial = TransformData::Binary(b"raw bytes".to_vec());
3350 let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
3351
3352 assert_eq!(result, b"raw bytes");
3353 }
3354
3355 #[test]
3358 fn enveloped_only_excludes_own_signature() {
3359 let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
3362 <data>hello</data>
3363 <ds:Signature Id="sig-other">
3364 <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
3365 </ds:Signature>
3366 <ds:Signature Id="sig-target">
3367 <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
3368 </ds:Signature>
3369 </root>"#;
3370 let doc = Document::parse(xml).unwrap();
3371
3372 let sig_node = doc
3374 .descendants()
3375 .find(|n| n.is_element() && n.attribute("Id") == Some("sig-target"))
3376 .unwrap();
3377
3378 let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
3379 let data = TransformData::NodeSet(node_set);
3380
3381 let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
3382 let node_set = result.into_node_set().unwrap();
3383
3384 let sig_other = doc
3386 .descendants()
3387 .find(|n| n.is_element() && n.attribute("Id") == Some("sig-other"))
3388 .unwrap();
3389 assert!(
3390 node_set.contains(sig_other),
3391 "other Signature elements should NOT be excluded"
3392 );
3393
3394 assert!(
3396 !node_set.contains(sig_node),
3397 "the specific Signature being verified should be excluded"
3398 );
3399 }
3400
3401 #[test]
3404 fn parse_transforms_enveloped_and_exc_c14n() {
3405 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3406 <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
3407 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
3408 </Transforms>"#;
3409 let doc = Document::parse(xml).unwrap();
3410 let transforms_node = doc.root_element();
3411
3412 let chain = parse_transforms(transforms_node).unwrap();
3413 assert_eq!(chain.len(), 2);
3414 assert!(matches!(chain[0], Transform::Enveloped));
3415 assert!(matches!(chain[1], Transform::C14n(_)));
3416 }
3417
3418 #[test]
3419 fn parse_transforms_rejects_unbounded_chain() {
3420 let entries = format!(r#"<Transform Algorithm="{BASE64_TRANSFORM_URI}"/>"#).repeat(65);
3423 let xml = format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{entries}</Transforms>"#);
3424 let doc = Document::parse(&xml).unwrap();
3425
3426 assert!(matches!(
3427 parse_transforms(doc.root_element()),
3428 Err(TransformError::Policy(
3429 crate::policy::PolicyViolation::ResourceLimit {
3430 resource: crate::policy::resource_name::REFERENCE_TRANSFORMS,
3431 maximum: MAX_TRANSFORMS_PER_REFERENCE,
3432 ..
3433 }
3434 ))
3435 ));
3436 }
3437
3438 #[test]
3439 fn parse_transforms_accepts_parameterless_base64() {
3440 let xml = format!(
3441 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">
3442 </Transform></Transforms>"#
3443 );
3444 let doc = Document::parse(&xml).unwrap();
3445
3446 let chain = parse_transforms(doc.root_element()).unwrap();
3447
3448 assert_eq!(chain.len(), 1);
3449 assert!(matches!(chain[0], Transform::Base64Decode));
3450 }
3451
3452 #[test]
3453 fn parse_transforms_rejects_base64_parameters() {
3454 for parameter in ["<Parameter/>", "unexpected", "\u{00A0}"] {
3455 let xml = format!(
3456 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
3457 );
3458 let doc = Document::parse(&xml).unwrap();
3459
3460 let result = parse_transforms(doc.root_element());
3461
3462 assert!(matches!(
3463 result,
3464 Err(TransformError::UnsupportedTransform(_))
3465 ));
3466 }
3467 }
3468
3469 #[test]
3470 fn parse_transforms_rejects_non_xpath_boundary_whitespace() {
3471 let xml = format!(
3474 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath> true()</XPath></Transform></Transforms>"#
3475 );
3476 let doc = Document::parse(&xml).unwrap();
3477
3478 let result = parse_transforms(doc.root_element());
3479
3480 assert!(matches!(result, Err(TransformError::XPath(_))));
3481 }
3482
3483 #[test]
3484 fn parse_transforms_with_inclusive_prefixes() {
3485 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
3486 xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
3487 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
3488 <ec:InclusiveNamespaces PrefixList="ds saml #default"/>
3489 </Transform>
3490 </Transforms>"#;
3491 let doc = Document::parse(xml).unwrap();
3492 let transforms_node = doc.root_element();
3493
3494 let chain = parse_transforms(transforms_node).unwrap();
3495 assert_eq!(chain.len(), 1);
3496 match &chain[0] {
3497 Transform::C14n(algo) => {
3498 assert!(algo.inclusive_prefixes().contains("ds"));
3499 assert!(algo.inclusive_prefixes().contains("saml"));
3500 assert!(algo.inclusive_prefixes().contains("")); }
3502 other => panic!("expected C14n, got: {other:?}"),
3503 }
3504 }
3505
3506 #[test]
3507 fn parse_transforms_ignores_wrong_ns_inclusive_namespaces() {
3508 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3511 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
3512 <InclusiveNamespaces xmlns="http://example.com/fake"
3513 PrefixList="attacker-controlled"/>
3514 </Transform>
3515 </Transforms>"#;
3516 let doc = Document::parse(xml).unwrap();
3517
3518 let chain = parse_transforms(doc.root_element()).unwrap();
3519 assert_eq!(chain.len(), 1);
3520 match &chain[0] {
3521 Transform::C14n(algo) => {
3522 assert!(
3524 algo.inclusive_prefixes().is_empty(),
3525 "should ignore InclusiveNamespaces in wrong namespace"
3526 );
3527 }
3528 other => panic!("expected C14n, got: {other:?}"),
3529 }
3530 }
3531
3532 #[test]
3533 fn parse_transforms_missing_prefix_list_is_error() {
3534 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
3537 xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
3538 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
3539 <ec:InclusiveNamespaces/>
3540 </Transform>
3541 </Transforms>"#;
3542 let doc = Document::parse(xml).unwrap();
3543
3544 let result = parse_transforms(doc.root_element());
3545 assert!(result.is_err());
3546 assert!(matches!(
3547 result.unwrap_err(),
3548 TransformError::UnsupportedTransform(_)
3549 ));
3550 }
3551
3552 #[test]
3553 fn parse_transforms_unsupported_algorithm() {
3554 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3555 <Transform Algorithm="http://example.com/unknown"/>
3556 </Transforms>"#;
3557 let doc = Document::parse(xml).unwrap();
3558
3559 let result = parse_transforms(doc.root_element());
3560 assert!(result.is_err());
3561 assert!(matches!(
3562 result.unwrap_err(),
3563 TransformError::UnsupportedTransform(_)
3564 ));
3565 }
3566
3567 #[test]
3568 fn parse_transforms_missing_algorithm() {
3569 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3570 <Transform/>
3571 </Transforms>"#;
3572 let doc = Document::parse(xml).unwrap();
3573
3574 let result = parse_transforms(doc.root_element());
3575 assert!(result.is_err());
3576 assert!(matches!(
3577 result.unwrap_err(),
3578 TransformError::UnsupportedTransform(_)
3579 ));
3580 }
3581
3582 #[test]
3583 fn parse_transforms_empty() {
3584 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"/>"#;
3585 let doc = Document::parse(xml).unwrap();
3586
3587 let chain = parse_transforms(doc.root_element()).unwrap();
3588 assert!(chain.is_empty());
3589 }
3590
3591 #[test]
3592 fn parse_transforms_accepts_enveloped_compat_xpath() {
3593 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3594 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3595 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3596 not(ancestor-or-self::dsig:Signature)
3597 </XPath>
3598 </Transform>
3599 </Transforms>"#;
3600 let doc = Document::parse(xml).unwrap();
3601
3602 let chain = parse_transforms(doc.root_element()).unwrap();
3603 assert_eq!(chain.len(), 1);
3604 assert!(matches!(chain[0], Transform::XpathExcludeAllSignatures));
3605 }
3606
3607 #[test]
3608 fn parse_transforms_accepts_general_xpath_expressions() {
3609 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3612 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3613 <XPath>self::node()</XPath>
3614 </Transform>
3615 </Transforms>"#;
3616 let doc = Document::parse(xml).unwrap();
3617
3618 let result = parse_transforms(doc.root_element()).unwrap();
3619 assert!(matches!(result.as_slice(), [Transform::XPath(_)]));
3620 }
3621
3622 #[test]
3623 fn parse_xpath_transform_ignores_comments_and_processing_instructions() {
3624 let xml = format!(
3627 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><!-- before --><?probe value?><XPath>true()</XPath><!-- after --><?done?></Transform></Transforms>"#
3628 );
3629 let doc = Document::parse(&xml).unwrap();
3630
3631 let transforms = parse_transforms(doc.root_element()).unwrap();
3632
3633 assert!(matches!(transforms.as_slice(), [Transform::XPath(_)]));
3634 }
3635
3636 #[test]
3637 fn parse_filter2_transform_ignores_comments_and_processing_instructions() {
3638 let xml = format!(
3641 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}"><!-- before --><?probe value?><XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">/root</XPath><!-- after --><?done?></Transform></Transforms>"#
3642 );
3643 let doc = Document::parse(&xml).unwrap();
3644
3645 let transforms = parse_transforms(doc.root_element()).unwrap();
3646
3647 assert!(matches!(
3648 transforms.as_slice(),
3649 [Transform::XPathFilter2(filters)] if filters.len() == 1
3650 ));
3651 }
3652
3653 #[test]
3654 fn parse_transforms_bounds_raw_xpath_parameter_text() {
3655 let padding = " ".repeat(MAX_XPATH_EXPRESSION_BYTES);
3658 let xml = format!(
3659 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath>{padding}true()</XPath></Transform></Transforms>"#
3660 );
3661 let doc = Document::parse(&xml).unwrap();
3662
3663 let error = parse_transforms(doc.root_element())
3664 .expect_err("raw XPath parameter text must obey the expression bound");
3665
3666 assert!(matches!(
3667 error,
3668 TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3669 resource: crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
3670 maximum: MAX_XPATH_EXPRESSION_BYTES,
3671 ..
3672 })
3673 ));
3674 }
3675
3676 #[test]
3677 fn xpath_namespace_limits_apply_to_each_expression() {
3678 let xml = format!(
3681 r#"<Transforms xmlns="{XMLDSIG_NS}">
3682 <Transform Algorithm="{XPATH_TRANSFORM_URI}">
3683 <XPath xmlns:a="urn:a">a:item</XPath>
3684 </Transform>
3685 <Transform Algorithm="{XPATH_TRANSFORM_URI}">
3686 <XPath xmlns:b="urn:b">b:item</XPath>
3687 </Transform>
3688 </Transforms>"#
3689 );
3690 let doc = Document::parse(&xml).unwrap();
3691 let resources = crate::policy::ResourcePolicy {
3692 max_xpath_namespace_bindings: 1,
3693 ..crate::policy::ResourcePolicy::default()
3694 };
3695 let mut budget = XPathSignatureParseBudget::from_resources(&resources);
3696
3697 let transforms = parse_transforms_with_budget(doc.root_element(), &mut budget)
3698 .expect("each XPath independently satisfies the one-binding ceiling");
3699
3700 assert_eq!(transforms.len(), 2);
3701 }
3702
3703 #[test]
3704 fn parse_transforms_applies_namespace_storage_limit_per_expression() {
3705 let declarations = (0..32)
3708 .map(|index| {
3709 format!(
3710 "xmlns:n{index}=\"urn:namespace:{index}:{}\"",
3711 "x".repeat(64)
3712 )
3713 })
3714 .collect::<Vec<_>>()
3715 .join(" ");
3716 let filters = (0..MAX_XPATH_FILTERS)
3717 .map(|_| {
3718 format!(
3719 r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
3720 )
3721 })
3722 .collect::<String>();
3723 let xml = format!(
3724 r#"<Transforms xmlns="{XMLDSIG_NS}" {declarations}><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform></Transforms>"#
3725 );
3726 let doc = Document::parse(&xml).unwrap();
3727
3728 let transforms = parse_transforms(doc.root_element())
3729 .expect("each expression remains below its namespace storage ceiling");
3730
3731 assert!(matches!(
3732 transforms.as_slice(),
3733 [Transform::XPathFilter2(filters)] if filters.len() == MAX_XPATH_FILTERS
3734 ));
3735 }
3736
3737 #[test]
3738 fn parse_transforms_rejects_xpath_in_wrong_namespace() {
3739 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3740 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3741 <foo:XPath xmlns:foo="http://example.com/ns">
3742 not(ancestor-or-self::dsig:Signature)
3743 </foo:XPath>
3744 </Transform>
3745 </Transforms>"#;
3746 let doc = Document::parse(xml).unwrap();
3747
3748 let result = parse_transforms(doc.root_element());
3749 assert!(result.is_err());
3750 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3751 }
3752
3753 #[test]
3754 fn parse_transforms_preserves_nonstandard_prefix_bindings() {
3755 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3759 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3760 <XPath xmlns:dsig="http://example.com/not-xmldsig">
3761 not(ancestor-or-self::dsig:Signature)
3762 </XPath>
3763 </Transform>
3764 </Transforms>"#;
3765 let doc = Document::parse(xml).unwrap();
3766
3767 let result = parse_transforms(doc.root_element()).unwrap();
3768 let [Transform::XPath(xpath)] = result.as_slice() else {
3769 panic!("expected general XPath transform");
3770 };
3771 assert_eq!(
3772 xpath.namespaces().get("dsig").map(String::as_str),
3773 Some("http://example.com/not-xmldsig")
3774 );
3775 }
3776
3777 #[test]
3778 fn parse_transforms_rejects_xpath_with_internal_whitespace_mutation() {
3779 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3780 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3781 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3782 not(ancestor-or-self::dsig:Signa ture)
3783 </XPath>
3784 </Transform>
3785 </Transforms>"#;
3786 let doc = Document::parse(xml).unwrap();
3787
3788 let result = parse_transforms(doc.root_element());
3789 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3790 }
3791
3792 #[test]
3793 fn parse_transforms_rejects_multiple_xpath_children() {
3794 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3795 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3796 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3797 not(ancestor-or-self::dsig:Signature)
3798 </XPath>
3799 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3800 not(ancestor-or-self::dsig:Signature)
3801 </XPath>
3802 </Transform>
3803 </Transforms>"#;
3804 let doc = Document::parse(xml).unwrap();
3805
3806 let result = parse_transforms(doc.root_element());
3807 assert!(result.is_err());
3808 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3809 }
3810
3811 #[test]
3812 fn parse_transforms_rejects_non_xpath_element_children() {
3813 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3814 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3815 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3816 not(ancestor-or-self::dsig:Signature)
3817 </XPath>
3818 <Extra/>
3819 </Transform>
3820 </Transforms>"#;
3821 let doc = Document::parse(xml).unwrap();
3822
3823 let result = parse_transforms(doc.root_element());
3824 assert!(result.is_err());
3825 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3826 }
3827
3828 #[test]
3829 fn parse_transforms_rejects_malformed_xpath_filter2_parameters() {
3830 for parameter in [
3834 r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2">//Data</XPath>"#,
3835 r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="exclude">//Data</XPath>"#,
3836 r#"<XPath xmlns="urn:wrong" Filter="intersect">//Data</XPath>"#,
3837 r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="intersect" Extra="value">//Data</XPath>"#,
3838 ] {
3839 let xml = format!(
3840 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
3841 );
3842 let doc = Document::parse(&xml).unwrap();
3843
3844 let result = parse_transforms(doc.root_element());
3845
3846 assert!(matches!(result, Err(TransformError::XPath(_))));
3847 }
3848 }
3849
3850 #[test]
3851 fn parse_transforms_rejects_empty_xpath_filter2_sequence() {
3852 let xml = format!(
3855 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}"/></Transforms>"#
3856 );
3857 let doc = Document::parse(&xml).unwrap();
3858
3859 let result = parse_transforms(doc.root_element());
3860
3861 assert!(matches!(result, Err(TransformError::XPath(_))));
3862 }
3863
3864 #[test]
3865 fn parse_transform_chain_hashes_xpath_document_once() {
3866 let filters = format!(
3869 r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
3870 )
3871 .repeat(MAX_XPATH_FILTERS);
3872 let transform = format!(
3873 r#"<Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform>"#
3874 );
3875 let xml =
3876 format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{transform}{transform}</Transforms>"#);
3877 let document = Document::parse(&xml).unwrap();
3878
3879 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
3880 let transforms = parse_transforms(document.root_element()).unwrap();
3881 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
3882
3883 assert_eq!(transforms.len(), 2);
3884 assert!(transforms.iter().all(
3885 |transform| matches!(transform, Transform::XPathFilter2(filters) if filters.len() == MAX_XPATH_FILTERS)
3886 ));
3887 assert_eq!(
3888 computations, 1,
3889 "one parsed transform chain must hash its source document once"
3890 );
3891 }
3892
3893 #[test]
3894 fn xpath_compat_excludes_other_signature_subtrees_too() {
3895 let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
3896 <payload>keep-me</payload>
3897 <ds:Signature Id="sig-1">
3898 <ds:SignedInfo/>
3899 <ds:SignatureValue>one</ds:SignatureValue>
3900 </ds:Signature>
3901 <ds:Signature Id="sig-2">
3902 <ds:SignedInfo/>
3903 <ds:SignatureValue>two</ds:SignatureValue>
3904 </ds:Signature>
3905 </root>"#;
3906 let doc = Document::parse(xml).unwrap();
3907 let signature_nodes: Vec<_> = doc
3908 .descendants()
3909 .filter(|node| {
3910 node.is_element()
3911 && node.tag_name().name() == "Signature"
3912 && node.tag_name().namespace() == Some(XMLDSIG_NS)
3913 })
3914 .collect();
3915 let sig_node = signature_nodes[0];
3916
3917 let enveloped = execute_transforms(
3918 sig_node,
3919 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
3920 &[
3921 Transform::Enveloped,
3922 Transform::C14n(C14nAlgorithm::new(
3923 crate::c14n::C14nMode::Inclusive1_0,
3924 false,
3925 )),
3926 ],
3927 )
3928 .unwrap();
3929 let xpath_compat = execute_transforms(
3930 sig_node,
3931 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
3932 &[
3933 Transform::XpathExcludeAllSignatures,
3934 Transform::C14n(C14nAlgorithm::new(
3935 crate::c14n::C14nMode::Inclusive1_0,
3936 false,
3937 )),
3938 ],
3939 )
3940 .unwrap();
3941
3942 let enveloped = String::from_utf8(enveloped).unwrap();
3943 let xpath_compat = String::from_utf8(xpath_compat).unwrap();
3944
3945 assert!(enveloped.contains("sig-2"));
3946 assert!(!xpath_compat.contains("sig-1"));
3947 assert!(!xpath_compat.contains("sig-2"));
3948 assert!(xpath_compat.contains("keep-me"));
3949 }
3950
3951 #[test]
3952 fn parse_transforms_inclusive_c14n_variants() {
3953 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3954 <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
3955 <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/>
3956 <Transform Algorithm="http://www.w3.org/2006/12/xml-c14n11"/>
3957 </Transforms>"#;
3958 let doc = Document::parse(xml).unwrap();
3959
3960 let chain = parse_transforms(doc.root_element()).unwrap();
3961 assert_eq!(chain.len(), 3);
3962 for t in &chain {
3964 assert!(matches!(t, Transform::C14n(_)));
3965 }
3966 }
3967
3968 #[test]
3969 fn parsed_xpath_rejects_node_id_collision_from_another_document() {
3970 let source = Document::parse(
3974 r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1</ds:XPath></ds:Transform></ds:Transforms></root>"#,
3975 )
3976 .unwrap();
3977 let transforms_node = source
3978 .descendants()
3979 .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
3980 .unwrap();
3981 let transforms = parse_transforms(transforms_node).unwrap();
3982
3983 let target = Document::parse(
3984 "<root><container><parameter><unrelated/></parameter></container></root>",
3985 )
3986 .unwrap();
3987 let error = execute_transforms(
3988 target.root_element(),
3989 TransformData::NodeSet(NodeSet::entire_document_without_comments(&target).unwrap()),
3990 &transforms,
3991 )
3992 .expect_err("parsed here() provenance must reject another XML document");
3993
3994 assert!(
3995 matches!(error, TransformError::XPath(ref message) if message.contains("same XML document"))
3996 );
3997 }
3998
3999 #[test]
4000 fn transform_chain_computes_document_identity_once() {
4001 let document = Document::parse(
4004 r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Signature><ds:SignedInfo><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1 or true()</ds:XPath></ds:Transform><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1 or true()</ds:XPath></ds:Transform></ds:Transforms></ds:Reference></ds:SignedInfo></ds:Signature></root>"#,
4005 )
4006 .unwrap();
4007 let transforms_node = document
4008 .descendants()
4009 .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
4010 .unwrap();
4011 let transforms = parse_transforms(transforms_node).unwrap();
4012 let signature = document
4013 .descendants()
4014 .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature")))
4015 .unwrap();
4016 let initial = NodeSet::entire_document_without_comments(&document)
4017 .map(TransformData::NodeSet)
4018 .unwrap();
4019
4020 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4021 execute_transforms(signature, initial, &transforms).unwrap();
4022 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4023
4024 assert_eq!(
4025 computations, 1,
4026 "one live document must be hashed once per chain"
4027 );
4028 }
4029
4030 #[test]
4031 fn transform_chain_state_keys_identity_by_document() {
4032 let first_document = Document::parse("<first/>").unwrap();
4035 let second_document = Document::parse("<second/>").unwrap();
4036 let state = TransformChainState::default();
4037
4038 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4039 let first_identity = state.xpath_document_identity(&first_document);
4040 let second_identity = state.xpath_document_identity(&second_document);
4041 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4042
4043 assert_ne!(first_identity, second_identity);
4044 assert_eq!(
4045 computations, 2,
4046 "each distinct live document must receive its own cached identity"
4047 );
4048 }
4049
4050 #[test]
4051 fn template_xpath_skips_document_identity_hash() {
4052 let document = Document::parse("<root><value/></root>").unwrap();
4055 let transforms = [
4056 Transform::XPath(XPathExpression::new("true()")),
4057 Transform::XPath(XPathExpression::new("true()")),
4058 ];
4059 let initial = NodeSet::entire_document_without_comments(&document)
4060 .map(TransformData::NodeSet)
4061 .unwrap();
4062
4063 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4064 execute_transforms(document.root_element(), initial, &transforms).unwrap();
4065 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4066
4067 assert_eq!(
4068 computations, 0,
4069 "XPath without parsed here() provenance must not hash XML"
4070 );
4071 }
4072
4073 #[test]
4076 fn saml_enveloped_signature_full_pipeline() {
4077 let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
4079 xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
4080 ID="_resp1">
4081 <saml:Assertion ID="_assert1">
4082 <saml:Subject>user@example.com</saml:Subject>
4083 </saml:Assertion>
4084 <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
4085 <ds:SignedInfo>
4086 <ds:Reference URI="">
4087 <ds:Transforms>
4088 <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
4089 <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
4090 </ds:Transforms>
4091 </ds:Reference>
4092 </ds:SignedInfo>
4093 <ds:SignatureValue>fakesig==</ds:SignatureValue>
4094 </ds:Signature>
4095 </samlp:Response>"#;
4096 let doc = Document::parse(xml).unwrap();
4097
4098 let sig_node = doc
4100 .descendants()
4101 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
4102 .unwrap();
4103
4104 let reference = doc
4106 .descendants()
4107 .find(|n| n.is_element() && n.tag_name().name() == "Reference")
4108 .unwrap();
4109 let transforms_elem = reference
4110 .children()
4111 .find(|n| n.is_element() && n.tag_name().name() == "Transforms")
4112 .unwrap();
4113 let transforms = parse_transforms(transforms_elem).unwrap();
4114 assert_eq!(transforms.len(), 2);
4115
4116 let initial =
4118 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
4119 let result = execute_transforms(sig_node, initial, &transforms).unwrap();
4120
4121 let output = String::from_utf8(result).unwrap();
4122
4123 assert!(!output.contains("Signature"), "Signature should be removed");
4125 assert!(
4126 !output.contains("SignedInfo"),
4127 "SignedInfo should be removed"
4128 );
4129 assert!(
4130 !output.contains("SignatureValue"),
4131 "SignatureValue should be removed"
4132 );
4133 assert!(
4134 !output.contains("fakesig"),
4135 "signature value should be removed"
4136 );
4137
4138 assert!(output.contains("samlp:Response"));
4140 assert!(output.contains("saml:Assertion"));
4141 assert!(output.contains("user@example.com"));
4142 }
4143}