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