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: ""
+ }
+ }
+}