diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ae4a4..ded8a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XML Catalog resolution (#27, the libxml2 `catalog.h` model). The new + `PureXML.Catalog` namespace parses an OASIS XML catalog (matching `public`, + `system`, `uri`, `rewriteSystem`, `rewriteURI`, and `group`/`catalog` by local + name) into a `Resolver` that maps public/system identifiers and URI names to + replacement URIs, with longest-prefix rewriting and system-over-public + precedence. `Resolver.entityResolver(loadingURI:)` builds a + `Parsing.EntityResolver` that resolves an external identifier through the + catalog and then loads the URI via an injected closure, so a catalog plus a + loader wires external entities and DTDs in while the default (no loader) keeps + XXE closed. - Schematron validation (#25, the libxml2 `schematron.h` model). `PureXML.Validation.Schematron(schema:)` compiles a Schematron schema (namespace-agnostic over the ISO and legacy namespaces) and `validate(_:)` diff --git a/README.md b/README.md index 1172a43..814a7be 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ flowchart TB Xslt["#3 XSLT 1.0"]:::epic XInclude["#24 XInclude, URI, xml:base"]:::epic C14n["#26 Canonical XML"]:::epic - Catalog["#27 XML Catalog"]:::epic + Catalog["#27 XML Catalog"]:::done Xslt --> C14n XInclude --> Catalog ``` diff --git a/Sources/Catalog/CatalogParser.swift b/Sources/Catalog/CatalogParser.swift new file mode 100644 index 0000000..c2279f8 --- /dev/null +++ b/Sources/Catalog/CatalogParser.swift @@ -0,0 +1,84 @@ +/// Accumulates catalog entries as the tree is walked. File-scope and private: an +/// internal detail of ``PureXML/Catalog/CatalogParser``. +private struct CatalogBuilder { + typealias RewriteRule = PureXML.Catalog.RewriteRule + + var systemMap: [String: String] = [:] + var publicMap: [String: String] = [:] + var uriMap: [String: String] = [:] + var rewriteSystem: [RewriteRule] = [] + var rewriteURI: [RewriteRule] = [] + + mutating func add(_ node: PureXML.Model.TreeNode) { + switch node.name?.localName { + case "system": + insert(node, key: "systemId", value: "uri", into: &systemMap) + case "public": + insert(node, key: "publicId", value: "uri", into: &publicMap) + case "uri": + insert(node, key: "name", value: "uri", into: &uriMap) + case "rewriteSystem": + appendRewrite(node, start: "systemIdStartString", into: &rewriteSystem) + case "rewriteURI": + appendRewrite(node, start: "uriStartString", into: &rewriteURI) + default: + break + } + } + + private func insert( + _ node: PureXML.Model.TreeNode, + key: String, + value: String, + into map: inout [String: String], + ) { + guard let identifier = attribute(node, key), let uri = attribute(node, value) else { return } + map[identifier] = uri + } + + private func appendRewrite( + _ node: PureXML.Model.TreeNode, + start: String, + into rules: inout [RewriteRule], + ) { + guard let startString = attribute(node, start), let rewrite = attribute(node, "rewritePrefix") else { return } + rules.append(RewriteRule(startString: startString, rewritePrefix: rewrite)) + } + + private func attribute(_ node: PureXML.Model.TreeNode, _ name: String) -> String? { + node.attributes.first { $0.name.localName == name || $0.name.description == name }?.value + } + + func resolver() -> PureXML.Catalog.Resolver { + PureXML.Catalog.Resolver( + systemMap: systemMap, + publicMap: publicMap, + uriMap: uriMap, + rewriteSystem: rewriteSystem, + rewriteURI: rewriteURI, + ) + } +} + +extension PureXML.Catalog { + /// Parses an OASIS XML catalog document into a ``Resolver``. Entries are + /// matched by local name, so the catalog namespace is not required; `group` + /// and `catalog` elements are recursed into. + enum CatalogParser { + static func parse(_ xml: String) throws -> Resolver { + let root = try PureXML.parseTree(xml) + var builder = CatalogBuilder() + collect(root, into: &builder) + return builder.resolver() + } + + private static func collect(_ node: PureXML.Model.TreeNode, into builder: inout CatalogBuilder) { + for child in node.children where child.kind == .element { + builder.add(child) + if child.name?.localName == "group" || child.name?.localName == "catalog" { + collect(child, into: &builder) + } + } + } + } +} diff --git a/Sources/Catalog/Resolver.swift b/Sources/Catalog/Resolver.swift new file mode 100644 index 0000000..edd9c5c --- /dev/null +++ b/Sources/Catalog/Resolver.swift @@ -0,0 +1,111 @@ +extension PureXML.Catalog { + /// A longest-prefix rewrite rule (`rewriteSystem`, `rewriteURI`). + struct RewriteRule: Sendable { + let startString: String + let rewritePrefix: String + } +} + +public extension PureXML.Catalog { + /// An OASIS XML Catalog (the libxml2 `catalog.h` model): it maps public and + /// system identifiers, and URI names, to replacement URIs. + /// + /// The catalog itself performs no I/O: it resolves an identifier to a URI + /// string. To actually load the target, build a ``PureXML/Parsing/EntityResolver`` + /// with ``entityResolver(loadingURI:)`` and supply a closure that turns a + /// resolved URI into text. With no such closure nothing is loaded, so XXE + /// stays closed. + /// + /// Supported entries (matched by local name, namespace-agnostic): `public`, + /// `system`, `uri`, `rewriteSystem`, `rewriteURI`, and `group` (recursed). + struct Resolver: Sendable { + private let systemMap: [String: String] + private let publicMap: [String: String] + private let uriMap: [String: String] + private let rewriteSystem: [RewriteRule] + private let rewriteURI: [RewriteRule] + + /// Parses an OASIS XML catalog document. + public init(_ xml: String) throws { + self = try CatalogParser.parse(xml) + } + + init( + systemMap: [String: String], + publicMap: [String: String], + uriMap: [String: String], + rewriteSystem: [RewriteRule], + rewriteURI: [RewriteRule], + ) { + self.systemMap = systemMap + self.publicMap = publicMap + self.uriMap = uriMap + self.rewriteSystem = rewriteSystem + self.rewriteURI = rewriteURI + } + + /// Resolves a system identifier: an exact `system` entry, else the longest + /// matching `rewriteSystem` prefix. + public func resolveSystem(_ systemID: String) -> String? { + systemMap[systemID] ?? Self.rewrite(systemID, with: rewriteSystem) + } + + /// Resolves a public identifier against the `public` entries. + public func resolvePublic(_ publicID: String) -> String? { + publicMap[publicID] + } + + /// Resolves a URI name: an exact `uri` entry, else the longest matching + /// `rewriteURI` prefix. + public func resolveURI(_ name: String) -> String? { + uriMap[name] ?? Self.rewrite(name, with: rewriteURI) + } + + /// Resolves an external identifier the way a parser would: the system + /// identifier takes precedence (OASIS rule), then the public identifier. + public func resolveExternalIdentifier(publicID: String?, systemID: String?) -> String? { + if let systemID, let resolved = resolveSystem(systemID) { + return resolved + } + if let publicID { return resolvePublic(publicID) } + return nil + } + + /// Builds a ``PureXML/Parsing/EntityResolver`` that resolves an external + /// entity or subset through this catalog and then loads the resolved URI + /// with `loadingURI`. An identifier the catalog cannot map, or a URI the + /// loader returns nil for, is refused, so the default posture stays closed. + public func entityResolver( + loadingURI: @escaping @Sendable (_ uri: String) -> String?, + ) -> PureXML.Parsing.EntityResolver { + PureXML.Parsing.EntityResolver( + resolveEntity: { _, identifier in + load(identifier, loadingURI) + }, + resolveExternalSubset: { identifier in + load(identifier, loadingURI) + }, + ) + } + + private func load( + _ identifier: PureXML.Parsing.ExternalID, + _ loadingURI: @Sendable (String) -> String?, + ) -> String? { + guard let uri = resolveExternalIdentifier(publicID: identifier.publicID, systemID: identifier.systemID) else { + return nil + } + return loadingURI(uri) + } + + /// The rewrite with the longest matching `startString`, applied as a prefix + /// replacement. + private static func rewrite(_ input: String, with rules: [RewriteRule]) -> String? { + let best = rules + .filter { input.hasPrefix($0.startString) } + .max { $0.startString.count < $1.startString.count } + guard let best else { return nil } + return best.rewritePrefix + input.dropFirst(best.startString.count) + } + } +} diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index 06bd6e3..dff917f 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -30,4 +30,7 @@ public enum PureXML { /// XPointer fragment-identifier types (the `element()` and `xpointer()` /// schemes over XPath). public enum XPointer {} + + /// OASIS XML Catalog types (resolving public/system identifiers to URIs). + public enum Catalog {} } diff --git a/Tests/CatalogTests.swift b/Tests/CatalogTests.swift new file mode 100644 index 0000000..aaf1e6f --- /dev/null +++ b/Tests/CatalogTests.swift @@ -0,0 +1,83 @@ +@testable import PureXML +import Testing + +@Suite("XML Catalog") +struct CatalogTests { + private let catalog = """ + + + + + + + + + + """ + + private func resolver() throws -> PureXML.Catalog.Resolver { + try PureXML.Catalog.Resolver(catalog) + } + + @Test("System and public identifiers resolve to their URIs") + func test_exactEntries() throws { + let resolver = try resolver() + #expect(resolver.resolveSystem("http://example.com/note.dtd") == "local/note.dtd") + #expect(resolver.resolvePublic("-//W3C//DTD XHTML 1.0//EN") == "local/xhtml1.dtd") + #expect(resolver.resolveURI("urn:logo") == "local/logo.png") + } + + @Test("rewriteSystem applies the longest matching prefix") + func test_rewriteSystem() throws { + let resolver = try resolver() + #expect(resolver.resolveSystem("http://example.com/dtd/foo.dtd") == "local/dtd/foo.dtd") + } + + @Test("rewriteURI inside a group is honored") + func test_rewriteURIInGroup() throws { + #expect(try resolver().resolveURI("http://example.com/img/a.png") == "local/img/a.png") + } + + @Test("An unmapped identifier resolves to nil") + func test_unmapped() throws { + #expect(try resolver().resolveSystem("http://other/x.dtd") == nil) + } + + @Test("The system identifier takes precedence over the public one") + func test_systemPrecedence() throws { + let resolver = try resolver() + let uri = resolver.resolveExternalIdentifier( + publicID: "-//W3C//DTD XHTML 1.0//EN", + systemID: "http://example.com/note.dtd", + ) + #expect(uri == "local/note.dtd") + } + + @Test("The catalog drives an entity resolver through an injected loader") + func test_entityResolverIntegration() throws { + let resolver = try resolver().entityResolver { uri in + uri == "local/note.dtd" ? "" : nil + } + let xml = "&greeting;" + let node = try PureXML.parse(xml, limits: .init(allowDoctype: true), resolver: resolver) + #expect(stringValue(node) == "hi") + } + + @Test("Without a loader the catalog resolver loads nothing, keeping XXE closed") + func test_noLoaderRefuses() throws { + let resolver = try resolver().entityResolver { _ in nil } + let xml = "&greeting;" + #expect(throws: PureXML.Parsing.ParseError.self) { + _ = try PureXML.parse(xml, limits: .init(allowDoctype: true), resolver: resolver) + } + } + + private func stringValue(_ node: PureXML.Model.Node) -> String { + switch node { + case let .document(children): children.map(stringValue).joined() + case let .element(element): element.children.map(stringValue).joined() + case let .text(value), let .cdata(value): value + default: "" + } + } +}