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