diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c86cc..490fb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` ` 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()`. diff --git a/README.md b/README.md index df5ec4c..aba59b1 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/Canonical/CanonicalOptions.swift b/Sources/Canonical/CanonicalOptions.swift index 38b50f8..39f436a 100644 --- a/Sources/Canonical/CanonicalOptions.swift +++ b/Sources/Canonical/CanonicalOptions.swift @@ -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. diff --git a/Sources/Canonical/CanonicalPrefixRewrite.swift b/Sources/Canonical/CanonicalPrefixRewrite.swift new file mode 100644 index 0000000..23af01a --- /dev/null +++ b/Sources/Canonical/CanonicalPrefixRewrite.swift @@ -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 + } +} diff --git a/Sources/Canonical/Canonicalizer.swift b/Sources/Canonical/Canonicalizer.swift index ba32f22..232f1e1 100644 --- a/Sources/Canonical/Canonicalizer.swift +++ b/Sources/Canonical/Canonicalizer.swift @@ -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 += "" } + case .processingInstruction: + if predicate(node) { output += node.value.isEmpty ? "" : "" } + 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 += "" + } + + /// 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 { diff --git a/Tests/CanonicalPrefixRewriteTests.swift b/Tests/CanonicalPrefixRewriteTests.swift new file mode 100644 index 0000000..5e45c88 --- /dev/null +++ b/Tests/CanonicalPrefixRewriteTests.swift @@ -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("", sequential) == "") + } + + @Test("Documents differing only in prefix spelling canonicalize identically") + func test_prefixIndependence() throws { + let withP = try c14n("", sequential) + let withQ = try c14n("", sequential) + #expect(withP == withQ) + } + + @Test("The default namespace is given a canonical prefix") + func test_defaultNamespaceRewritten() throws { + #expect(try c14n("", sequential) == "") + } + + @Test("Prefixes are numbered in document order of first use") + func test_documentOrderNumbering() throws { + let xml = "" + // urn:b is used first (by ), so it becomes n0; urn:a becomes n1. + #expect(try c14n(xml, sequential) == "") + } + + @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")], + ) + // with p bound to urn:x: type becomes the canonical prefix. + let output = try c14n("", options) + #expect(output == "") + } + + @Test("Retain (the default) leaves prefixes untouched") + func test_retainDefault() throws { + #expect(try c14n("", .inclusive) == "") + } +} diff --git a/Tests/CanonicalSelectionTests.swift b/Tests/CanonicalSelectionTests.swift new file mode 100644 index 0000000..13fac7e --- /dev/null +++ b/Tests/CanonicalSelectionTests.swift @@ -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("") { $0.name?.localName != "b" } + #expect(output == "") + } + + @Test("An excluded middle element is omitted but its selected child is kept") + func test_omitMiddleKeepChild() throws { + let output = try select("") { $0.name?.localName != "skip" } + #expect(output == "") + } + + @Test("A selected node exposed by omitted ancestors re-declares its namespace context") + func test_exposedReDeclaresNamespace() throws { + let output = try select("") { $0.name?.localName == "keep" } + #expect(output == "") + } + + @Test("Selecting every node reproduces the ordinary canonical form") + func test_selectAllMatchesWholeTree() throws { + let xml = "hi" + let selected = try select(xml) { _ in true } + let whole = try PureXML.Canonical.canonicalize(PureXML.parse(xml)) + #expect(selected == whole) + #expect(selected == "hi") + } +}