Merge epic/27-catalog: OASIS XML Catalog resolution
This commit is contained in:
commit
2970a3962f
10
CHANGELOG.md
10
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(_:)`
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
84
Sources/Catalog/CatalogParser.swift
Normal file
84
Sources/Catalog/CatalogParser.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
Sources/Catalog/Resolver.swift
Normal file
111
Sources/Catalog/Resolver.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
83
Tests/CatalogTests.swift
Normal 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: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user