Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,23 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --all-features

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features

clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
Expand All @@ -39,7 +39,7 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- run: cargo publish --token ${{ secrets.CRATES_IO_TOKEN }}

github-release:
runs-on: ubuntu-latest
needs: publish
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
293 changes: 293 additions & 0 deletions tests/c14n_golden.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
//! Golden file tests: compare our C14N output byte-for-byte against
//! xmllint-generated reference outputs.
//!
//! Golden outputs generated by:
//! - `xmllint --c14n <file>` → inclusive C14N 1.0 (with comments)
//! - `xmllint --c14n11 <file>` → inclusive C14N 1.1 (with comments)
//! - `xmllint --exc-c14n <file>` → exclusive C14N 1.0 (with comments)
//!
//! Note: xmllint always produces "with comments" output (there is no flag
//! to strip comments). Therefore `assert_c14n_matches_golden` defaults to
//! `with_comments: true` to match xmllint behavior.
//!
//! XPath-subset vectors (merlin c14n-0..27) are skipped — require XPath
//! evaluator (P4-008). Only full-document canonicalization is tested here.
//!
//! Subtree tests for exc-c14n-one use SHA-1 digest comparison against
//! DigestValues embedded in the signed XML (validates subtree C14N
//! without needing XPath).

use std::collections::HashSet;
use std::fs;

use ring::digest;
use xml_sec::c14n::{canonicalize, canonicalize_xml, C14nAlgorithm, C14nMode};

// ─── Helpers ────────────────────────────────────────────────────────────────

fn fixture(path: &str) -> String {
let full = format!("tests/fixtures/c14n/{path}");
fs::read_to_string(&full).unwrap_or_else(|e| panic!("cannot read fixture {full}: {e}"))
}

fn fixture_bytes(path: &str) -> Vec<u8> {
let full = format!("tests/fixtures/c14n/{path}");
fs::read(&full).unwrap_or_else(|e| panic!("cannot read fixture {full}: {e}"))
}

fn sha1_base64(data: &[u8]) -> String {
let hash = digest::digest(&digest::SHA1_FOR_LEGACY_USE_ONLY, data);
base64_encode(hash.as_ref())
}

fn base64_encode(data: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(data)
}

fn assert_c14n_matches_golden(xml: &[u8], mode: C14nMode, golden: &str, label: &str) {
assert_c14n_matches_golden_comments(xml, mode, true, golden, label);
}

fn assert_c14n_matches_golden_comments(
xml: &[u8],
mode: C14nMode,
with_comments: bool,
golden: &str,
label: &str,
) {
let algo = C14nAlgorithm::new(mode, with_comments);
let result = canonicalize_xml(xml, &algo)
.unwrap_or_else(|e| panic!("{label}: canonicalize failed: {e}"));
let result_str = String::from_utf8(result).expect("invalid utf8");
let golden_content = fixture(golden);
// Truncate to ~500 chars for readable error messages (golden files can be 23KB+).
// Use char boundary to avoid panic on multi-byte UTF-8.
let got_preview: String = result_str.chars().take(500).collect();
let exp_preview: String = golden_content.chars().take(500).collect();
assert_eq!(
result_str, golden_content,
"\n{label}: C14N output differs from xmllint golden\n--- GOT (first 500) ---\n{got_preview}\n--- EXPECTED (first 500) ---\n{exp_preview}",
);
}

// ─── Merlin C14N Three: full-document tests ─────────────────────────────────

/// Inclusive C14N 1.0 of Merlin signature.xml (full document, no XPath subset).
/// Golden output: xmllint --c14n signature.xml
#[test]
fn merlin_signature_inclusive_c14n10() {
let xml = fixture_bytes("merlin-c14n-three/signature.xml");
assert_c14n_matches_golden(
&xml,
C14nMode::Inclusive1_0,
"merlin-c14n-three/full-doc-c14n10.xml",
"merlin/inclusive-1.0",
);
}

/// Inclusive C14N 1.1 of Merlin signature.xml (full document).
/// For full documents, C14N 1.1 produces identical output to C14N 1.0
/// (xml:base fixup only differs for document subsets).
#[test]
fn merlin_signature_inclusive_c14n11() {
let xml = fixture_bytes("merlin-c14n-three/signature.xml");
assert_c14n_matches_golden(
&xml,
C14nMode::Inclusive1_1,
"merlin-c14n-three/full-doc-c14n11.xml",
"merlin/inclusive-1.1",
);
}

/// Exclusive C14N of Merlin signature.xml (full document).
/// Golden output: xmllint --exc-c14n signature.xml
#[test]
fn merlin_signature_exclusive_c14n() {
let xml = fixture_bytes("merlin-c14n-three/signature.xml");
assert_c14n_matches_golden(
&xml,
C14nMode::Exclusive1_0,
"merlin-c14n-three/full-doc-exc-c14n.xml",
"merlin/exclusive",
);
}

// ─── Merlin Exc-C14N One: full-document tests ───────────────────────────────

/// Inclusive C14N 1.0 of exc-signature.xml (full document).
#[test]
fn exc_signature_inclusive_c14n10() {
let xml = fixture_bytes("merlin-exc-c14n-one/exc-signature.xml");
assert_c14n_matches_golden(
&xml,
C14nMode::Inclusive1_0,
"merlin-exc-c14n-one/full-doc-c14n10.xml",
"exc-c14n-one/inclusive-1.0",
);
}

/// Exclusive C14N of exc-signature.xml (full document).
///
/// Note: xmllint --exc-c14n renders xmlns:bar on <Foo> even though "bar" is
/// not visibly utilized on that element (only on descendant <bar:Baz>).
/// Per Exclusive C14N spec §3, only visibly-utilized namespaces should appear.
/// Our implementation correctly omits xmlns:bar from <Foo> and renders it on
/// <bar:Baz> where it IS visibly utilized. Correctness proven by subtree
/// digest tests matching the original Merlin DigestValues.
///
/// Golden file generated by our implementation (not xmllint).
#[test]
fn exc_signature_exclusive_c14n() {
let xml = fixture_bytes("merlin-exc-c14n-one/exc-signature.xml");
assert_c14n_matches_golden(
&xml,
C14nMode::Exclusive1_0,
"merlin-exc-c14n-one/full-doc-exc-c14n.xml",
"exc-c14n-one/exclusive",
);
}

// ─── C14N 1.1 xml:base test vector ─────────────────────────────────────────

/// C14N 1.0 of xml-base-input.xml (full document).
#[test]
fn c14n11_xml_base_inclusive_c14n10() {
let xml = fixture_bytes("c14n11/xml-base-input.xml");
assert_c14n_matches_golden(
&xml,
C14nMode::Inclusive1_0,
"c14n11/full-doc-c14n10.xml",
"c14n11/inclusive-1.0",
);
}

/// C14N 1.1 of xml-base-input.xml (full document).
/// For full documents, identical to C14N 1.0. The xml:base fixup behavior
/// only diverges for document subsets where ancestors are excluded.
#[test]
fn c14n11_xml_base_inclusive_c14n11() {
let xml = fixture_bytes("c14n11/xml-base-input.xml");
assert_c14n_matches_golden(
&xml,
C14nMode::Inclusive1_1,
"c14n11/full-doc-c14n11.xml",
"c14n11/inclusive-1.1",
);
}

// ─── Merlin Exc-C14N One: subtree digest tests ─────────────────────────────
//
// The exc-signature.xml contains 4 References that each canonicalize
// the <dsig:Object Id="to-be-signed"> subtree with different exc-c14n
// configurations. We extract the subtree, canonicalize it, and compare
// SHA-1 digests against the DigestValues embedded in the signature.
//
// This tests subtree exclusive C14N without needing XPath evaluation.

/// Find the element with a given Id attribute value in a roxmltree document.
fn find_element_by_id<'a>(
doc: &'a roxmltree::Document<'a>,
id_value: &str,
) -> roxmltree::Node<'a, 'a> {
doc.descendants()
.find(|n| n.attribute("Id") == Some(id_value))
.unwrap_or_else(|| panic!("element with Id=\"{id_value}\" not found"))
}

/// Build a node-set predicate that includes a node and all its descendants.
fn subtree_predicate(root: roxmltree::Node) -> impl Fn(roxmltree::Node) -> bool {
let mut ids = HashSet::new();
let mut stack = vec![root];
while let Some(n) = stack.pop() {
ids.insert(n.id());
for child in n.children() {
stack.push(child);
}
}
move |n: roxmltree::Node| ids.contains(&n.id())
}

/// Canonicalize a subtree identified by Id and return the canonical bytes.
fn canonicalize_subtree_by_id(xml_str: &str, id_value: &str, algo: &C14nAlgorithm) -> Vec<u8> {
let doc = roxmltree::Document::parse(xml_str).expect("parse XML");
let target = find_element_by_id(&doc, id_value);
let pred = subtree_predicate(target);
let mut output = Vec::new();
canonicalize(&doc, Some(&pred), algo, &mut output).expect("canonicalize subtree");
output
}

/// Exclusive C14N of <dsig:Object Id="to-be-signed"> without PrefixList.
/// Expected SHA-1 digest: 7yOTjUu+9oEhShgyIIXDLjQ08aY= (from first Reference)
#[test]
fn exc_c14n_subtree_no_prefix_list() {
let xml = fixture("merlin-exc-c14n-one/exc-signature.xml");
let algo = C14nAlgorithm::new(C14nMode::Exclusive1_0, false);
let canonical = canonicalize_subtree_by_id(&xml, "to-be-signed", &algo);
let digest = sha1_base64(&canonical);
assert_eq!(
digest,
"7yOTjUu+9oEhShgyIIXDLjQ08aY=",
"exc-c14n subtree (no PrefixList) digest mismatch\ncanonical output: {:?}",
String::from_utf8_lossy(&canonical)
);
}

/// Exclusive C14N of <dsig:Object Id="to-be-signed"> with PrefixList="bar #default".
/// Expected SHA-1 digest: 09xMy0RTQM1Q91demYe/0F6AGXo= (from second Reference)
#[test]
fn exc_c14n_subtree_with_prefix_list() {
let xml = fixture("merlin-exc-c14n-one/exc-signature.xml");
let algo = C14nAlgorithm::new(C14nMode::Exclusive1_0, false).with_prefix_list("bar #default");
let canonical = canonicalize_subtree_by_id(&xml, "to-be-signed", &algo);
let digest = sha1_base64(&canonical);
assert_eq!(
digest,
"09xMy0RTQM1Q91demYe/0F6AGXo=",
"exc-c14n subtree (PrefixList='bar #default') digest mismatch\ncanonical output: {:?}",
String::from_utf8_lossy(&canonical)
);
}

/// Exclusive C14N WithComments of <dsig:Object Id="to-be-signed">.
/// Expected SHA-1 digest: ZQH+SkCN8c5y0feAr+aRTZDwyvY= (from third Reference)
#[test]
fn exc_c14n_subtree_with_comments() {
let xml = fixture("merlin-exc-c14n-one/exc-signature.xml");
let algo = C14nAlgorithm::new(C14nMode::Exclusive1_0, true);
let canonical = canonicalize_subtree_by_id(&xml, "to-be-signed", &algo);
let digest = sha1_base64(&canonical);
assert_eq!(
digest,
"ZQH+SkCN8c5y0feAr+aRTZDwyvY=",
"exc-c14n subtree (WithComments) digest mismatch\ncanonical output: {:?}",
String::from_utf8_lossy(&canonical)
);
}

/// Exclusive C14N WithComments + PrefixList="bar #default".
/// Expected SHA-1 digest: a1cTqBgbqpUt6bMJN4C6zFtnoyo= (from fourth Reference)
#[test]
fn exc_c14n_subtree_with_comments_and_prefix_list() {
let xml = fixture("merlin-exc-c14n-one/exc-signature.xml");
let algo = C14nAlgorithm::new(C14nMode::Exclusive1_0, true).with_prefix_list("bar #default");
let canonical = canonicalize_subtree_by_id(&xml, "to-be-signed", &algo);
let digest = sha1_base64(&canonical);
assert_eq!(
digest,
"a1cTqBgbqpUt6bMJN4C6zFtnoyo=",
"exc-c14n subtree (WithComments + PrefixList) digest mismatch\ncanonical output: {:?}",
String::from_utf8_lossy(&canonical)
);
}

// ─── XPath subset vectors — explicitly skipped ──────────────────────────────
//
// merlin-c14n-three/c14n-0.txt through c14n-27.txt are all XPath-subset
// canonicalization results. Without an XPath 1.0 evaluator (planned for
// P4-008), we cannot generate the node sets needed to test against these
// golden files. They remain in tests/fixtures/ for when P4-008 is
// implemented.
//
// See also: arch/ROADMAP.md gap G002.
16 changes: 16 additions & 0 deletions tests/fixtures/c14n/c14n11/full-doc-c14n10.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<ietf:c14n11XmlBaseDoc1 xmlns:ietf="http://www.ietf.org" xmlns:w3c="http://www.w3.org" xml:base="http://xmlbase.example.org/xmlbase0/">
<ietf:e1 xml:base="/xmlbase1/">
<ietf:e11 xml:base="/xmlbase11/">
<ietf:e111 xml:base="/xmlbase111/"></ietf:e111>
</ietf:e11>
<ietf:e12 at="2">
<ietf:e121 xml:base="/xmlbase121/"></ietf:e121>
</ietf:e12>
</ietf:e1>
<ietf:e2>
<ietf:e21 xml:base="/xmlbase21/"></ietf:e21>
</ietf:e2>
<ietf:e3>
<ietf:e31 at="3"></ietf:e31>
</ietf:e3>
</ietf:c14n11XmlBaseDoc1>
16 changes: 16 additions & 0 deletions tests/fixtures/c14n/c14n11/full-doc-c14n11.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<ietf:c14n11XmlBaseDoc1 xmlns:ietf="http://www.ietf.org" xmlns:w3c="http://www.w3.org" xml:base="http://xmlbase.example.org/xmlbase0/">
<ietf:e1 xml:base="/xmlbase1/">
<ietf:e11 xml:base="/xmlbase11/">
<ietf:e111 xml:base="/xmlbase111/"></ietf:e111>
</ietf:e11>
<ietf:e12 at="2">
<ietf:e121 xml:base="/xmlbase121/"></ietf:e121>
</ietf:e12>
</ietf:e1>
<ietf:e2>
<ietf:e21 xml:base="/xmlbase21/"></ietf:e21>
</ietf:e2>
<ietf:e3>
<ietf:e31 at="3"></ietf:e31>
</ietf:e3>
</ietf:c14n11XmlBaseDoc1>
Loading
Loading