Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '\r' => out.push_str("&#xD;"),
            c => out.push(c),
        }
    }
}

fn escape_attr(text: &str, out: &mut String) {
    for c in text.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '"' => out.push_str("&quot;"),
            '\t' => out.push_str("&#x9;"),
            '\n' => out.push_str("&#xA;"),
            '\r' => out.push_str("&#xD;"),
            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>"
        );
    }
}