-
Notifications
You must be signed in to change notification settings - Fork 0
feat(test): add donor test fixtures and C14N golden file tests #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a31b9e9
feat(test): add donor test fixtures and C14N golden file tests
polaz 5167ec9
docs(test): add xmllint with-comments note to golden test module doc
polaz 2efcf49
fix(test): use char-safe truncation in golden test error messages
polaz 99b59df
build(deps): bump actions/checkout from v4 to v6
polaz 8062d54
fix(test): panic on I/O errors in fixture file counter
polaz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.