PureXML/Sources/Catalog/Resolver.swift
Mihaela Mihaljevic 634270d529 feat(catalog): OASIS XML Catalog resolution
Add the PureXML.Catalog namespace: parse an OASIS XML catalog (public,
system, uri, rewriteSystem, rewriteURI, group/catalog by local name) into a
Resolver mapping public/system identifiers and URI names to URIs, with
longest-prefix rewriting and system-over-public precedence. entityResolver
(loadingURI:) builds a Parsing.EntityResolver that resolves an external
identifier through the catalog and loads the URI via an injected closure, so
a catalog plus loader wires entities and DTDs in while the default keeps XXE
closed.

Closes #57
Closes #58
Closes #27
2026-06-08 06:03:02 +02:00

112 lines
4.5 KiB
Swift

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