Merge epic/27-catalog: OASIS XML Catalog resolution

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 06:03:02 +02:00
commit 2970a3962f
6 changed files with 292 additions and 1 deletions

View File

@ -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(_:)`

View File

@ -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
```

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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 {}
}

83
Tests/CatalogTests.swift Normal file
View File

@ -0,0 +1,83 @@
@testable import PureXML
import Testing
@Suite("XML Catalog")
struct CatalogTests {
private let catalog = """
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<public publicId="-//W3C//DTD XHTML 1.0//EN" uri="local/xhtml1.dtd"/>
<system systemId="http://example.com/note.dtd" uri="local/note.dtd"/>
<rewriteSystem systemIdStartString="http://example.com/dtd/" rewritePrefix="local/dtd/"/>
<uri name="urn:logo" uri="local/logo.png"/>
<group>
<rewriteURI uriStartString="http://example.com/img/" rewritePrefix="local/img/"/>
</group>
</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" ? "<!ENTITY greeting \"hi\">" : nil
}
let xml = "<!DOCTYPE r SYSTEM \"http://example.com/note.dtd\"><r>&greeting;</r>"
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 = "<!DOCTYPE r SYSTEM \"http://example.com/note.dtd\"><r>&greeting;</r>"
#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: ""
}
}
}