From b13cdca739bf5bcd9bd9ed025e9114a64fe24f92 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 15:52:18 +0200 Subject: [PATCH] feat(xinclude): content-negotiation hints, cycle detection, text+fragment Three XInclude gaps from the audit: - accept / accept-language / encoding are surfaced to the loader via a new XIncludeRequest and a process(_:base:loading:) overload, so a loader can honor content negotiation. The existing loadingURI form is kept. - Cycle detection tracks the visited (resolved-URI) set along the current inclusion chain and raises XIncludeError.cycle, instead of relying only on the depth backstop; a non-cyclic diamond include is not flagged. - parse="text" combined with an xpointer is rejected (XIncludeError.textWithFragment), since a fragment cannot select within unparsed text. xpointer range() inclusion still depends on the XPointer range() scheme (tracked in the XPointer issue). Part of #87 --- Sources/XInclude/XIncludeError.swift | 7 +++ Sources/XInclude/XIncludeProcessor.swift | 79 ++++++++++++++++++------ Sources/XInclude/XIncludeRequest.swift | 32 ++++++++++ Tests/XIncludeNegotiationTests.swift | 66 ++++++++++++++++++++ Tests/XIncludeTests.swift | 2 +- 5 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 Sources/XInclude/XIncludeRequest.swift create mode 100644 Tests/XIncludeNegotiationTests.swift diff --git a/Sources/XInclude/XIncludeError.swift b/Sources/XInclude/XIncludeError.swift index a7acaf9..8645373 100644 --- a/Sources/XInclude/XIncludeError.swift +++ b/Sources/XInclude/XIncludeError.swift @@ -7,12 +7,19 @@ public extension PureXML.XInclude { case unresolved(String) /// Includes nested past the recursion limit (a likely include loop). case toodeep + /// The same resource is included along its own inclusion chain. + case cycle(String) + /// `parse="text"` was combined with an `xpointer`/fragment, which selects a + /// subtree and so is meaningless for an unparsed text inclusion. + case textWithFragment public var description: String { switch self { case .missingHref: "an xi:include has no href and no fallback" case let .unresolved(href): "could not resolve xi:include href '\(href)' and no fallback" case .toodeep: "xi:include nesting is too deep (possible loop)" + case let .cycle(uri): "xi:include forms a cycle including '\(uri)'" + case .textWithFragment: "xi:include with parse=\"text\" must not carry an xpointer" } } } diff --git a/Sources/XInclude/XIncludeProcessor.swift b/Sources/XInclude/XIncludeProcessor.swift index bd5dece..5a07b6d 100644 --- a/Sources/XInclude/XIncludeProcessor.swift +++ b/Sources/XInclude/XIncludeProcessor.swift @@ -5,48 +5,65 @@ private struct XIncludeRun { typealias Node = PureXML.Model.Node typealias Element = PureXML.Model.Element - let load: (String) -> String? + typealias Request = PureXML.XInclude.XIncludeRequest + + let load: (Request) -> String? let maxDepth = 30 let namespace = "http://www.w3.org/2001/XInclude" - func process(_ node: Node, base: String, depth: Int) throws -> [Node] { + func process(_ node: Node, base: String, depth: Int, visited: Set) throws -> [Node] { switch node { case let .document(children): - try [.document(children.flatMap { try process($0, base: base, depth: depth) })] + try [.document(children.flatMap { try process($0, base: base, depth: depth, visited: visited) })] case let .element(element): - try processElement(element, base: base, depth: depth) + try processElement(element, base: base, depth: depth, visited: visited) default: [node] } } - private func processElement(_ element: Element, base: String, depth: Int) throws -> [Node] { + private func processElement(_ element: Element, base: String, depth: Int, visited: Set) throws -> [Node] { if isInclude(element) { - return try resolveInclude(element, base: base, depth: depth) + return try resolveInclude(element, base: base, depth: depth, visited: visited) } let elementBase = baseURI(of: element, base: base) - let children = try element.children.flatMap { try process($0, base: elementBase, depth: depth) } + let children = try element.children.flatMap { try process($0, base: elementBase, depth: depth, visited: visited) } return [.element(Element(name: element.name, attributes: element.attributes, children: children))] } - private func resolveInclude(_ element: Element, base: String, depth: Int) throws -> [Node] { + private func resolveInclude(_ element: Element, base: String, depth: Int, visited: Set) throws -> [Node] { guard depth < maxDepth else { throw PureXML.XInclude.XIncludeError.toodeep } let elementBase = baseURI(of: element, base: base) guard let href = attribute(element, "href") else { - return try fallback(element, base: base, depth: depth, error: .missingHref) + return try fallback(element, base: base, depth: depth, visited: visited, error: .missingHref) } let resolved = PureXML.XInclude.URIReference.resolve(href, against: elementBase) - guard let content = load(resolved) else { - return try fallback(element, base: base, depth: depth, error: .unresolved(href)) + let isText = attribute(element, "parse") == "text" + let xpointer = attribute(element, "xpointer") + if isText, xpointer != nil { throw PureXML.XInclude.XIncludeError.textWithFragment } + // A resource already on its own inclusion chain is a cycle. + guard !visited.contains(resolved) else { throw PureXML.XInclude.XIncludeError.cycle(resolved) } + guard let content = load(request(element, uri: resolved, isText: isText)) else { + return try fallback(element, base: base, depth: depth, visited: visited, error: .unresolved(href)) } - if attribute(element, "parse") == "text" { + if isText { return [.text(content)] } guard let parsed = try? PureXML.parse(content) else { - return try fallback(element, base: base, depth: depth, error: .unresolved(href)) + return try fallback(element, base: base, depth: depth, visited: visited, error: .unresolved(href)) } - let selected = selectedNodes(from: parsed, xpointer: attribute(element, "xpointer")) - return try selected.flatMap { try process($0, base: resolved, depth: depth + 1) } + let selected = selectedNodes(from: parsed, xpointer: xpointer) + return try selected.flatMap { try process($0, base: resolved, depth: depth + 1, visited: visited.union([resolved])) } + } + + private func request(_ element: Element, uri: String, isText: Bool) -> Request { + Request( + uri: uri, + accept: attribute(element, "accept"), + acceptLanguage: attribute(element, "accept-language"), + encoding: isText ? attribute(element, "encoding") : nil, + isText: isText, + ) } private func selectedNodes(from parsed: Node, xpointer: String?) -> [Node] { @@ -73,6 +90,7 @@ private struct XIncludeRun { _ element: Element, base: String, depth: Int, + visited: Set, error: PureXML.XInclude.XIncludeError, ) throws -> [Node] { guard let fallback = element.children.first(where: isFallback), @@ -80,7 +98,7 @@ private struct XIncludeRun { else { throw error } - return try fallbackElement.children.flatMap { try process($0, base: base, depth: depth) } + return try fallbackElement.children.flatMap { try process($0, base: base, depth: depth, visited: visited) } } private func baseURI(of element: Element, base: String) -> String { @@ -118,8 +136,7 @@ public extension PureXML.XInclude { base: String = "", loadingURI: @escaping (_ uri: String) -> String?, ) throws -> PureXML.Model.Node { - let run = XIncludeRun(load: loadingURI) - return try run.process(node, base: base, depth: 0).first ?? node + try process(node, base: base) { loadingURI($0.uri) } } /// Parses `xml` and processes its `xi:include` elements. @@ -130,4 +147,30 @@ public extension PureXML.XInclude { ) throws -> PureXML.Model.Node { try process(PureXML.parse(xml), base: base, loadingURI: loadingURI) } + + /// Processes `xi:include` elements with a content-negotiation-aware loader: the + /// loader receives an ``XIncludeRequest`` carrying the resolved URI plus the + /// include's `accept`, `accept-language`, and (for `parse="text"`) `encoding` + /// hints, and returns the loaded text or nil to fall back. A resource included + /// along its own inclusion chain raises ``XIncludeError/cycle(_:)``; a + /// `parse="text"` include carrying an `xpointer` raises + /// ``XIncludeError/textWithFragment``. + static func process( + _ node: PureXML.Model.Node, + base: String = "", + loading: @escaping (_ request: XIncludeRequest) -> String?, + ) throws -> PureXML.Model.Node { + let run = XIncludeRun(load: loading) + return try run.process(node, base: base, depth: 0, visited: []).first ?? node + } + + /// Parses `xml` and processes its `xi:include` elements with a + /// content-negotiation-aware loader. See ``process(_:base:loading:)-(Node,_,_)``. + static func process( + _ xml: String, + base: String = "", + loading: @escaping (_ request: XIncludeRequest) -> String?, + ) throws -> PureXML.Model.Node { + try process(PureXML.parse(xml), base: base, loading: loading) + } } diff --git a/Sources/XInclude/XIncludeRequest.swift b/Sources/XInclude/XIncludeRequest.swift new file mode 100644 index 0000000..a9b8ff8 --- /dev/null +++ b/Sources/XInclude/XIncludeRequest.swift @@ -0,0 +1,32 @@ +public extension PureXML.XInclude { + /// A request to load one `xi:include` target, carrying the resolved URI and the + /// include's content-negotiation hints, so a loader can honor them. PureXML + /// performs no I/O itself; the loader decides what (if anything) to fetch. + struct XIncludeRequest: Equatable, Sendable { + /// The resolved absolute (or base-relative) URI to load. + public let uri: String + /// The `accept` attribute, an HTTP Accept hint, or nil. + public let accept: String? + /// The `accept-language` attribute, an HTTP Accept-Language hint, or nil. + public let acceptLanguage: String? + /// The `encoding` attribute for a `parse="text"` inclusion, so the loader + /// can decode the bytes; nil for a parsed (XML) inclusion. + public let encoding: String? + /// Whether the inclusion is unparsed text (`parse="text"`) rather than XML. + public let isText: Bool + + public init( + uri: String, + accept: String? = nil, + acceptLanguage: String? = nil, + encoding: String? = nil, + isText: Bool = false, + ) { + self.uri = uri + self.accept = accept + self.acceptLanguage = acceptLanguage + self.encoding = encoding + self.isText = isText + } + } +} diff --git a/Tests/XIncludeNegotiationTests.swift b/Tests/XIncludeNegotiationTests.swift new file mode 100644 index 0000000..d605e2d --- /dev/null +++ b/Tests/XIncludeNegotiationTests.swift @@ -0,0 +1,66 @@ +@testable import PureXML +import Testing + +@Suite("XInclude content negotiation, cycle detection, text+fragment") +struct XIncludeNegotiationTests { + private func roots(_ node: PureXML.Model.Node) -> [PureXML.Model.Element] { + guard case let .document(children) = node else { return [] } + return children.compactMap(\.element) + } + + @Test("accept, accept-language, and encoding are surfaced to the loader") + func test_requestHints() throws { + let xml = """ + + + + """ + var seen: PureXML.XInclude.XIncludeRequest? + _ = try PureXML.XInclude.process(xml, loading: { request in + seen = request + return "loaded" + }) + #expect(seen?.uri == "t.txt") + #expect(seen?.accept == "text/plain") + #expect(seen?.acceptLanguage == "fr") + #expect(seen?.encoding == "iso-8859-1") + #expect(seen?.isText == true) + } + + @Test("A self-including resource is detected as a cycle") + func test_cycle() throws { + let selfInclude = """ + + """ + #expect(throws: PureXML.XInclude.XIncludeError.self) { + _ = try PureXML.XInclude.process(selfInclude, base: "a.xml", loadingURI: { _ in selfInclude }) + } + } + + @Test("A non-cyclic diamond include does not falsely trip cycle detection") + func test_diamondNotCycle() throws { + // root includes b and c; both include leaf. Same resource on two separate + // branches is not a cycle. + let leaf = "" + let xml = """ + + + + + """ + let result = try PureXML.XInclude.process(xml, loadingURI: { $0 == "leaf.xml" ? leaf : nil }) + #expect(roots(result).first?.children.compactMap(\.element).count == 2) + } + + @Test("parse=text with an xpointer is rejected") + func test_textWithFragment() { + let xml = """ + + + + """ + #expect(throws: PureXML.XInclude.XIncludeError.textWithFragment) { + _ = try PureXML.XInclude.process(xml, loadingURI: { _ in "x" }) + } + } +} diff --git a/Tests/XIncludeTests.swift b/Tests/XIncludeTests.swift index f6ba0e1..732972a 100644 --- a/Tests/XIncludeTests.swift +++ b/Tests/XIncludeTests.swift @@ -73,7 +73,7 @@ struct XIncludeTests { func test_unresolved() { let xml = "" #expect(throws: PureXML.XInclude.XIncludeError.self) { - _ = try PureXML.XInclude.process(xml) { _ in nil } + _ = try PureXML.XInclude.process(xml, loadingURI: { _ in nil }) } }