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
This commit is contained in:
parent
68a8ff2887
commit
b13cdca739
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>) 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<String>) 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<String>) 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<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
32
Sources/XInclude/XIncludeRequest.swift
Normal file
32
Sources/XInclude/XIncludeRequest.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
66
Tests/XIncludeNegotiationTests.swift
Normal file
66
Tests/XIncludeNegotiationTests.swift
Normal file
@ -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 = """
|
||||
<doc xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<xi:include href="t.txt" parse="text" accept="text/plain" accept-language="fr" encoding="iso-8859-1"/>
|
||||
</doc>
|
||||
"""
|
||||
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 = """
|
||||
<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/></doc>
|
||||
"""
|
||||
#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 = "<leaf/>"
|
||||
let xml = """
|
||||
<doc xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<xi:include href="leaf.xml"/>
|
||||
<xi:include href="leaf.xml"/>
|
||||
</doc>
|
||||
"""
|
||||
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 = """
|
||||
<doc xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<xi:include href="t.txt" parse="text" xpointer="element(/1)"/>
|
||||
</doc>
|
||||
"""
|
||||
#expect(throws: PureXML.XInclude.XIncludeError.textWithFragment) {
|
||||
_ = try PureXML.XInclude.process(xml, loadingURI: { _ in "x" })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -73,7 +73,7 @@ struct XIncludeTests {
|
||||
func test_unresolved() {
|
||||
let xml = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><xi:include href=\"missing.xml\"/></doc>"
|
||||
#expect(throws: PureXML.XInclude.XIncludeError.self) {
|
||||
_ = try PureXML.XInclude.process(xml) { _ in nil }
|
||||
_ = try PureXML.XInclude.process(xml, loadingURI: { _ in nil })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user