diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e03018..b9f00a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Canonical XML (#26, the libxml2 `c14n.h` model). The new `PureXML.Canonical` + namespace renders the C14N form of a node: namespace declarations sorted by + prefix (default first) and attributes by namespace URI then local name, empty + elements expanded to start/end pairs, CDATA folded into escaped text, and the + C14N text/attribute escaping rules. Inclusive mode renders every in-scope + namespace; exclusive mode renders only the namespaces an element and its + attributes visibly use (with an `InclusiveNamespaces` prefix list). Comments + are omitted unless requested. For whole-tree canonicalization C14N 1.0 and 1.1 + coincide, so the single inclusive mode covers both. - XInclude, URI resolution, and `xml:base` (#24, the libxml2 `xinclude.h`/`uri.h` model). The new `PureXML.XInclude` namespace adds RFC 3986 reference resolution (`URIReference.resolve(_:against:)`, with dot-segment removal) and diff --git a/README.md b/README.md index c20f8fd..c04ac87 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ flowchart TB classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827 Xslt["#3 XSLT 1.0"]:::epic XInclude["#24 XInclude, URI, xml:base"]:::done - C14n["#26 Canonical XML"]:::epic + C14n["#26 Canonical XML"]:::done Catalog["#27 XML Catalog"]:::done Xslt --> C14n XInclude --> Catalog diff --git a/Sources/Canonical/CanonicalOptions.swift b/Sources/Canonical/CanonicalOptions.swift new file mode 100644 index 0000000..f0a83b8 --- /dev/null +++ b/Sources/Canonical/CanonicalOptions.swift @@ -0,0 +1,38 @@ +public extension PureXML.Canonical { + /// Whether to render every in-scope namespace (inclusive C14N) or only the + /// namespaces an element and its attributes actually use (exclusive C14N). + enum Mode: Equatable, Sendable { + case inclusive + case exclusive + } + + /// Options for canonicalization. The default is inclusive C14N without + /// comments, matching the common XML-signature use. + /// + /// For whole-document and whole-subtree canonicalization, C14N 1.0 and 1.1 + /// produce identical output (their differences only affect `xml:*` attribute + /// inheritance into selected document subsets), so this single inclusive mode + /// covers both. + struct Options: Equatable, Sendable { + public var mode: Mode + public var includeComments: Bool + /// Prefixes always rendered in exclusive mode even when not visibly used + /// (the `InclusiveNamespaces` PrefixList). + public var inclusiveNamespacePrefixes: [String] + + public init( + mode: Mode = .inclusive, + includeComments: Bool = false, + inclusiveNamespacePrefixes: [String] = [], + ) { + self.mode = mode + self.includeComments = includeComments + self.inclusiveNamespacePrefixes = inclusiveNamespacePrefixes + } + + /// Inclusive C14N without comments. + public static let inclusive = Options(mode: .inclusive) + /// Exclusive C14N without comments. + public static let exclusive = Options(mode: .exclusive) + } +} diff --git a/Sources/Canonical/Canonicalizer.swift b/Sources/Canonical/Canonicalizer.swift new file mode 100644 index 0000000..1fae1f1 --- /dev/null +++ b/Sources/Canonical/Canonicalizer.swift @@ -0,0 +1,185 @@ +public extension PureXML.Canonical { + /// Produces the Canonical XML (C14N) form of a node (the libxml2 `c14n.h` + /// model): UTF-8 text with namespace declarations and attributes in canonical + /// order, empty elements expanded to start/end pairs, character and attribute + /// escaping normalized, and namespaces rendered per the inclusive or exclusive + /// rules. Comments are omitted unless requested. Reimplemented from the C14N + /// specification. + struct Canonicalizer: Sendable { + public let options: Options + + public init(options: Options = .inclusive) { + self.options = options + } + + /// Canonicalizes a node. + public func canonicalize(_ node: PureXML.Model.Node) -> String { + var output = "" + emit(node, inScope: [:], rendered: [:], output: &output) + return output + } + + private func emit( + _ node: PureXML.Model.Node, + inScope: [String: String], + rendered: [String: String], + output: inout String, + ) { + switch node { + case let .document(children): + for child in children { + emit(child, inScope: inScope, rendered: rendered, output: &output) + } + case let .element(element): + emit(element, inScope: inScope, rendered: rendered, output: &output) + case let .text(value), let .cdata(value): + output += Self.escapeText(value) + case let .comment(value): + if options.includeComments { output += "" } + case let .processingInstruction(target, data): + output += data.isEmpty ? "" : "" + } + } + + private func emit( + _ element: PureXML.Model.Element, + inScope: [String: String], + rendered: [String: String], + output: inout String, + ) { + let declarations = Self.namespaceDeclarations(element) + var childInScope = inScope + for (prefix, uri) in declarations { + childInScope[prefix] = uri + } + let attributes = Self.plainAttributes(element) + let toRender = namespacesToRender(element, declarations: declarations, 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 element.children { + emit(child, inScope: childInScope, rendered: childRendered, output: &output) + } + output += "" + } + + // MARK: Namespace selection + + private func namespacesToRender( + _ element: PureXML.Model.Element, + declarations: [(String, String)], + inScope: [String: String], + attributes: [PureXML.Model.Attribute], + rendered: [String: String], + ) -> [(String, String)] { + switch options.mode { + case .inclusive: + declarations.filter { rendered[$0.0] != $0.1 } + case .exclusive: + exclusiveNamespaces(element, inScope: inScope, attributes: attributes, rendered: rendered) + } + } + + private func exclusiveNamespaces( + _ element: PureXML.Model.Element, + inScope: [String: String], + attributes: [PureXML.Model.Attribute], + rendered: [String: String], + ) -> [(String, String)] { + var utilized: Set = [element.name.prefix ?? ""] + for attribute in attributes where attribute.name.prefix != nil { + utilized.insert(attribute.name.prefix ?? "") + } + utilized.formUnion(options.inclusiveNamespacePrefixes) + + var result: [(String, String)] = [] + for prefix in utilized { + let uri = inScope[prefix] ?? "" + if prefix.isEmpty { + if uri != (rendered[""] ?? "") { result.append(("", uri)) } + } else if !uri.isEmpty, rendered[prefix] != uri { + result.append((prefix, uri)) + } + } + return result + } + + // MARK: Attribute and namespace rendering + + private static func renderNamespace(_ prefix: String, _ uri: String) -> String { + prefix.isEmpty ? " xmlns=\"\(escapeAttribute(uri))\"" : " xmlns:\(prefix)=\"\(escapeAttribute(uri))\"" + } + + private static func attributeOrder(_ lhs: PureXML.Model.Attribute, _ rhs: PureXML.Model.Attribute) -> Bool { + let leftURI = lhs.name.namespaceURI ?? "" + let rightURI = rhs.name.namespaceURI ?? "" + if leftURI != rightURI { return leftURI < rightURI } + return lhs.name.localName < rhs.name.localName + } + + private static func namespaceDeclarations(_ element: PureXML.Model.Element) -> [(String, String)] { + element.attributes.compactMap { attribute in + let name = attribute.name + if name.prefix == nil, name.localName == "xmlns" { return ("", attribute.value) } + if name.prefix == "xmlns" { return (name.localName, attribute.value) } + return nil + } + } + + private static func plainAttributes(_ element: PureXML.Model.Element) -> [PureXML.Model.Attribute] { + element.attributes.filter { attribute in + let name = attribute.name + return name.prefix != "xmlns" && !(name.prefix == nil && name.localName == "xmlns") + } + } + + // MARK: Escaping + + private static func escapeText(_ value: String) -> String { + var result = "" + for character in value { + switch character { + case "&": result += "&" + case "<": result += "<" + case ">": result += ">" + case "\r": result += " " + default: result.append(character) + } + } + return result + } + + private static func escapeAttribute(_ value: String) -> String { + var result = "" + for character in value { + switch character { + case "&": result += "&" + case "<": result += "<" + case "\"": result += """ + case "\t": result += " " + case "\n": result += " " + case "\r": result += " " + default: result.append(character) + } + } + return result + } + } +} + +public extension PureXML.Canonical { + /// Canonicalizes a node with the given options. + static func canonicalize(_ node: PureXML.Model.Node, options: Options = .inclusive) -> String { + Canonicalizer(options: options).canonicalize(node) + } +} diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index e13e530..e3a9443 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -36,4 +36,7 @@ public enum PureXML { /// XInclude processing, URI reference resolution, and `xml:base`. public enum XInclude {} + + /// Canonical XML (C14N) serialization types. + public enum Canonical {} } diff --git a/Tests/CanonicalTests.swift b/Tests/CanonicalTests.swift new file mode 100644 index 0000000..38a59d0 --- /dev/null +++ b/Tests/CanonicalTests.swift @@ -0,0 +1,72 @@ +@testable import PureXML +import Testing + +@Suite("Canonical XML") +struct CanonicalTests { + private func c14n(_ xml: String, _ options: PureXML.Canonical.Options = .inclusive) throws -> String { + try PureXML.Canonical.canonicalize(PureXML.parse(xml), options: options) + } + + @Test("Empty elements expand to start/end pairs") + func test_emptyElement() throws { + #expect(try c14n("") == "") + } + + @Test("Attributes are sorted by namespace URI then local name") + func test_attributeOrdering() throws { + #expect(try c14n("") == "") + } + + @Test("Text special characters are escaped, but not quotes") + func test_textEscaping() throws { + #expect(try c14n("1 < 2 & 3 > 4") == "1 < 2 & 3 > 4") + } + + @Test("Attribute values escape quotes and whitespace, not >") + func test_attributeEscaping() throws { + let xml = "" + #expect(try c14n(xml) == "") + } + + @Test("Comments are omitted by default and kept when requested") + func test_comments() throws { + #expect(try c14n("") == "") + let withComments = PureXML.Canonical.Options(includeComments: true) + #expect(try c14n("", withComments) == "") + } + + @Test("CDATA is rendered as escaped text") + func test_cdata() throws { + #expect(try c14n("") == "x<y") + } + + @Test("Namespace declarations are rendered and sorted, default first") + func test_namespaceOrdering() throws { + let xml = "" + #expect(try c14n(xml) == "") + } + + @Test("Inclusive C14N keeps a namespace declared but only used by a child") + func test_inclusiveKeepsUnused() throws { + let xml = "" + #expect(try c14n(xml) == "") + } + + @Test("Exclusive C14N drops a namespace no element visibly uses") + func test_exclusiveDropsUnused() throws { + let xml = "" + #expect(try c14n(xml, .exclusive) == "") + } + + @Test("Exclusive C14N renders a namespace at the element that uses it") + func test_exclusiveRendersAtUse() throws { + let xml = "" + #expect(try c14n(xml, .exclusive) == "") + } + + @Test("A redundant child redeclaration is not rendered twice") + func test_noRedundantDeclaration() throws { + let xml = "" + #expect(try c14n(xml) == "") + } +}