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