use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use roxmltree::{Document, Node, NodeType};
use sha2::{Digest, Sha256, Sha384, Sha512};
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::LazyLock;
use super::{DS_NS, WsSecCanonicalizationKind, WsSecDigestMethod, XML_NS};
use super::{WsSecCanonicalizationProfile, WsSecCanonicalizedReference};
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
static EMPTY_NS_MAP: LazyLock<BTreeMap<String, String>> = LazyLock::new(BTreeMap::new);
pub fn canonicalize_reference(
xml: &str,
uri: &str,
profile: WsSecCanonicalizationProfile,
) -> Result<WsSecCanonicalizedReference> {
let doc = Document::parse(xml).map_err(|e| {
AsxError::new(
ErrorCode::ParseFailed,
format!("failed to parse XML for canonicalization: {e}"),
ErrorContext::new("wssec_canonicalize").with_message_id(uri.to_string()),
)
})?;
canonicalize_reference_from_doc(&doc, uri, &profile)
}
pub fn canonicalize_enveloped_document(
doc: &Document<'_>,
profile: &WsSecCanonicalizationProfile,
signature: Node<'_, '_>,
) -> Result<WsSecCanonicalizedReference> {
let mut out = String::new();
try_serialize_node_omitting(
doc.root(),
&mut out,
profile,
&EMPTY_NS_MAP,
None,
Some(signature),
)
.map_err(|_err| {
AsxError::new(
ErrorCode::SecurityVerificationFailed,
"failed to canonicalize enveloped document",
ErrorContext::new("wssec_canonicalize"),
)
})?;
let digest = Sha256::digest(out.as_bytes());
Ok(WsSecCanonicalizedReference {
uri: String::new(),
canonical_bytes: out.into_bytes(),
digest_value_base64: BASE64_STANDARD.encode(digest),
})
}
pub fn canonicalize_reference_from_doc(
doc: &Document<'_>,
uri: &str,
profile: &WsSecCanonicalizationProfile,
) -> Result<WsSecCanonicalizedReference> {
canonicalize_reference_from_doc_with_inclusive_ns(doc, uri, profile, None)
}
pub fn canonicalize_reference_from_doc_with_inclusive_ns(
doc: &Document<'_>,
uri: &str,
profile: &WsSecCanonicalizationProfile,
inclusive_ns_prefixes_override: Option<&[String]>,
) -> Result<WsSecCanonicalizedReference> {
let target_id = normalize_same_document_uri(uri)?;
let target = resolve_same_document_reference_target(doc, uri, target_id)?;
let mut out = String::new();
try_serialize_node_with_inclusive_ns(
target,
&mut out,
profile,
&EMPTY_NS_MAP,
inclusive_ns_prefixes_override,
)
.map_err(|_err| {
AsxError::new(
ErrorCode::SecurityVerificationFailed,
"failed to canonicalize XML reference subtree",
ErrorContext::new("wssec_canonicalize").with_message_id(uri.to_string()),
)
})?;
let digest = Sha256::digest(out.as_bytes());
let digest_b64 = BASE64_STANDARD.encode(digest);
Ok(WsSecCanonicalizedReference {
uri: uri.to_string(),
canonical_bytes: out.into_bytes(),
digest_value_base64: digest_b64,
})
}
#[cfg(test)]
pub(crate) fn canonicalize_reference_digest_base64_from_doc_with_inclusive_ns(
doc: &Document<'_>,
uri: &str,
profile: &WsSecCanonicalizationProfile,
inclusive_ns_prefixes_override: Option<&[String]>,
digest_method: WsSecDigestMethod,
) -> Result<String> {
let target_id = normalize_same_document_uri(uri)?;
let digest = canonicalize_reference_digest_from_same_document_target_id_with_inclusive_ns(
doc,
uri,
target_id,
profile,
inclusive_ns_prefixes_override,
digest_method,
)?;
Ok(BASE64_STANDARD.encode(digest))
}
pub(crate) fn canonicalize_reference_digest_from_same_document_target_id_with_inclusive_ns(
doc: &Document<'_>,
uri: &str,
target_id: &str,
profile: &WsSecCanonicalizationProfile,
inclusive_ns_prefixes_override: Option<&[String]>,
digest_method: WsSecDigestMethod,
) -> Result<Vec<u8>> {
let target = resolve_same_document_reference_target(doc, uri, target_id)?;
canonicalize_reference_digest_from_target_with_inclusive_ns(
target,
profile,
inclusive_ns_prefixes_override,
digest_method,
)
}
pub fn canonicalize_reference_digest_from_doc_with_inclusive_ns_and_index(
index: &SameDocumentReferenceIndex<'_>,
uri: &str,
profile: &WsSecCanonicalizationProfile,
inclusive_ns_prefixes_override: Option<&[String]>,
digest_method: WsSecDigestMethod,
) -> Result<Vec<u8>> {
let target_id = normalize_same_document_uri(uri)?;
let target = resolve_same_document_reference_target_with_index(index, uri, target_id)?;
canonicalize_reference_digest_from_target_with_inclusive_ns(
target,
profile,
inclusive_ns_prefixes_override,
digest_method,
)
}
fn canonicalize_reference_digest_from_target_with_inclusive_ns(
target: Node<'_, '_>,
profile: &WsSecCanonicalizationProfile,
inclusive_ns_prefixes_override: Option<&[String]>,
digest_method: WsSecDigestMethod,
) -> Result<Vec<u8>> {
let mut hasher_out = DigestFmtWriter::new(digest_method);
try_serialize_node_with_inclusive_ns(
target,
&mut hasher_out,
profile,
&EMPTY_NS_MAP,
inclusive_ns_prefixes_override,
)
.map_err(|_err| {
AsxError::new(
ErrorCode::SecurityVerificationFailed,
"failed to canonicalize XML reference subtree",
ErrorContext::new("wssec_canonicalize"),
)
})?;
Ok(hasher_out.finalize())
}
enum DigestFmtWriter {
Sha256(Sha256),
Sha384(Sha384),
Sha512(Sha512),
}
impl DigestFmtWriter {
fn new(method: WsSecDigestMethod) -> Self {
match method {
WsSecDigestMethod::Sha256 => Self::Sha256(Sha256::new()),
WsSecDigestMethod::Sha384 => Self::Sha384(Sha384::new()),
WsSecDigestMethod::Sha512 => Self::Sha512(Sha512::new()),
}
}
fn finalize(self) -> Vec<u8> {
match self {
Self::Sha256(h) => h.finalize().to_vec(),
Self::Sha384(h) => h.finalize().to_vec(),
Self::Sha512(h) => h.finalize().to_vec(),
}
}
}
impl std::fmt::Write for DigestFmtWriter {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
match self {
Self::Sha256(h) => h.update(s.as_bytes()),
Self::Sha384(h) => h.update(s.as_bytes()),
Self::Sha512(h) => h.update(s.as_bytes()),
}
Ok(())
}
}
enum SameDocumentReferenceEntry<'a> {
Unique(Node<'a, 'a>),
Ambiguous,
}
pub struct SameDocumentReferenceIndex<'a> {
entries: HashMap<&'a str, SameDocumentReferenceEntry<'a>>,
}
impl std::fmt::Debug for SameDocumentReferenceIndex<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SameDocumentReferenceIndex")
.field("ids", &self.entries.keys().collect::<Vec<_>>())
.finish()
}
}
impl<'a> SameDocumentReferenceIndex<'a> {
pub(crate) fn build(doc: &'a Document<'a>) -> Self {
Self::build_impl(doc, None)
}
pub(crate) fn build_for_targets<'t>(
doc: &'a Document<'a>,
target_ids: impl IntoIterator<Item = &'t str>,
) -> Self {
let wanted: HashSet<&str> = target_ids.into_iter().collect();
if wanted.is_empty() {
return Self {
entries: HashMap::new(),
};
}
Self::build_impl(doc, Some(&wanted))
}
fn build_impl(doc: &'a Document<'a>, wanted: Option<&HashSet<&str>>) -> Self {
let mut entries: HashMap<&'a str, SameDocumentReferenceEntry<'a>> = HashMap::new();
for node in doc.descendants().filter(Node::is_element) {
let mut node_ids: Vec<&str> = Vec::new();
for attr in node.attributes() {
if is_reference_id_attr(attr.namespace(), attr.name()) {
let value = attr.value();
if wanted.is_some_and(|ids| !ids.contains(value)) {
continue;
}
if !node_ids.contains(&value) {
node_ids.push(value);
}
}
}
for value in node_ids {
use std::collections::hash_map::Entry;
match entries.entry(value) {
Entry::Vacant(slot) => {
slot.insert(SameDocumentReferenceEntry::Unique(node));
}
Entry::Occupied(mut slot) => {
let new_entry = match slot.get() {
SameDocumentReferenceEntry::Unique(existing)
if existing.id() == node.id() =>
{
None
}
_ => Some(SameDocumentReferenceEntry::Ambiguous),
};
if let Some(entry) = new_entry {
slot.insert(entry);
}
}
}
}
}
Self { entries }
}
}
pub(crate) fn normalize_same_document_uri(uri: &str) -> Result<&str> {
if uri.trim() != uri {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!("non-canonical reference URI with surrounding whitespace: {uri}"),
ErrorContext::new("wssec_reference_uri"),
));
}
if !uri.starts_with('#') {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!("unsupported reference URI: {uri}"),
ErrorContext::new("wssec_reference_uri"),
));
}
if uri.len() == 1 {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!("empty same-document URI fragment is not supported in strict mode: {uri}"),
ErrorContext::new("wssec_reference_uri"),
));
}
let fragment = &uri[1..];
if fragment.contains('%') {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"percent-encoded same-document URI fragments are not supported in strict mode: {uri}"
),
ErrorContext::new("wssec_reference_uri"),
));
}
if !fragment.is_ascii() {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"non-ASCII same-document URI fragments are not supported in strict mode: {uri}"
),
ErrorContext::new("wssec_reference_uri"),
));
}
if fragment.contains('#') {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"nested fragment markers are not supported in strict same-document URIs: {uri}"
),
ErrorContext::new("wssec_reference_uri"),
));
}
if fragment.chars().any(char::is_whitespace) {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!("whitespace is not allowed inside strict same-document URI fragments: {uri}"),
ErrorContext::new("wssec_reference_uri"),
));
}
if fragment.chars().any(char::is_control) {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"control characters are not allowed in strict same-document URI fragments: {uri}"
),
ErrorContext::new("wssec_reference_uri"),
));
}
let mut chars = fragment.chars();
let first = chars.next().expect("fragment is non-empty");
let first_is_valid = first.is_ascii_alphabetic() || first == '_';
let rest_is_valid =
chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ':'));
if !first_is_valid || !rest_is_valid {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"non-canonical same-document URI fragment characters are not supported in strict mode: {uri}"
),
ErrorContext::new("wssec_reference_uri"),
));
}
if fragment.contains(':') {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"namespace-like delimiters are not supported in strict same-document URI fragments: {uri}"
),
ErrorContext::new("wssec_reference_uri"),
));
}
Ok(fragment)
}
fn resolve_same_document_reference_target<'a>(
doc: &'a Document<'a>,
uri: &str,
target_id: &str,
) -> Result<Node<'a, 'a>> {
let index = SameDocumentReferenceIndex::build(doc);
resolve_same_document_reference_target_with_index(&index, uri, target_id)
}
pub(crate) fn resolve_same_document_reference_target_with_index<'a>(
index: &SameDocumentReferenceIndex<'a>,
uri: &str,
target_id: &str,
) -> Result<Node<'a, 'a>> {
match index.entries.get(target_id) {
Some(SameDocumentReferenceEntry::Unique(node)) => Ok(*node),
Some(SameDocumentReferenceEntry::Ambiguous) => Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"ambiguous same-document reference target for URI {uri}: multiple elements share ID {target_id}"
),
ErrorContext::new("wssec_canonicalize").with_message_id(uri.to_string()),
)),
None => Err(AsxError::new(
ErrorCode::InvalidInput,
format!("could not resolve same-document reference target for URI {uri}"),
ErrorContext::new("wssec_canonicalize").with_message_id(uri.to_string()),
)),
}
}
fn is_reference_id_attr(namespace: Option<&str>, name: &str) -> bool {
if namespace == Some(XML_NS) && name == "id" {
return true;
}
name.eq_ignore_ascii_case("id")
}
pub(crate) fn is_ds_element(node: Node<'_, '_>, local_name: &str) -> bool {
node.tag_name().namespace() == Some(DS_NS) && node.tag_name().name() == local_name
}
pub(crate) fn swa_attachment_digest_input<'a>(
content_type: Option<&str>,
bytes: &'a [u8],
) -> Result<Cow<'a, [u8]>> {
let media = content_type
.map(|ct| {
ct.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
})
.unwrap_or_default();
let is_xml = media == "text/xml" || media == "application/xml" || media.ends_with("+xml");
if is_xml {
let text = std::str::from_utf8(bytes).map_err(|err| {
AsxError::new(
ErrorCode::ParseFailed,
format!("XML-typed attachment is not valid UTF-8: {err}"),
ErrorContext::new("wssec_swa_attachment"),
)
})?;
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let doc = Document::parse(text).map_err(|err| {
AsxError::new(
ErrorCode::ParseFailed,
format!(
"XML-typed attachment does not parse; the SwA transform requires \
canonicalizing XML content: {err}"
),
ErrorContext::new("wssec_swa_attachment"),
)
})?;
let profile = WsSecCanonicalizationProfile {
kind: WsSecCanonicalizationKind::Exclusive,
include_comments: false,
inclusive_ns_prefixes: Vec::new(),
};
let mut out = String::new();
try_serialize_node(doc.root(), &mut out, &profile, &EMPTY_NS_MAP).map_err(|_| {
AsxError::new(
ErrorCode::SecurityVerificationFailed,
"failed to canonicalize XML attachment content",
ErrorContext::new("wssec_swa_attachment"),
)
})?;
return Ok(Cow::Owned(out.into_bytes()));
}
if media.starts_with("text/") {
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\r' => {
out.extend_from_slice(b"\r\n");
if bytes.get(i + 1) == Some(&b'\n') {
i += 1;
}
}
b'\n' => out.extend_from_slice(b"\r\n"),
other => out.push(other),
}
i += 1;
}
return Ok(Cow::Owned(out));
}
Ok(Cow::Borrowed(bytes))
}
pub fn canonical_vector_diff(expected: &str, actual: &str) -> String {
let expected_lines: Vec<&str> = expected.lines().collect();
let actual_lines: Vec<&str> = actual.lines().collect();
let max_len = expected_lines.len().max(actual_lines.len());
let mut out = Vec::new();
for idx in 0..max_len {
let line_no = idx + 1;
let expected_line = expected_lines.get(idx).copied().unwrap_or("<missing>");
let actual_line = actual_lines.get(idx).copied().unwrap_or("<missing>");
if expected_line != actual_line {
out.push(format!("L{line_no}: -{expected_line}"));
out.push(format!("L{line_no}: +{actual_line}"));
}
}
if out.is_empty() {
"no diff".to_string()
} else {
out.join("\n")
}
}
#[cfg(test)]
pub(crate) fn canonicalize_document(
xml: &str,
profile: &WsSecCanonicalizationProfile,
) -> Result<String> {
let doc = Document::parse(xml).map_err(|e| {
AsxError::new(
ErrorCode::ParseFailed,
format!("failed to parse XML for whole-document canonicalization: {e}"),
ErrorContext::new("wssec_canonicalize_document"),
)
})?;
let mut out = String::new();
serialize_root_node(&doc, &mut out, profile)?;
Ok(out)
}
#[cfg(test)]
fn serialize_root_node(
doc: &Document<'_>,
out: &mut String,
profile: &WsSecCanonicalizationProfile,
) -> Result<()> {
use roxmltree::NodeType as NT;
let root = doc.root();
let doc_elem_idx = root
.children()
.enumerate()
.find(|(_, n)| n.is_element())
.map(|(i, _)| i);
for (i, child) in root.children().enumerate() {
let is_after_doc_elem = doc_elem_idx.is_some_and(|di| i > di);
let is_before_doc_elem = doc_elem_idx.is_some_and(|di| i < di);
match child.node_type() {
NT::Element => {
try_serialize_node_with_inclusive_ns(child, out, profile, &EMPTY_NS_MAP, None)
.map_err(|_| {
AsxError::new(
ErrorCode::SecurityVerificationFailed,
"failed to canonicalize document element",
ErrorContext::new("wssec_canonicalize_document"),
)
})?;
}
NT::PI => {
if let Some(pi) = child.pi() {
if is_after_doc_elem {
out.push('\n');
}
out.push_str("<?");
out.push_str(pi.target);
if let Some(data) = pi.value
&& !data.is_empty()
{
out.push(' ');
out.push_str(data);
}
out.push_str("?>");
if is_before_doc_elem {
out.push('\n');
}
}
}
NT::Comment if profile.include_comments => {
if is_after_doc_elem {
out.push('\n');
}
out.push_str("<!--");
out.push_str(child.text().unwrap_or_default());
out.push_str("-->");
if is_before_doc_elem {
out.push('\n');
}
}
_ => {
}
}
}
Ok(())
}
pub(crate) fn try_serialize_node(
node: Node<'_, '_>,
out: &mut impl std::fmt::Write,
profile: &WsSecCanonicalizationProfile,
parent_ns: &BTreeMap<String, String>,
) -> std::fmt::Result {
try_serialize_node_with_inclusive_ns(node, out, profile, parent_ns, None)
}
pub(crate) fn try_serialize_node_with_inclusive_ns(
node: Node<'_, '_>,
out: &mut impl std::fmt::Write,
profile: &WsSecCanonicalizationProfile,
parent_ns: &BTreeMap<String, String>,
inclusive_ns_prefixes_override: Option<&[String]>,
) -> std::fmt::Result {
try_serialize_node_omitting(
node,
out,
profile,
parent_ns,
inclusive_ns_prefixes_override,
None,
)
}
pub(crate) fn try_serialize_node_omitting(
node: Node<'_, '_>,
out: &mut impl std::fmt::Write,
profile: &WsSecCanonicalizationProfile,
parent_ns: &BTreeMap<String, String>,
inclusive_ns_prefixes_override: Option<&[String]>,
omit: Option<Node<'_, '_>>,
) -> std::fmt::Result {
serialize_subtree(
node,
out,
profile,
parent_ns,
inclusive_ns_prefixes_override,
omit,
true,
)
}
fn serialize_subtree(
node: Node<'_, '_>,
out: &mut impl std::fmt::Write,
profile: &WsSecCanonicalizationProfile,
parent_ns: &BTreeMap<String, String>,
inclusive_ns_prefixes_override: Option<&[String]>,
omit: Option<Node<'_, '_>>,
apex: bool,
) -> std::fmt::Result {
if let Some(omit) = omit
&& node == omit
{
return Ok(());
}
match node.node_type() {
NodeType::Element => serialize_element(
node,
out,
profile,
parent_ns,
inclusive_ns_prefixes_override,
omit,
apex,
),
NodeType::Text => {
if let Some(text) = node.text() {
escape_text(text, out)?;
}
Ok(())
}
NodeType::Comment => {
if profile.include_comments {
out.write_str("<!--")?;
out.write_str(node.text().unwrap_or_default())?;
out.write_str("-->")?;
}
Ok(())
}
NodeType::PI => {
if let Some(pi) = node.pi() {
out.write_str("<?")?;
out.write_str(pi.target)?;
if let Some(data) = pi.value
&& !data.is_empty()
{
out.write_char(' ')?;
out.write_str(data)?;
}
out.write_str("?>")?;
}
Ok(())
}
NodeType::Root => {
for child in node.children() {
serialize_subtree(
child,
out,
profile,
parent_ns,
inclusive_ns_prefixes_override,
omit,
apex,
)?;
}
Ok(())
}
}
}
fn element_source_prefix<'a>(node: Node<'a, 'a>) -> &'a str {
let text = node.document().input_text();
let tail = &text[node.range().start + 1..]; let end = tail
.find(|c: char| c.is_whitespace() || c == '>' || c == '/')
.unwrap_or(tail.len());
let qname = &tail[..end];
match qname.rfind(':') {
Some(idx) => &qname[..idx],
None => "",
}
}
fn inherited_xml_attributes<'a>(
node: Node<'a, 'a>,
doc_text: &'a str,
) -> Vec<(&'a str, &'a str, String)> {
let mut inherited: Vec<(&str, &str, String)> = Vec::new();
for ancestor in node.ancestors().skip(1).filter(|n| n.is_element()) {
for attr in ancestor.attributes() {
if attr.namespace() != Some(XML_NS) {
continue;
}
let local = attr.name();
if node
.attributes()
.any(|own| own.namespace() == Some(XML_NS) && own.name() == local)
|| inherited.iter().any(|(seen, _, _)| *seen == local)
{
continue;
}
let prefix = attribute_source_prefix(doc_text, &attr);
let qname = if prefix.is_empty() {
local.to_string()
} else {
format!("{prefix}:{local}")
};
inherited.push((local, attr.value(), qname));
}
}
inherited
}
fn attribute_source_prefix<'input>(
doc_text: &'input str,
attr: &roxmltree::Attribute<'_, 'input>,
) -> &'input str {
let qname = &doc_text[attr.range_qname()];
match qname.rfind(':') {
Some(idx) => &qname[..idx],
None => "",
}
}
fn serialize_element(
node: Node<'_, '_>,
out: &mut impl std::fmt::Write,
profile: &WsSecCanonicalizationProfile,
parent_ns: &BTreeMap<String, String>,
inclusive_ns_prefixes_override: Option<&[String]>,
omit: Option<Node<'_, '_>>,
apex: bool,
) -> std::fmt::Result {
let doc_text = node.document().input_text();
let tag_ns = node.tag_name().namespace();
let tag_local = node.tag_name().name();
let tag_prefix = element_source_prefix(node);
let tag_qname: Cow<'_, str> = if tag_prefix.is_empty() {
Cow::Borrowed(tag_local)
} else {
Cow::Owned(format!("{tag_prefix}:{tag_local}"))
};
let mut utilized: BTreeMap<&str, &str> = BTreeMap::new();
if matches!(profile.kind, WsSecCanonicalizationKind::Inclusive) {
for ns in node.namespaces() {
let prefix = ns.name().unwrap_or("");
let uri = ns.uri();
if uri == XML_NS {
continue;
}
if uri.is_empty() && !prefix.is_empty() {
continue;
}
utilized.insert(prefix, uri);
}
} else {
if let Some(ns_uri) = tag_ns
&& ns_uri != XML_NS
{
utilized.insert(tag_prefix, ns_uri);
}
if tag_ns.is_none() {
utilized.insert("", "");
}
for a in node.attributes() {
if let Some(ns_uri) = a.namespace()
&& ns_uri != XML_NS
{
utilized.insert(attribute_source_prefix(doc_text, &a), ns_uri);
}
}
}
let prefix_to_uri: HashMap<&str, &str> = node
.namespaces()
.map(|ns| {
let prefix = ns.name().unwrap_or("");
(prefix, ns.uri())
})
.collect();
if matches!(profile.kind, WsSecCanonicalizationKind::Exclusive) {
let effective_inclusive_ns_prefixes =
inclusive_ns_prefixes_override.unwrap_or(&profile.inclusive_ns_prefixes);
for token in effective_inclusive_ns_prefixes {
let prefix: &str = if token == "#default" {
""
} else {
token.as_str()
};
if let Some(&uri) = prefix_to_uri.get(prefix)
&& uri != XML_NS
{
utilized.entry(prefix).or_insert(uri);
}
}
}
let mut ns_emitted: BTreeMap<String, String> = BTreeMap::new();
out.write_char('<')?;
out.write_str(&tag_qname)?;
for (&prefix, &uri) in &utilized {
let parent_uri = parent_ns.get(prefix).map(String::as_str);
let should_emit = if uri.is_empty() && prefix.is_empty() {
parent_uri.is_some_and(|p| !p.is_empty())
} else {
parent_uri != Some(uri)
};
if should_emit {
if prefix.is_empty() {
out.write_str(" xmlns=\"")?;
} else {
out.write_str(" xmlns:")?;
out.write_str(prefix)?;
out.write_str("=\"")?;
}
escape_attr_value(uri, out)?;
out.write_char('"')?;
ns_emitted.insert(prefix.to_string(), uri.to_string());
}
}
let mut attrs: Vec<(Option<&str>, &str, String, &str)> = node
.attributes()
.map(|a| {
let prefix = attribute_source_prefix(doc_text, &a);
let qname = if prefix.is_empty() {
a.name().to_string()
} else {
format!("{prefix}:{}", a.name())
};
(a.namespace(), a.name(), qname, a.value())
})
.collect();
if apex && matches!(profile.kind, WsSecCanonicalizationKind::Inclusive) {
for (local, value, qname) in inherited_xml_attributes(node, doc_text) {
attrs.push((Some(XML_NS), local, qname, value));
}
}
attrs.sort_unstable_by(|a, b| {
let a_ns = a.0.unwrap_or("");
let b_ns = b.0.unwrap_or("");
match a_ns.cmp(b_ns) {
std::cmp::Ordering::Equal => a.1.cmp(b.1),
other => other,
}
});
for (_, _, qname, value) in attrs {
out.write_char(' ')?;
out.write_str(&qname)?;
out.write_str("=\"")?;
escape_attr_value(value, out)?;
out.write_char('"')?;
}
out.write_char('>')?;
let child_ns: Cow<'_, BTreeMap<String, String>> = if ns_emitted.is_empty() {
Cow::Borrowed(parent_ns)
} else {
let mut owned = parent_ns.clone();
owned.extend(ns_emitted);
Cow::Owned(owned)
};
for child in node.children() {
serialize_subtree(
child,
out,
profile,
child_ns.as_ref(),
inclusive_ns_prefixes_override,
omit,
false,
)?;
}
out.write_str("</")?;
out.write_str(&tag_qname)?;
out.write_char('>')?;
Ok(())
}
pub(crate) fn escape_text(input: &str, out: &mut impl std::fmt::Write) -> std::fmt::Result {
for ch in input.chars() {
match ch {
'<' => {
out.write_str("<")?;
}
'>' => {
out.write_str(">")?;
}
'&' => {
out.write_str("&")?;
}
'\r' => {
out.write_str("
")?;
}
_ => {
out.write_char(ch)?;
}
}
}
Ok(())
}
pub(crate) fn escape_attr_value(input: &str, out: &mut impl std::fmt::Write) -> std::fmt::Result {
for ch in input.chars() {
match ch {
'&' => {
out.write_str("&")?;
}
'<' => {
out.write_str("<")?;
}
'"' => {
out.write_str(""")?;
}
'\t' => {
out.write_str("	")?;
}
'\n' => {
out.write_str("
")?;
}
'\r' => {
out.write_str("
")?;
}
_ => {
out.write_char(ch)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod w3c_c14n_vectors {
use super::{
EMPTY_NS_MAP, canonical_vector_diff, canonicalize_document, escape_attr_value, escape_text,
try_serialize_node_with_inclusive_ns,
};
use crate::crypto::wssec::{WsSecCanonicalizationKind, WsSecCanonicalizationProfile};
use roxmltree::Document;
fn inc_profile() -> WsSecCanonicalizationProfile {
WsSecCanonicalizationProfile {
kind: WsSecCanonicalizationKind::Inclusive,
include_comments: false,
inclusive_ns_prefixes: Vec::new(),
}
}
fn inc_with_comments() -> WsSecCanonicalizationProfile {
WsSecCanonicalizationProfile {
kind: WsSecCanonicalizationKind::Inclusive,
include_comments: true,
inclusive_ns_prefixes: Vec::new(),
}
}
fn exc_profile() -> WsSecCanonicalizationProfile {
WsSecCanonicalizationProfile {
kind: WsSecCanonicalizationKind::Exclusive,
include_comments: false,
inclusive_ns_prefixes: Vec::new(),
}
}
fn canonicalize_element_by_id(
xml: &str,
id_value: &str,
profile: &WsSecCanonicalizationProfile,
) -> String {
let doc = Document::parse(xml).expect("parse");
let target = doc
.descendants()
.find(|n| {
n.is_element()
&& n.attributes()
.any(|a| a.name().eq_ignore_ascii_case("id") && a.value() == id_value)
})
.unwrap_or_else(|| panic!("element with id={id_value} not found"));
let mut out = String::new();
try_serialize_node_with_inclusive_ns(target, &mut out, profile, &EMPTY_NS_MAP, None)
.expect("serialize");
out
}
fn canonicalize_element_by_id_with_inc_list(
xml: &str,
id_value: &str,
profile: &WsSecCanonicalizationProfile,
inc_prefixes: &[String],
) -> String {
let doc = Document::parse(xml).expect("parse");
let target = doc
.descendants()
.find(|n| {
n.is_element()
&& n.attributes()
.any(|a| a.name().eq_ignore_ascii_case("id") && a.value() == id_value)
})
.unwrap_or_else(|| panic!("element with id={id_value} not found"));
let mut out = String::new();
try_serialize_node_with_inclusive_ns(
target,
&mut out,
profile,
&EMPTY_NS_MAP,
Some(inc_prefixes),
)
.expect("serialize");
out
}
#[test]
fn w3c_3_1_pis_outside_doc_elem_uncommented() {
let xml = r#"<?xml-stylesheet href="doc.xsl" type="text/xsl"?><doc>Hello, world!</doc><?pi-without-data?>"#;
let expected = concat!(
"<?xml-stylesheet href=\"doc.xsl\" type=\"text/xsl\"?>\n",
"<doc>Hello, world!</doc>\n",
"<?pi-without-data?>"
);
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert_eq!(
result,
expected,
"W3C §3.1 PI placement diff:\n{}",
canonical_vector_diff(expected, &result)
);
}
#[test]
fn w3c_3_1_pis_and_comments_commented_form() {
let xml = r#"<?xml-stylesheet href="doc.xsl" type="text/xsl"?><doc>Hello, world!<!-- Comment 1 --></doc><?pi-without-data?><!-- Comment 2 --><!-- Comment 3 -->"#;
let expected = concat!(
"<?xml-stylesheet href=\"doc.xsl\" type=\"text/xsl\"?>\n",
"<doc>Hello, world!<!-- Comment 1 --></doc>\n",
"<?pi-without-data?>\n",
"<!-- Comment 2 -->\n",
"<!-- Comment 3 -->"
);
let result = canonicalize_document(xml, &inc_with_comments()).expect("c14n");
assert_eq!(
result,
expected,
"W3C §3.1 commented form diff:\n{}",
canonical_vector_diff(expected, &result)
);
}
#[test]
fn w3c_3_2_whitespace_in_document_content_preserved() {
let xml = "<doc> <clean> </clean> <dirty> A B </dirty> </doc>";
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains(" A B "),
"W3C §3.2: whitespace in content not preserved: {result}"
);
assert!(
result.contains("> </clean>"),
"W3C §3.2: whitespace before close tag stripped: {result}"
);
}
#[test]
fn w3c_3_3_empty_elements_expanded_to_start_end_tags() {
let xml = r#"<doc><e1/><e2></e2></doc>"#;
let expected = "<doc><e1></e1><e2></e2></doc>";
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert_eq!(
result,
expected,
"W3C §3.3 empty element expansion diff:\n{}",
canonical_vector_diff(expected, &result)
);
}
#[test]
fn w3c_3_3_attribute_namespace_uri_sort_order() {
let xml = r##"<e5 a:attr="out" b:attr="sorted" attr2="all" attr="I'm"
xmlns:b="http://www.ietf.org"
xmlns:a="http://www.w3.org"
xmlns="http://example.org"></e5>"##;
let expected = r##"<e5 xmlns="http://example.org" xmlns:a="http://www.w3.org" xmlns:b="http://www.ietf.org" attr="I'm" attr2="all" b:attr="sorted" a:attr="out"></e5>"##;
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert_eq!(
result,
expected,
"W3C §3.3 attribute sort order diff:\n{}",
canonical_vector_diff(expected, &result)
);
}
#[test]
fn w3c_3_3_namespace_declarations_sorted_by_prefix() {
let xml = r#"<e xmlns:z="urn:z" xmlns:a="urn:a" xmlns:m="urn:m" z:x="1"></e>"#;
let expected_inc = r#"<e xmlns:a="urn:a" xmlns:m="urn:m" xmlns:z="urn:z" z:x="1"></e>"#;
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert_eq!(
result,
expected_inc,
"§3.3 namespace sort (Inclusive):\n{}",
canonical_vector_diff(expected_inc, &result)
);
}
#[test]
fn w3c_3_3_superfluous_namespace_suppression() {
let xml = r#"<e6 xmlns="http://example.org" xmlns:a="http://www.w3.org">
<e7>
<e8 xmlns="">
<e9></e9>
</e8>
</e7>
</e6>"#;
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains(r#"<e6 xmlns="http://example.org" xmlns:a="http://www.w3.org">"#),
"e6 must declare both namespaces: {result}"
);
assert!(
!result.contains(r#"<e7 xmlns"#),
"e7 must not re-declare inherited namespaces: {result}"
);
assert!(
result.contains(r#"<e8 xmlns="">"#),
"e8 must emit xmlns=\"\" to undeclare default ns: {result}"
);
let e9_start = result.find("<e9").expect("e9 missing");
let e9_end = result[e9_start..].find('>').unwrap();
let e9_open = &result[e9_start..e9_start + e9_end + 1];
assert!(
!e9_open.contains("xmlns"),
"e9 must not re-declare xmlns=\"\" (already inherited from e8): {e9_open}"
);
}
#[test]
fn w3c_3_4_cr_in_text_becomes_num_ref() {
let xml = "<doc><t>line1
line2</t></doc>";
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains("line1
line2"),
"CR (from 
 numeric ref) must become 
 in text content: {result}"
);
}
#[test]
fn w3c_3_4_text_entity_escaping() {
let xml = "<doc><t><tag> & rest</t></doc>";
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains("<tag> & rest"),
"text entity escaping failed: {result}"
);
}
#[test]
fn w3c_3_4_cdata_expanded_and_escaped() {
let xml = r#"<doc><![CDATA[value>"0" && value<"10"]]></doc>"#;
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains("value>"),
"CDATA > must become >: {result}"
);
assert!(
result.contains("value<"),
"CDATA < must become <: {result}"
);
assert!(
result.contains("&&"),
"CDATA && must become &&: {result}"
);
}
#[test]
fn w3c_3_4_attribute_whitespace_escaping() {
let xml = "<doc><e attr=\"tab	lf
cr
end\"></e></doc>";
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains(r#"attr="tab	lf
cr
end""#),
"attribute whitespace chars must be escaped: {result}"
);
}
#[test]
fn w3c_3_4_double_quote_escaped_in_attribute() {
let xml = r#"<doc><e attr='say "hello"'></e></doc>"#;
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains(r#"attr="say "hello"""#),
"inner \" in attribute must become ": {result}"
);
}
#[test]
fn w3c_3_6_non_ascii_emitted_as_utf8() {
let xml = "<doc>\u{00A9}\u{20AC}</doc>";
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains('\u{00A9}'),
"© must be UTF-8 in output: {result}"
);
assert!(
result.contains('\u{20AC}'),
"€ must be UTF-8 in output: {result}"
);
assert!(
!result.contains("©") && !result.contains("€"),
"no numeric escapes for non-ASCII: {result}"
);
}
#[test]
fn escape_text_helper_covers_required_chars() {
let mut out = String::new();
escape_text("<>&\r\t\n", &mut out).unwrap();
assert_eq!(out, "<>&
\t\n");
}
#[test]
fn escape_attr_value_helper_covers_required_chars() {
let mut out = String::new();
escape_attr_value("<>&\"\t\n\r", &mut out).unwrap();
assert_eq!(out, "<>&"	

");
}
#[test]
fn escape_attr_value_single_quote_not_escaped() {
let mut out = String::new();
escape_attr_value("I'm here", &mut out).unwrap();
assert_eq!(out, "I'm here");
}
#[test]
fn w3c_exc_c14n_2_1_enveloping_does_not_pollute() {
let xml_standalone = r#"<n1:elem1 id="e1" xmlns:n1="http://b.example">content</n1:elem1>"#;
let xml_enveloped = r#"<n0:pdu xmlns:n0="http://a.example">
<n1:elem1 id="e1" xmlns:n1="http://b.example">content</n1:elem1>
</n0:pdu>"#;
let c14n_standalone = canonicalize_element_by_id(xml_standalone, "e1", &exc_profile());
let c14n_enveloped = canonicalize_element_by_id(xml_enveloped, "e1", &exc_profile());
assert_eq!(
c14n_standalone, c14n_enveloped,
"Exc-C14N §2.1: elem1 must be invariant across enveloping contexts.\nStandalone: {c14n_standalone}\nEnveloped: {c14n_enveloped}"
);
assert!(
!c14n_enveloped.contains("n0"),
"Exc-C14N §2.1: n0 namespace must not appear: {c14n_enveloped}"
);
assert!(
c14n_enveloped.contains(r#"xmlns:n1="http://b.example""#),
"Exc-C14N §2.1: n1 namespace must appear: {c14n_enveloped}"
);
}
#[test]
fn w3c_exc_c14n_2_2_elem2_invariant_across_contexts() {
let xml_ctx1 = r#"<n0:local xmlns:n0="foo:bar" xmlns:n3="ftp://example.org">
<n1:elem2 id="elem2" xmlns:n1="http://example.net" xml:lang="en">
<n3:stuff xmlns:n3="ftp://example.org"></n3:stuff>
</n1:elem2>
</n0:local>"#;
let xml_ctx2 = r#"<n2:pdu xmlns:n1="http://example.com"
xmlns:n2="http://foo.example"
xml:lang="fr"
xml:space="retain">
<n1:elem2 id="elem2" xmlns:n1="http://example.net" xml:lang="en">
<n3:stuff xmlns:n3="ftp://example.org"></n3:stuff>
</n1:elem2>
</n2:pdu>"#;
let c14n1 = canonicalize_element_by_id(xml_ctx1, "elem2", &exc_profile());
let c14n2 = canonicalize_element_by_id(xml_ctx2, "elem2", &exc_profile());
assert_eq!(
c14n1, c14n2,
"Exc-C14N §2.2: elem2 must be invariant.\nCtx1: {c14n1}\nCtx2: {c14n2}"
);
assert!(
!c14n1.contains("n0"),
"n0 must not appear in Exc-C14N elem2: {c14n1}"
);
assert!(
!c14n1.contains("n2"),
"n2 must not appear in Exc-C14N elem2: {c14n1}"
);
assert!(
c14n1.contains(r#"xmlns:n1="http://example.net""#),
"n1 namespace must appear: {c14n1}"
);
assert!(
c14n1.contains(r#"xmlns:n3="ftp://example.org""#),
"n3 namespace must appear on n3:stuff: {c14n1}"
);
}
#[test]
fn exc_c14n_inclusive_ns_prefix_list_forces_declaration() {
let xml = r#"<root xmlns:dsig="http://www.w3.org/2000/09/xmldsig#"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<child id="c1">content</child>
</root>"#;
let c14n_no_list = canonicalize_element_by_id(xml, "c1", &exc_profile());
assert!(
!c14n_no_list.contains("dsig"),
"Without prefix list, dsig must not appear: {c14n_no_list}"
);
let inc_prefixes = vec!["dsig".to_string()];
let c14n_with_list =
canonicalize_element_by_id_with_inc_list(xml, "c1", &exc_profile(), &inc_prefixes);
assert!(
c14n_with_list.contains(r#"xmlns:dsig="http://www.w3.org/2000/09/xmldsig#""#),
"With PrefixList=dsig, dsig namespace must appear: {c14n_with_list}"
);
assert!(
!c14n_with_list.contains("soap"),
"soap not in prefix list, must not appear: {c14n_with_list}"
);
}
#[test]
fn inc_c14n_default_namespace_undeclaration_not_repeated_in_descendants() {
let xml = r#"<root xmlns="urn:default">
<child xmlns="">
<inner></inner>
</child>
</root>"#;
let result = canonicalize_document(xml, &inc_profile()).expect("c14n");
assert!(
result.contains(r#"<child xmlns="">"#),
"child must emit xmlns=\"\": {result}"
);
let inner_start = result.find("<inner").expect("inner missing");
let inner_tag_end = result[inner_start..].find('>').unwrap();
let inner_open = &result[inner_start..inner_start + inner_tag_end + 1];
assert!(
!inner_open.contains("xmlns"),
"inner must not re-declare xmlns=\"\": {inner_open}"
);
}
#[test]
fn inclusive_vs_exclusive_namespace_accumulation() {
let xml = r#"<root xmlns:a="urn:a" xmlns:b="urn:b">
<child id="c1" a:x="1">text</child>
</root>"#;
let inc = canonicalize_element_by_id(xml, "c1", &inc_profile());
let exc = canonicalize_element_by_id(xml, "c1", &exc_profile());
assert!(
inc.contains(r#"xmlns:a="urn:a""#),
"Inclusive: xmlns:a must appear: {inc}"
);
assert!(
inc.contains(r#"xmlns:b="urn:b""#),
"Inclusive: xmlns:b must appear: {inc}"
);
assert!(
exc.contains(r#"xmlns:a="urn:a""#),
"Exclusive: xmlns:a must appear (used by a:x): {exc}"
);
assert!(
!exc.contains(r#"xmlns:b"#),
"Exclusive: xmlns:b must NOT appear (not visibly utilized): {exc}"
);
}
#[test]
fn exc_c14n_renders_default_ns_undeclaration_for_no_ns_child() {
let xml =
r#"<root xmlns="urn:default" id="r"><child xmlns=""><inner></inner></child></root>"#;
let result = canonicalize_element_by_id(xml, "r", &exc_profile());
assert!(
result.contains(r#"<child xmlns="">"#),
"child in no namespace under a non-empty default must emit xmlns=\"\": {result}"
);
assert!(
result.contains("<inner></inner>"),
"inner must not re-declare xmlns=\"\": {result}"
);
}
#[test]
fn exc_c14n_no_ns_apex_without_context_emits_no_undeclaration() {
let xml = r#"<doc id="d"><leaf></leaf></doc>"#;
let result = canonicalize_element_by_id(xml, "d", &exc_profile());
assert_eq!(result, "<doc id=\"d\"><leaf></leaf></doc>");
}
#[test]
fn exc_c14n_xml_ns_attrs_not_propagated_from_ancestors() {
let xml = r#"<outer xml:lang="fr" xml:space="preserve">
<inner id="inner">content</inner>
</outer>"#;
let result = canonicalize_element_by_id(xml, "inner", &exc_profile());
assert!(
!result.contains("xml:lang"),
"Exc-C14N: xml:lang from ancestor must not appear: {result}"
);
assert!(
!result.contains("xml:space"),
"Exc-C14N: xml:space from ancestor must not appear: {result}"
);
}
#[test]
fn wssec_signed_info_exc_c14n_with_wsse_inclusive_ns() {
let xml = r#"<S12:Envelope
xmlns:S12="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<S12:Header>
<wsse:Security>
<ds:Signature>
<ds:SignedInfo id="siginfo">
<ds:CanonicalizationMethod
Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
<ec:InclusiveNamespaces PrefixList="wsse"
xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:CanonicalizationMethod>
</ds:SignedInfo>
</ds:Signature>
</wsse:Security>
</S12:Header>
<S12:Body/>
</S12:Envelope>"#;
let inc_prefixes = vec!["wsse".to_string()];
let result =
canonicalize_element_by_id_with_inc_list(xml, "siginfo", &exc_profile(), &inc_prefixes);
assert!(
result.contains(r#"xmlns:ds="http://www.w3.org/2000/09/xmldsig#""#),
"ds namespace must appear: {result}"
);
assert!(
result.contains(r#"xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd""#),
"wsse namespace must appear (in PrefixList): {result}"
);
assert!(
!result.contains("S12"),
"S12 namespace must not appear in SignedInfo: {result}"
);
}
#[test]
fn inc_c14n_apex_inherits_ancestor_xml_attributes() {
let xml = r#"<outer xml:lang="fr" xml:space="preserve"><inner id="t">x</inner></outer>"#;
let result = canonicalize_element_by_id(xml, "t", &inc_profile());
assert_eq!(
result, r#"<inner id="t" xml:lang="fr" xml:space="preserve">x</inner>"#,
"C14N 1.0 §2.3: in-scope xml:* attributes join the apex element"
);
}
#[test]
fn inc_c14n_nearest_ancestor_wins_and_own_attributes_survive() {
let xml = r#"<a xml:lang="fr" xml:space="preserve"><b xml:lang="de"><c id="t" xml:space="default">x</c></b></a>"#;
let result = canonicalize_element_by_id(xml, "t", &inc_profile());
assert_eq!(
result,
r#"<c id="t" xml:lang="de" xml:space="default">x</c>"#
);
}
#[test]
fn exc_c14n_apex_does_not_inherit_ancestor_xml_attributes() {
let xml = r#"<outer xml:lang="fr" xml:space="preserve"><inner id="t">x</inner></outer>"#;
let result = canonicalize_element_by_id(xml, "t", &exc_profile());
assert_eq!(result, r#"<inner id="t">x</inner>"#);
}
#[test]
fn inc_c14n_descendants_do_not_repeat_inherited_xml_attributes() {
let xml = r#"<outer xml:lang="fr"><inner id="t"><deep/></inner></outer>"#;
let result = canonicalize_element_by_id(xml, "t", &inc_profile());
assert_eq!(
result,
r#"<inner id="t" xml:lang="fr"><deep></deep></inner>"#
);
}
#[test]
fn pi_data_is_emitted_verbatim() {
let doc = roxmltree::Document::parse("<r><?target data ?></r>").expect("parse");
let mut out = String::new();
try_serialize_node_with_inclusive_ns(
doc.root_element(),
&mut out,
&inc_profile(),
&EMPTY_NS_MAP,
None,
)
.expect("serialize");
assert_eq!(out, "<r><?target data ?></r>");
}
#[test]
fn pi_without_data_omits_the_separating_space() {
let doc = roxmltree::Document::parse("<r><?target?></r>").expect("parse");
let mut out = String::new();
try_serialize_node_with_inclusive_ns(
doc.root_element(),
&mut out,
&inc_profile(),
&EMPTY_NS_MAP,
None,
)
.expect("serialize");
assert_eq!(out, "<r><?target?></r>");
}
}