diff --git a/scripts/patches/xmloxide-0.5.0.patch b/scripts/patches/xmloxide-0.5.0.patch index 93c44949..b2323a56 100644 --- a/scripts/patches/xmloxide-0.5.0.patch +++ b/scripts/patches/xmloxide-0.5.0.patch @@ -88,7 +88,7 @@ f.debug_struct("ParseOptions") .field("recover", &self.recover) .field("no_blanks", &self.no_blanks) -@@ -238,6 +239,6 @@ +@@ -238,6 +239,14 @@ /// Returns `ParseError` if the input is not well-formed XML and recovery /// mode is not enabled. pub fn parse_str_with_options(input: &str, options: &ParseOptions) -> Result { @@ -96,6 +96,14 @@ + let parser = xml::XmlParser::new(input, options)?; parser.parse() } ++ ++/// Parses a document without a DOCTYPE, returning the source range of each node. ++pub(crate) fn parse_str_recording_positions( ++ input: &str, ++ options: &ParseOptions, ++) -> Result<(Document, alloc::vec::Vec), ParseError> { ++ xml::XmlParser::new(input, options)?.parse_recording_positions() ++} --- a/xmloxide/src/parser/input.rs +++ b/xmloxide/src/parser/input.rs @@ -19,10 +19,99 @@ @@ -275,7 +283,7 @@ // ------------------------------------------------------------------------- // Position checkpointing (for backtracking) // ------------------------------------------------------------------------- -@@ -266,22 +332,19 @@ +@@ -266,25 +332,25 @@ // ParserInput // ------------------------------------------------------------------------- @@ -305,7 +313,13 @@ /// The input bytes (must be valid UTF-8). input: &'a [u8], -@@ -313,16 +376,11 @@ ++ /// The same input as text, whose slices need no second UTF-8 validation. ++ text: &'a str, ++ + /// Current byte offset in `input`. + pos: usize, + +@@ -313,16 +379,11 @@ recover: bool, /// Accumulated diagnostics (warnings and recoverable errors). @@ -324,7 +338,7 @@ /// Whether the DTD internal subset contained parameter entity references. /// Per XML 1.0 §4.1 WFC: Entity Declared, undeclared entity references -@@ -336,10 +394,6 @@ +@@ -336,10 +397,6 @@ /// external DTD subset that was not read. pub(crate) has_external_dtd: bool, @@ -335,7 +349,7 @@ /// Optional callback for resolving external entities. entity_resolver: Option, } -@@ -347,7 +401,28 @@ +@@ -347,8 +404,30 @@ impl<'a> ParserInput<'a> { /// Creates a new `ParserInput` from a UTF-8 string with default limits. pub fn new(input: &'a str) -> Self { @@ -363,9 +377,11 @@ + Ok(Self { + storage, input: input.as_bytes(), ++ text: input, pos: 0, line: 1, -@@ -358,19 +433,12 @@ + column: 1, +@@ -358,19 +437,12 @@ entity_expansions: 0, max_entity_expansions: DEFAULT_MAX_ENTITY_EXPANSIONS, recover: false, @@ -388,7 +404,7 @@ } /// Sets the maximum nesting depth. -@@ -398,13 +466,32 @@ +@@ -398,13 +470,32 @@ self.recover } @@ -423,7 +439,16 @@ "maximum nesting depth exceeded ({})", self.max_depth ))); -@@ -458,7 +545,7 @@ +@@ -455,10 +546,16 @@ + self.pos + } + ++ /// The input from `start` to `end`, unless that splits a character. ++ #[inline] ++ pub fn text(&self, start: usize, end: usize) -> Option<&'a str> { ++ self.text.get(start..end) ++ } ++ /// Returns a slice of the raw input bytes from `start` to `end`. #[allow(dead_code)] #[inline] @@ -432,7 +457,7 @@ &self.input[start..end] } -@@ -528,7 +615,7 @@ +@@ -528,7 +625,7 @@ if remaining.len() < len { return None; } @@ -441,7 +466,7 @@ .ok() .and_then(|s| s.chars().next()) } -@@ -537,9 +624,10 @@ +@@ -537,9 +634,10 @@ /// Advances the position by `count` bytes, updating line/column. #[inline] @@ -453,7 +478,7 @@ if self.pos < self.input.len() { if self.input[self.pos] == b'\n' { self.line += 1; -@@ -549,15 +637,16 @@ +@@ -549,15 +647,16 @@ } self.pos += 1; } @@ -473,7 +498,7 @@ if ch == '\n' { self.line += 1; self.column = 1; -@@ -565,23 +654,25 @@ +@@ -565,23 +664,25 @@ self.column += 1; } self.pos += len; @@ -502,7 +527,7 @@ if self.pos >= self.input.len() { return Err(self.fatal("unexpected end of input")); } -@@ -608,11 +699,11 @@ +@@ -608,11 +709,11 @@ if self.recover { self.push_diagnostic( ErrorSeverity::Error, @@ -517,7 +542,7 @@ ); } } -@@ -623,16 +714,16 @@ +@@ -623,16 +724,16 @@ let ch = self .peek_char() .ok_or_else(|| self.fatal("unexpected end of input"))?; @@ -538,7 +563,7 @@ } } Ok(ch) -@@ -640,31 +731,46 @@ +@@ -640,31 +741,46 @@ // -- Bulk scanning -- @@ -599,7 +624,7 @@ } /// Scans forward to find the next attribute value delimiter: the given -@@ -672,22 +778,16 @@ +@@ -672,22 +788,16 @@ /// Returns the number of safe bytes before the delimiter. /// Avoids the 256-byte lookup table of `scan_until_any`. #[inline] @@ -627,7 +652,7 @@ } /// Scans forward to find the next occurrence of any of the given marker -@@ -696,63 +796,72 @@ +@@ -696,63 +806,72 @@ /// Uses a 256-byte lookup table for O(1) per-byte matching instead of /// linear `contains()` on the marker slice. #[allow(dead_code)] @@ -727,7 +752,7 @@ let end = self.pos + count; let slice = &self.input[self.pos..end]; // Fast path: if no newlines in the slice, just bump column -@@ -766,22 +875,21 @@ +@@ -766,22 +885,21 @@ } } } else { @@ -755,7 +780,7 @@ "expected '{}', found '{}'", expected as char, b as char ))); -@@ -791,12 +899,13 @@ +@@ -791,12 +909,13 @@ /// Consumes bytes and asserts they match the `expected` sequence. #[inline] @@ -771,7 +796,7 @@ } else { // Fall back to per-byte for precise error reporting for &b in expected { -@@ -828,51 +937,30 @@ +@@ -828,51 +947,30 @@ /// Skips whitespace characters. Returns `true` if any were consumed. #[inline] @@ -836,7 +861,7 @@ return Err(self.fatal("whitespace required")); } Ok(()) -@@ -880,23 +968,18 @@ +@@ -880,23 +978,18 @@ // -- Take while -- @@ -869,7 +894,7 @@ } // -- Name parsing (XML 1.0 §2.3) -- -@@ -910,7 +993,16 @@ +@@ -910,7 +1003,16 @@ /// Uses an ASCII fast path that scans name characters as bytes, /// avoiding per-character UTF-8 decoding for the common case. #[inline] @@ -887,7 +912,7 @@ let start = self.pos; if self.pos >= self.input.len() { return Err(self.fatal("expected name, found end of input")); -@@ -919,26 +1011,35 @@ +@@ -919,26 +1021,35 @@ let first = self.input[self.pos]; // ASCII fast path: most XML names are pure ASCII @@ -923,15 +948,16 @@ } // Input is guaranteed valid UTF-8 and we only consumed ASCII bytes - let name = std::str::from_utf8(&self.input[start..self.pos]) -+ self.storage.charge(len)?; -+ let name = core::str::from_utf8(&self.input[start..self.pos]) - .map_err(|_| self.fatal("invalid UTF-8 in name"))?; +- .map_err(|_| self.fatal("invalid UTF-8 in name"))?; - return Ok(name.to_string()); -+ return Ok(name); ++ self.storage.charge(len)?; ++ return self ++ .text(start, self.pos) ++ .ok_or_else(|| self.fatal("invalid UTF-8 in name")); } // Fall through: hit a non-ASCII continuation byte, continue // with the char-by-char path below. -@@ -948,16 +1049,22 @@ +@@ -948,16 +1059,22 @@ let ch = self .peek_char() .ok_or_else(|| self.fatal("expected name"))?; @@ -959,7 +985,7 @@ } else { break; } -@@ -965,90 +1072,32 @@ +@@ -965,90 +1082,31 @@ let len = self.pos - start; if len > self.max_name_length { @@ -971,11 +997,11 @@ } - let name = std::str::from_utf8(&self.input[start..self.pos]) -+ self.storage.charge(len)?; -+ let name = core::str::from_utf8(&self.input[start..self.pos]) - .map_err(|_| self.fatal("invalid UTF-8 in name"))?; +- .map_err(|_| self.fatal("invalid UTF-8 in name"))?; - Ok(name.to_string()) -+ Ok(name) ++ self.storage.charge(len)?; ++ self.text(start, self.pos) ++ .ok_or_else(|| self.fatal("invalid UTF-8 in name")) } /// Parses a name from the input and checks whether it matches `expected`. @@ -1061,7 +1087,7 @@ } /// Parses a name and checks whether it matches the given prefix + local -@@ -1060,111 +1109,46 @@ +@@ -1060,111 +1118,46 @@ &mut self, prefix: Option<&str>, local: &str, @@ -1193,7 +1219,7 @@ /// Parses an entity or character reference (`&...;`). /// /// Handles the five built-in XML entities (`amp`, `lt`, `gt`, `apos`, -@@ -1175,8 +1159,8 @@ +@@ -1175,8 +1168,8 @@ /// Increments the entity expansion counter and returns an error if the /// limit is exceeded. #[cfg(test)] @@ -1204,7 +1230,7 @@ self.parse_reference_into(&mut buf)?; Ok(buf) } -@@ -1190,34 +1174,47 @@ +@@ -1190,34 +1183,47 @@ /// /// Returns the resolved text as a `&str` slice of `buf` (the portion /// that was appended), which callers can use for validation. @@ -1262,7 +1288,7 @@ if dec.is_empty() { return Err(self.fatal("empty decimal character reference")); } -@@ -1226,68 +1223,191 @@ +@@ -1226,68 +1232,191 @@ }; self.expect_byte(b';')?; @@ -1503,7 +1529,7 @@ } } -@@ -1295,196 +1415,128 @@ +@@ -1295,196 +1424,128 @@ /// /// Per XML 1.0 §4.4, when an entity's replacement text is included, /// character references and entity references within it are resolved. @@ -1802,7 +1828,7 @@ // -- Attribute value parsing (XML 1.0 §3.3.3) -- /// Parses a quoted attribute value with entity resolution and -@@ -1493,19 +1545,23 @@ +@@ -1493,20 +1554,25 @@ /// Uses bulk scanning to find the next `&`, `<`, or quote character, /// then extracts safe chunks with a single `push_str()` instead of /// processing character by character. @@ -1822,15 +1848,18 @@ if safe_len > 0 { let start = self.pos; - let chunk = std::str::from_utf8(&self.input[start..start + safe_len]) +- .map_err(|_| self.fatal("invalid UTF-8 in attribute value"))?; + self.storage.charge(safe_len)?; + self.storage.charge(safe_len)?; + self.storage.charge(safe_len)?; + self.storage.charge(safe_len)?; -+ let chunk = core::str::from_utf8(&self.input[start..start + safe_len]) - .map_err(|_| self.fatal("invalid UTF-8 in attribute value"))?; ++ let chunk = self ++ .text(start, start + safe_len) ++ .ok_or_else(|| self.fatal("invalid UTF-8 in attribute value"))?; // Fast byte-level pre-check: only validate when bytes suggest // possible invalid chars (0x7F or U+FFFE/U+FFFF sequences). -@@ -1516,30 +1572,18 @@ + if let Some(bad) = may_contain_invalid_xml_chars(chunk.as_bytes()) +@@ -1516,30 +1582,18 @@ if self.recover { self.push_diagnostic( ErrorSeverity::Error, @@ -1868,7 +1897,7 @@ } if self.at_end() { -@@ -1548,57 +1592,45 @@ +@@ -1548,57 +1602,45 @@ let b = self.input[self.pos]; if b == quote { @@ -1941,7 +1970,7 @@ self.expect_byte(quote)?; Ok(value) } -@@ -1606,21 +1638,44 @@ +@@ -1606,21 +1648,44 @@ // -- Error helpers -- /// Creates a fatal `ParseError` at the current location. @@ -1994,7 +2023,7 @@ } } -@@ -1630,100 +1685,111 @@ +@@ -1630,100 +1695,168 @@ /// Manages namespace scope for XML parsers. /// @@ -2017,10 +2046,17 @@ +pub(crate) struct NamespaceResolver { + stack: S::Values>>, + default_ns: Option, ++ /// Bindings while they are few, compared without hashing. Beyond ++ /// [`SHORT_PREFIXES`] they all move to `prefixed_ns` for constant-time lookups. ++ short_prefixes: S::Values<(S::Text, S::Text)>, + prefixed_ns: S::Names, ++ spilled: bool, + storage: S, +} + ++/// Documents rarely bind more prefixes at once than this. ++const SHORT_PREFIXES: usize = 8; ++ +enum NamespaceUndo { + Default(Option), + Prefix(S::Text, Option), @@ -2050,7 +2086,9 @@ default_ns: None, - prefixed_ns, - } ++ short_prefixes: storage.values(0)?, + prefixed_ns: storage.names(), ++ spilled: false, + storage, + }; + resolver.push_scope()?; @@ -2093,15 +2131,61 @@ - self.prefixed_ns.insert(pfx.clone(), prev_uri); + NamespaceUndo::Prefix(prefix, previous) => { + if let Some(uri) = previous { -+ self.prefixed_ns.insert(prefix, uri)?; ++ self.store_prefix(prefix, uri)?; } else { - self.prefixed_ns.remove(pfx); -+ self.prefixed_ns.remove(prefix.as_ref(), self.storage)?; ++ self.remove_prefix(prefix.as_ref())?; } } } } } ++ Ok(()) ++ } ++ ++ /// The position of `prefix` among the short bindings. ++ fn short_position(&self, prefix: &str) -> Result, crate::xml::dom::ParseError> { ++ for (index, (key, _)) in self.short_prefixes.iter().enumerate() { ++ self.storage.charge(1 + prefix.len().min(key.len()))?; ++ if key.as_ref() == prefix { ++ return Ok(Some(index)); ++ } ++ } ++ Ok(None) ++ } ++ ++ fn store_prefix( ++ &mut self, ++ prefix: S::Text, ++ uri: S::Text, ++ ) -> Result, crate::xml::dom::ParseError> { ++ if self.spilled { ++ return self.prefixed_ns.insert(prefix, uri); ++ } ++ if let Some(index) = self.short_position(prefix.as_ref())? { ++ return Ok(Some(core::mem::replace( ++ &mut self.short_prefixes[index].1, ++ uri, ++ ))); ++ } ++ self.short_prefixes.push((prefix, uri))?; ++ if self.short_prefixes.len() > SHORT_PREFIXES { ++ while let Some((key, value)) = self.short_prefixes.pop() { ++ self.prefixed_ns.insert(key, value)?; ++ } ++ self.spilled = true; ++ } ++ Ok(None) ++ } ++ ++ fn remove_prefix(&mut self, prefix: &str) -> Result<(), crate::xml::dom::ParseError> { ++ if self.spilled { ++ self.prefixed_ns.remove(prefix, self.storage)?; ++ } else if let Some(index) = self.short_position(prefix)? { ++ let last = self.short_prefixes.len() - 1; ++ self.short_prefixes.swap(index, last); ++ self.short_prefixes.pop(); ++ } + Ok(()) } @@ -2130,7 +2214,7 @@ + // The cache owns its key; the undo frame needs only that key, + // while the previous (possibly large) URI moves without a copy. + let key = self.storage.text(prefix.as_ref())?; -+ let previous = self.prefixed_ns.insert(prefix, uri)?; ++ let previous = self.store_prefix(prefix, uri)?; + NamespaceUndo::Prefix(key, previous) } + }; @@ -2145,26 +2229,32 @@ /// Use `prefix = None` to resolve the default namespace. - pub fn resolve(&self, prefix: Option<&str>) -> Option<&str> { - match prefix { +- None => self.default_ns.as_deref().filter(|s| !s.is_empty()), +- Some(pfx) => self +- .prefixed_ns +- .get(pfx) +- .map(String::as_str) +- .filter(|s| !s.is_empty()), +- } + pub fn resolve( + &self, + prefix: Option<&str>, + ) -> Result, crate::xml::dom::ParseError> { + self.storage.charge(1)?; -+ Ok(match prefix { - None => self.default_ns.as_deref().filter(|s| !s.is_empty()), - Some(pfx) => self - .prefixed_ns -- .get(pfx) -- .map(String::as_str) -+ .get(pfx, self.storage)? -+ .map(AsRef::as_ref) - .filter(|s| !s.is_empty()), -- } -+ }) ++ let Some(prefix) = prefix else { ++ return Ok(self.default_ns.as_deref().filter(|s| !s.is_empty())); ++ }; ++ let uri = if self.spilled { ++ self.prefixed_ns.get(prefix, self.storage)? ++ } else { ++ self.short_position(prefix)? ++ .map(|index| &self.short_prefixes[index].1) ++ }; ++ Ok(uri.map(AsRef::as_ref).filter(|s| !s.is_empty())) } } -@@ -1736,54 +1802,56 @@ +@@ -1736,54 +1869,56 @@ /// The opening `"; let doc = parse(input); @@ -6242,7 +6390,7 @@ assert_eq!(output, format!("{DECL}{input}\n")); } -@@ -1694,7 +1731,7 @@ +@@ -1694,7 +1784,7 @@ fn test_roundtrip_cdata() { let input = ""; let doc = parse(input); @@ -6251,7 +6399,7 @@ assert_eq!(output, format!("{DECL}{input}\n")); } -@@ -1702,7 +1739,7 @@ +@@ -1702,7 +1792,7 @@ fn test_roundtrip_pi() { let input = ""; let doc = parse(input); @@ -6260,7 +6408,7 @@ assert_eq!(output, format!("{DECL}{input}\n")); } -@@ -1710,7 +1747,7 @@ +@@ -1710,7 +1800,7 @@ fn test_roundtrip_xml_declaration() { let input = ""; let doc = parse(input); @@ -6269,7 +6417,7 @@ assert_eq!( output, "\n\n" -@@ -1898,13 +1935,13 @@ +@@ -1898,13 +1988,13 @@ // Simple DOCTYPE (no whitespace between DOCTYPE and root in input) let input = ""; let doc = parse(input); @@ -6285,7 +6433,7 @@ assert_eq!( output, format!("{DECL}\n") -@@ -1914,7 +1951,7 @@ +@@ -1914,7 +2004,7 @@ let input = ""; let doc = parse(input); @@ -6294,7 +6442,7 @@ assert_eq!( output, format!( -@@ -2061,7 +2098,7 @@ +@@ -2061,7 +2151,7 @@ let element = doc.first_child(entity_ref).unwrap(); assert_eq!(doc.node_name(element), Some("b")); // Serialization re-emits the entity reference, preserving roundtrip. @@ -6303,7 +6451,7 @@ assert!( out.contains("&e;"), "unexpected serialization: {out}" -@@ -2069,6 +2106,20 @@ +@@ -2069,6 +2159,20 @@ } #[test] @@ -6324,7 +6472,7 @@ fn test_parse_entity_charref_markup() { // "<b>hi</b>" expands to "hi" at declaration time // (§4.5) and must be parsed as markup at reference time. -@@ -2086,7 +2137,7 @@ +@@ -2086,7 +2190,7 @@ // inside the entity expansion. let input = "hi\">]>&e;"; let doc = parse(input); @@ -6333,7 +6481,7 @@ let doc2 = parse(&out); let root2 = doc2.root_element().unwrap(); assert_eq!(doc2.text_content(root2), "hi"); -@@ -2144,9 +2195,9 @@ +@@ -2144,9 +2248,9 @@ panic!("expected depth-limited parse to fail"); }; assert!( @@ -6345,7 +6493,7 @@ ); } -@@ -2169,9 +2220,10 @@ +@@ -2169,9 +2273,10 @@ panic!("expected amplification attack to be rejected"); }; assert!( diff --git a/src/c14n/ns_common.rs b/src/c14n/ns_common.rs index d55c51d2..d2a3d528 100644 --- a/src/c14n/ns_common.rs +++ b/src/c14n/ns_common.rs @@ -11,7 +11,7 @@ use crate::xml::dom::storage::Storage as DomStorage; use super::C14nError; use super::NodeVisibility; use super::prefix::has_in_scope_default_namespace; -use super::scratch::{List, NamespaceMap, NamespaceRendering, Scratch}; +use super::scratch::{List, NamespaceMap, NamespaceRendering, Scratch, changed}; /// Collect, filter, and sort namespace declarations for a single element. /// @@ -33,7 +33,7 @@ use super::scratch::{List, NamespaceMap, NamespaceRendering, Scratch}; /// # Returns /// /// `(sorted_ns_decls, updated_rendered_map)` — the declarations to emit and -/// the binding map to pass to child elements. +/// the binding map for child elements, `None` when it is `parent_rendered`. pub(super) fn collect_ns_declarations<'a, S: Scratch, D: DomStorage>( node: Node<'a, '_, D>, parent_rendered: &S::Namespaces<'a>, @@ -42,7 +42,7 @@ pub(super) fn collect_ns_declarations<'a, S: Scratch, D: DomStorage>( include_prefix: impl Fn(&str, &str) -> Result, scratch: &S, ) -> Result, C14nError> { - let mut rendered = parent_rendered.copy()?; + let mut rendered = None; let mut decls = scratch.values(); for (ordinal, ns) in node.namespaces().enumerate() { @@ -70,7 +70,7 @@ pub(super) fn collect_ns_declarations<'a, S: Scratch, D: DomStorage>( { decls.push(("", ""))?; } - rendered.remove(prefix)?; + changed(&mut rendered, parent_rendered)?.remove(prefix)?; } continue; } @@ -114,7 +114,7 @@ pub(super) fn collect_ns_declarations<'a, S: Scratch, D: DomStorage>( // Update the rendered map for child elements. for &(prefix, uri) in decls.iter() { - rendered.insert(prefix, false, Some(uri))?; + changed(&mut rendered, parent_rendered)?.insert(prefix, false, Some(uri))?; } Ok((decls, rendered)) @@ -156,6 +156,7 @@ mod tests { .map(|(p, u)| (p.to_owned(), u.to_owned())) .collect(), rendered + .unwrap_or(namespaces) .into_pairs() .map(|(p, u)| (p.to_owned(), u.to_owned())) .collect(), diff --git a/src/c14n/ns_exclusive.rs b/src/c14n/ns_exclusive.rs index 04e637f6..60fc582b 100644 --- a/src/c14n/ns_exclusive.rs +++ b/src/c14n/ns_exclusive.rs @@ -12,7 +12,7 @@ use super::C14nError; use super::NodeVisibility; use super::ns_common::collect_ns_declarations; use super::prefix::{attribute_prefix, element_prefix}; -use super::scratch::{List, NamespaceMap, NamespaceRendering, Scratch}; +use super::scratch::{List, NamespaceMap, NamespaceRendering, NamespaceState, Scratch, changed}; use super::serialize::NsRenderer; /// Exclusive C14N namespace renderer. @@ -123,7 +123,7 @@ impl NsRenderer for ExclusiveNsRenderer<'_> { && declarations.iter().any(|(declared, _)| *declared == prefix)) ); let nearest_utilizer_omitted_namespace = - parent_rendered.get(prefix, true)? == super::scratch::NamespaceState::Excluded; + parent_rendered.get(prefix, true)? == NamespaceState::Excluded; let uses_exclusive_rendering = utilized.get(prefix, false)?.is_present() && !self.inclusive_prefixes.contains(prefix); @@ -143,7 +143,11 @@ impl NsRenderer for ExclusiveNsRenderer<'_> { // nearest output ancestor that visibly utilized its prefix, // not merely with the physically effective output binding. if uses_exclusive_rendering { - rendered.insert(prefix, true, selected_here.then_some(uri))?; + let value = selected_here.then_some(uri); + let current = rendered.as_ref().unwrap_or(parent_rendered); + if current.get(prefix, true)? != NamespaceState::binding(value) { + changed(&mut rendered, parent_rendered)?.insert(prefix, true, value)?; + } } } } diff --git a/src/c14n/scratch.rs b/src/c14n/scratch.rs index 03bb190d..e72f4df9 100644 --- a/src/c14n/scratch.rs +++ b/src/c14n/scratch.rs @@ -21,7 +21,7 @@ pub(crate) enum NamespaceState<'a> { } impl<'a> NamespaceState<'a> { - fn binding(entry: Option<&'a str>) -> Self { + pub(crate) fn binding(entry: Option<&'a str>) -> Self { match entry { None => Self::Excluded, Some(uri) => Self::Bound(uri), @@ -163,11 +163,24 @@ pub(crate) trait Scratch { fn text(&self, parts: &[&str]) -> Result; } +/// An element's declarations, and the map its children read when it differs +/// from the parent's. Most elements change nothing, so most copy nothing. pub(crate) type NamespaceRendering<'a, S> = ( ::Values<(&'a str, &'a str)>, - ::Namespaces<'a>, + Option<::Namespaces<'a>>, ); +/// `rendered`, copied from `parent` before its first change. +pub(crate) fn changed<'m, 'a, M: NamespaceMap<'a>>( + rendered: &'m mut Option, + parent: &M, +) -> Result<&'m mut M, C14nError> { + if rendered.is_none() { + *rendered = Some(parent.copy()?); + } + Ok(rendered.as_mut().expect("assigned above")) +} + pub(crate) struct Native; impl List for Vec { diff --git a/src/c14n/serialize.rs b/src/c14n/serialize.rs index 1db00b8d..39333068 100644 --- a/src/c14n/serialize.rs +++ b/src/c14n/serialize.rs @@ -34,7 +34,15 @@ const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; type TrackedPositions = Vec<(NodeId, usize)>; -struct CanonicalOutput<'a, W: ByteSink + ?Sized, S: Scratch = Native> { +/// One attribute of a start tag: namespace URI, local name, prefix and value. +type StartTagAttribute<'n, S> = ( + &'n str, + &'n str, + &'n str, + AttributeValue<'n, ::Text>, +); + +struct CanonicalOutput<'a, 'n, W: ByteSink + ?Sized, S: Scratch = Native> { writer: &'a mut W, position: usize, max_len: usize, @@ -46,6 +54,9 @@ struct CanonicalOutput<'a, W: ByteSink + ?Sized, S: Scratch = Native> { tracked_positions: Vec<(NodeId, usize)>, xml_base_resolution: &'a XmlBaseResolutionBudget, scratch: &'a S, + /// Start-tag working space, empty between tags. One start tag finishes + /// before the next begins, so one capacity serves the whole walk. + attributes: S::Values>, } enum AttributeValue<'a, T: Text> { @@ -61,13 +72,13 @@ impl AttributeValue<'_, T> { } } -impl ByteSink for CanonicalOutput<'_, W, S> { +impl ByteSink for CanonicalOutput<'_, '_, W, S> { fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), C14nError> { self.write_all(bytes) } } -impl CanonicalOutput<'_, W, S> { +impl CanonicalOutput<'_, '_, W, S> { fn write_all(&mut self, bytes: &[u8]) -> Result<(), C14nError> { debug_assert!(self.position <= self.max_len); if bytes.len() > self.max_len - self.position { @@ -174,7 +185,8 @@ impl<'a> CanonicalOutputOptions<'a> { /// Trait for namespace rendering strategies (inclusive vs exclusive). pub(crate) trait NsRenderer { /// Compute namespace declarations to emit for this element. - /// Returns `(sorted_ns_decls, updated_rendered_map)`. + /// Returns `(sorted_ns_decls, updated_rendered_map)`, the map `None` when + /// children read `parent_rendered` unchanged. /// /// `parent_rendered` maps prefix → URI for what the nearest output ancestor /// already declared in the canonical form. @@ -389,6 +401,7 @@ pub(super) fn serialize_with_scratch( ns_renderer: &dyn NsRenderer, config: C14nConfig, parent_rendered: &S::Namespaces<'n>, - output: &mut CanonicalOutput<'_, W, S>, + output: &mut CanonicalOutput<'_, 'n, W, S>, ) -> Result<(), C14nError> { let is_doc_root = parent.node_type() == NodeType::Root; @@ -537,7 +550,7 @@ fn serialize_selected_axes_of_omitted_element< visibility: Option<&dyn NodeVisibility>, ns_renderer: &dyn NsRenderer, parent_rendered: &S::Namespaces<'n>, - output: &mut CanonicalOutput<'_, W, S>, + output: &mut CanonicalOutput<'_, 'n, W, S>, ) -> Result<(), C14nError> { let Some(visibility) = visibility else { return Ok(()); @@ -621,7 +634,7 @@ fn serialize_element<'n, W: ByteSink + ?Sized, S: Scratch, D: DomStorage>( ns_renderer: &dyn NsRenderer, config: C14nConfig, parent_rendered: &S::Namespaces<'n>, - output: &mut CanonicalOutput<'_, W, S>, + output: &mut CanonicalOutput<'_, 'n, W, S>, ) -> Result<(), C14nError> { let rendered = serialize_start_tag( node, @@ -637,7 +650,7 @@ fn serialize_element<'n, W: ByteSink + ?Sized, S: Scratch, D: DomStorage>( with_comments, ns_renderer, config, - &rendered, + rendered.as_ref().unwrap_or(parent_rendered), output, )?; output.write_all(b"( ns_renderer: &dyn NsRenderer, config: C14nConfig, parent_rendered: &S::Namespaces<'n>, - output: &mut CanonicalOutput<'_, W, S>, -) -> Result, C14nError> { + output: &mut CanonicalOutput<'_, 'n, W, S>, +) -> Result>, C14nError> { let (ns_decls, rendered) = ns_renderer.render_namespaces(node, parent_rendered, visibility, output.scratch)?; @@ -702,7 +715,7 @@ fn serialize_start_tag<'n, W: ByteSink + ?Sized, S: Scratch, D: DomStorage>( // Build unified list: (ns_uri, local_name, prefix, value) // Using Cow to avoid allocations when no fixup is needed. - let mut all_attrs = output.scratch.values(); + let mut all_attrs = core::mem::replace(&mut output.attributes, output.scratch.values()); for (ordinal, attr) in node.attributes().enumerate() { output.scratch.charge(attr.name().len().saturating_add(1))?; if let Some(set) = visibility @@ -743,7 +756,7 @@ fn serialize_start_tag<'n, W: ByteSink + ?Sized, S: Scratch, D: DomStorage>( let resolved_value = if config.fixup_xml_base && name == "base" { // C14N 1.1: inherited xml:base uses the resolved effective value match effective_parent_base.as_ref() { - Some(base) => AttributeValue::Borrowed(base.view()?), + Some(base) => AttributeValue::Owned(output.scratch.text(&[base.view()?])?), None => AttributeValue::Borrowed(value), } } else { @@ -778,6 +791,8 @@ fn serialize_start_tag<'n, W: ByteSink + ?Sized, S: Scratch, D: DomStorage>( // Always use form, never self-closing. output.write_all(b">")?; + while all_attrs.pop().is_some() {} + output.attributes = all_attrs; Ok(rendered) } @@ -785,7 +800,7 @@ fn effective_parent_base( node: Node<'_, '_, D>, visibility: Option<&dyn NodeVisibility>, config: C14nConfig, - output: &mut CanonicalOutput<'_, W, S>, + output: &mut CanonicalOutput<'_, '_, W, S>, ) -> Result, C14nError> { let parent_not_in_set = if let Some(set) = visibility { match node.parent().filter(Node::is_element) { @@ -829,7 +844,7 @@ pub(super) fn map_xml_base_error(error: XmlBaseResolutionError) -> C14nError { fn write_doc_level_prefix( is_doc_root: bool, follows_document_element: bool, - output: &mut CanonicalOutput<'_, W, S>, + output: &mut CanonicalOutput<'_, '_, W, S>, ) -> Result<(), C14nError> { if is_doc_root && follows_document_element { output.write_all(b"\n")?; @@ -841,7 +856,7 @@ fn write_doc_level_prefix( fn write_doc_level_suffix( is_doc_root: bool, follows_document_element: bool, - output: &mut CanonicalOutput<'_, W, S>, + output: &mut CanonicalOutput<'_, '_, W, S>, ) -> Result<(), C14nError> { if is_doc_root && !follows_document_element { output.write_all(b"\n")?; @@ -961,7 +976,7 @@ fn collect_inherited_xml_attrs<'a, S: Scratch, D: DomStorage>( /// avoiding ambiguity when multiple prefixes bind the same namespace URI. fn write_qualified_name( node: Node<'_, '_, D>, - output: &mut CanonicalOutput<'_, W, S>, + output: &mut CanonicalOutput<'_, '_, W, S>, ) -> Result<(), C14nError> { let prefix = element_prefix(node); if !prefix.is_empty() { diff --git a/src/document.rs b/src/document.rs index 103d9e45..656e79df 100644 --- a/src/document.rs +++ b/src/document.rs @@ -272,6 +272,20 @@ impl XmlParseWorkBudget { Ok(()) } + /// Refuse work the remaining allowance cannot hold, charging nothing: the + /// parse that follows charges it, once, for the bytes it really reads. + #[cfg(feature = "xmldsig-sign")] + pub(crate) fn admit(&self, bytes: usize) -> Result<(), crate::policy::PolicyViolation> { + if bytes > self.remaining.get() { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES, + maximum: self.maximum, + actual: self.consumed().saturating_add(bytes), + }); + } + Ok(()) + } + #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) fn charge(&self, bytes: usize) -> Result<(), XmlDocumentError> { self.charge_policy(bytes).map_err(Into::into) @@ -675,6 +689,74 @@ pub struct XmlDocument { requires_internal_dtd: bool, settings: DocumentParseSettings, cell: DocumentCell, + #[cfg(feature = "xmldsig-sign")] + atomic: AtomicMutation, +} + +/// The generation an all-or-nothing mutation started from, kept from its first +/// commit on, so that no copy of the document is parsed in advance. +#[cfg(feature = "xmldsig-sign")] +enum AtomicMutation { + /// No atomic mutation is running. + Idle, + /// One is running and has not committed yet. + Started, + /// One is running; this is the cell and generation to return to. + Replaced(DocumentCell, u64), +} + +/// The serialization with source ranges already replaced in text. +#[cfg(feature = "xmldsig-sign")] +pub(crate) struct SplicedElement { + /// The whole document with the new content. + pub(crate) xml: String, + /// Each replaced source range with its new length, in document order. + pub(crate) spans: Vec<(core::ops::Range, usize)>, + /// Upper bound of the nodes the splice added. + pub(crate) added_nodes: usize, + /// The deepest level the new content reaches. + pub(crate) depth: usize, + /// The content adds no ID and no namespace scope the parsed fragment did not + /// have, so it may join the final mutation unparsed. + pub(crate) inert: bool, +} + +#[cfg(feature = "xmldsig-sign")] +impl SplicedElement { + /// `xml` with `original` of a source of `source_len` bytes replaced; the + /// source around that range is unchanged. + pub(crate) fn replacing( + source_len: usize, + original: core::ops::Range, + xml: String, + (added_nodes, depth, inert): (usize, usize, bool), + ) -> Self { + let replaced = original.len() + xml.len() - source_len; + Self { + xml, + spans: alloc::vec![(original, replaced)], + added_nodes, + depth, + inert, + } + } +} + +fn render_content_replacement_edits( + source: &str, + edits: &[ContentReplacementEdit<'_>], + projected: usize, +) -> String { + let mut output = String::with_capacity(projected); + let mut cursor = 0; + for edit in edits { + output.push_str(&source[cursor..edit.range.start]); + edit.write_output(source, &mut output); + cursor = edit.range.end; + } + output.push_str(&source[cursor..]); + debug_assert_eq!(output.len(), projected); + output } #[cfg(feature = "xmlenc")] @@ -893,6 +975,8 @@ impl XmlDocument { requires_internal_dtd, settings, cell, + #[cfg(feature = "xmldsig-sign")] + atomic: AtomicMutation::Idle, }) } @@ -982,13 +1066,26 @@ impl XmlDocument { self.cell.into_owner() } + /// Make the following commits one all-or-nothing mutation. #[cfg(feature = "xmldsig-sign")] - pub(crate) fn staged_copy_with_budget( - &self, - settings: DocumentParseSettings, - budget: &XmlParseWorkBudget, - ) -> Result { - Self::parse_with_settings_and_budget(self.as_xml().to_owned(), settings, budget) + pub(crate) fn begin_atomic_mutation(&mut self) { + self.atomic = AtomicMutation::Started; + } + + /// End the mutation: success leaves one new generation, failure leaves the + /// starting serialization and generation exactly as they were. + #[cfg(feature = "xmldsig-sign")] + pub(crate) fn finish_atomic_mutation(&mut self, succeeded: bool) { + match core::mem::replace(&mut self.atomic, AtomicMutation::Idle) { + AtomicMutation::Replaced(_, generation) if succeeded => { + self.generation = generation + 1; + } + AtomicMutation::Replaced(cell, generation) => { + self.cell = cell; + self.generation = generation; + } + AtomicMutation::Idle | AtomicMutation::Started => {} + } } /// Parser work spent on the current generation and every earlier one. @@ -997,14 +1094,6 @@ impl XmlDocument { recorded_work(&self.cell) } - #[cfg(feature = "xmldsig-sign")] - pub(crate) fn commit_staged(&mut self, staged: Self) -> Result<(), XmlDocumentError> { - // Signing mutates and validates every staged generation under the active - // policy. Moving that retained cell preserves atomicity without parsing - // the complete signed document once more merely to commit it. - self.commit_cell(staged.cell) - } - /// Borrow the retained parsed view without reparsing. pub fn with_view(&self, operation: impl for<'a> FnOnce(DocumentView<'a>) -> R) -> R { self.cell.with_dependent(|_, parsed| { @@ -1365,40 +1454,93 @@ impl XmlDocument { validate_base64_replacements(replacements)?; let (edits, projected) = self.prepare_content_replacement_edits(replacements, maximum_bytes)?; - Ok(self.render_content_replacement_edits(&edits, projected)) + Ok(render_content_replacement_edits( + self.as_xml(), + &edits, + projected, + )) + } + + /// Apply the node and depth ceilings of the final generation before anything + /// is computed for it. Each of `targets` receives one text node. With a + /// splice the node count is an upper bound: a reused placeholder counts as + /// added. + #[cfg(feature = "xmldsig-sign")] + pub(crate) fn validate_commit_limits( + &self, + targets: impl Iterator, + spliced: Option<&SplicedElement>, + settings: DocumentParseSettings, + ) -> Result<(), XmlDocumentError> { + let (added_nodes, depth) = + spliced.map_or((0, 0), |spliced| (spliced.added_nodes, spliced.depth)); + if depth > settings.depth_limit { + return Err(XmlDocumentError::DocumentTooDeep { + maximum: settings.depth_limit, + actual: depth, + }); + } + let projected_nodes = self.with_view(|view| { + let mut count = view.node_count() + added_nodes; + for target in targets { + // The target's descendants (itself included) are part of `count`. + count -= view.resolve_mutation_node(target)?.descendants().count() - 1; + count += 1; + } + Ok::<_, XmlDocumentError>(count) + })?; + if projected_nodes > settings.nodes_limit as usize { + return Err(XmlDocumentError::ProjectedNodeLimit { + maximum: settings.nodes_limit as usize, + }); + } + Ok(()) } /// Serialize the generation that committing these base64 replacements under /// `settings` would produce, without parsing it. Base64 character data cannot - /// change element structure, so the commit parse would only recheck the - /// projected size and node count, which are checked here instead. + /// change element structure, and `validate_commit_limits` has already applied + /// what the commit parse would recheck. #[cfg(feature = "xmldsig-sign")] pub(crate) fn project_base64_commit( &self, replacements: &[(NodeIdentity, String)], settings: DocumentParseSettings, + spliced: Option<&SplicedElement>, ) -> Result { validate_base64_replacements(replacements)?; - let (edits, projected) = + let (mut edits, mut projected) = self.prepare_content_replacement_edits(replacements, settings.max_bytes)?; - let projected_nodes = self.with_view(|view| { - replacements - .iter() - .try_fold(view.node_count(), |count, (target, replacement)| { - // The content becomes exactly one text node, or none when empty. The - // target's descendants (itself included) are part of `count`. - let replaced = view.resolve_mutation_node(*target)?.descendants().count() - 1; - Ok::<_, XmlDocumentError>( - count - replaced + usize::from(!replacement.is_empty()), - ) - }) - })?; - if projected_nodes > settings.nodes_limit as usize { - return Err(XmlDocumentError::ProjectedNodeLimit { - maximum: settings.nodes_limit as usize, - }); + let mut source = self.as_xml(); + if let Some(spliced) = spliced { + source = &spliced.xml; + // Everything after a span moved with it; nothing inside one is + // replaced, as no DigestValue or SignatureValue lives there. + for edit in &mut edits { + let (mut grown, mut shrunk) = (0, 0); + for (span, length) in &spliced.spans { + if edit.range.start >= span.end { + grown += *length; + shrunk += span.len(); + } else if edit.range.end > span.start { + return Err(XmlDocumentError::InvalidReplacement( + "replacement target lies inside a spliced range".into(), + )); + } + } + // Every counted span lies before the edit, so this stays in range. + edit.range = edit.range.start + grown - shrunk..edit.range.end + grown - shrunk; + } + // Replacements may have shortened the document: add before subtracting. + projected = projected + spliced.xml.len() - self.as_xml().len(); + if projected > settings.max_bytes { + return Err(XmlDocumentError::DocumentTooLarge { + maximum: settings.max_bytes, + actual: projected, + }); + } } - Ok(self.render_content_replacement_edits(&edits, projected)) + Ok(render_content_replacement_edits(source, &edits, projected)) } fn replace_contents_inner( @@ -1421,7 +1563,7 @@ impl XmlDocument { 0 }; - let output = self.render_content_replacement_edits(&edits, projected); + let output = render_content_replacement_edits(self.as_xml(), &edits, projected); if policy_bounded { self.replace_serialized_with_settings(output, settings, budget)?; } else { @@ -1507,23 +1649,6 @@ impl XmlDocument { Ok((edits, projected)) } - fn render_content_replacement_edits( - &self, - edits: &[ContentReplacementEdit<'_>], - projected: usize, - ) -> String { - let mut output = String::with_capacity(projected); - let mut cursor = 0; - for edit in edits { - output.push_str(&self.as_xml()[cursor..edit.range.start]); - edit.write_output(self.as_xml(), &mut output); - cursor = edit.range.end; - } - output.push_str(&self.as_xml()[cursor..]); - debug_assert_eq!(output.len(), projected); - output - } - fn validate_content_edits( &self, edits: &[ContentReplacementEdit<'_>], @@ -1726,7 +1851,13 @@ impl XmlDocument { XmlDocumentError::InvalidReplacement("document generation overflow".into()) })?; record_work(&mut next, recorded_work(&self.cell)); - self.cell = next; + let previous = core::mem::replace(&mut self.cell, next); + #[cfg(feature = "xmldsig-sign")] + if matches!(self.atomic, AtomicMutation::Started) { + self.atomic = AtomicMutation::Replaced(previous, self.generation); + } + #[cfg(not(feature = "xmldsig-sign"))] + drop(previous); self.generation = next_generation; Ok(()) } @@ -2957,13 +3088,18 @@ fn scan_preflight_event( 1, )); } - let end = remaining.find(['<', '&']).unwrap_or(remaining.len()); - let text = &remaining[..end]; + let bytes = remaining.as_bytes(); + let end = bytes + .iter() + .position(|byte| matches!(byte, b'<' | b'&')) + .unwrap_or(remaining.len()); Ok(( PreflightEvent::CharacterData { - xml_whitespace: text - .chars() - .all(|character| matches!(character, ' ' | '\t' | '\r' | '\n')), + // Only text outside the document element is judged by this. + xml_whitespace: state.depth == 0 + && bytes[..end] + .iter() + .all(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n')), }, end, )) @@ -2980,13 +3116,23 @@ fn scan_preflight_opening( return Ok((PreflightEvent::Done, remaining.len())); }; let opening = &remaining[..=end]; - let (element_name, attributes) = opening_tag_attributes(opening, storage)?; - let attribute_source = - element_attribute_reference_source(opening, element_name, &attributes, dtd)?; + // Attributes matter here for their namespace declarations and DTD defaults. + // A tag with neither has nothing to split, however many attributes it has. + let (attribute_source, namespace_declarations) = + if dtd.attribute_defaults.len() == 0 && !opening.contains("xmlns") { + ( + element_attribute_reference_source(opening, "", &[], dtd)?, + storage.values(0)?, + ) + } else { + let (element_name, attributes) = opening_tag_attributes(opening, storage)?; + ( + element_attribute_reference_source(opening, element_name, &attributes, dtd)?, + namespace_declarations(element_name, &attributes, dtd, state, budget)?, + ) + }; // The frame later revisits this attribute/reference window. storage.charge(attribute_source.len())?; - let namespace_declarations = - namespace_declarations(element_name, &attributes, dtd, state, budget)?; let event = if opening[..opening.len() - 1].trim_end().ends_with('/') { PreflightEvent::Empty { attribute_source, diff --git a/src/parser_xmloxide/parser/input.rs b/src/parser_xmloxide/parser/input.rs index 87727965..9cf87f0b 100644 --- a/src/parser_xmloxide/parser/input.rs +++ b/src/parser_xmloxide/parser/input.rs @@ -348,6 +348,9 @@ pub(crate) struct ParserInput<'a, S: NameStorage = Native> { /// The input bytes (must be valid UTF-8). input: &'a [u8], + /// The same input as text, whose slices need no second UTF-8 validation. + text: &'a str, + /// Current byte offset in `input`. pos: usize, @@ -424,6 +427,7 @@ impl<'a, S: NameStorage> ParserInput<'a, S> { Ok(Self { storage, input: input.as_bytes(), + text: input, pos: 0, line: 1, column: 1, @@ -542,6 +546,12 @@ impl<'a, S: NameStorage> ParserInput<'a, S> { self.pos } + /// The input from `start` to `end`, unless that splits a character. + #[inline] + pub fn text(&self, start: usize, end: usize) -> Option<&'a str> { + self.text.get(start..end) + } + /// Returns a slice of the raw input bytes from `start` to `end`. #[allow(dead_code)] #[inline] @@ -1037,9 +1047,9 @@ impl<'a, S: NameStorage> ParserInput<'a, S> { } // Input is guaranteed valid UTF-8 and we only consumed ASCII bytes self.storage.charge(len)?; - let name = core::str::from_utf8(&self.input[start..self.pos]) - .map_err(|_| self.fatal("invalid UTF-8 in name"))?; - return Ok(name); + return self + .text(start, self.pos) + .ok_or_else(|| self.fatal("invalid UTF-8 in name")); } // Fall through: hit a non-ASCII continuation byte, continue // with the char-by-char path below. @@ -1079,9 +1089,8 @@ impl<'a, S: NameStorage> ParserInput<'a, S> { } self.storage.charge(len)?; - let name = core::str::from_utf8(&self.input[start..self.pos]) - .map_err(|_| self.fatal("invalid UTF-8 in name"))?; - Ok(name) + self.text(start, self.pos) + .ok_or_else(|| self.fatal("invalid UTF-8 in name")) } /// Parses a name from the input and checks whether it matches `expected`. @@ -1561,8 +1570,9 @@ impl ParserInput<'_, S> { self.storage.charge(safe_len)?; self.storage.charge(safe_len)?; self.storage.charge(safe_len)?; - let chunk = core::str::from_utf8(&self.input[start..start + safe_len]) - .map_err(|_| self.fatal("invalid UTF-8 in attribute value"))?; + let chunk = self + .text(start, start + safe_len) + .ok_or_else(|| self.fatal("invalid UTF-8 in attribute value"))?; // Fast byte-level pre-check: only validate when bytes suggest // possible invalid chars (0x7F or U+FFFE/U+FFFF sequences). if let Some(bad) = may_contain_invalid_xml_chars(chunk.as_bytes()) @@ -1690,10 +1700,17 @@ impl<'a, S: NameStorage> ParserInput<'a, S> { pub(crate) struct NamespaceResolver { stack: S::Values>>, default_ns: Option, + /// Bindings while they are few, compared without hashing. Beyond + /// [`SHORT_PREFIXES`] they all move to `prefixed_ns` for constant-time lookups. + short_prefixes: S::Values<(S::Text, S::Text)>, prefixed_ns: S::Names, + spilled: bool, storage: S, } +/// Documents rarely bind more prefixes at once than this. +const SHORT_PREFIXES: usize = 8; + enum NamespaceUndo { Default(Option), Prefix(S::Text, Option), @@ -1714,7 +1731,9 @@ impl NamespaceResolver { let mut resolver = Self { stack: storage.values(0)?, default_ns: None, + short_prefixes: storage.values(0)?, prefixed_ns: storage.names(), + spilled: false, storage, }; resolver.push_scope()?; @@ -1738,9 +1757,9 @@ impl NamespaceResolver { } NamespaceUndo::Prefix(prefix, previous) => { if let Some(uri) = previous { - self.prefixed_ns.insert(prefix, uri)?; + self.store_prefix(prefix, uri)?; } else { - self.prefixed_ns.remove(prefix.as_ref(), self.storage)?; + self.remove_prefix(prefix.as_ref())?; } } } @@ -1749,6 +1768,52 @@ impl NamespaceResolver { Ok(()) } + /// The position of `prefix` among the short bindings. + fn short_position(&self, prefix: &str) -> Result, crate::xml::dom::ParseError> { + for (index, (key, _)) in self.short_prefixes.iter().enumerate() { + self.storage.charge(1 + prefix.len().min(key.len()))?; + if key.as_ref() == prefix { + return Ok(Some(index)); + } + } + Ok(None) + } + + fn store_prefix( + &mut self, + prefix: S::Text, + uri: S::Text, + ) -> Result, crate::xml::dom::ParseError> { + if self.spilled { + return self.prefixed_ns.insert(prefix, uri); + } + if let Some(index) = self.short_position(prefix.as_ref())? { + return Ok(Some(core::mem::replace( + &mut self.short_prefixes[index].1, + uri, + ))); + } + self.short_prefixes.push((prefix, uri))?; + if self.short_prefixes.len() > SHORT_PREFIXES { + while let Some((key, value)) = self.short_prefixes.pop() { + self.prefixed_ns.insert(key, value)?; + } + self.spilled = true; + } + Ok(None) + } + + fn remove_prefix(&mut self, prefix: &str) -> Result<(), crate::xml::dom::ParseError> { + if self.spilled { + self.prefixed_ns.remove(prefix, self.storage)?; + } else if let Some(index) = self.short_position(prefix)? { + let last = self.short_prefixes.len() - 1; + self.short_prefixes.swap(index, last); + self.short_prefixes.pop(); + } + Ok(()) + } + /// Binds a namespace prefix to a URI in the current scope. /// /// Use `prefix = None` for the default namespace (`xmlns="..."`). @@ -1764,7 +1829,7 @@ impl NamespaceResolver { // The cache owns its key; the undo frame needs only that key, // while the previous (possibly large) URI moves without a copy. let key = self.storage.text(prefix.as_ref())?; - let previous = self.prefixed_ns.insert(prefix, uri)?; + let previous = self.store_prefix(prefix, uri)?; NamespaceUndo::Prefix(key, previous) } }; @@ -1782,14 +1847,16 @@ impl NamespaceResolver { prefix: Option<&str>, ) -> Result, crate::xml::dom::ParseError> { self.storage.charge(1)?; - Ok(match prefix { - None => self.default_ns.as_deref().filter(|s| !s.is_empty()), - Some(pfx) => self - .prefixed_ns - .get(pfx, self.storage)? - .map(AsRef::as_ref) - .filter(|s| !s.is_empty()), - }) + let Some(prefix) = prefix else { + return Ok(self.default_ns.as_deref().filter(|s| !s.is_empty())); + }; + let uri = if self.spilled { + self.prefixed_ns.get(prefix, self.storage)? + } else { + self.short_position(prefix)? + .map(|index| &self.short_prefixes[index].1) + }; + Ok(uri.map(AsRef::as_ref).filter(|s| !s.is_empty())) } } diff --git a/src/parser_xmloxide/parser/mod.rs b/src/parser_xmloxide/parser/mod.rs index 06a99b84..cbc90f8e 100644 --- a/src/parser_xmloxide/parser/mod.rs +++ b/src/parser_xmloxide/parser/mod.rs @@ -242,3 +242,11 @@ pub fn parse_str_with_options(input: &str, options: &ParseOptions) -> Result Result<(Document, alloc::vec::Vec), ParseError> { + xml::XmlParser::new(input, options)?.parse_recording_positions() +} diff --git a/src/parser_xmloxide/parser/xml.rs b/src/parser_xmloxide/parser/xml.rs index 517d5b2b..a72d6ee2 100644 --- a/src/parser_xmloxide/parser/xml.rs +++ b/src/parser_xmloxide/parser/xml.rs @@ -5,6 +5,7 @@ use crate::xml::dom::storage::names::{NameStorage, Names}; use crate::xml::dom::storage::{Native, Storage, Values}; +use crate::xml::dom::{PositionRecorder, SourceKind, SourceNode}; use crate::parser_xmloxide::error::{ErrorSeverity, ParseDiagnostic}; use crate::parser_xmloxide::tree::{Attribute, Document, NodeId, NodeKind}; @@ -68,6 +69,10 @@ const DEFAULT_MAX_AMPLIFICATION: usize = 5; /// resolver-provided external entities and stack safety. const MAX_ENTITY_DEPTH: u32 = 32; +/// A parsed document with the source ranges of its nodes. +pub(crate) type Located = (Document, ::Values); +type Finished = (Document, Option>); + /// The core XML parser. pub(crate) struct XmlParser<'a, S: NameStorage = Native> { /// Shared low-level input state (position, peek, advance, name parsing, etc.). @@ -91,6 +96,8 @@ pub(crate) struct XmlParser<'a, S: NameStorage = Native> { expansion_size: usize, /// Current nesting depth of general entity expansion (0 = document level). entity_depth: u32, + /// Source ranges of the nodes, when the caller has no lexical sidecar. + positions: Option>, } impl<'a> XmlParser<'a> { @@ -128,11 +135,25 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { input_size: input.len(), expansion_size: 0, entity_depth: 0, + positions: None, }) } /// Main parse entry point. Parses the entire document. - pub fn parse(mut self) -> Result, ParseError> { + pub fn parse(self) -> Result, ParseError> { + self.parse_tree().map(|(document, _)| document) + } + + /// Parses a document without a DOCTYPE and returns its nodes' source ranges, + /// in the order the projector visits them. + pub(crate) fn parse_recording_positions(mut self) -> Result, ParseError> { + self.positions = Some(PositionRecorder::new(self.storage)?); + let (document, positions) = self.parse_tree()?; + let positions = positions.expect("recording was enabled above"); + Ok((document, positions.into_nodes())) + } + + fn parse_tree(mut self) -> Result, ParseError> { // Parse optional XML declaration — must be at the very start of the // document with no leading whitespace (XML 1.0 §2.8). if self.input.looking_at(b" XmlParser<'a, S> { // Parsing is complete and the parser is consumed. Moving the arena // avoids Document::default allocating a replacement tree on return. - Ok(self.doc) + Ok((self.doc, self.positions)) } // --- XML Declaration --- @@ -205,6 +226,11 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { fn parse_xml_declaration(&mut self) -> Result<(), ParseError> { let decl = parse_xml_decl(&mut self.input)?; + // XML 1.0 §2.8 allows a later 1.x only to a processor implementing that + // version's character model. This parser implements XML 1.0. + if decl.version != "1.0" { + return Err(self.input.fatal("unsupported XML version")); + } self.doc.version = Some(self.storage.text(decl.version)?); self.doc.encoding = self.storage.optional_text(decl.encoding)?; self.doc.standalone = decl.standalone; @@ -756,6 +782,9 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { parent: NodeId, ) -> Result<(NodeId, Option), ParseError> { self.input.increment_depth()?; + if let Some(positions) = &mut self.positions { + positions.start_element(self.input.pos())?; + } self.input.expect_byte(b'<')?; let name = self.input.parse_name()?; let mut attributes = self.storage.values(0)?; @@ -854,6 +883,9 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { // Empty element tag if self.input.looking_at(b"/>") { self.input.advance(2)?; + if let Some(positions) = &mut self.positions { + positions.end_element(self.input.pos()); + } if has_ns_decls { self.ns.pop_scope()?; } @@ -900,6 +932,9 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { } self.input.skip_whitespace()?; self.input.expect_byte(b'>')?; + if let Some(positions) = &mut self.positions { + positions.end_element(self.input.pos()); + } // Pop the namespace scope when leaving this element (only if we pushed). if has_ns_decls { @@ -938,6 +973,7 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { // See XML 1.0 §2.4: [14] CharData fn parse_char_data(&mut self, parent: NodeId) -> Result<(), ParseError> { + let source_start = self.input.pos(); let mut text = self.storage.text("")?; while !self.input.at_end() { @@ -946,8 +982,10 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { let safe_len = self.input.scan_char_data()?; if safe_len > 0 { let start = self.input.pos(); - let chunk = core::str::from_utf8(self.input.slice(start, start + safe_len)) - .map_err(|_| self.input.fatal("invalid UTF-8 in character data"))?; + let chunk = self + .input + .text(start, start + safe_len) + .ok_or_else(|| self.input.fatal("invalid UTF-8 in character data"))?; // Fast byte-level pre-check for invalid XML chars (0x7F, // U+FFFE, U+FFFF). Skips the expensive char-by-char // validation for the 99.9% of chunks that are clean. @@ -1031,6 +1069,9 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { } let text_id = self.doc.try_create_node(NodeKind::Text { content: text })?; self.doc.append_child(parent, text_id); + if let Some(positions) = &mut self.positions { + positions.leaf(SourceKind::Text, source_start..self.input.pos())?; + } } Ok(()) @@ -1360,11 +1401,15 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { // See XML 1.0 §2.5: [15] Comment fn parse_comment(&mut self, parent: NodeId) -> Result<(), ParseError> { + let start = self.input.pos(); let content = parse_comment_content(&mut self.input)?; let comment_id = self.doc.try_create_node(NodeKind::Comment { content: self.storage.text(content)?, })?; self.doc.append_child(parent, comment_id); + if let Some(positions) = &mut self.positions { + positions.leaf(SourceKind::Comment, start..self.input.pos())?; + } Ok(()) } @@ -1372,11 +1417,15 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { // See XML 1.0 §2.7: [18] CDSect fn parse_cdata(&mut self, parent: NodeId) -> Result<(), ParseError> { + let start = self.input.pos(); let content = parse_cdata_content(&mut self.input)?; let cdata_id = self.doc.try_create_node(NodeKind::CData { content: self.storage.text(content)?, })?; self.doc.append_child(parent, cdata_id); + if let Some(positions) = &mut self.positions { + positions.leaf(SourceKind::CData, start..self.input.pos())?; + } Ok(()) } @@ -1384,12 +1433,16 @@ impl<'a, S: NameStorage> XmlParser<'a, S> { // See XML 1.0 §2.6: [16] PI fn parse_processing_instruction(&mut self, parent: NodeId) -> Result<(), ParseError> { + let start = self.input.pos(); let (target, data) = parse_pi_content(&mut self.input)?; let pi_id = self.doc.try_create_node(NodeKind::ProcessingInstruction { target: self.storage.text(target)?, data: self.storage.optional_text(data)?, })?; self.doc.append_child(parent, pi_id); + if let Some(positions) = &mut self.positions { + positions.leaf(SourceKind::Pi, start..self.input.pos())?; + } Ok(()) } } diff --git a/src/xml/dom/mod.rs b/src/xml/dom/mod.rs index a6ceb8bc..ab42b796 100644 --- a/src/xml/dom/mod.rs +++ b/src/xml/dom/mod.rs @@ -21,6 +21,8 @@ use alloc::string::String; use core::fmt; use self::preflight::LexicalPreflight; +#[cfg(feature = "xml-backend-xmloxide")] +pub(crate) use self::preflight::{PositionRecorder, SourceKind, SourceNode}; pub use tree::{ Ancestors, Attribute, Attributes, Children, Descendants, Document, ExpandedName, Namespace, @@ -91,6 +93,15 @@ impl XmlBackend { .filter(|backend| backend.is_available()) } + /// Whether this parse needs no lexical scan: xmloxide knows every source + /// range, and only a DOCTYPE, whose keyword is case-sensitive (XML 1.0 + /// production 28), brings declarations and entity expansion. + fn records_positions(self, input: &str) -> bool { + cfg!(feature = "xml-backend-xmloxide") + && self == Self::Xmloxide + && !input.contains("( self, input: &'input str, diff --git a/src/xml/dom/preflight.rs b/src/xml/dom/preflight.rs index 324ec18c..32e9c22a 100644 --- a/src/xml/dom/preflight.rs +++ b/src/xml/dom/preflight.rs @@ -10,7 +10,7 @@ use super::ParseError; use super::storage::{Native, Storage, Values}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum SourceKind { +pub(crate) enum SourceKind { Element, Text, CData, @@ -19,11 +19,48 @@ pub(super) enum SourceKind { Pi, } -struct SourceNode { +pub(crate) struct SourceNode { kind: SourceKind, range: Range, } +/// The sidecar a parser fills while it walks a document that has no DOCTYPE, +/// where no entity expansion separates its nodes from their source. +#[cfg(feature = "xml-backend-xmloxide")] +pub(crate) struct PositionRecorder { + nodes: S::Values, + open: S::Values, +} + +#[cfg(feature = "xml-backend-xmloxide")] +impl PositionRecorder { + pub(crate) fn new(storage: S) -> Result { + Ok(Self { + nodes: storage.values(0)?, + open: storage.values(0)?, + }) + } + + pub(crate) fn start_element(&mut self, start: usize) -> Result<(), ParseError> { + enforce_depth(self.open.len() + 1)?; + self.open.push(self.nodes.len())?; + self.leaf(SourceKind::Element, start..start) + } + + pub(crate) fn end_element(&mut self, end: usize) { + let index = self.open.pop().expect("every end follows its start"); + self.nodes[index].range.end = end; + } + + pub(crate) fn leaf(&mut self, kind: SourceKind, range: Range) -> Result<(), ParseError> { + push_source_position(&mut self.nodes, SourceNode { kind, range }) + } + + pub(crate) fn into_nodes(self) -> S::Values { + self.nodes + } +} + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct DtdAttributeName<'a>(Option<&'a str>, &'a str, Option<&'a str>, &'a str); @@ -128,6 +165,9 @@ pub(super) struct LexicalPreflight<'input, S: Storage = Native> { nodes: S::Values, attributes: S::Declarations<'input>, storage: S, + /// The backend records the source positions itself. + #[cfg(feature = "xml-backend-xmloxide")] + unscanned: bool, #[cfg(feature = "xml-backend-roxmltree")] doctype: Option>, } @@ -139,6 +179,25 @@ impl<'input> LexicalPreflight<'input> { } impl<'input, S: Storage> LexicalPreflight<'input, S> { + /// The preflight of a document that declares nothing: no attribute + /// declarations exist, and the parser supplies the positions. + pub(super) fn unscanned(storage: S) -> Result { + Ok(Self { + nodes: storage.values(0)?, + attributes: storage.declarations(), + storage, + #[cfg(feature = "xml-backend-xmloxide")] + unscanned: true, + #[cfg(feature = "xml-backend-roxmltree")] + doctype: None, + }) + } + + #[cfg(feature = "xml-backend-xmloxide")] + pub(super) fn is_unscanned(&self) -> bool { + self.unscanned + } + pub(super) fn scan_in( input: &'input str, allow_dtd: bool, @@ -243,6 +302,8 @@ impl<'input, S: Storage> LexicalPreflight<'input, S> { nodes, attributes, storage, + #[cfg(feature = "xml-backend-xmloxide")] + unscanned: false, #[cfg(feature = "xml-backend-roxmltree")] doctype, }) @@ -308,11 +369,8 @@ impl<'input, S: Storage> LexicalPreflight<'input, S> { } #[cfg(feature = "xml-backend-xmloxide")] - pub(super) fn positions(&self) -> PositionCursor<'_> { - PositionCursor { - positions: &self.nodes, - next: 0, - } + pub(super) fn source_nodes(&self) -> &[SourceNode] { + &self.nodes } } @@ -444,7 +502,11 @@ pub(super) struct PositionCursor<'a> { } #[cfg(feature = "xml-backend-xmloxide")] -impl PositionCursor<'_> { +impl<'a> PositionCursor<'a> { + pub(super) fn new(positions: &'a [SourceNode]) -> Self { + Self { positions, next: 0 } + } + pub(super) fn take(&mut self, expected: SourceKind) -> Result, ParseError> { let Some(node) = self.positions.get(self.next) else { return Err(ParseError::SourceMapMismatch { @@ -474,6 +536,9 @@ impl PositionCursor<'_> { } } +#[cfg(all(test, feature = "xml-backend-xmloxide"))] +mod recorder_tests; + #[cfg(all(test, feature = "xmldsig"))] mod accounted_tests { use super::super::storage::Accounted; diff --git a/src/xml/dom/preflight/recorder_tests.rs b/src/xml/dom/preflight/recorder_tests.rs new file mode 100644 index 00000000..2206ada9 --- /dev/null +++ b/src/xml/dom/preflight/recorder_tests.rs @@ -0,0 +1,111 @@ +use super::*; +use crate::parser_xmloxide::parser::{ParseOptions, parse_str_recording_positions}; + +fn options() -> ParseOptions { + crate::xml::dom::xmloxide::backend_options() +} + +/// The parser's sidecar stands in for the scanner's, so both must describe a +/// document with the same nodes at the same offsets. +#[test] +fn recorded_positions_match_the_lexical_scan() { + let documents = [ + "", + "", + "", + "textmoretail", + "\n\n\n\n", + "a&bAcB", + "<", + "onethree", + "xy", + "\r\n line\r second\n", + " ", + "\u{20AC}<\u{00E9}/>\u{1F600}", + "", + ]; + for xml in documents { + let scanned = LexicalPreflight::scan(xml, false).expect("the scanner accepts it"); + let (_, recorded) = + parse_str_recording_positions(xml, &options()).expect("the parser accepts it"); + let describe = |nodes: &[SourceNode]| { + nodes + .iter() + .map(|node| (node.kind, node.range.clone())) + .collect::>() + }; + assert_eq!(describe(&recorded), describe(&scanned.nodes), "{xml}"); + } +} + +/// Without a DOCTYPE the scanner no longer runs, so the parser alone must refuse +/// what it refused: a document only the scanner stopped would now be accepted. +#[test] +fn the_parser_refuses_what_the_lexical_scan_refused() { + let malformed = [ + "", + " ", + "text", + "text", + "text", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "<1r/>", + "<:r/>", + "", + "", + "", + "&", + "&bad;", + "�", + "�", + "�", + "&#xZ;", + "]]>", + "\u{1}", + "\u{FFFE}", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + " ", + "", + "", + "<\u{1680}/>", + "", + "", + "", + ]; + let mut gaps = alloc::vec::Vec::new(); + for xml in malformed { + if LexicalPreflight::scan(xml, false).is_err() + && parse_str_recording_positions(xml, &options()).is_ok() + { + gaps.push(xml); + } + } + assert!(gaps.is_empty(), "only the scanner refused: {gaps:?}"); +} diff --git a/src/xml/dom/tree.rs b/src/xml/dom/tree.rs index 83efab23..baecb457 100644 --- a/src/xml/dom/tree.rs +++ b/src/xml/dom/tree.rs @@ -498,7 +498,11 @@ impl<'input> Document<'input> { options: ParsingOptions, backend: XmlBackend, ) -> Result { - let preflight = super::LexicalPreflight::scan(input, options.allow_dtd)?; + let preflight = if backend.records_positions(input) { + super::LexicalPreflight::unscanned(super::storage::Native)? + } else { + super::LexicalPreflight::scan(input, options.allow_dtd)? + }; backend.parse(input, options, &preflight) } @@ -510,7 +514,11 @@ impl<'input> Document<'input> { storage: super::storage::Accounted<'w, W>, ) -> Result>, super::AccountedParseError> { - let preflight = super::LexicalPreflight::scan_in(input, options.allow_dtd, storage)?; + let preflight = if backend.records_positions(input) { + super::LexicalPreflight::unscanned(storage)? + } else { + super::LexicalPreflight::scan_in(input, options.allow_dtd, storage)? + }; backend.parse_in(input, options, &preflight, storage) } } @@ -1331,6 +1339,7 @@ mod tests { raw(), super::ParsingOptions::default(), &preflight, + preflight.source_nodes(), super::super::storage::Accounted(&memory), ) .expect("paid projection fits"); @@ -1345,6 +1354,7 @@ mod tests { raw(), super::ParsingOptions::default(), &preflight, + preflight.source_nodes(), super::super::storage::Accounted(&denied) ), Err(super::ParseError::SharedExecution( @@ -1379,6 +1389,7 @@ mod tests { allow_dtd: false, }, &preflight, + preflight.source_nodes(), Accounted(&memory), ); assert!(matches!(result, Err(super::ParseError::NodesLimitReached))); diff --git a/src/xml/dom/xmloxide.rs b/src/xml/dom/xmloxide.rs index e3713271..362e5396 100644 --- a/src/xml/dom/xmloxide.rs +++ b/src/xml/dom/xmloxide.rs @@ -10,7 +10,7 @@ use xmloxide::tree::{NodeId as XmloxNodeId, NodeKind as XmloxNodeKind}; use super::{ Document, LexicalPreflight, ParseError, ParsingOptions, XmlBackendImplementation, - preflight::{PositionCursor, SourceKind}, + preflight::{PositionCursor, SourceKind, SourceNode}, tree::{AttributeData, NamespaceData, NamespaceSet, NodeId, NodeKind, TreeBuilder}, }; @@ -25,23 +25,36 @@ impl XmloxideBackend { storage: super::storage::Accounted<'w, W>, ) -> Result>, super::AccountedParseError> { - let parsed = xmloxide::parser::XmlParser::with_storage(input, &backend_options(), storage) - .and_then(xmloxide::parser::XmlParser::parse) - .map_err(|error| match error { - xmloxide::error::ParseError::Resource(error) => { - super::AccountedParseError::Semantic(error) - } - xmloxide::error::ParseError::Syntax { - message, - location, - diagnostics, - } => super::AccountedParseError::Xmloxide(xmloxide::error::ParseError::Syntax { - message, - location, - diagnostics: diagnostics.into_owned(), - }), - })?; - Ok(project_in(input, parsed, options, preflight, storage)?) + let (parsed, recorded) = + xmloxide::parser::XmlParser::with_storage(input, &backend_options(), storage) + .and_then(|parser| { + if preflight.is_unscanned() { + let (parsed, positions) = parser.parse_recording_positions()?; + Ok((parsed, Some(positions))) + } else { + Ok((parser.parse()?, None)) + } + }) + .map_err(|error| match error { + xmloxide::error::ParseError::Resource(error) => { + super::AccountedParseError::Semantic(error) + } + xmloxide::error::ParseError::Syntax { + message, + location, + diagnostics, + } => { + super::AccountedParseError::Xmloxide(xmloxide::error::ParseError::Syntax { + message, + location, + diagnostics: diagnostics.into_owned(), + }) + } + })?; + let positions = recorded.as_deref().unwrap_or(preflight.source_nodes()); + Ok(project_in( + input, parsed, options, preflight, positions, storage, + )?) } } @@ -87,15 +100,21 @@ impl XmlBackendImplementation for XmloxideBackend { options: ParsingOptions, preflight: &LexicalPreflight, ) -> Result, ParseError> { - let parsed = xmloxide::parser::parse_str_with_options(input, &backend_options()).map_err( - |error| match error { - xmloxide::error::ParseError::Resource(error) => error, - error @ xmloxide::error::ParseError::Syntax { .. } => ParseError::Backend { - backend: "xmloxide", - message: error.to_string(), - }, + let failure = |error| match error { + xmloxide::error::ParseError::Resource(error) => error, + error @ xmloxide::error::ParseError::Syntax { .. } => ParseError::Backend { + backend: "xmloxide", + message: error.to_string(), }, - )?; + }; + if preflight.is_unscanned() { + let (parsed, positions) = + xmloxide::parser::parse_str_recording_positions(input, &backend_options()) + .map_err(failure)?; + return project_in(input, parsed, options, preflight, &positions, Native); + } + let parsed = + xmloxide::parser::parse_str_with_options(input, &backend_options()).map_err(failure)?; project(input, parsed, options, preflight) } } @@ -106,7 +125,8 @@ pub(super) fn project<'input>( options: ParsingOptions, preflight: &LexicalPreflight, ) -> Result, ParseError> { - project_in(input, parsed, options, preflight, Native) + let positions = preflight.source_nodes(); + project_in(input, parsed, options, preflight, positions, Native) } pub(super) fn project_in<'input, S: AdoptText, P: Storage, R: NameStorage>( @@ -114,6 +134,7 @@ pub(super) fn project_in<'input, S: AdoptText, P: Storage, R: NameStorage>( parsed: xmloxide::Document, options: ParsingOptions, preflight: &LexicalPreflight<'_, P>, + positions: &[SourceNode], storage: S, ) -> Result, ParseError> { if options.nodes_limit == 0 { @@ -127,7 +148,7 @@ pub(super) fn project_in<'input, S: AdoptText, P: Storage, R: NameStorage>( storage, )?, source: parsed, - positions: preflight.positions(), + positions: PositionCursor::new(positions), nodes_limit: options.nodes_limit, }; let root = projector @@ -189,6 +210,17 @@ impl, R: NameStorage> Projector<'_, '_, S, R> { inherited_range, range_actionable, } => { + // The next sibling waits under this node's subtree, so the + // stack grows with depth and not with an element's width. + if let Some(next) = self.source.next_sibling(source) { + stack.push(ProjectionFrame::Enter { + source: next, + parent, + namespace_owner, + inherited_range: inherited_range.clone(), + range_actionable, + })?; + } // Each node is entered once, so its payload moves out. let kind = core::mem::replace( &mut self.source.node_mut(source).kind, @@ -439,18 +471,14 @@ impl, R: NameStorage> Projector<'_, '_, S, R> { inherited_range: Option<&Range>, range_actionable: bool, ) -> Result<(), ParseError> { - // Walk linked siblings backwards instead of allocating a second child - // vector merely to reverse iteration. - let mut child = self.source.last_child(source); - while let Some(current) = child { + if let Some(first) = self.source.first_child(source) { stack.push(ProjectionFrame::Enter { - source: current, + source: first, parent, namespace_owner, inherited_range: inherited_range.cloned(), range_actionable, })?; - child = self.source.prev_sibling(current); } Ok(()) } diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index ce4370d5..cda3b436 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -511,26 +511,61 @@ pub(super) fn merge_key_info_source_at_index_with_budget( let merged = merge_key_info_source_in_document( &document, xml, - key_info_source, + (key_info_source, &[]), target_signature, policy, budget, - )?; + )? + .xml; parse_mutation_xml_with_budget(&merged, policy, budget)?; Ok(merged) } +/// The nodes under `wrapper`, and how deep they reach once inside `key_info`. +fn key_info_source_extent( + wrapper: Node<'_, '_>, + key_info: Node<'_, '_>, + id_attributes: &[crate::IdAttributeRegistration], +) -> (usize, usize, bool) { + // Depth counts elements alone, as the parser's document metrics do. + let element_depth = |node: Node<'_, '_>| node.ancestors().filter(Node::is_element).count(); + let wrapper_depth = element_depth(wrapper); + let elements = || wrapper.descendants().filter(Node::is_element); + let source_depth = elements() + .map(|node| element_depth(node) - wrapper_depth) + .max() + .unwrap_or(0); + // The wrapper declares exactly the scope of `KeyInfo`, so the fragment's + // parse bounded every element's bindings unless the source merges into a + // child with a scope of its own. An ID can change what a `Reference` resolves. + let scope = key_info.namespaces().count(); + let inert = key_info + .descendants() + .filter(Node::is_element) + .all(|node| node.namespaces().count() == scope) + && !elements().any(|node| { + node.attributes() + .any(|attribute| crate::xml::is_id_attribute(node, attribute, id_attributes)) + }); + ( + wrapper.descendants().count() - 1, + source_depth + element_depth(key_info), + inert, + ) +} + /// Merge writer output into the target `/` of `xml`, whose -/// current parse is `document`. The result is not reparsed here: the caller -/// commits it through a parse that validates the whole merged document. +/// current parse is `document`. The result is not reparsed here: the writer's +/// content is parsed in its namespace context, and the extent it reports lets +/// the caller apply the document limits to the whole. pub(super) fn merge_key_info_source_in_document( document: &crate::xml::dom::Document<'_>, xml: &str, - key_info_source: &str, + (key_info_source, id_attributes): (&str, &[crate::IdAttributeRegistration]), target_signature: usize, policy: Option<&crate::policy::SigningPolicy>, budget: Option<&XmlParseWorkBudget>, -) -> Result { +) -> Result { let Some(signature) = signature_node(document, target_signature) else { return Err(XmlMutationError::ValueCountMismatch { element: "Signature", @@ -556,6 +591,7 @@ pub(super) fn merge_key_info_source_in_document( // inherited prefixes have exactly the semantics they will have in KeyInfo. let wrapped_source = wrap_key_info_children(key_info_source, key_info, policy)?; let source_document = parse_mutation_xml_with_budget(&wrapped_source, policy, budget)?; + let extent = key_info_source_extent(source_document.root_element(), key_info, id_attributes); let sources = source_document .root_element() .children() @@ -633,7 +669,13 @@ pub(super) fn merge_key_info_source_in_document( budget, )?; } - Ok(output) + let range = key_info.range(); + Ok(crate::document::SplicedElement::replacing( + xml.len(), + range, + output, + extent, + )) } fn merge_one_key_info_source_at_index_with_options( diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index b8362ba9..6d7e9531 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -65,7 +65,9 @@ use super::uri::{ validate_signing_reference_uri, }; use super::verify::parse_signature_children; -use crate::document::{DocumentParseSettings, XmlDocument, XmlDocumentError, XmlParseWorkBudget}; +use crate::document::{ + DocumentParseSettings, SplicedElement, XmlDocument, XmlDocumentError, XmlParseWorkBudget, +}; /// Result for one computed signing-template reference digest. #[derive(Debug, Clone, PartialEq, Eq)] @@ -1858,14 +1860,13 @@ impl<'a> SignContext<'a> { &self.policy.resources, self.xml_backend, ); - let mut staged = document - .staged_copy_with_budget( - self.document_parse_settings(), - budgets.transforms.xml_parse_work(), - ) - .map_err(map_owned_document_mutation_error)?; - self.sign_document_in_place::(&mut staged, &mut budgets)?; - commit_signed_staged(document, staged, &self.policy) + budgets + .transforms + .xml_parse_work() + .admit(document.as_xml().len())?; + atomically(document, |document| { + self.sign_document_in_place::(document, &mut budgets) + }) } fn sign_document_in_place( @@ -2103,10 +2104,9 @@ impl<'a> SignContext<'a> { target_signature: usize, operation: &mut SigningExecution<'_>, key_info: OperationNodeId, - output: SigningKeyOutput, + encapsulated: Option<(crate::NodeIdentity, crate::provider::EncapsulatedKey)>, has_external_resources: bool, - ) -> Result { - let (key_info_content, encapsulated) = output; + ) -> Result<(OperationNodeId, Vec), SigningError> { operation.extend(); let kem_mutation = if let Some((target, result)) = encapsulated { let mutation = @@ -2131,85 +2131,178 @@ impl<'a> SignContext<'a> { // Validate reference inputs and build the normalization candidate only after // independent key output has passed its own byte boundary. The execution pass // revalidates the resulting document defensively after controlled mutations. - let c14n_candidate = materialize_second_edition_c14n11_candidate( + let generated = second_edition_c14n11_edits( document, target_signature, &self.policy, has_external_resources, )?; - let normalization = if let Some(candidate) = c14n_candidate { - let mutation = - operation.add_node(OperationNodeKind::Mutation, OperationStage::Resolve, None); - operation.add_dependency(mutation, kem_mutation.unwrap_or(key_info))?; - operation.compile()?; - operation.run_document_transition(mutation, document, |document, budgets| { - document - .replace_serialized_with_settings( - candidate, - self.document_parse_settings(), - Some(budgets.transforms.xml_parse_work()), - ) - .map_err(map_owned_document_mutation_error) - })?; - operation.extend(); - Some(mutation) - } else { - None + Ok((kem_mutation.unwrap_or(key_info), generated)) + } + + /// Commit generated canonicalization as its own generation, for a `Reference` + /// that reads it or a form the final mutation cannot stand in for. + fn commit_generated_c14n( + &self, + document: &mut XmlDocument, + operation: &mut SigningExecution<'_>, + setup_gate: OperationNodeId, + generated: Vec, + ) -> Result { + let candidate = c14n11_candidate(document, generated); + let mutation = + operation.add_node(OperationNodeKind::Mutation, OperationStage::Resolve, None); + operation.add_dependency(mutation, setup_gate)?; + operation.compile()?; + operation.run_document_transition(mutation, document, |document, budgets| { + document + .replace_serialized_with_settings( + candidate, + self.document_parse_settings(), + Some(budgets.transforms.xml_parse_work()), + ) + .map_err(map_owned_document_mutation_error) + })?; + operation.extend(); + Ok(mutation) + } + + /// The writer's `KeyInfo` spliced into a copy of the serialization. The + /// helper checks its namespace wrapper and every projected replacement + /// against policy before allocating that copy. + fn splice_signing_key_info( + &self, + document: &XmlDocument, + target_signature: usize, + key_info_content: &str, + budget: &XmlParseWorkBudget, + ) -> Result { + let spliced = document.with_view(|view| { + merge_key_info_source_in_document( + view.document(), + view.xml(), + (key_info_content, self.id_attributes), + target_signature, + Some(&self.policy), + Some(budget), + ) + })?; + self.policy + .resources + .validate_xml_document_len(spliced.xml.len())?; + Ok(spliced) + } + + /// Analyze the `Reference` set and settle how generated canonicalization and + /// the writer's `KeyInfo` join the document: in the final mutation when + /// nothing signed reads them, or as their own generations when something may. + fn plan_signing_splice( + &self, + document: &mut XmlDocument, + operation: &mut SigningExecution<'_>, + (request, transform_options): (SigningReferenceRequest<'_, '_>, TransformOptions<'_>), + mut setup_gate: OperationNodeId, + key_info_content: Option, + mut generated: Vec, + ) -> Result { + let target_signature = request.target_signature; + // A DTD's attribute default would reach the new elements when they are + // parsed (XML 1.0 §3.3.2), and its ATTLIST could type a new ID, neither + // of which text inserted here can stand in for. + let doctype = (key_info_content.is_some() || !generated.is_empty()) + && document.as_xml().contains(">(); + let deferred = SigningReferenceRequest { + generated: &references, + speculative: true, + ..request }; - let setup_gate = normalization.or(kem_mutation).unwrap_or(key_info); - self.populate_signing_key_info( - document, - target_signature, - operation, - setup_gate, - key_info_content, - ) + // A `Reference` may read what these add, or name an ID only the writer + // provides, so a failed analysis is decided after the commit. + let analysis = (!doctype) + .then(|| analyze_signing_references(document, operation, deferred, transform_options)) + .and_then(Result::ok) + .filter(|analyzed| digests_can_defer(document, analyzed) && analyzed.1.splice_unread); + if let Some(analyzed) = analysis { + let budget = operation.budgets_mut().transforms.xml_parse_work(); + let key_info = key_info_content + .as_deref() + .map(|content| { + self.splice_signing_key_info(document, target_signature, content, budget) + }) + .transpose()?; + if key_info.as_ref().is_none_or(|spliced| spliced.inert) { + let splice = DeferredSplice::new(document.as_xml(), key_info, generated); + self.policy + .resources + .validate_xml_document_len(splice.spliced.xml.len())?; + return Ok((analyzed, setup_gate, Some(splice))); + } + } + if !generated.is_empty() { + setup_gate = self.commit_generated_c14n(document, operation, setup_gate, generated)?; + } + if let Some(content) = key_info_content { + setup_gate = self.populate_signing_key_info( + document, + target_signature, + operation, + setup_gate, + &content, + )?; + } + let analyzed = analyze_signing_references(document, operation, request, transform_options)?; + Ok((analyzed, setup_gate, None)) } + /// Commit `KeyInfo` as its own generation, for a `Reference` that reads it. fn populate_signing_key_info( &self, document: &mut XmlDocument, target_signature: usize, operation: &mut SigningExecution<'_>, setup_gate: OperationNodeId, - key_info_content: Option, + key_info_content: &str, ) -> Result { - let setup_gate = if let Some(key_info_content) = key_info_content { - let mutation = - operation.add_node(OperationNodeKind::Mutation, OperationStage::Resolve, None); - operation.add_dependency(mutation, setup_gate)?; - operation.compile()?; - operation.run_document_transition(mutation, document, |document, budgets| { - // The mutation helper checks its namespace wrapper and every projected - // replacement against policy before allocating the committed candidate; - // the commit below parses and validates the merged document. - let populated = document.with_view(|view| { - merge_key_info_source_in_document( - view.document(), - view.xml(), - &key_info_content, - target_signature, - Some(&self.policy), - Some(budgets.transforms.xml_parse_work()), - ) - })?; - self.policy - .resources - .validate_xml_document_len(populated.len())?; - document - .replace_serialized_with_settings( - populated, - self.document_parse_settings(), - Some(budgets.transforms.xml_parse_work()), - ) - .map_err(map_owned_document_mutation_error) - })?; - operation.extend(); - mutation - } else { - setup_gate - }; - Ok(setup_gate) + let mutation = + operation.add_node(OperationNodeKind::Mutation, OperationStage::Resolve, None); + operation.add_dependency(mutation, setup_gate)?; + operation.compile()?; + operation.run_document_transition(mutation, document, |document, budgets| { + let populated = self.splice_signing_key_info( + document, + target_signature, + key_info_content, + budgets.transforms.xml_parse_work(), + )?; + document + .replace_serialized_with_settings( + populated.xml, + self.document_parse_settings(), + Some(budgets.transforms.xml_parse_work()), + ) + .map_err(map_owned_document_mutation_error) + })?; + operation.extend(); + Ok(mutation) } fn admit_signature_output( @@ -2219,7 +2312,7 @@ impl<'a> SignContext<'a> { signing_key: &dyn SigningKey, algorithm: SignatureAlgorithm, hmac_output_length_bits: Option, - pending_digests: &[(crate::NodeIdentity, String)], + (pending_digests, key_info): (&[(crate::NodeIdentity, String)], Option<&SplicedElement>), ) -> Result<(usize, crate::NodeIdentity), SigningError> { let expected_signature_len = expected_signature_output_len( signing_key, @@ -2259,9 +2352,28 @@ impl<'a> SignContext<'a> { }, )? - document.as_xml().len(); } + if let Some(spliced) = key_info { + // `spliced` and the source share everything outside its spans. The + // replacements above may have shortened the document, so this adds + // before it subtracts. + projected_document_len = + projected_document_len + spliced.xml.len() - document.as_xml().len(); + } self.policy .resources .validate_xml_document_len(projected_document_len)?; + // Every ceiling of the final generation is decided here, once, before + // the private key is used. + document + .validate_commit_limits( + pending_digests + .iter() + .map(|(target, _)| *target) + .chain([signature_value_node]), + key_info, + self.document_parse_settings(), + ) + .map_err(map_owned_document_mutation_error)?; Ok((expected_signature_len, signature_value_node)) } @@ -2272,7 +2384,10 @@ impl<'a> SignContext<'a> { operation: &mut SigningExecution<'_>, (plan_nodes, pending_digests): (SigningPlanNodes, Vec<(crate::NodeIdentity, String)>), signing_key: &dyn SigningKey, + splice: Option<&DeferredSplice>, ) -> Result { + let key_info = splice.map(|splice| &splice.spliced); + let insertions = splice.map_or(&[][..], |splice| splice.insertions.as_slice()); self.policy .resources .validate_xml_document_len(document.as_xml().len())?; @@ -2284,6 +2399,7 @@ impl<'a> SignContext<'a> { budgets, target_signature, &pending_digests, + insertions, )?; budgets .transforms @@ -2298,7 +2414,7 @@ impl<'a> SignContext<'a> { signing_key, algorithm, hmac_output_length_bits, - &pending_digests, + (&pending_digests, key_info), )?; let signature_value = operation.run(plan_nodes.crypto, || { self.provider @@ -2326,7 +2442,14 @@ impl<'a> SignContext<'a> { })?; let mut replacement = pending_digests; replacement.push((signature_value_node, signature_b64)); - O::finish(self, operation, plan_nodes.mutation, document, &replacement) + O::finish( + self, + operation, + plan_nodes.mutation, + document, + &replacement, + key_info, + ) } fn sign_template_at_index_with_budgets( @@ -2381,34 +2504,46 @@ impl<'a> SignContext<'a> { .into()); } }; - let setup_gate = self.apply_signing_setup( + let (setup_gate, generated) = self.apply_signing_setup( document, target_signature, &mut operation, key_info, - (key_info_content, encapsulated), + encapsulated, external_resources.is_configured(), )?; - let (plan_nodes, pending_digests) = - fill_reference_digest_values_in_dependency_order_with_operation( - document, - transform_options, - self.provider, - &mut operation, - SigningReferenceRequest { - target_signature, - id_attributes: self.id_attributes, - external_resources: &external_resources, - }, - Some(setup_gate), - true, - )?; + let request = SigningReferenceRequest { + target_signature, + id_attributes: self.id_attributes, + external_resources: &external_resources, + generated: &[], + speculative: false, + }; + let (analyzed, setup_gate, splice) = self.plan_signing_splice( + document, + &mut operation, + (request, transform_options), + setup_gate, + key_info_content, + generated, + )?; + let (plan_nodes, pending_digests) = fill_analyzed_reference_digest_values( + document, + transform_options, + self.provider, + &mut operation, + request, + Some(setup_gate), + true, + analyzed, + )?; self.finish_signing_template::( document, target_signature, &mut operation, (plan_nodes, pending_digests), signing_key, + splice.as_ref(), ) } @@ -2464,18 +2599,17 @@ impl<'a> SignContext<'a> { &self.policy.resources, self.xml_backend, ); - let mut staged = document - .staged_copy_with_budget( - self.document_parse_settings(), - budgets.transforms.xml_parse_work(), + budgets + .transforms + .xml_parse_work() + .admit(document.as_xml().len())?; + atomically(document, |document| { + self.sign_document_with_builder_in_place::( + document, + builder, + &mut budgets, ) - .map_err(map_owned_document_mutation_error)?; - self.sign_document_with_builder_in_place::( - &mut staged, - builder, - &mut budgets, - )?; - commit_signed_staged(document, staged, &self.policy) + }) } fn sign_document_with_builder_in_place( @@ -2565,6 +2699,7 @@ trait SignedOutput { mutation: OperationNodeId, document: &mut XmlDocument, replacement: &[(crate::NodeIdentity, String)], + key_info: Option<&SplicedElement>, ) -> Result; } @@ -2580,15 +2715,27 @@ impl SignedOutput for CommitDocument { mutation: OperationNodeId, document: &mut XmlDocument, replacement: &[(crate::NodeIdentity, String)], + key_info: Option<&SplicedElement>, ) -> Result<(), SigningError> { operation.run_document_transition(mutation, document, |document, budgets| { - document - .replace_base64_contents_with_budget( - replacement, - context.document_parse_settings(), - budgets.transforms.xml_parse_work(), - ) - .map_err(map_owned_document_mutation_error) + let settings = context.document_parse_settings(); + let parse_work = budgets.transforms.xml_parse_work(); + match key_info { + Some(_) => { + let signed = document.project_base64_commit(replacement, settings, key_info); + signed.and_then(|signed| { + document.replace_serialized_with_settings( + signed, + settings, + Some(parse_work), + ) + }) + } + None => { + document.replace_base64_contents_with_budget(replacement, settings, parse_work) + } + } + .map_err(map_owned_document_mutation_error) })?; context .policy @@ -2610,10 +2757,11 @@ impl SignedOutput for SerializeText { mutation: OperationNodeId, document: &mut XmlDocument, replacement: &[(crate::NodeIdentity, String)], + key_info: Option<&SplicedElement>, ) -> Result { let signed = operation.run_document_projection(mutation, document, |document| { document - .project_base64_commit(replacement, context.document_parse_settings()) + .project_base64_commit(replacement, context.document_parse_settings(), key_info) .map_err(map_owned_document_mutation_error) })?; context @@ -2755,8 +2903,100 @@ struct SigningReferenceRequest<'a, 'resources> { target_signature: usize, id_attributes: &'a [crate::IdAttributeRegistration], external_resources: &'a ExternalResourceContext<'resources>, + /// `DigestValue` nodes of the `Reference`s whose generated canonicalization + /// is not in the document yet; their chains end with it regardless. + generated: &'a [NodeId], + /// The analysis only asks whether a splice can wait for the final mutation. + speculative: bool, +} + +/// The Second Edition transform one `Reference` gains. +struct GeneratedC14n { + digest_value: NodeId, + digest_method: NodeId, + /// Whether it joins existing `Transforms`, instead of bringing the element. + appends: bool, + /// Whether nothing lies between the `Transforms` end tag and `DigestMethod` + /// that a search for that end tag in canonical output could stop on. + canonical_in_place: bool, + in_signed_info: bool, + /// The depth the new content reaches. + depth: usize, + edit: SourceEdit, + /// The canonical form of the new content. + canonical: String, } +/// Canonical text `SignedInfo` gains for a generated transform. +struct CanonicalInsertion { + digest_method: NodeId, + appends: bool, + text: String, +} + +/// What joins the document in the final mutation instead of a generation of +/// its own, because nothing signed reads it. +struct DeferredSplice { + spliced: SplicedElement, + insertions: Vec, +} + +impl DeferredSplice { + fn new(source: &str, key_info: Option, generated: Vec) -> Self { + let (mut added_nodes, mut depth) = key_info + .as_ref() + .map_or((0, 0), |spliced| (spliced.added_nodes, spliced.depth)); + let mut insertions = Vec::with_capacity(generated.len()); + let mut parts = Vec::with_capacity(generated.len() + 1); + for edit in generated { + added_nodes += if edit.appends { 1 } else { 2 }; + depth = depth.max(edit.depth); + if edit.in_signed_info { + insertions.push(CanonicalInsertion { + digest_method: edit.digest_method, + appends: edit.appends, + text: edit.canonical, + }); + } + parts.push((edit.edit.range, edit.edit.replacement)); + } + if let Some(spliced) = key_info { + // Everything before its single span is the source's. + for (range, length) in spliced.spans { + let replacement = spliced.xml[range.start..range.start + length].to_owned(); + parts.push((range, replacement)); + } + } + parts.sort_unstable_by_key(|(range, _)| range.start); + let mut xml = String::with_capacity( + source.len() + parts.iter().map(|(_, text)| text.len()).sum::(), + ); + let mut cursor = 0; + let mut spans = Vec::with_capacity(parts.len()); + for (range, replacement) in parts { + xml.push_str(&source[cursor..range.start]); + xml.push_str(&replacement); + cursor = range.end; + spans.push((range, replacement.len())); + } + xml.push_str(&source[cursor..]); + Self { + spliced: SplicedElement { + xml, + spans, + added_nodes, + depth, + inert: true, + }, + insertions, + } + } +} + +/// The analyzed `Reference`s, the gate digests follow, and what the final +/// mutation still has to add. +type PlannedReferences = (AnalyzedReferences, OperationNodeId, Option); + impl<'a, 'resources> SigningReferenceRequest<'a, 'resources> { fn uri_resolution( self, @@ -2980,15 +3220,16 @@ fn collect_signing_reference_inputs( SigningSignatureTarget::Index(request.target_signature), )?; let signed_info = find_required_child(signature, "SignedInfo")?; - let signed_info_references = + let mut signed_info_references = parse_signing_references_with_budget(signed_info, &mut budgets.xpath_parse)?; + append_generated_c14n(&mut signed_info_references, request.generated); validate_signing_references( &signed_info_references, signed_info_references.len(), Some(policy), request.external_resources.is_configured(), )?; - let manifest_references = if process_manifests { + let mut manifest_references = if process_manifests { parse_signing_manifest_references( signature, &mut budgets.xpath_parse, @@ -2998,6 +3239,7 @@ fn collect_signing_reference_inputs( } else { Vec::new() }; + append_generated_c14n(&mut manifest_references, request.generated); Ok::<_, SigningDigestError>((signed_info_references, manifest_references)) })? }; @@ -3019,6 +3261,18 @@ fn collect_signing_reference_inputs( }) } +/// End the chain of each listed `Reference` with the transform it will gain. +fn append_generated_c14n(references: &mut [SigningReference], generated: &[NodeId]) { + for reference in references { + if generated.contains(&reference.digest_value_node_id) { + let algorithm = + crate::c14n::C14nAlgorithm::from_uri(SECOND_EDITION_GENERATION_C14N_URI) + .expect("Canonical XML 1.1 is an implemented canonicalization"); + reference.transforms.push(Transform::C14n(algorithm)); + } + } +} + fn analyze_signing_reference_inputs( document: &XmlDocument, policy: &crate::policy::SigningPolicy, @@ -3027,11 +3281,7 @@ fn analyze_signing_reference_inputs( transform_options: TransformOptions<'_>, references: &SigningReferenceInputs, ) -> Result { - let signed_info_references = &references.signed_info; - let manifest_references = &references.manifests; - let reference_limit = references.maximum; - let process_manifests = references.process_manifests; - let all_references = signed_info_references.iter().chain(manifest_references); + let all_references = references.signed_info.iter().chain(&references.manifests); if all_references.clone().all(|reference| { reference.transforms.iter().all(|transform| { matches!( @@ -3064,6 +3314,7 @@ fn analyze_signing_reference_inputs( view.document(), signature, &analysis_references, + request.generated, transform_options, &budgets.transforms, request.uri_resolution(policy.transforms.same_document_id_semantics), @@ -3074,6 +3325,37 @@ fn analyze_signing_reference_inputs( return Ok(plan); } } + // Without the current tree a splice has to be committed anyway, so the caller + // is told before this parses a document it would discard. + if request.speculative { + return Err(SigningDigestError::InvalidStructure( + "a splice over placeholder analysis needs its own generation".into(), + )); + } + analyze_signing_placeholder_generation( + document, + policy, + budgets, + request, + transform_options, + references, + ) +} + +/// Analyze the `Reference` set on a parse of the document with placeholder text +/// in every value the operation fills. +fn analyze_signing_placeholder_generation( + document: &XmlDocument, + policy: &crate::policy::SigningPolicy, + budgets: &mut SigningOperationBudgets, + request: SigningReferenceRequest<'_, '_>, + transform_options: TransformOptions<'_>, + references: &SigningReferenceInputs, +) -> Result { + let signed_info_references = &references.signed_info; + let manifest_references = &references.manifests; + let reference_limit = references.maximum; + let process_manifests = references.process_manifests; let placeholder = "AA=="; // SignatureValue is the final mutable value in the signing pipeline. Give // it concrete character data during analysis so references that retain the @@ -3124,14 +3406,21 @@ fn analyze_signing_reference_inputs( reference_limit, )?); } + // A transform here can read node content, not only keep or drop subtrees, + // so KeyInfo may shape its output even when it does not appear there. reference_dependency_levels( &analysis_doc, analysis_signature, &analysis_references, + &[], transform_options, &budgets.transforms, request.uri_resolution(policy.transforms.same_document_id_semantics), ) + .map(|plan| SigningDependencyPlan { + splice_unread: false, + ..plan + }) } #[derive(Clone, Copy)] @@ -3275,6 +3564,7 @@ fn compute_deferred_reference_digests( }) } +#[cfg(test)] fn fill_reference_digest_values_in_dependency_order_with_operation( document: &mut XmlDocument, transform_options: TransformOptions<'_>, @@ -3287,6 +3577,31 @@ fn fill_reference_digest_values_in_dependency_order_with_operation( setup_gate: Option, defer_digests: bool, ) -> Result<(SigningPlanNodes, Vec<(crate::NodeIdentity, String)>), SigningDigestError> { + let analyzed = analyze_signing_references(document, operation, request, transform_options)?; + fill_analyzed_reference_digest_values( + document, + transform_options, + provider, + operation, + request, + setup_gate, + defer_digests, + analyzed, + ) +} + +/// The Reference inputs of the current generation and what they depend on. +type AnalyzedReferences = (SigningReferenceInputs, SigningDependencyPlan); + +fn analyze_signing_references( + document: &XmlDocument, + operation: &mut OperationExecutionContext< + crate::policy::SigningPolicy, + &mut SigningOperationBudgets, + >, + request: SigningReferenceRequest<'_, '_>, + transform_options: TransformOptions<'_>, +) -> Result { let policy = operation.policy().clone(); let references = collect_signing_reference_inputs(document, &policy, operation.budgets_mut(), request)?; @@ -3298,20 +3613,17 @@ fn fill_reference_digest_values_in_dependency_order_with_operation( transform_options, &references, )?; - let (node_levels, plan_nodes) = - compile_signing_operation_plan(operation, &dependency_plan, setup_gate)?; - let level_request = LevelDigestRequest { - transform_options, - provider, - target_signature: request.target_signature, - uri_resolution: request.uri_resolution(policy.transforms.same_document_id_semantics), - }; - // When no Reference reads another's digest and every DigestValue is empty, - // all digests come from the current generation; SignedInfo canonicalization - // and the final commit receive them instead of an intermediate generation. - if defer_digests - && let [level] = dependency_plan.levels.as_slice() - && let [node_level] = node_levels.as_slice() + Ok((references, dependency_plan)) +} + +/// Whether every digest comes from the current generation: no `Reference` reads +/// another's digest and every `DigestValue` is empty, so `SignedInfo` +/// canonicalization and the final commit receive them with no generation between. +fn digests_can_defer( + document: &XmlDocument, + (references, dependency_plan): &AnalyzedReferences, +) -> bool { + dependency_plan.levels.len() == 1 && document.with_view(|view| { references .signed_info @@ -3323,6 +3635,39 @@ fn fill_reference_digest_values_in_dependency_order_with_operation( .is_some_and(|node| node.children().next().is_none()) }) }) +} + +#[expect( + clippy::too_many_arguments, + reason = "each argument is one part of the signing operation already in progress" +)] +fn fill_analyzed_reference_digest_values( + document: &mut XmlDocument, + transform_options: TransformOptions<'_>, + provider: &dyn crate::provider::CryptoProvider, + operation: &mut OperationExecutionContext< + crate::policy::SigningPolicy, + &mut SigningOperationBudgets, + >, + request: SigningReferenceRequest<'_, '_>, + setup_gate: Option, + defer_digests: bool, + analyzed: AnalyzedReferences, +) -> Result<(SigningPlanNodes, Vec<(crate::NodeIdentity, String)>), SigningDigestError> { + let can_defer = defer_digests && digests_can_defer(document, &analyzed); + let (references, dependency_plan) = analyzed; + let policy = operation.policy().clone(); + let (node_levels, plan_nodes) = + compile_signing_operation_plan(operation, &dependency_plan, setup_gate)?; + let level_request = LevelDigestRequest { + transform_options, + provider, + target_signature: request.target_signature, + uri_resolution: request.uri_resolution(policy.transforms.same_document_id_semantics), + }; + if can_defer + && let [level] = dependency_plan.levels.as_slice() + && let [node_level] = node_levels.as_slice() { let pending = compute_deferred_reference_digests( operation, @@ -3382,6 +3727,8 @@ fn fill_reference_digest_values_in_dependency_order( target_signature, id_attributes, external_resources: &external_resources, + generated: &[], + speculative: false, }, None, false, @@ -3392,24 +3739,23 @@ fn fill_reference_digest_values_in_dependency_order( struct SigningDependencyPlan { dependencies: Vec>, levels: Vec>, + /// No `Reference` can observe the target `KeyInfo` or a `Reference` that + /// gains a generated transform. + splice_unread: bool, } -fn reference_dependency_levels( +/// Every node whose content signing changes or adds, with the index of what +/// fills it: a `Reference`, `SignatureValue` at `references.len()`, and one past +/// that the target `KeyInfo` and every `Reference` in `generated`. +fn tracked_signing_nodes( doc: &Document<'_>, signature: Node<'_, '_>, references: &[SigningReference], - transform_options: TransformOptions<'_>, - execution_budget: &TransformExecutionBudget, - uri_resolution: SigningUriResolution<'_, '_>, -) -> Result { - let resolver = uri_resolution.external_resources.bind( - doc, - uri_resolution.id_attributes, - uri_resolution.same_document_id_semantics, - ); + generated: &[NodeId], +) -> Result, SigningDigestError> { let terminal_signature_value_index = references.len(); let signature_value = find_required_child(signature, "SignatureValue")?; - let mut tracked_mutable_nodes = references + let mut tracked = references .iter() .enumerate() .flat_map(|(index, reference)| { @@ -3422,14 +3768,49 @@ fn reference_dependency_levels( ) }) .collect::>(); - tracked_mutable_nodes.push((terminal_signature_value_index, signature_value.id())); - tracked_mutable_nodes.extend( + tracked.push((terminal_signature_value_index, signature_value.id())); + tracked.extend( signature_value .children() .filter(Node::is_text) .map(|node| (terminal_signature_value_index, node.id())), ); - let analyses = references + tracked.extend( + signature + .children() + .filter(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .flat_map(Node::descendants) + .map(|node| (terminal_signature_value_index + 1, node.id())), + ); + tracked.extend( + generated + .iter() + .filter_map(|digest_value| doc.get_node(*digest_value)) + .filter_map(Node::parent) + .flat_map(Node::descendants) + .map(|node| (terminal_signature_value_index + 1, node.id())), + ); + Ok(tracked) +} + +fn reference_dependency_levels( + doc: &Document<'_>, + signature: Node<'_, '_>, + references: &[SigningReference], + generated: &[NodeId], + transform_options: TransformOptions<'_>, + execution_budget: &TransformExecutionBudget, + uri_resolution: SigningUriResolution<'_, '_>, +) -> Result { + let resolver = uri_resolution.external_resources.bind( + doc, + uri_resolution.id_attributes, + uri_resolution.same_document_id_semantics, + ); + let terminal_signature_value_index = references.len(); + let key_info_index = terminal_signature_value_index + 1; + let tracked_mutable_nodes = tracked_signing_nodes(doc, signature, references, generated)?; + let mut analyses = references .iter() .map(|reference| { let origin = signing_reference_origin(doc, reference)?; @@ -3458,6 +3839,11 @@ fn reference_dependency_levels( "Reference dependency cycle includes the mutable SignatureValue".into(), )); } + // Spliced content orders no digest; only whether a Reference covers it matters. + let mut splice_unread = true; + for dependencies in &mut analyses { + splice_unread &= !dependencies.remove(&key_info_index); + } let mut remaining_dependencies = analyses.clone(); let mut completed = vec![false; references.len()]; let mut levels = Vec::new(); @@ -3493,6 +3879,7 @@ fn reference_dependency_levels( Ok(SigningDependencyPlan { dependencies: analyses, levels, + splice_unread, }) } @@ -3610,23 +3997,32 @@ fn signing_document_root(view: crate::document::DocumentView<'_>) -> crate::Node view.root_element() } -fn commit_signed_staged( +/// Returns `document` to its starting generation unless signing finished, +/// including when signing unwinds. +struct AtomicSigning<'a> { + document: &'a mut XmlDocument, + succeeded: bool, +} + +impl Drop for AtomicSigning<'_> { + fn drop(&mut self) { + self.document.finish_atomic_mutation(self.succeeded); + } +} + +/// Run `sign` so that it commits one generation or none. +fn atomically( document: &mut XmlDocument, - staged: XmlDocument, - policy: &crate::policy::SigningPolicy, -) -> Result<(), SigningError> { - let mut operation = OperationExecutionContext::new( - policy.clone(), - (), - Some((document.identity(), document.generation())), - ); - let mutation = operation.add_node(OperationNodeKind::Mutation, OperationStage::Mutation, None); - operation.compile()?; - operation.run_document_transition(mutation, document, |document, ()| { - document - .commit_staged(staged) - .map_err(map_owned_document_mutation_error) - }) + sign: impl FnOnce(&mut XmlDocument) -> Result, +) -> Result { + document.begin_atomic_mutation(); + let mut guard = AtomicSigning { + document, + succeeded: false, + }; + let result = sign(guard.document); + guard.succeeded = result.is_ok(); + result } fn validate_signing_references( @@ -3846,6 +4242,7 @@ fn canonicalize_signed_info( budgets: &mut SigningOperationBudgets, target_signature: usize, pending_digests: &[(crate::NodeIdentity, String)], + insertions: &[CanonicalInsertion], ) -> Result< ( SignatureAlgorithm, @@ -3890,7 +4287,7 @@ fn canonicalize_signed_info( .into()); } let mut canonical_signed_info = Vec::new(); - let canonicalized = if pending_digests.is_empty() { + let canonicalized = if pending_digests.is_empty() && insertions.is_empty() { crate::c14n::canonicalize_visible_to_writer_bounded( doc, Some(&crate::c14n::SubtreeVisibility { @@ -3909,6 +4306,7 @@ fn canonicalize_signed_info( &signed_info.c14n_method, budgets, &pending_digests, + insertions, &mut canonical_signed_info, ) }; @@ -3940,6 +4338,12 @@ const SECOND_EDITION_GENERATION_C14N_URI: &str = "http://www.w3.org/2006/12/xml- /// element's start is that end tag. Base64 text needs no escaping (C14N 1.0 /// section 5.2), so inserting it there yields the canonical form of the filled /// element. Digests outside `SignedInfo` are not part of its output. +/// +/// A generated transform lies before the `DigestMethod` of its `Reference` or at +/// the end of its `Transforms`, whose end tag is then the last one before that +/// `DigestMethod`. It takes the prefix of an element already in scope there, so +/// neither mode renders a namespace on it (Exclusive C14N section 3), and its +/// `Algorithm` URI needs no escaping. /// fn canonicalize_with_pending_digests( document: &Document<'_>, @@ -3947,6 +4351,7 @@ fn canonicalize_with_pending_digests( algorithm: &crate::c14n::C14nAlgorithm, budgets: &SigningOperationBudgets, pending_digests: &[(NodeId, &str)], + insertions: &[CanonicalInsertion], output: &mut Vec, ) -> Result<(), crate::c14n::C14nError> { let digests = pending_digests @@ -3954,8 +4359,16 @@ fn canonicalize_with_pending_digests( .copied() .filter(|(node, _)| signed_info.subtree_contains_id(*node)) .collect::>(); - let tracked = digests.keys().copied().collect::>(); - let inserted = digests.values().map(|digest| digest.len()).sum::(); + let tracked = digests + .keys() + .copied() + .chain(insertions.iter().map(|insertion| insertion.digest_method)) + .collect::>(); + let inserted = digests.values().map(|digest| digest.len()).sum::() + + insertions + .iter() + .map(|insertion| insertion.text.len()) + .sum::(); let remaining = budgets.transforms.remaining_c14n_output(); // Output that cannot hold the inserted digests exceeds the limit in either form. let Some(allowance) = remaining.checked_sub(inserted) else { @@ -3978,18 +4391,38 @@ fn canonicalize_with_pending_digests( tracked.len(), "every SignedInfo element is visible to its own canonicalization" ); - positions.sort_unstable_by_key(|(_, position)| *position); + let mut splices = Vec::with_capacity(positions.len()); + for (node, start) in positions.drain(..) { + if let Some(digest) = digests.get(&node) { + let end_tag = unfilled[start..] + .windows(2) + .position(|bytes| bytes == b" Result, SigningDigestError> { + let generated = + second_edition_c14n11_edits(document, target_signature, policy, has_external_resources)?; + Ok((!generated.is_empty()).then(|| c14n11_candidate(document, generated))) +} + +/// The serialization with every generated transform in place. +fn c14n11_candidate(document: &XmlDocument, generated: Vec) -> String { + let mut candidate = document.as_xml().to_owned(); + let mut edits = generated + .into_iter() + .map(|generated| generated.edit) + .collect::>(); + edits.sort_unstable_by_key(|edit| core::cmp::Reverse(edit.range.start)); + for edit in edits { + candidate.replace_range(edit.range, &edit.replacement); + } + candidate +} + +/// The Second Edition transform each `Reference` still needs, as source edits. +fn second_edition_c14n11_edits( + document: &XmlDocument, + target_signature: usize, + policy: &crate::policy::SigningPolicy, + has_external_resources: bool, +) -> Result, SigningDigestError> { let edits = document.with_view(|view| { let signature = find_signing_signature_node( view.document(), SigningSignatureTarget::Index(target_signature), )?; + let signed_info = find_required_child(signature, "SignedInfo")?; let reference_nodes = generation_reference_nodes(signature, policy)?; let mut edits = Vec::new(); @@ -4148,36 +4608,70 @@ fn materialize_second_edition_c14n11_candidate( } let edit = c14n11_edit_for_reference(view.xml(), reference_node, &structure)?; - edits.push(edit); + edits.push(generated_c14n( + signed_info, + reference_node, + &structure, + edit, + )); } Ok::<_, SigningDigestError>(edits) })?; if edits.is_empty() { - return Ok(None); + return Ok(edits); } - let projected_len = edits - .iter() - .try_fold(document.as_xml().len(), |total, edit| { + let projected_len = edits.iter().map(|generated| &generated.edit).try_fold( + document.as_xml().len(), + |total, edit| { total .checked_sub(edit.range.len()) .and_then(|value| value.checked_add(edit.replacement.len())) .ok_or_else(|| { SigningDigestError::InvalidStructure("document length overflow".into()) }) - })?; + }, + )?; policy .resources .validate_xml_document_len(projected_len) .map_err(SigningDigestError::Policy)?; + Ok(edits) +} - let mut candidate = document.as_xml().to_owned(); - let mut edits = edits; - edits.sort_unstable_by_key(|edit| std::cmp::Reverse(edit.range.start)); - for edit in edits { - candidate.replace_range(edit.range, &edit.replacement); +/// How `edit` joins `reference`, and its canonical form. +fn generated_c14n( + signed_info: Node<'_, '_>, + reference: Node<'_, '_>, + structure: &SigningReferenceStructure<'_>, + edit: SourceEdit, +) -> GeneratedC14n { + let owner = structure.transforms_node.unwrap_or(reference); + let qualifier = xml_qualifier(owner.prefix().unwrap_or_default()); + let transform = format!( + "<{qualifier}Transform Algorithm=\"{SECOND_EDITION_GENERATION_C14N_URI}\">" + ); + let appends = structure.transforms_node.is_some(); + let canonical_in_place = structure.transforms_node.is_none_or(|transforms| { + core::iter::successors(transforms.next_sibling(), |node| node.next_sibling()) + .take_while(|node| node.id() != structure.digest_method_node.id()) + .all(|node| node.is_text()) + }); + GeneratedC14n { + digest_value: structure.digest_value_node.id(), + digest_method: structure.digest_method_node.id(), + appends, + canonical_in_place, + in_signed_info: signed_info.subtree_contains_id(reference.id()), + // Depth counts elements alone, as the parser's document metrics do. + depth: reference.ancestors().filter(Node::is_element).count() + 2, + edit, + canonical: if appends { + transform + } else { + format!("<{qualifier}Transforms>{transform}") + }, } - Ok(Some(candidate)) } fn c14n11_edit_for_transforms( @@ -4273,6 +4767,9 @@ mod generation_output_tests; #[cfg(test)] mod deferred_commit_tests; +#[cfg(test)] +mod generated_c14n_tests; + fn xml_qualifier(prefix: &str) -> String { if prefix.is_empty() { String::new() diff --git a/src/xmldsig/sign/deferred_commit_tests.rs b/src/xmldsig/sign/deferred_commit_tests.rs index f78d6414..5bcd50ed 100644 --- a/src/xmldsig/sign/deferred_commit_tests.rs +++ b/src/xmldsig/sign/deferred_commit_tests.rs @@ -144,7 +144,7 @@ fn independent_canonical_signed_info( ) -> Vec { let document = XmlDocument::parse(signed).expect("signed output must parse"); let mut budgets = SigningOperationBudgets::from_resources(&policy.resources); - canonicalize_signed_info(&document, policy, &mut budgets, 0, &[]) + canonicalize_signed_info(&document, policy, &mut budgets, 0, &[], &[]) .expect("signed output SignedInfo must canonicalize") .2 } diff --git a/src/xmldsig/sign/error_conversion_tests.rs b/src/xmldsig/sign/error_conversion_tests.rs index 16279e06..360534a1 100644 --- a/src/xmldsig/sign/error_conversion_tests.rs +++ b/src/xmldsig/sign/error_conversion_tests.rs @@ -411,14 +411,13 @@ fn owned_signing_commits_the_validated_stage_without_reparsing() { let xml = r##"content"##; let policy = crate::policy::SigningPolicy::default(); let context = SignContext::new(&FixedRsaSigningKey).policy(policy.clone()); - let source = XmlDocument::parse(xml).expect("fixture must parse"); let mut measured = SigningOperationBudgets::from_resources(&policy.resources); - let mut staged = source - .staged_copy_with_budget( - DocumentParseSettings::from_policy(policy.xml, &policy.resources), - measured.transforms.xml_parse_work(), - ) - .expect("staging must parse"); + let mut staged = XmlDocument::parse_with_settings_and_budget( + xml.to_owned(), + DocumentParseSettings::from_policy(policy.xml, &policy.resources), + measured.transforms.xml_parse_work(), + ) + .expect("staging must parse"); context .sign_document_in_place::(&mut staged, &mut measured) .expect("staged signing must succeed"); @@ -437,6 +436,68 @@ fn owned_signing_commits_the_validated_stage_without_reparsing() { assert!(!document.as_xml().contains("")); } +#[test] +fn a_key_info_writer_adds_no_document_parse() { + // Nothing signed reads KeyInfo here, so the writer's output joins the final + // generation: its own fragment is parsed, the document only at the commit. + struct NameWriter; + + impl KeyInfoWriter for NameWriter { + fn write_key_info(&self, _: &dyn SigningKey) -> Result { + Ok(format!(r#"signer"#)) + } + } + + let padding = "x".repeat(16 * 1024); + let exclusive = "http://www.w3.org/2001/10/xml-exc-c14n#"; + let xml = format!( + concat!( + r#"{padding}"#, + r#""#, + r#""#, + r#""#, + r##""##, + r#""#, + r#""#, + r#""#, + r#""#, + ), + padding = padding, + exclusive = exclusive, + ); + let policy = crate::policy::SigningPolicy::default(); + let context = SignContext::new(&FixedRsaSigningKey) + .policy(policy.clone()) + .key_info_writer(&NameWriter); + let mut document = XmlDocument::parse(xml.as_str()).expect("fixture must parse"); + let mut measured = SigningOperationBudgets::from_resources(&policy.resources); + context + .sign_document_in_place::(&mut document, &mut measured) + .expect("signing must succeed"); + assert!(document.as_xml().contains("signer")); + let once = XmlParseWorkBudget::from_resources(&policy.resources); + XmlDocument::parse_with_settings_and_budget( + document.as_xml().to_owned(), + DocumentParseSettings::from_policy(policy.xml, &policy.resources), + &once, + ) + .expect("the signed generation must parse"); + let work = measured.transforms.xml_parse_work().consumed(); + assert!( + work < once.consumed() * 3 / 2, + "{work} against {}", + once.consumed() + ); + + let text = SignContext::new(&FixedRsaSigningKey) + .policy(policy) + .key_info_writer(&NameWriter) + .sign_template(&xml) + .expect("text signing must succeed"); + assert_eq!(text, document.as_xml()); +} + #[test] fn builder_signing_fits_the_document_to_parse_work_ratio() { // A builder operation at the configured document ceiling must fit the @@ -512,7 +573,11 @@ fn dtd_capable_staged_copy_charges_both_parser_passes() { parsing_policy.resources.max_xml_parse_work_bytes = xml.len(); let budget = XmlParseWorkBudget::from_resources(&parsing_policy.resources); assert!(matches!( - document.staged_copy_with_budget(DocumentParseSettings::default(), &budget), + XmlDocument::parse_with_settings_and_budget( + document.as_xml().to_owned(), + DocumentParseSettings::default(), + &budget, + ), Err(XmlDocumentError::Policy(PolicyViolation::ResourceLimit { resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES, maximum, diff --git a/src/xmldsig/sign/generated_c14n_tests.rs b/src/xmldsig/sign/generated_c14n_tests.rs new file mode 100644 index 00000000..59479aeb --- /dev/null +++ b/src/xmldsig/sign/generated_c14n_tests.rs @@ -0,0 +1,458 @@ +use super::*; +use core::cell::RefCell; + +const C14N11: &str = "http://www.w3.org/2006/12/xml-c14n11"; +const ENVELOPED: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"; + +/// A key that keeps the bytes it was asked to sign. +#[derive(Default)] +struct RecordingKey { + signed: RefCell>, +} + +impl SigningKey for RecordingKey { + fn sign(&self, _: SignatureAlgorithm, canonical: &[u8]) -> Result, SigningKeyError> { + self.signed.borrow_mut().extend_from_slice(canonical); + Ok(vec![0x5a; 256]) + } + + fn public_key_info(&self) -> Result { + Ok(SigningPublicKeyInfo::Rsa { + spki_der: Vec::new(), + modulus: vec![0x80; 256], + exponent: vec![1, 0, 1], + }) + } +} + +/// `xml` signed through the text entry point and through the retained one, +/// which must agree, with the canonical `SignedInfo` the key received. +fn signed(xml: &str, policy: &crate::policy::SigningPolicy) -> (String, Vec) { + let key = RecordingKey::default(); + let text = SignContext::new(&key) + .policy(policy.clone()) + .sign_template(xml) + .unwrap_or_else(|error| panic!("{error}: {xml}")); + let canonical = key.signed.take(); + let mut document = XmlDocument::parse(xml).expect("fixture parses"); + SignContext::new(&key) + .policy(policy.clone()) + .sign_document(&mut document) + .unwrap_or_else(|error| panic!("{error}: {xml}")); + assert_eq!(document.as_xml(), text); + assert_eq!(key.signed.take(), canonical); + (text, canonical) +} + +/// A template over `reference`, in the namespace form `open` declares. +fn template(canonicalization: &str, open: &str, q: &str, reference: &str) -> String { + format!( + concat!( + "content", + "<{q}Signature {open}><{q}SignedInfo>", + "<{q}CanonicalizationMethod Algorithm=\"{canonicalization}\"/>", + "<{q}SignatureMethod ", + "Algorithm=\"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256\"/>", + "{reference}<{q}SignatureValue/>", + ), + q = q, + open = open, + canonicalization = canonicalization, + reference = reference, + ) +} + +fn reference(q: &str, uri: &str, transforms: &str) -> String { + format!( + concat!( + "<{q}Reference URI=\"{uri}\">{transforms}", + "<{q}DigestMethod Algorithm=\"http://www.w3.org/2001/04/xmlenc#sha256\"/>", + "<{q}DigestValue/>", + ), + q = q, + uri = uri, + transforms = transforms, + ) +} + +#[test] +fn a_generated_canonicalization_signs_exactly_like_an_explicit_one() { + // XMLDSig Second Edition generation makes C14N 1.1 explicit. Whether that + // transform is committed first or joins the final mutation, the document + // and the signed bytes must be the ones a template already stating it gives. + let policy = crate::policy::SigningPolicy::default(); + for canonicalization in [ + "http://www.w3.org/2001/10/xml-exc-c14n#", + "http://www.w3.org/2001/10/xml-exc-c14n#WithComments", + "http://www.w3.org/TR/2001/REC-xml-c14n-20010315", + "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments", + C14N11, + ] { + for (open, q) in [ + ("xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"", "ds:"), + ("xmlns=\"http://www.w3.org/2000/09/xmldsig#\"", ""), + ] { + let last = format!("<{q}Transform Algorithm=\"{C14N11}\"/>"); + let enveloped = format!("<{q}Transform Algorithm=\"{ENVELOPED}\"/>"); + let cases = [ + ( + "#payload", + String::new(), + format!("<{q}Transforms>{last}"), + ), + ( + "#payload", + format!("<{q}Transforms/>"), + format!("<{q}Transforms>{last}"), + ), + ( + "#payload", + format!("<{q}Transforms />"), + format!("<{q}Transforms >{last}"), + ), + ( + "#payload", + format!("<{q}Transforms>\n \n "), + format!("<{q}Transforms>\n {last}\n "), + ), + ( + "", + format!("<{q}Transforms>{enveloped}"), + format!("<{q}Transforms>{enveloped}{last}"), + ), + ( + "", + format!("<{q}Transforms>{enveloped}\n"), + format!("<{q}Transforms>{enveloped}\n{last}"), + ), + ( + "#payload", + "\n".to_owned(), + format!("\n<{q}Transforms>{last}"), + ), + ]; + for (uri, generated, explicit) in &cases { + let generated = template(canonicalization, open, q, &reference(q, uri, generated)); + let explicit = template(canonicalization, open, q, &reference(q, uri, explicit)); + assert_eq!( + signed(&generated, &policy), + signed(&explicit, &policy), + "{generated}" + ); + } + } + } +} + +#[test] +fn a_generated_canonicalization_covers_every_reference_of_a_signature() { + // Several edits, with the KeyInfo writer's splice after them in the document. + struct NameWriter; + + impl KeyInfoWriter for NameWriter { + fn write_key_info(&self, _: &dyn SigningKey) -> Result { + Ok(format!(r#"signer"#)) + } + } + + let q = "ds:"; + let open = "xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\""; + let exclusive = "http://www.w3.org/2001/10/xml-exc-c14n#"; + let last = format!("<{q}Transforms><{q}Transform Algorithm=\"{C14N11}\"/>"); + let build = |transforms: &str| { + let references = + reference(q, "#payload", transforms) + &reference(q, "#payload", transforms); + template(exclusive, open, q, &references) + .replace("", "") + }; + let sign = |xml: &str| { + let key = RecordingKey::default(); + let text = SignContext::new(&key) + .key_info_writer(&NameWriter) + .sign_template(xml) + .expect("signing succeeds"); + (text, key.signed.take()) + }; + assert_eq!(sign(&build("")), sign(&build(&last))); +} + +#[test] +fn a_generated_canonicalization_read_by_another_reference_commits_first() { + // The outer Reference digests the Manifest holding the edited inner one, so + // it must see the transform: a deferred insertion would sign other bytes. + let policy = crate::policy::SigningPolicy { + manifest_processing: crate::policy::ManifestProcessing::Process, + ..crate::policy::SigningPolicy::default() + }; + let q = "ds:"; + let open = "xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\""; + let exclusive = "http://www.w3.org/2001/10/xml-exc-c14n#"; + let outer = format!("<{q}Transforms><{q}Transform Algorithm=\"{exclusive}\"/>"); + let last = format!("<{q}Transforms><{q}Transform Algorithm=\"{C14N11}\"/>"); + let build = |inner: &str| { + template(exclusive, open, q, &reference(q, "#manifest", &outer)).replace( + "", + &format!( + "{}", + reference(q, "#payload", inner) + ), + ) + }; + assert_eq!(signed(&build(""), &policy), signed(&build(&last), &policy)); +} + +#[test] +fn a_generated_canonicalization_takes_the_prefix_of_foreign_transforms() { + // Exclusive C14N renders a namespace only where it is visibly used + // (section 3): the new child reuses the declaration on its parent. + let policy = crate::policy::SigningPolicy::default(); + let declared = "")), + ); + let explicit = template( + exclusive, + open, + "ds:", + &reference( + "ds:", + "#payload", + &format!("{declared}>"), + ), + ); + assert_eq!(signed(&generated, &policy), signed(&explicit, &policy)); +} + +#[test] +fn a_chain_read_through_a_placeholder_parses_no_more_for_generation() { + // XPath analysis needs placeholder text, which rules deferral out. That must + // be known before the placeholder document is parsed: one analysis parse, + // one for the generated transform, one for the signed generation. + let policy = crate::policy::SigningPolicy::default(); + let xpath = "\ + true()"; + let xml = template( + "http://www.w3.org/2001/10/xml-exc-c14n#", + "xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"", + "ds:", + &reference("ds:", "#payload", xpath), + ) + .replace("content", &"x".repeat(16 * 1024)); + let key = RecordingKey::default(); + let mut document = XmlDocument::parse(xml.as_str()).expect("fixture parses"); + let mut measured = SigningOperationBudgets::from_resources(&policy.resources); + SignContext::new(&key) + .policy(policy.clone()) + .sign_document_in_place::(&mut document, &mut measured) + .expect("signing succeeds"); + let once = XmlParseWorkBudget::from_resources(&policy.resources); + XmlDocument::parse_with_settings_and_budget( + document.as_xml().to_owned(), + DocumentParseSettings::from_policy(policy.xml, &policy.resources), + &once, + ) + .expect("the signed generation parses"); + let consumed = measured.transforms.xml_parse_work().consumed(); + assert!(consumed <= once.consumed() * 3, "{consumed}"); +} + +const OPEN_DS: &str = "xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\""; +const EXCLUSIVE: &str = "http://www.w3.org/2001/10/xml-exc-c14n#"; + +/// A writer returning fixed `KeyInfo` content. +struct FixedWriter(&'static str); + +impl KeyInfoWriter for FixedWriter { + fn write_key_info(&self, _: &dyn SigningKey) -> Result { + Ok(self.0.to_owned()) + } +} + +fn with_key_info(reference: &str, key_info: &str) -> String { + template(EXCLUSIVE, OPEN_DS, "ds:", reference).replace( + "", + &format!("{key_info}"), + ) +} + +fn explicit_transforms() -> String { + format!("") +} + +#[test] +fn a_shrinking_signature_value_keeps_the_projected_length_in_range() { + // Stale SignatureValue content longer than the new value makes the value + // replacements shorten the document before the splice grows it again. + let policy = crate::policy::SigningPolicy::default(); + let stale = format!( + "{}", + " ".repeat(1024) + ); + let build = |transforms: &str| { + template( + EXCLUSIVE, + OPEN_DS, + "ds:", + &reference("ds:", "#payload", transforms), + ) + .replace("", &stale) + }; + let last = format!(""); + assert_eq!(signed(&build(""), &policy), signed(&build(&last), &policy)); +} + +#[test] +fn a_generated_transform_is_held_to_the_depth_the_parser_counts() { + // root/Signature/SignedInfo/Reference/Transforms/Transform has depth 6. + let xml = template(EXCLUSIVE, OPEN_DS, "ds:", &reference("ds:", "#payload", "")); + let sign = |max_xml_depth: usize| { + let mut policy = crate::policy::SigningPolicy::default(); + policy.resources.max_xml_depth = max_xml_depth; + SignContext::new(&RecordingKey::default()) + .policy(policy) + .sign_template(&xml) + }; + sign(6).expect("depth 6 is within the limit"); + assert!(sign(5).is_err()); +} + +#[test] +fn a_spliced_key_info_is_held_to_the_depth_the_parser_counts() { + // Elements alone count: root/Signature/KeyInfo/a/b/c/d is 7, whatever text + // sits in the deepest one. + let xml = with_key_info( + &reference("ds:", "#payload", &explicit_transforms()), + "", + ); + let writer = FixedWriter("text"); + let sign = |max_xml_depth: usize| { + let mut policy = crate::policy::SigningPolicy::default(); + policy.resources.max_xml_depth = max_xml_depth; + SignContext::new(&RecordingKey::default()) + .policy(policy) + .key_info_writer(&writer) + .sign_template(&xml) + }; + sign(7).expect("depth 7 is within the limit"); + assert!(sign(6).is_err()); +} + +#[test] +fn a_merged_placeholder_is_held_to_the_namespace_limit() { + // The fragment and the document each pass; only the merged element, with + // the placeholder's binding and the writer's, can exceed the limit. The text + // path does not parse its result, so it must decide as the commit does. + let xml = with_key_info( + &reference("ds:", "#payload", &explicit_transforms()), + "", + ); + let writer = FixedWriter( + "signer", + ); + let verdicts = (1..8) + .map(|max_xml_namespace_bindings| { + let mut policy = crate::policy::SigningPolicy::default(); + policy.resources.max_xml_namespace_bindings = max_xml_namespace_bindings; + let key = RecordingKey::default(); + let context = SignContext::new(&key) + .policy(policy) + .key_info_writer(&writer); + let text = context.sign_template(&xml).is_ok(); + let mut document = XmlDocument::parse(xml.as_str()).expect("fixture parses"); + (text, context.sign_document(&mut document).is_ok()) + }) + .collect::>(); + assert!(verdicts.contains(&(false, false))); + assert!(verdicts.contains(&(true, true))); + assert!( + verdicts.iter().all(|(text, document)| text == document), + "{verdicts:?}" + ); +} + +#[test] +fn an_id_from_the_writer_is_seen_by_reference_resolution() { + // The new ID makes `#payload` ambiguous in the signed document, so a + // signature over the original target could never verify. + let xml = with_key_info( + &reference("ds:", "#payload", &explicit_transforms()), + "", + ); + let writer = FixedWriter(""); + let result = SignContext::new(&RecordingKey::default()) + .key_info_writer(&writer) + .sign_template(&xml); + assert!(result.is_err(), "{result:?}"); +} + +#[test] +fn a_writer_with_a_placeholder_analysis_parses_no_more_for_deferral() { + // XPath analysis rules deferral out, which must be known before the + // placeholder document is parsed: KeyInfo, analysis and the signed generation. + let policy = crate::policy::SigningPolicy::default(); + let xpath = "\ + true()"; + let transforms = format!("{xpath}"); + let xml = with_key_info(&reference("ds:", "#payload", &transforms), "") + .replace("content", &"x".repeat(16 * 1024)); + let writer = FixedWriter("signer"); + let key = RecordingKey::default(); + let mut document = XmlDocument::parse(xml.as_str()).expect("fixture parses"); + let mut measured = SigningOperationBudgets::from_resources(&policy.resources); + SignContext::new(&key) + .policy(policy.clone()) + .key_info_writer(&writer) + .sign_document_in_place::(&mut document, &mut measured) + .expect("signing succeeds"); + let once = XmlParseWorkBudget::from_resources(&policy.resources); + XmlDocument::parse_with_settings_and_budget( + document.as_xml().to_owned(), + DocumentParseSettings::from_policy(policy.xml, &policy.resources), + &once, + ) + .expect("the signed generation parses"); + let consumed = measured.transforms.xml_parse_work().consumed(); + assert!(consumed < once.consumed() * 7 / 2, "{consumed}"); +} + +#[test] +fn a_generated_canonicalization_adds_no_document_parse() { + // The document is parsed for the signed generation alone. A DOCTYPE keeps + // the committed path, as a declared default could reach the new element. + let padding = "x".repeat(16 * 1024); + let xml = template( + "http://www.w3.org/2001/10/xml-exc-c14n#", + "xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"", + "ds:", + &reference("ds:", "#payload", ""), + ) + .replace("content", &padding); + let policy = crate::policy::SigningPolicy::default(); + let key = RecordingKey::default(); + let context = SignContext::new(&key).policy(policy.clone()); + let mut document = XmlDocument::parse(xml.as_str()).expect("fixture parses"); + let mut measured = SigningOperationBudgets::from_resources(&policy.resources); + context + .sign_document_in_place::(&mut document, &mut measured) + .expect("signing succeeds"); + assert!(document.as_xml().contains(C14N11)); + let once = XmlParseWorkBudget::from_resources(&policy.resources); + XmlDocument::parse_with_settings_and_budget( + document.as_xml().to_owned(), + DocumentParseSettings::from_policy(policy.xml, &policy.resources), + &once, + ) + .expect("the signed generation parses"); + assert_eq!( + measured.transforms.xml_parse_work().consumed(), + once.consumed() + ); +} diff --git a/src/xmldsig/whitespace.rs b/src/xmldsig/whitespace.rs index c6f941df..f2748751 100644 --- a/src/xmldsig/whitespace.rs +++ b/src/xmldsig/whitespace.rs @@ -2,6 +2,22 @@ use alloc::{string::String, vec, vec::Vec}; +/// Whether a byte is a RFC 4648 §4 alphabet character. +const BASE64_ALPHABET: [bool; 256] = { + let mut table = [false; 256]; + let mut byte = 0_u8; + loop { + table[byte as usize] = matches!( + byte, + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' + ); + if byte == u8::MAX { + break table; + } + byte += 1; + } +}; + /// Borrow XML text and validate its exact decoded size without normalizing it /// into a second heap buffer. Callers reserve this size before calling decode. pub(crate) struct XmlBase64Payload<'a, 'input> { @@ -42,30 +58,50 @@ impl<'a, 'input> XmlBase64Payload<'a, 'input> { text_len = text_len .checked_add(text.len()) .ok_or("base64 size overflow")?; - for byte in text.bytes() { + let mut rest = text.as_bytes(); + while let [byte, ..] = rest { if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') { + rest = &rest[1..]; continue; } if normalized_len >= max_normalized { return Err("maximum allowed base64 length"); } - if byte == b'=' { + let run = rest + .iter() + .position(|byte| !BASE64_ALPHABET[usize::from(*byte)]) + .unwrap_or(rest.len()); + if run == 0 { + if *byte != b'=' { + return Err("invalid base64 character or XML whitespace"); + } padding += 1; if padding > 2 { return Err("invalid base64 padding"); } - } else if padding != 0 - || !matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/') - { + #[cfg(feature = "xmlenc")] + { + tail[normalized_len % 4] = b'='; + } + // Bounded by `max_normalized`, checked above. + normalized_len += 1; + rest = &rest[1..]; + continue; + } + if padding != 0 { return Err("invalid base64 character or XML whitespace"); } + if run > max_normalized - normalized_len { + return Err("maximum allowed base64 length"); + } + // Only the final quartet is read back, so the run's last bytes + // land where a byte-at-a-time walk would leave them. #[cfg(feature = "xmlenc")] - { - tail[normalized_len % 4] = byte; + for (offset, byte) in rest[..run].iter().enumerate().skip(run.saturating_sub(4)) { + tail[(normalized_len + offset) % 4] = *byte; } - normalized_len = normalized_len - .checked_add(1) - .ok_or("base64 size overflow")?; + normalized_len += run; + rest = &rest[run..]; } } if !normalized_len.is_multiple_of(4) { @@ -109,8 +145,21 @@ impl<'a, 'input> XmlBase64Payload<'a, 'input> { pub(crate) fn normalized(&self) -> Result { self.validate()?; let mut value = String::with_capacity(self.normalized_len); - for byte in self.normalized_bytes() { - value.push(char::from(byte)); + let texts = self + .node + .children() + .filter(crate::xml::dom::Node::is_text) + .filter_map(crate::xml::dom::Node::text); + for mut rest in texts { + // XML whitespace is ASCII, so these offsets are character boundaries. + while let Some(end) = rest + .bytes() + .position(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n')) + { + value.push_str(&rest[..end]); + rest = &rest[end + 1..]; + } + value.push_str(rest); } Ok(value) }