From 3d56d19cbd6d5f698bd42f9b4b21641b76c2671c Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 08:24:17 +0200 Subject: [PATCH] feat(parsing): per-entity base-URI tracking for nested external entities A relative system identifier declared inside an external entity resolves against that entity's own URI (RFC 3986, the C14N resolver): ExternalID gains base and resolvedSystemID, the scanner threads the current entity's URI through external-subset and PE scans, and resolvers receive the merged identifier. Clears eduni E18; the eduni baseline now holds only the grapheme-lexing class (#135). Closes #138 --- CHANGELOG.md | 1 + README.md | 4 +- Sources/Parsing/DoctypeScanner.swift | 12 ++++- Sources/Parsing/DoctypeScannerEntities.swift | 21 ++++++++- Sources/Parsing/DoctypeScannerStrict.swift | 6 +-- Sources/Parsing/ExternalID.swift | 20 +++++++- Tests/EntityBaseURITests.swift | 49 ++++++++++++++++++++ Tests/W3CEduniSuiteTests.swift | 6 +-- 8 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 Tests/EntityBaseURITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4204a..5dd2967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Per-entity base-URI tracking (#138): a relative system identifier declared inside an external entity now resolves against that entity's own URI (RFC 3986, reusing the C14N resolver), so nested external entities find their siblings; `ExternalID` gains an optional `base` and a `resolvedSystemID`, the DTD scanner threads the current entity's URI through external-subset and parameter-entity scans, and resolvers receive the already-merged identifier (raw identifiers unchanged when no base applies). Clears eduni errata-2e/E18; the eduni baseline now holds only the #135 grapheme-lexing class. - Encoding-declaration contradictions are fatal (#137, 4.3.3): a document whose XML declaration names a 16- or 32-bit encoding over BOM-less 8-bit bytes is rejected; a BOM followed by a declaration outside its family (a UTF-8 BOM with `encoding='iso-8859-1'`, a UTF-16 BOM with an 8-bit name) is rejected, with the declaration read through the BOM's own byte pattern; and an external entity whose text declaration names a higher XML version than the document's is refused (errata E38), in both the external-subset and external-entity paths. The eduni not-wf baseline is now completely empty. - Namespaces 1.0 constraints (#136): the namespace layer now enforces the binding rules as located parse errors: malformed qualified names (`foo:`, `:foo`, `a:b:c`, `xmlns:`) are rejected; the `xmlns` prefix may never be declared; `xml` may be bound only to its own namespace name, which no other prefix (nor the default) may take, likewise the xmlns namespace name; a prefix may not be undeclared (Namespaces 1.0); attributes must be distinct by expanded name (the same URI+local name through two prefixes is a duplicate); PI targets and entity/notation names are NCNames; and a namespace declaration whose attribute is DTD-declared with a tokenized type binds its normalized value (so ` urn:x ` and `urn:x` are one namespace name, eduni ns 012). Clears all 18 namespace cases from the eduni baseline, leaving only the four #137 encoding-mismatch cases; xmltest valid/sa/012 (an attribute literally named `:`) joins the documented deviations per the suite's own NAMESPACE='no' flag, mirrored by the eduni runner's new NAMESPACE filter. - W3C xmlconf eduni sections (#121, #127): the Edinburgh errata suites (2e, 3e, and the 5th-edition 4e), Namespaces 1.0 with its errata, and the misc cases run via the XMLCONF_ROOT runner; XML 1.1 and Namespaces 1.1 are explicitly out of scope and EDITION-gated cases skip when they do not apply to the Fifth Edition. The burn-down fixed a real spec bug and several errata behaviors: attribute values now get the 3.3.3 CDATA normalization (literal whitespace becomes a space before reference decoding, so character-referenced whitespace survives, fixing errata E36 and E20 semantics) and tokenized normalization collapses only SPACE characters; an undeclared general entity in content or an attribute value is a deferred validity finding instead of a fatal error when unread external declarations might supply it (production 68; the reference stays literal, nothing is fetched), matching the scanner-side split; the errata E15 family is enforced (any reference, comment, PI, or whitespace inside an element declared EMPTY; direct or double-escaped character-reference whitespace as character data in element content, while entity-supplied whitespace stays legal); errata E2 reports duplicate tokens in enumerated and NOTATION declarations; errata E14's declaration-completed-inside-a-PE-replacement parses and reports VC: Proper Declaration/PE Nesting; the ID family must be NCNames under namespace validation; and DTD-strict mode requires namespace declarations to be declared like any attribute. Remaining deviations are exact and classified: 24+1 grapheme-cluster lexing cases (a combining mark after an ASCII delimiter merges into one Swift Character), 18 namespace-constraint cases, 4 encoding-declaration mismatches, and E18 (per-entity base-URI tracking), each filed as its own issue. diff --git a/README.md b/README.md index 7c7cb4e..c950262 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ flowchart TB ScalarLex["#135 scalar-level lexing (grapheme clusters)"]:::todo NSConstraints["#136 namespace constraints: complete"]:::done EncodingMismatch["#137 encoding-declaration mismatches: complete"]:::done - BaseURI["#138 per-entity base URIs"]:::todo + BaseURI["#138 per-entity base URIs: complete"]:::done Epic122["#122 official spec suites"]:::epic XSTS["#129 W3C XSD test suite (XSTS)"]:::todo XSLTTS["#130 OASIS XSLT 1.0 suite (+XPath corpus)"]:::todo @@ -125,7 +125,7 @@ flowchart TB PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite -currently runs **917 tests in 155 suites** (`swift test`). +currently runs **919 tests in 156 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/Parsing/DoctypeScanner.swift b/Sources/Parsing/DoctypeScanner.swift index 4072103..b45bd41 100644 --- a/Sources/Parsing/DoctypeScanner.swift +++ b/Sources/Parsing/DoctypeScanner.swift @@ -54,6 +54,12 @@ struct DTDScanner { /// resolver refused them): a reference to one is not an undeclared-entity /// error, the processor simply did not read it. var unresolvedParameterEntities: Set = [] + /// The URI of the entity whose text is being scanned (nil for the + /// document): relative identifiers declared here resolve against it. + var currentBase: String? + /// Each external parameter entity's own resolved URI, the base for the + /// identifiers its replacement declares. + var parameterEntityBases: [String: String] = [:] var parameterBudget: Int /// Bounds parameter-entity injection recursion (modularized DTDs nest, but /// only a little); deeper references are ignored rather than trapping. @@ -112,7 +118,11 @@ struct DTDScanner { } var sub = Reader(text) inExternalContext = true - defer { inExternalContext = false } + currentBase = id.resolvedSystemID + defer { + inExternalContext = false + currentBase = nil + } try scanTextDeclaration(&sub, at: mark) try scanDeclarations(&sub, depth: 1, terminatedByBracket: false, at: mark) } diff --git a/Sources/Parsing/DoctypeScannerEntities.swift b/Sources/Parsing/DoctypeScannerEntities.swift index aacb5cc..b7e8ed8 100644 --- a/Sources/Parsing/DoctypeScannerEntities.swift +++ b/Sources/Parsing/DoctypeScannerEntities.swift @@ -9,7 +9,7 @@ extension DTDScanner { /// resolver keeps XXE closed. mutating func resolveExternalEntities() { for (name, id) in doctype.externalEntities where doctype.entities[name] == nil { - if let text = resolver.resolveEntity(name, id) { + if let text = resolver.resolveEntity(name, baseResolved(id)) { // An external parsed entity may begin with a text declaration, // which is not part of its replacement text (4.3.1). doctype.entities[name] = try? strippingTextDeclaration(text) @@ -17,6 +17,15 @@ extension DTDScanner { } } + /// The identifier with its base applied into the system ID, so a resolver + /// that only reads `systemID` still sees the resolved path. + func baseResolved(_ id: ExternalID) -> ExternalID { + var resolved = id + resolved.systemID = id.resolvedSystemID + resolved.base = nil + return resolved + } + /// Handles a bare `%name;` between declarations by injecting the parameter /// entity's replacement text and scanning its declarations. Bounded by depth /// and the expansion budget. @@ -49,6 +58,13 @@ extension DTDScanner { } parameterBudget -= replacement.count var sub = Reader(replacement) + // Identifiers declared inside the replacement resolve against the + // parameter entity's own URI (per-entity base, RFC 3986). + let outerBase = currentBase + if let base = parameterEntityBases[name] { + currentBase = base + } + defer { currentBase = outerBase } try scanDeclarations(&sub, depth: depth + 1, terminatedByBracket: false, at: mark) } @@ -64,9 +80,10 @@ extension DTDScanner { } } else if isParameter { if doctype.parameterEntities[name] == nil { - if let text = resolver.resolveExternalSubset(id) { + if let text = resolver.resolveExternalSubset(baseResolved(id)) { if let stripped = try? strippingTextDeclaration(text) { doctype.parameterEntities[name] = expandParameterReferences(stripped) + parameterEntityBases[name] = id.resolvedSystemID } else { unresolvedParameterEntities.insert(name) } diff --git a/Sources/Parsing/DoctypeScannerStrict.swift b/Sources/Parsing/DoctypeScannerStrict.swift index 9861dac..f5e21f8 100644 --- a/Sources/Parsing/DoctypeScannerStrict.swift +++ b/Sources/Parsing/DoctypeScannerStrict.swift @@ -203,7 +203,7 @@ extension DTDScanner { guard let systemID = scanLiteral(&reader) else { throw ParseError.invalidEntityDeclaration(mark) } - return ExternalID(systemID: systemID) + return ExternalID(systemID: systemID, base: currentBase) } if reader.consume("PUBLIC") { return try parsePublicID(&reader, requireSystem: requireSystem, at: mark) @@ -230,7 +230,7 @@ extension DTDScanner { guard let systemID = scanLiteral(&reader) else { throw ParseError.invalidEntityDeclaration(mark) } - return ExternalID(publicID: publicID, systemID: systemID) + return ExternalID(publicID: publicID, systemID: systemID, base: currentBase) } // Notations may carry a public identifier alone. var systemID = "" @@ -238,7 +238,7 @@ extension DTDScanner { reader.skipSpace() if let literal = scanLiteral(&reader) { systemID = literal } } - return ExternalID(publicID: publicID, systemID: systemID) + return ExternalID(publicID: publicID, systemID: systemID, base: currentBase) } /// `PubidChar ::= #x20 | #xD | #xA | [a-zA-Z0-9] | [-'()+,./:=?;!*#@$_%]` diff --git a/Sources/Parsing/ExternalID.swift b/Sources/Parsing/ExternalID.swift index 731954b..d76681e 100644 --- a/Sources/Parsing/ExternalID.swift +++ b/Sources/Parsing/ExternalID.swift @@ -13,10 +13,28 @@ public extension PureXML.Parsing { public var publicID: String? /// The system identifier (the URI or path literal). public var systemID: String + /// The URI of the entity whose text declared this identifier, when + /// known: a relative system identifier resolves against it (RFC 3986), + /// so nested external entities find their siblings. + public var base: String? - public init(publicID: String? = nil, systemID: String) { + public init(publicID: String? = nil, systemID: String, base: String? = nil) { self.publicID = publicID self.systemID = systemID + self.base = base + } + + /// The system identifier with any base applied, ready for a resolver. + public var resolvedSystemID: String { + guard let base else { return systemID } + let merged = PureXML.Canonical.Canonicalizer.resolveURI(systemID, against: base) + // The RFC 3986 merge assumes an absolute base; when both base and + // reference are relative paths, keep the merged path relative. + let baseHasScheme = base.split(separator: "/", maxSplits: 1)[0].hasSuffix(":") + if !base.hasPrefix("/"), !baseHasScheme, !systemID.hasPrefix("/"), merged.hasPrefix("/") { + return String(merged.dropFirst()) + } + return merged } } } diff --git a/Tests/EntityBaseURITests.swift b/Tests/EntityBaseURITests.swift new file mode 100644 index 0000000..cf90659 --- /dev/null +++ b/Tests/EntityBaseURITests.swift @@ -0,0 +1,49 @@ +@testable import PureXML +import Testing + +/// Per-entity base-URI tracking (#138): a relative system identifier inside +/// an external entity resolves against that entity's own URI, not the +/// document's, so nested external entities find their siblings (RFC 3986). +@Suite("Entity base URIs") +struct EntityBaseURITests { + @Test("Nested relative identifiers resolve against the declaring entity's URI") + func test_nestedRelativeResolution() throws { + // The eduni E18 shape: subdir1/pe declares ../subdir2/extpe, which + // declares the general entity the document references. + let files: [String: String] = [ + "subdir1/pe": "\n", + "subdir2/extpe": "", + ] + let resolver = PureXML.Parsing.EntityResolver( + resolveEntity: { _, id in files[id.systemID] }, + resolveExternalSubset: { id in files[id.systemID] }, + ) + let xml = """ + + + %pe; + %intpe; + ]> + &ent; + """ + let node = try PureXML.parse(xml, limits: .init(allowDoctype: true), resolver: resolver) + guard case let .document(children) = node, let foo = children.compactMap(\.element).first, + case let .text(value)? = foo.children.first + else { + Issue.record("no text") + return + } + #expect(value == "resolved") + } + + @Test("The resolver still sees raw identifiers when no base applies") + func test_documentLevelUnchanged() { + let resolver = PureXML.Parsing.EntityResolver( + resolveEntity: { _, id in id.systemID == "plain.ent" ? "ok" : nil }, + resolveExternalSubset: { _ in nil }, + ) + let xml = "]>\n&e;" + #expect((try? PureXML.parse(xml, limits: .init(allowDoctype: true), resolver: resolver)) != nil) + } +} diff --git a/Tests/W3CEduniSuiteTests.swift b/Tests/W3CEduniSuiteTests.swift index 49677a2..4dbc2d8 100644 --- a/Tests/W3CEduniSuiteTests.swift +++ b/Tests/W3CEduniSuiteTests.swift @@ -29,13 +29,11 @@ struct W3CEduniSuiteTests { /// not-wf cases this implementation knowingly accepts (none). private let knownNotWFAccepted: Set = [] - /// valid cases this implementation knowingly fails, two classes: the + /// valid cases this implementation knowingly fails, one class: the /// grapheme-cluster lexing limitation (a combining mark directly after an /// ASCII delimiter merges into one Swift Character, so scalar-level 5e - /// name characters are mis-lexed; its own issue under #121) and E18, - /// which needs per-entity base-URI tracking for nested external entities. + /// name characters are mis-lexed; #135). private let knownValidFailures: Set = [ - "errata-2e/E18.xml", "errata-4e/ibm04v01.xml", "errata-4e/ibm05v03.xml", "errata-4e/ibm07v01.xml", "errata-4e/ibm85n107.xml", "errata-4e/ibm85n114.xml", "errata-4e/ibm85n119.xml", "errata-4e/ibm85n121.xml", "errata-4e/ibm85n122.xml", "errata-4e/ibm85n136.xml",