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