diff --git a/Sources/Catalog/CatalogParser.swift b/Sources/Catalog/CatalogParser.swift index 434fddc..1fb3d55 100644 --- a/Sources/Catalog/CatalogParser.swift +++ b/Sources/Catalog/CatalogParser.swift @@ -18,46 +18,47 @@ private struct CatalogBuilder { var nextCatalogs: [String] = [] var preferPublic = true - mutating func add(_ node: PureXML.Model.TreeNode) { + mutating func add(_ node: PureXML.Model.TreeNode, base: String) { switch node.name?.localName { case "system": - insert(node, key: "systemId", value: "uri", into: &systemMap) + insert(node, key: "systemId", base: base, into: &systemMap) case "public": - insert(node, key: "publicId", value: "uri", into: &publicMap) + insert(node, key: "publicId", base: base, into: &publicMap) case "uri": - insert(node, key: "name", value: "uri", into: &uriMap) + insert(node, key: "name", base: base, into: &uriMap) case "rewriteSystem": - appendRewrite(node, start: "systemIdStartString", into: &rewriteSystem) + appendRewrite(node, start: "systemIdStartString", base: base, into: &rewriteSystem) case "rewriteURI": - appendRewrite(node, start: "uriStartString", into: &rewriteURI) + appendRewrite(node, start: "uriStartString", base: base, into: &rewriteURI) case "systemSuffix": - appendSuffix(node, suffix: "systemIdSuffix", into: &systemSuffix) + appendSuffix(node, suffix: "systemIdSuffix", base: base, into: &systemSuffix) case "uriSuffix": - appendSuffix(node, suffix: "uriSuffix", into: &uriSuffix) + appendSuffix(node, suffix: "uriSuffix", base: base, into: &uriSuffix) default: - addDelegation(node) + addDelegation(node, base: base) } } private func appendSuffix( _ node: PureXML.Model.TreeNode, suffix: String, + base: String, into rules: inout [SuffixRule], ) { guard let suffixString = attribute(node, suffix), let uri = attribute(node, "uri") else { return } - rules.append(SuffixRule(suffixString: suffixString, uri: uri)) + rules.append(SuffixRule(suffixString: suffixString, uri: resolve(uri, base))) } - private mutating func addDelegation(_ node: PureXML.Model.TreeNode) { + private mutating func addDelegation(_ node: PureXML.Model.TreeNode, base: String) { switch node.name?.localName { case "delegateSystem": - appendDelegate(node, start: "systemIdStartString", into: &delegateSystem) + appendDelegate(node, start: "systemIdStartString", base: base, into: &delegateSystem) case "delegatePublic": - appendDelegate(node, start: "publicIdStartString", into: &delegatePublic) + appendDelegate(node, start: "publicIdStartString", base: base, into: &delegatePublic) case "delegateURI": - appendDelegate(node, start: "uriStartString", into: &delegateURI) + appendDelegate(node, start: "uriStartString", base: base, into: &delegateURI) case "nextCatalog": - if let catalog = attribute(node, "catalog") { nextCatalogs.append(catalog) } + if let catalog = attribute(node, "catalog") { nextCatalogs.append(resolve(catalog, base)) } default: break } @@ -66,29 +67,38 @@ private struct CatalogBuilder { private func appendDelegate( _ node: PureXML.Model.TreeNode, start: String, + base: String, into rules: inout [DelegateRule], ) { guard let startString = attribute(node, start), let catalog = attribute(node, "catalog") else { return } - rules.append(DelegateRule(startString: startString, catalog: catalog)) + rules.append(DelegateRule(startString: startString, catalog: resolve(catalog, base))) } private func insert( _ node: PureXML.Model.TreeNode, key: String, - value: String, + base: String, into map: inout [String: String], ) { - guard let identifier = attribute(node, key), let uri = attribute(node, value) else { return } - map[identifier] = uri + guard let identifier = attribute(node, key), let uri = attribute(node, "uri") else { return } + map[identifier] = resolve(uri, base) } private func appendRewrite( _ node: PureXML.Model.TreeNode, start: String, + base: 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)) + rules.append(RewriteRule(startString: startString, rewritePrefix: resolve(rewrite, base))) + } + + /// Resolves a catalog replacement URI against the in-scope base (RFC 3986). An + /// empty base leaves a relative URI relative, so a catalog with no base behaves + /// exactly as before. + private func resolve(_ uri: String, _ base: String) -> String { + base.isEmpty ? uri : PureXML.XInclude.URIReference.resolve(uri, against: base) } private func attribute(_ node: PureXML.Model.TreeNode, _ name: String) -> String? { @@ -116,15 +126,16 @@ private struct CatalogBuilder { 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. + /// and `catalog` elements are recursed into. Replacement URIs are resolved + /// against `baseURI` and the in-scope `xml:base` (RFC 3986). enum CatalogParser { - static func parse(_ xml: String) throws -> Resolver { + static func parse(_ xml: String, baseURI: String = "") throws -> Resolver { let root = try PureXML.parseTree(xml) var builder = CatalogBuilder() if let catalog = catalogElement(root), prefer(of: catalog) == "system" { builder.preferPublic = false } - collect(root, into: &builder) + collect(root, base: baseURI, into: &builder) return builder.resolver() } @@ -136,13 +147,23 @@ extension PureXML.Catalog { node.attributes.first { $0.name.localName == "prefer" }?.value } - private static func collect(_ node: PureXML.Model.TreeNode, into builder: inout CatalogBuilder) { + private static func collect(_ node: PureXML.Model.TreeNode, base: String, into builder: inout CatalogBuilder) { for child in node.children where child.kind == .element { - builder.add(child) + let childBase = elementBase(child, base) + builder.add(child, base: childBase) if child.name?.localName == "group" || child.name?.localName == "catalog" { - collect(child, into: &builder) + collect(child, base: childBase, into: &builder) } } } + + /// The base URI in scope for `node`: its own `xml:base` (resolved against + /// the inherited base), or the inherited base when it declares none. + private static func elementBase(_ node: PureXML.Model.TreeNode, _ inherited: String) -> String { + guard let xmlBase = node.attributes.first(where: { $0.name.localName == "base" && $0.name.prefix == "xml" })?.value else { + return inherited + } + return inherited.isEmpty ? xmlBase : PureXML.XInclude.URIReference.resolve(xmlBase, against: inherited) + } } } diff --git a/Sources/Catalog/Resolver.swift b/Sources/Catalog/Resolver.swift index 5f196c2..d5ce915 100644 --- a/Sources/Catalog/Resolver.swift +++ b/Sources/Catalog/Resolver.swift @@ -50,9 +50,13 @@ public extension PureXML.Catalog { /// always wins; this only governs the fallback. private let preferPublic: Bool - /// Parses an OASIS XML catalog document. - public init(_ xml: String) throws { - self = try CatalogParser.parse(xml) + /// Parses an OASIS XML catalog document. Replacement URIs are resolved + /// against `baseURI` (the catalog's own location) and any in-scope + /// `xml:base`, so a catalog that uses relative URIs resolves them like a + /// validating processor would. An empty `baseURI` leaves relative URIs as + /// written. + public init(_ xml: String, baseURI: String = "") throws { + self = try CatalogParser.parse(xml, baseURI: baseURI) } init( diff --git a/Tests/CatalogBaseURITests.swift b/Tests/CatalogBaseURITests.swift new file mode 100644 index 0000000..8405712 --- /dev/null +++ b/Tests/CatalogBaseURITests.swift @@ -0,0 +1,67 @@ +@testable import PureXML +import Testing + +@Suite("Catalog xml:base and RFC 3986 URI resolution") +struct CatalogBaseURITests { + private let nsAttr = "xmlns=\"urn:oasis:names:tc:entity:xmlns:xml:catalog\"" + + @Test("A relative entry URI resolves against the catalog base URI") + func test_baseURI() throws { + let catalog = """ + + + + """ + let resolver = try PureXML.Catalog.Resolver(catalog, baseURI: "http://example.org/cat/catalog.xml") + #expect(resolver.resolveSystem("urn:x") == "http://example.org/cat/dtd/x.dtd") + } + + @Test("xml:base on the catalog element scopes its entries") + func test_xmlBaseOnCatalog() throws { + let catalog = """ + + + + """ + let resolver = try PureXML.Catalog.Resolver(catalog) + #expect(resolver.resolveURI("urn:u") == "http://example.org/base/schema.xsd") + } + + @Test("A nested xml:base resolves relative to the outer base") + func test_nestedXmlBase() throws { + let catalog = """ + + + + + + """ + let resolver = try PureXML.Catalog.Resolver(catalog) + #expect(resolver.resolveSystem("urn:s") == "http://example.org/a/b/x.dtd") + } + + @Test("rewritePrefix and an absolute URI are resolved or left absolute") + func test_rewriteAndAbsolute() throws { + let catalog = """ + + + + + """ + let resolver = try PureXML.Catalog.Resolver(catalog, baseURI: "http://example.org/c/catalog.xml") + #expect(resolver.resolveSystem("urn:p:thing") == "http://example.org/c/local/thing") + // An absolute entry URI stays absolute regardless of the base. + #expect(resolver.resolveSystem("urn:abs") == "http://other.org/y.dtd") + } + + @Test("With no base, relative URIs are left unchanged (backward compatible)") + func test_noBaseUnchanged() throws { + let catalog = """ + + + + """ + let resolver = try PureXML.Catalog.Resolver(catalog) + #expect(resolver.resolveSystem("urn:x") == "x.dtd") + } +}