crates/nornir-auth-saml/src/sp/c14n.rs
//! **Exclusive XML Canonicalization 1.0, without comments**
//! (<http://www.w3.org/2001/10/xml-exc-c14n#>), for ONE subtree — the only
//! canonicalisation a SAML signature uses.
//!
//! The rules, as implemented (and checked byte for byte against libxml2's
//! output in `tests/sp_interop.rs`):
//!
//! * an element renders a namespace declaration only for a prefix it
//! **visibly utilises** — its own prefix (the default namespace when it has
//! none) and its attributes' prefixes — or one named in the
//! `InclusiveNamespaces PrefixList`, and only when the nearest output
//! ancestor did not already render that prefix with that URI. The default
//! namespace undeclared (`xmlns=""`) is rendered only when an output ancestor
//! rendered a non-empty default;
//! * declarations are sorted by prefix (the default first); attributes by
//! namespace URI, then local name (unqualified attributes first);
//! * `xml:*` attributes are not imported from ancestors (that is inclusive
//! C14N's rule, not this one's), and `xmlns:xml` is never rendered;
//! * empty elements are written as a start and an end tag; comments are
//! dropped; processing instructions are kept;
//! * text escapes `&` `<` `>` and `#xD`; attribute values escape `&` `<` `"`
//! and `#x9` `#xA` `#xD`.
//!
//! The input is a [`roxmltree`] document, so entity and character references
//! are already expanded, line ends are normalised and CDATA is plain text —
//! exactly the XPath data model C14N is defined over. roxmltree keeps no
//! prefixes, so element and attribute QNames are read back from the source
//! text by their byte ranges.
use std::collections::BTreeMap;
use roxmltree::{Document, Node, NodeId, NodeType};
const XML_PREFIX: &str = "xml";
/// The canonical form of `node`'s subtree. `inclusive_prefixes` is the
/// `InclusiveNamespaces PrefixList` (`#default`, or an empty string, names the
/// default namespace); `exclude` is an element left out with its whole subtree
/// — the enveloped `Signature` the enveloped-signature transform removes.
pub fn exclusive(
doc: &Document,
node: Node,
inclusive_prefixes: &[&str],
exclude: Option<NodeId>,
) -> String {
let inclusive: Vec<&str> = inclusive_prefixes
.iter()
.map(|p| if *p == "#default" { "" } else { *p })
.collect();
let mut out = String::with_capacity(4096);
let in_effect = BTreeMap::new();
match node.node_type() {
NodeType::Element => element(doc, node, &inclusive, exclude, &in_effect, &mut out),
NodeType::Text => escape_text(node.text().unwrap_or_default(), &mut out),
_ => {}
}
out
}
/// The QName an element was written with, read from the source.
fn element_qname<'a>(doc: &'a Document, node: Node) -> &'a str {
let text = doc.input_text();
let start = node.range().start;
let rest = &text[start..];
let rest = rest.strip_prefix('<').unwrap_or(rest);
let end = rest
.find(|c: char| c.is_ascii_whitespace() || c == '/' || c == '>')
.unwrap_or(rest.len());
&rest[..end]
}
fn prefix_of(qname: &str) -> &str {
qname.split_once(':').map(|(p, _)| p).unwrap_or("")
}
/// The URI bound to `prefix` on `node` ("" for an unbound default).
fn in_scope(node: Node, prefix: &str) -> Option<String> {
if prefix.is_empty() {
return Some(node.default_namespace().unwrap_or("").to_owned());
}
node.namespaces()
.find(|ns| ns.name() == Some(prefix))
.map(|ns| ns.uri().to_owned())
}
fn element(
doc: &Document,
node: Node,
inclusive: &[&str],
exclude: Option<NodeId>,
in_effect: &BTreeMap<String, String>,
out: &mut String,
) {
let qname = element_qname(doc, node);
let own_prefix = prefix_of(qname);
// Attributes, with the QName each was written with.
let mut attrs: Vec<(&str, &str, &str, &str)> = Vec::new(); // (ns uri, local, qname, value)
for a in node.attributes() {
let aq = &doc.input_text()[a.range_qname()];
attrs.push((a.namespace().unwrap_or(""), a.name(), aq, a.value()));
}
// The namespace nodes this element may render: what it visibly utilises,
// and the inclusive prefixes in scope.
let mut wanted: BTreeMap<String, String> = BTreeMap::new();
// An unprefixed element in no namespace utilises the EMPTY default, which
// renders as `xmlns=""` only under an ancestor that rendered a real one.
let own_uri = node.tag_name().namespace().unwrap_or("").to_owned();
if own_prefix != XML_PREFIX {
wanted.insert(own_prefix.to_owned(), own_uri);
}
for (uri, _, aq, _) in &attrs {
let p = prefix_of(aq);
if !p.is_empty() && p != XML_PREFIX {
wanted.insert(p.to_owned(), (*uri).to_owned());
}
}
for p in inclusive {
if *p == XML_PREFIX || wanted.contains_key(*p) {
continue;
}
if let Some(uri) = in_scope(node, p) {
wanted.insert((*p).to_owned(), uri);
}
}
let mut rendered: Vec<(String, String)> = Vec::new();
for (prefix, uri) in wanted {
let current = in_effect.get(&prefix).map(String::as_str).unwrap_or("");
if current != uri {
rendered.push((prefix, uri));
}
}
// BTreeMap order is already prefix order with "" (the default) first.
out.push('<');
out.push_str(qname);
for (prefix, uri) in &rendered {
if prefix.is_empty() {
out.push_str(" xmlns=\"");
} else {
out.push_str(" xmlns:");
out.push_str(prefix);
out.push_str("=\"");
}
escape_attr(uri, out);
out.push('"');
}
attrs.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
for (_, _, aq, value) in &attrs {
out.push(' ');
out.push_str(aq);
out.push_str("=\"");
escape_attr(value, out);
out.push('"');
}
out.push('>');
let child_effect;
let effect = if rendered.is_empty() {
in_effect
} else {
let mut next = in_effect.clone();
for (prefix, uri) in rendered {
next.insert(prefix, uri);
}
child_effect = next;
&child_effect
};
for child in node.children() {
match child.node_type() {
NodeType::Element => {
if Some(child.id()) == exclude {
continue;
}
element(doc, child, inclusive, exclude, effect, out);
}
NodeType::Text => escape_text(child.text().unwrap_or_default(), out),
NodeType::PI => {
if let Some(pi) = child.pi() {
out.push_str("<?");
out.push_str(pi.target);
if let Some(value) = pi.value {
if !value.is_empty() {
out.push(' ');
out.push_str(value);
}
}
out.push_str("?>");
}
}
NodeType::Comment | NodeType::Root => {}
}
}
out.push_str("</");
out.push_str(qname);
out.push('>');
}
fn escape_text(text: &str, out: &mut String) {
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'\r' => out.push_str("
"),
c => out.push(c),
}
}
}
fn escape_attr(text: &str, out: &mut String) {
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'"' => out.push_str("""),
'\t' => out.push_str("	"),
'\n' => out.push_str("
"),
'\r' => out.push_str("
"),
c => out.push(c),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn c14n(xml: &str, local: &str, prefixes: &[&str]) -> String {
let doc = Document::parse(xml).unwrap();
let node = doc
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == local)
.unwrap();
exclusive(&doc, node, prefixes, None)
}
#[test]
fn renders_only_visibly_utilised_namespaces() {
let xml = r#"<r xmlns:a="urn:a" xmlns:b="urn:b"><a:x>t</a:x></r>"#;
assert_eq!(c14n(xml, "x", &[]), r#"<a:x xmlns:a="urn:a">t</a:x>"#);
}
#[test]
fn an_inclusive_prefix_is_rendered_at_the_apex() {
let xml = r#"<r xmlns:a="urn:a" xmlns:b="urn:b"><a:x>t</a:x></r>"#;
assert_eq!(
c14n(xml, "x", &["b"]),
r#"<a:x xmlns:a="urn:a" xmlns:b="urn:b">t</a:x>"#
);
}
#[test]
fn a_declaration_already_in_effect_is_not_repeated() {
let xml = r#"<a:r xmlns:a="urn:a"><a:x><a:y/></a:x></a:r>"#;
assert_eq!(
c14n(xml, "r", &[]),
r#"<a:r xmlns:a="urn:a"><a:x><a:y></a:y></a:x></a:r>"#
);
}
#[test]
fn the_excluded_subtree_is_dropped_and_comments_too() {
let doc = Document::parse(r#"<r><!--c--><s><t/></s>x</r>"#).unwrap();
let s = doc.descendants().find(|n| n.has_tag_name("s")).unwrap();
assert_eq!(
exclusive(&doc, doc.root_element(), &[], Some(s.id())),
"<r>x</r>"
);
}
}