feat(canonical): C14N 2.0 sequential prefix rewrite, QName-aware values, node-set selection (#85)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 04:06:02 +02:00
parent e77b39a449
commit b3bbacb4e8
7 changed files with 419 additions and 3 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- C14N node-subset canonicalization and Canonical XML 2.0 parameters (#85), completing the canonicalization audit. `Canonicalizer.canonicalize(_ subtree: TreeNode)` canonicalizes a fragment in place: the apex element renders the namespace context in scope from its omitted ancestors and inherits their `xml:base`/`xml:lang`/`xml:space` when it does not set them, so a signed fragment is independent of where it sat. `canonicalize(_:including:)` canonicalizes a node-set selected by a `TreeNode` predicate (XPath/position-based selection): an excluded element's tags are omitted but its selected descendants are kept and re-declare their in-scope namespaces. `Options.prefixRewrite = .sequential` rewrites every prefix to a canonical `n0`/`n1`/... in document order (eliminating the default namespace and placing declarations at first use), and `Options.qnameAwareLabels` extends that rewrite into QName-valued attributes and elements, so documents differing only in prefix spelling canonicalize to identical bytes.
- Serialization `textEscaping` round-trip option (#91), completing the serialization audit. `Options.textEscaping` defaults to `.standard` (escape only `&`, `<`, `>`); `.roundTrip` also escapes a carriage return as `&#xD;` in text and CDATA-as-text output, so a literal `\r` survives the parser's end-of-line normalization and the document round-trips byte for byte. (The CDATA-section vs escaped-text toggle, `cdataAsText`, was already present.)
- DOM tree: first-class doctype, entity-reference, and namespace nodes, plus `ownerDocument`, `adopt`, and `importNode` (#90), completing the tree-model audit. `TreeNodeKind` gains `.doctype`, `.entityReference`, and `.namespace`, each with a factory (`TreeNode.doctype(name:publicID:systemID:internalSubset:)`, `.entityReference(_:children:)`, `.namespace(prefix:uri:)`) and typed accessors; doctype and namespace nodes drop out of the content value-`Node` projection while an entity reference splices its replacement into place. `ownerDocument` derives the owning document from the parent chain; `adopt` and `importNode` move or copy a subtree into another document, re-declaring every namespace it relied on from an outer scope so the result is self-contained.
- Schematron `key()`, `document()`, and `current()` in tests (#80), completing the Schematron audit. Test, let, and message expressions can now resolve nodes through `xsl:key` declarations, load an external document for cross-document checks (via an injected `documentLoader`), and refer to the rule context node with `current()`.

View File

@ -77,10 +77,10 @@ flowchart TB
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Epic71["#71 Beyond-parity libxml2 audit"]:::epic
AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90, serialization #91"]:::done
AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90, serialization #91, C14N #85"]:::done
Epic74["#74 Parser-breadth sub-epic: standalone/decl #95, XML 1.1 #96, reader #98, recovery #100 done; encodings #97 #99 open"]:::epic
Partial["Partial in main: XInclude #87, XPath/XPointer #88"]:::todo
Open["Open: HTML5 #83 #84, C14N #85, encoding tables #97 #99, validator decomposition #101"]:::todo
Open["Open: HTML5 #83 #84, encoding tables #97 #99, validator decomposition #101"]:::todo
Epic71 --> AuditDone
AuditDone --> Epic74
Epic74 --> Partial
@ -91,7 +91,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **675 tests in 107 suites** (`swift test`).
currently runs **690 tests in 110 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -6,6 +6,37 @@ public extension PureXML.Canonical {
case exclusive
}
/// The Canonical XML 2.0 `PrefixRewrite` parameter: how namespace prefixes
/// are spelled in the output.
enum PrefixRewrite: Equatable, Sendable {
/// Keep each declaration's original prefix (C14N 1.0/1.1 behavior).
case retain
/// Rewrite every prefix to a sequential canonical name (`n0`, `n1`, ...)
/// assigned in document order of first use, so two documents that differ
/// only in how they spell their prefixes canonicalize to the same bytes.
/// The reserved `xml` prefix is never rewritten.
case sequential
}
/// A Canonical XML 2.0 `QNameAware` label: an attribute (or element) whose
/// textual value is a QName, so its prefix is rewritten alongside the
/// structural prefixes under ``PrefixRewrite/sequential``.
struct QNameAwareLabel: Equatable, Sendable {
/// The namespace URI of the labelled attribute or element (empty for none).
public let namespaceURI: String
/// The local name of the labelled attribute or element.
public let localName: String
/// Whether the label names an element (its text content is the QName)
/// rather than an attribute (its value is the QName).
public let isElement: Bool
public init(namespaceURI: String = "", localName: String, isElement: Bool = false) {
self.namespaceURI = namespaceURI
self.localName = localName
self.isElement = isElement
}
}
/// Options for canonicalization. The default is inclusive C14N without
/// comments, matching the common XML-signature use.
///
@ -23,17 +54,26 @@ public extension PureXML.Canonical {
/// trailing whitespace is stripped from each text node, and a text node
/// that is all whitespace is dropped.
public var trimTextNodes: Bool
/// The Canonical XML 2.0 `PrefixRewrite` parameter (default ``PrefixRewrite/retain``).
public var prefixRewrite: PrefixRewrite
/// The Canonical XML 2.0 `QNameAware` labels: attributes or elements whose
/// value is a QName, so its prefix is rewritten under sequential rewrite.
public var qnameAwareLabels: [QNameAwareLabel]
public init(
mode: Mode = .inclusive,
includeComments: Bool = false,
inclusiveNamespacePrefixes: [String] = [],
trimTextNodes: Bool = false,
prefixRewrite: PrefixRewrite = .retain,
qnameAwareLabels: [QNameAwareLabel] = [],
) {
self.mode = mode
self.includeComments = includeComments
self.inclusiveNamespacePrefixes = inclusiveNamespacePrefixes
self.trimTextNodes = trimTextNodes
self.prefixRewrite = prefixRewrite
self.qnameAwareLabels = qnameAwareLabels
}
/// Inclusive C14N without comments.

View File

@ -0,0 +1,177 @@
extension PureXML.Canonical {
/// The Canonical XML 2.0 sequential prefix rewrite. Implemented as a pre-pass
/// that rebuilds the tree using canonical prefixes (`n0`, `n1`, ... in
/// document order of first use), drops the original namespace declarations,
/// and hoists one canonical declaration per namespace onto the document
/// element. The rewritten tree is then rendered exclusive-style so each
/// declaration appears at the first element that uses it, with no default
/// namespace (every name carries a prefix). The reserved `xml` prefix is left
/// untouched.
enum PrefixRewriter {
private static let xmlNamespace = "http://www.w3.org/XML/1998/namespace"
/// Rewrites a node tree's prefixes (and any QName-valued labels) to their
/// canonical sequential form.
static func rewrite(_ node: PureXML.Model.Node, labels: [QNameAwareLabel]) -> PureXML.Model.Node {
var assignment: [String: String] = [:]
var order = 0
assign(node, inScope: [:], labels: labels, &assignment, &order)
return transform(node, inScope: [:], assignment: assignment, labels: labels, isRoot: true)
}
// MARK: First pass: assign a canonical prefix per visibly-used namespace
private static func assign(
_ node: PureXML.Model.Node,
inScope: [String: String],
labels: [QNameAwareLabel],
_ assignment: inout [String: String],
_ order: inout Int,
) {
switch node {
case let .document(children):
for child in children {
assign(child, inScope: inScope, labels: labels, &assignment, &order)
}
case let .element(element):
var childInScope = inScope
for (prefix, uri) in declarations(element) {
childInScope[prefix] = uri
}
record(element.name.namespaceURI, &assignment, &order)
for attribute in element.attributes where attribute.name.prefix != nil {
record(attribute.name.namespaceURI, &assignment, &order)
}
for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
}
record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
for child in element.children {
assign(child, inScope: childInScope, labels: labels, &assignment, &order)
}
default:
break
}
}
/// The namespace URI a QName value refers to through its prefix, or nil.
private static func qnameNamespace(_ value: String?, inScope: [String: String]) -> String? {
guard let value else { return nil }
let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
return inScope[parts.count == 2 ? String(parts[0]) : ""]
}
private static func isQNameAttribute(_ name: PureXML.Model.QualifiedName, _ labels: [QNameAwareLabel]) -> Bool {
labels.contains { !$0.isElement && $0.localName == name.localName && $0.namespaceURI == (name.namespaceURI ?? "") }
}
private static func record(_ uri: String?, _ assignment: inout [String: String], _ order: inout Int) {
guard let uri, uri != xmlNamespace, assignment[uri] == nil else { return }
assignment[uri] = "n\(order)"
order += 1
}
// MARK: Second pass: rebuild with canonical prefixes
private static func transform(
_ node: PureXML.Model.Node,
inScope: [String: String],
assignment: [String: String],
labels: [QNameAwareLabel],
isRoot: Bool,
) -> PureXML.Model.Node {
switch node {
case let .document(children):
.document(children.map { transform($0, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot) })
case let .element(element):
.element(transform(element, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot))
default:
node
}
}
private static func transform(
_ element: PureXML.Model.Element,
inScope: [String: String],
assignment: [String: String],
labels: [QNameAwareLabel],
isRoot: Bool,
) -> PureXML.Model.Element {
var childInScope = inScope
for (prefix, uri) in declarations(element) {
childInScope[prefix] = uri
}
var attributes = isRoot ? canonicalDeclarations(assignment) : []
for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
}
let children = element.children.map { transform($0, inScope: childInScope, assignment: assignment, labels: labels, isRoot: false) }
let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
return PureXML.Model.Element(
name: rename(element.name, assignment: assignment),
attributes: attributes,
children: qualified.map { [PureXML.Model.Node.text($0)] } ?? children,
)
}
/// One `xmlns:nK="uri"` declaration per assigned namespace, hoisted to the
/// document element so every canonical prefix is in scope from the top.
private static func canonicalDeclarations(_ assignment: [String: String]) -> [PureXML.Model.Attribute] {
assignment.sorted { $0.value < $1.value }.map { uri, prefix in
PureXML.Model.Attribute("xmlns:\(prefix)", uri)
}
}
private static func rewrite(
_ attribute: PureXML.Model.Attribute,
inScope: [String: String],
assignment: [String: String],
labels: [QNameAwareLabel],
) -> PureXML.Model.Attribute {
let name = rename(attribute.name, assignment: assignment)
guard isQNameAttribute(attribute.name, labels), let value = qname(attribute.value, inScope: inScope, assignment: assignment) else {
return PureXML.Model.Attribute(name: name, value: attribute.value)
}
return PureXML.Model.Attribute(name: name, value: value)
}
/// A qualified name with its prefix replaced by the canonical one. Returns
/// nil when there is nothing to rewrite (no bound prefix for the value).
private static func qname(_ value: String?, inScope: [String: String], assignment: [String: String]) -> String? {
guard let value else { return nil }
let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
let prefix = parts.count == 2 ? String(parts[0]) : ""
let local = parts.count == 2 ? String(parts[1]) : value
guard let uri = inScope[prefix], let canonical = assignment[uri] else { return nil }
return "\(canonical):\(local)"
}
private static func rename(_ name: PureXML.Model.QualifiedName, assignment: [String: String]) -> PureXML.Model.QualifiedName {
guard let uri = name.namespaceURI, uri != xmlNamespace, let canonical = assignment[uri] else { return name }
return PureXML.Model.QualifiedName(prefix: canonical, localName: name.localName, namespaceURI: uri)
}
private static func declarations(_ element: PureXML.Model.Element) -> [(String, String)] {
element.attributes.compactMap { attribute in
declaredPrefix(of: attribute.name).map { ($0, attribute.value) }
}
}
private static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
if name.prefix == nil, name.localName == "xmlns" { return "" }
if name.prefix == "xmlns" { return name.localName }
return nil
}
}
}
extension PureXML.Model.Element {
/// The text content of this element when it is labelled QName-aware as an
/// element (a single text child holding a QName), or nil otherwise.
func text(matching labels: [PureXML.Canonical.QNameAwareLabel]) -> String? {
guard labels.contains(where: { $0.isElement && $0.localName == name.localName && $0.namespaceURI == (name.namespaceURI ?? "") }) else { return nil }
guard children.count == 1, case let .text(value) = children[0] else { return nil }
return value
}
}

View File

@ -14,6 +14,15 @@ public extension PureXML.Canonical {
/// Canonicalizes a node.
public func canonicalize(_ node: PureXML.Model.Node) -> String {
if options.prefixRewrite == .sequential {
// C14N 2.0 sequential rewrite: rebuild with canonical prefixes,
// then render exclusive-style so declarations land at first use.
let rewritten = PrefixRewriter.rewrite(node, labels: options.qnameAwareLabels)
var rendering = options
rendering.prefixRewrite = .retain
rendering.mode = .exclusive
return Canonicalizer(options: rendering).canonicalize(rewritten)
}
var output = ""
emit(node, inScope: [:], rendered: [:], output: &output)
return output
@ -265,6 +274,103 @@ public extension PureXML.Canonical {
}
}
public extension PureXML.Canonical.Canonicalizer {
/// Canonicalizes a node-set: only the nodes the predicate selects appear in
/// the output (C14N's XPath/position-based node selection). An excluded
/// element's start and end tags are omitted but its selected descendants are
/// kept, with each one rendering the namespace context in scope at its
/// position, so signing a discontiguous selection is well defined. Predicate
/// identity is by ``PureXML/Model/TreeNode`` reference, so a caller can select
/// specific nodes (for example an XPath result set).
func canonicalize(_ node: PureXML.Model.TreeNode, including predicate: (PureXML.Model.TreeNode) -> Bool) -> String {
var output = ""
emitSelected(node, inScope: Self.inScopeNamespaces(above: node), rendered: [:], including: predicate, output: &output)
return output
}
private func emitSelected(
_ node: PureXML.Model.TreeNode,
inScope: [String: String],
rendered: [String: String],
including predicate: (PureXML.Model.TreeNode) -> Bool,
output: inout String,
) {
switch node.kind {
case .document:
for child in node.children {
emitSelected(child, inScope: inScope, rendered: rendered, including: predicate, output: &output)
}
case .element:
emitSelected(element: node, inScope: inScope, rendered: rendered, including: predicate, output: &output)
case .text, .cdata:
if predicate(node) { output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value) }
case .comment:
if predicate(node), options.includeComments { output += "<!--\(node.value)-->" }
case .processingInstruction:
if predicate(node) { output += node.value.isEmpty ? "<?\(node.name?.description ?? "")?>" : "<?\(node.name?.description ?? "") \(node.value)?>" }
case .doctype, .entityReference, .namespace:
break
}
}
private func emitSelected(
element node: PureXML.Model.TreeNode,
inScope: [String: String],
rendered: [String: String],
including predicate: (PureXML.Model.TreeNode) -> Bool,
output: inout String,
) {
let element = PureXML.Model.Element(name: node.name ?? .init(""), attributes: node.attributes, children: [])
var childInScope = inScope
for (prefix, uri) in Self.namespaceDeclarations(element) {
childInScope[prefix] = uri
}
guard predicate(node) else {
// Omitted element: its namespaces stay in scope for descendants, but
// nothing is rendered, so a selected descendant renders them itself.
for child in node.children {
emitSelected(child, inScope: childInScope, rendered: rendered, including: predicate, output: &output)
}
return
}
let attributes = Self.plainAttributes(element)
let toRender = selectedNamespaces(element, inScope: childInScope, attributes: attributes, rendered: rendered)
var childRendered = rendered
for (prefix, uri) in toRender {
childRendered[prefix] = uri
}
output += "<" + element.name.description
for (prefix, uri) in toRender.sorted(by: { $0.0 < $1.0 }) {
output += Self.renderNamespace(prefix, uri)
}
for attribute in attributes.sorted(by: Self.attributeOrder) {
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
}
output += ">"
for child in node.children {
emitSelected(child, inScope: childInScope, rendered: childRendered, including: predicate, output: &output)
}
output += "</\(element.name.description)>"
}
/// The namespaces a selected element renders: in inclusive mode every in-scope
/// binding not yet rendered (so an exposed element re-declares its inherited
/// context); in exclusive mode only those it visibly uses.
private func selectedNamespaces(
_ element: PureXML.Model.Element,
inScope: [String: String],
attributes: [PureXML.Model.Attribute],
rendered: [String: String],
) -> [(String, String)] {
switch options.mode {
case .inclusive:
inScope.filter { rendered[$0.key] != $0.value }.map { ($0.key, $0.value) }
case .exclusive:
exclusiveNamespaces(element, inScope: inScope, attributes: attributes, rendered: rendered)
}
}
}
public extension PureXML.Canonical {
/// Canonicalizes a node with the given options.
static func canonicalize(_ node: PureXML.Model.Node, options: Options = .inclusive) -> String {

View File

@ -0,0 +1,51 @@
@testable import PureXML
import Testing
@Suite("Canonical XML 2.0: sequential prefix rewrite and QName-aware values")
struct CanonicalPrefixRewriteTests {
private func c14n(_ xml: String, _ options: PureXML.Canonical.Options) throws -> String {
try PureXML.Canonical.canonicalize(PureXML.parse(xml), options: options)
}
private let sequential = PureXML.Canonical.Options(prefixRewrite: .sequential)
@Test("Sequential rewrite renames a prefix to n0 and declares it at first use")
func test_basicRewrite() throws {
#expect(try c14n("<r xmlns:p=\"urn:x\"><p:c/></r>", sequential) == "<r><n0:c xmlns:n0=\"urn:x\"></n0:c></r>")
}
@Test("Documents differing only in prefix spelling canonicalize identically")
func test_prefixIndependence() throws {
let withP = try c14n("<r xmlns:p=\"urn:x\"><p:c/></r>", sequential)
let withQ = try c14n("<r xmlns:q=\"urn:x\"><q:c/></r>", sequential)
#expect(withP == withQ)
}
@Test("The default namespace is given a canonical prefix")
func test_defaultNamespaceRewritten() throws {
#expect(try c14n("<a xmlns=\"urn:d\"><b/></a>", sequential) == "<n0:a xmlns:n0=\"urn:d\"><n0:b></n0:b></n0:a>")
}
@Test("Prefixes are numbered in document order of first use")
func test_documentOrderNumbering() throws {
let xml = "<r xmlns:b=\"urn:b\" xmlns:a=\"urn:a\"><b:x/><a:y/></r>"
// urn:b is used first (by <b:x>), so it becomes n0; urn:a becomes n1.
#expect(try c14n(xml, sequential) == "<r><n0:x xmlns:n0=\"urn:b\"></n0:x><n1:y xmlns:n1=\"urn:a\"></n1:y></r>")
}
@Test("A QName-aware attribute value has its prefix rewritten too")
func test_qnameAwareAttribute() throws {
let options = PureXML.Canonical.Options(
prefixRewrite: .sequential,
qnameAwareLabels: [.init(localName: "type")],
)
// <e type="p:T"> with p bound to urn:x: type becomes the canonical prefix.
let output = try c14n("<r xmlns:p=\"urn:x\"><p:e type=\"p:T\"/></r>", options)
#expect(output == "<r><n0:e xmlns:n0=\"urn:x\" type=\"n0:T\"></n0:e></r>")
}
@Test("Retain (the default) leaves prefixes untouched")
func test_retainDefault() throws {
#expect(try c14n("<r xmlns:p=\"urn:x\"><p:c/></r>", .inclusive) == "<r xmlns:p=\"urn:x\"><p:c></p:c></r>")
}
}

View File

@ -0,0 +1,41 @@
@testable import PureXML
import Testing
@Suite("Canonical XML: node-set (position-based) selection")
struct CanonicalSelectionTests {
private func select(
_ xml: String,
_ options: PureXML.Canonical.Options = .inclusive,
where predicate: @escaping (PureXML.Model.TreeNode) -> Bool,
) throws -> String {
let document = try PureXML.parseTree(xml)
return PureXML.Canonical.Canonicalizer(options: options).canonicalize(document, including: predicate)
}
@Test("An excluded element and its subtree are dropped")
func test_excludeSubtree() throws {
let output = try select("<r><a/><b/></r>") { $0.name?.localName != "b" }
#expect(output == "<r><a></a></r>")
}
@Test("An excluded middle element is omitted but its selected child is kept")
func test_omitMiddleKeepChild() throws {
let output = try select("<r><skip><keep/></skip></r>") { $0.name?.localName != "skip" }
#expect(output == "<r><keep></keep></r>")
}
@Test("A selected node exposed by omitted ancestors re-declares its namespace context")
func test_exposedReDeclaresNamespace() throws {
let output = try select("<r xmlns:p=\"urn:x\"><skip><p:keep/></skip></r>") { $0.name?.localName == "keep" }
#expect(output == "<p:keep xmlns:p=\"urn:x\"></p:keep>")
}
@Test("Selecting every node reproduces the ordinary canonical form")
func test_selectAllMatchesWholeTree() throws {
let xml = "<r xmlns:p=\"urn:x\"><p:c>hi</p:c></r>"
let selected = try select(xml) { _ in true }
let whole = try PureXML.Canonical.canonicalize(PureXML.parse(xml))
#expect(selected == whole)
#expect(selected == "<r xmlns:p=\"urn:x\"><p:c>hi</p:c></r>")
}
}