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