diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57501d7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + linux: + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) + runs-on: ubuntu-24.04 + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + # The libxml2 system-library target finds libxml2 through pkg-config on Linux. + - name: Install libxml2 + run: | + apt-get update + apt-get install -y --no-install-recommends pkg-config libxml2-dev + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS + + macos: + # libxml2 comes from the macOS SDK; no Homebrew packages are needed. + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) + runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS + + ios: + name: iOS Simulator + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_26.6.app + xcodebuild -version + + # On a fresh runner the simulator service can start late, and xcodebuild then lists no + # simulators at all ("Unable to find a device"). Wait until it offers the one we test on. + # Listing devices with simctl makes the service refresh its runtimes + # (actions/runner-images#12948). + - name: Wait for the simulator + run: | + deadline=$((SECONDS + 300)) + while [ $SECONDS -lt $deadline ]; do + xcrun simctl list devices available > /dev/null 2>&1 || true + if destinations=$(xcodebuild -showdestinations -scheme PerfectXML) \ + && grep -q "name:iPhone 17 }" <<< "$destinations"; then + exit 0 + fi + echo "No iPhone 17 simulator yet; retrying in 5 seconds" + sleep 5 + done + echo "No iPhone 17 simulator after 5 minutes" + xcrun simctl list devices available + exit 1 + + # The library builds at the package's iOS 15 deployment target, so this also catches + # library APIs that need a newer iOS (Xcode may build the test bundle itself at a + # later target; Xcode 27 used iOS 17). The package has no dependencies, so treating + # warnings as errors here doesn't clash with Xcode's warning suppression for packages. + - name: Test + run: | + xcodebuild test -scheme PerfectXML \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -destination-timeout 120 \ + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES diff --git a/PADockerfile b/PADockerfile deleted file mode 100644 index bd9f082..0000000 --- a/PADockerfile +++ /dev/null @@ -1 +0,0 @@ -RUN apt-get -y update && apt-get install -y libxml2-dev pkg-config diff --git a/Package.swift b/Package.swift index cf6b206..b4f9011 100644 --- a/Package.swift +++ b/Package.swift @@ -3,14 +3,15 @@ import PackageDescription // Inline system library wrapping libxml2 — replaces Perfect-libxml2. // -// No `pkgConfig` on macOS: recent CommandLineTools/Xcode SDKs bundle +// No `pkgConfig` on Apple platforms: the macOS and iOS SDKs bundle // libxml2's headers (and, on some SDK versions, their own libxml-2.0.pc) // directly, and Clang finds them via its default system search path with // zero extra flags. If a Homebrew libxml2 is *also* on PKG_CONFIG_PATH, // asking pkg-config here can resolve to Homebrew's copy instead, mixing // its headers with the SDK's in the same module build ("conflicting -// types" for symbols like __oldXMLWDcompatibility). See -// Documentation/libxml2-pkgconfig-collision.md. +// types" for symbols like __oldXMLWDcompatibility). The manifest is +// evaluated on the build host, so `os(macOS)` also covers iOS builds made +// on a Mac. // Linux has no bundled system copy, so pkg-config + apt stays required there. #if os(macOS) let libxml2Target: Target = .systemLibrary(name: "libxml2") @@ -20,6 +21,7 @@ let libxml2Target: Target = .systemLibrary( pkgConfig: "libxml-2.0", providers: [ .apt(["libxml2-dev"]), + .yum(["libxml2-devel"]), ] ) #endif @@ -28,6 +30,7 @@ let package = Package( name: "PerfectXML", platforms: [ .macOS(.v12), + .iOS(.v15), ], products: [ .library(name: "PerfectXML", targets: ["PerfectXML"]) diff --git a/README.md b/README.md index 428a295..7578252 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Swift 6.2 - Platforms macOS 12+ + Platforms macOS 12+ | iOS 15+ | Linux License Apache 2.0 @@ -13,46 +13,31 @@

XML & HTML parsing support, modernized for Swift 6. This is real, load-bearing infrastructure: -`Perfect-FileMaker` imports `PerfectXML` to parse the FileMaker XML Data API. +`Perfect-FileMaker` imports `PerfectXML` to parse FileMaker Server's XML Custom Web Publishing responses. -It implements most of the DOM Core level 2 *read-only* APIs plus XPath support, and also includes a SAX push-parser, a streaming reader (`XMLStream`), and Codable integration for decoding XML directly into model types (see [Additional APIs](#additional-apis) below). +It implements most of the DOM Core level 2 *read-only* APIs plus XPath support, and also includes a SAX push-parser, a streaming reader (`XMLStream`), and a minimal `XMLEncoder` that encodes flat `Encodable` structs as XML (see [Additional APIs](#additional-apis) below). -**Requirements:** Swift tools version 6.2, macOS 12+ (`platforms: [.macOS(.v12)]` in `Package.swift`). No formal Linux platform entry is declared in `Package.swift`, but the underlying `libxml2` systemLibrary target ships an `.apt(["libxml2-dev"])` provider for Linux builds. +**Requirements:** Swift 6.2 or newer (`swift-tools-version: 6.2`); macOS 12+, iOS 15+, or Linux. On macOS and iOS, libxml2 comes from the SDK. On Linux, install `libxml2-dev` and `pkg-config` (see [Linux Build Notes](#linux-build-notes)). CI builds and tests on Linux (Swift 6.2 and 6.4), macOS (Xcode 26.0.1 and 26.6) and the iOS Simulator, with warnings treated as errors. -**Concurrency:** this is a synchronous, non-async/await library. Every public class (document/node/parser types) is `@unchecked Sendable` over an underlying libxml2 pointer, each with an explicit rationale comment, as part of the Swift 6.2 strict-concurrency modernization. +**Concurrency:** this is a synchronous, non-async/await library. The document, node and parser classes (`XDocument`, `HTMLDocument`, the `XNode` subclasses, `SAXParser` and `XMLStream`) are `@unchecked Sendable` over an underlying libxml2 pointer, each with an explicit rationale comment. They have no internal locking, so use each instance from one task at a time. `XMLEncoder` is not `Sendable`; use one per task. -**Security:** DOM parsing passes `XML_PARSE_NONET` to defend against XXE / external-entity / DTD-based injection. If you're evaluating this package as a parsing trust boundary, that's the relevant flag. +**Security:** external entities (``, the classic XXE attack) are never loaded or substituted, and external DTDs aren't loaded. libxml2 takes its entity-substitution default from a libxml2 global (`xmlSubstituteEntitiesDefault`; per-thread in threaded builds), so `XDocument` and `XMLStream` force substitution off on their own parser contexts; other code turning the global on, on whatever thread a task runs, doesn't change that. Parsing also passes `XML_PARSE_NONET` as defence in depth against network fetches. Entity references stay references in the parsed tree rather than being substituted during parsing. Tests cover the `file://` case with the global both off and on. The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch. ## Building -`Perfect-XML` has zero external Swift package dependencies — the only dependency is a local `libxml2` `.systemLibrary` target (`pkgConfig: "libxml-2.0"`) inlined directly into this repo's `Package.swift`, which replaces what used to be a separate `Perfect-libxml2` package. System `libxml2` (via `brew`/`apt`) is still required at build time; see the platform notes below. +`Perfect-XML` has no external Swift package dependencies. libxml2 is wrapped by a local `.systemLibrary` target inlined into this repo's `Package.swift`, which replaces the separate `Perfect-libxml2` package that 3.x depended on. The system libxml2 is still required at build time; see the platform notes below. ```swift -.package(url: "https://github.com/PerfectlySoft/Perfect-XML.git", branch: "main") +.package(url: "https://github.com/PerfectlySoft/Perfect-XML.git", from: "4.0.0") ``` -## macOS Build Notes +and add `.product(name: "PerfectXML", package: "Perfect-XML")` to your target's dependencies. -If you receive a compile error that says the following, you need to install and link libxml2 +## macOS and iOS Build Notes -``` -note: you may be able to install libxml-2.0 using your system-packager: - - brew install libxml2 - -Compile Swift Module 'PerfectXML' (2 sources) -:1:9: note: in file included from :1: -#import "libxml2.h" -``` - -To install and link libxml2 with homebrew, use the following two commands - -``` -brew install libxml2 -brew link --force libxml2 -``` +Nothing to install: the macOS and iOS SDKs include libxml2, and the package uses that copy without pkg-config. A Homebrew libxml2 isn't needed. Don't `brew link --force libxml2` for this package: mixing Homebrew's libxml2 headers with the SDK's can fail the build with "conflicting types" errors. ## Linux Build Notes @@ -518,10 +503,22 @@ Beyond the DOM + XPath surface documented above, `Sources/PerfectXML` also inclu - **SAX push-parser** (`SAX.swift`) — event-driven parsing for large documents where building a full DOM tree isn't desirable. - **Streaming reader** (`XMLStream.swift`) — an incremental/streaming XML reader. -- **Codable integration** (`Codable.swift`) — an `XMLEncoder`/decoding bridge for decoding XML directly into `Codable` model types. +- **XML encoding** (`Codable.swift`) — `XMLEncoder().encode(value, rootName:namespace:)` encodes a flat `Encodable` struct of primitive values as an XML document, one child element per property. A `namespace:` becomes the root's default namespace (``). Nested values, arrays (including an empty top-level array) and single values aren't supported and throw `XMLEncoderError`. There is no XML decoder. These aren't yet covered by worked examples in this README; see the source files and `Tests/PerfectXMLTests/PerfectXMLTests.swift` for usage. +## Migrating from 3.x + +4.0.0 is a major release; the [release notes](../../releases/tag/4.0.0) list every change. The ones most likely to need attention: + +* **Toolchain and platforms.** Swift tools 6.2 is required. The package declares macOS 12+ and iOS 15+ (3.x declared no platforms). +* **No more `Perfect-libxml2` dependency.** libxml2 is wrapped inside this package. The C module is still named `perfectxml2`. On macOS, the SDK's libxml2 is used; a Homebrew libxml2 is no longer needed. +* **`Sendable`.** The node, document and parser classes are `@unchecked Sendable`. They have no internal locking: use each document, `SAXParser` or `XMLStream` from one task at a time. +* **`XNode.ownerDocument` is read-only** (`private(set)`). +* **`XMLStream` no longer loads external DTDs.** 3.x enabled DTD loading by mistake. +* **External entities stay unloaded even if the libxml2 global says otherwise.** `XDocument` and `XMLStream` force entity substitution off, so even on a thread where something called `xmlSubstituteEntitiesDefault(1)`, `file://` entity contents don't appear in `nodeValue`. 3.x's `xmlParseDoc` honoured the global. +* **`XMLEncoder`.** It throws instead of crashing on an unsupported shape, and also throws for a top-level array (an empty one used to encode as ``). `namespace:` now writes ``; 3.x wrote the malformed `…`. `XMLDecoderError`, which nothing threw, is removed. + ## Testing Run the test suite with: diff --git a/Sources/PerfectXML/Codable.swift b/Sources/PerfectXML/Codable.swift index 259c63b..b16e8c8 100644 --- a/Sources/PerfectXML/Codable.swift +++ b/Sources/PerfectXML/Codable.swift @@ -110,13 +110,6 @@ struct UnsupportedEncoder: Encoder { } } -public struct XMLDecoderError: Error { - public let msg: String - public init(_ m: String) { - msg = m - } -} - public struct XMLEncoderError: Error { public let msg: String public init(_ m: String) { @@ -138,10 +131,19 @@ struct XMLCodingKey: CodingKey { } } +/// Encodes a flat `Encodable` struct of primitive values as an XML +/// document: one child element per property, inside a root element. +/// +/// Not `Sendable`: an encoder holds its output while encoding, so use one +/// instance per task (or a new one per call). public class XMLEncoder: Encoder { public let codingPath: [CodingKey] public let userInfo: [CodingUserInfoKey : Any] var encoded: String = "" + /// Set when an unsupported container is vended. Encoding something + /// into one throws, but an empty one (a top-level empty array, say) + /// is never written to, so `encode` checks this afterwards. + var vendedUnsupportedContainer = false init(rootName: String, codingPath cp: [CodingKey] = []) { codingPath = cp + [XMLCodingKey(rootName)] @@ -153,19 +155,30 @@ public class XMLEncoder: Encoder { userInfo = [:] } + /// Encodes `value` as `…`. If `namespace` is + /// given, it becomes the default namespace of the root element and + /// its children (``). + /// + /// Throws `XMLEncoderError` for anything but a flat keyed container of + /// primitive values, including top-level arrays (even empty ones), + /// single values and nested `Encodable` properties. public func encode(_ value: A, rootName: String, namespace: String? = nil) throws -> Data { encoded = "" + vendedUnsupportedContainer = false try value.encode(to: self) - let namePrefix: String + guard !vendedUnsupportedContainer else { + throw XMLEncoderError(unsupportedShapeMessage) + } + let namespaceAttribute: String if let ns = namespace { - namePrefix = ":\(ns)" + namespaceAttribute = " xmlns=\"\(escapeAttributeValue(ns))\"" } else { - namePrefix = "" + namespaceAttribute = "" } if encoded.isEmpty { - encoded = "<\(rootName)\(namePrefix)/>" + encoded = "<\(rootName)\(namespaceAttribute)/>" } else { - encoded = "<\(rootName)\(namePrefix)>\(encoded)" + encoded = "<\(rootName)\(namespaceAttribute)>\(encoded)" } guard let data = encoded.data(using: .utf8) else { throw XMLEncoderError("Invalid encoding was generated.") @@ -178,14 +191,22 @@ public class XMLEncoder: Encoder { } public func unkeyedContainer() -> UnkeyedEncodingContainer { - UnsupportedEncodingContainer(codingPath: codingPath) + vendedUnsupportedContainer = true + return UnsupportedEncodingContainer(codingPath: codingPath) } public func singleValueContainer() -> SingleValueEncodingContainer { - UnsupportedEncodingContainer(codingPath: codingPath) + vendedUnsupportedContainer = true + return UnsupportedEncodingContainer(codingPath: codingPath) } } +private func escapeAttributeValue(_ s: String) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: "\"", with: """) +} + class XMLEncodingContainer: KeyedEncodingContainerProtocol { typealias Key = K let codingPath: [CodingKey] @@ -275,11 +296,13 @@ class XMLEncodingContainer: KeyedEncodingContainerProtocol { } func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: K) -> KeyedEncodingContainer where NestedKey : CodingKey { - KeyedEncodingContainer(UnsupportedKeyedEncodingContainer(codingPath: codingPath)) + parent.vendedUnsupportedContainer = true + return KeyedEncodingContainer(UnsupportedKeyedEncodingContainer(codingPath: codingPath)) } func nestedUnkeyedContainer(forKey key: K) -> UnkeyedEncodingContainer { - UnsupportedEncodingContainer(codingPath: codingPath) + parent.vendedUnsupportedContainer = true + return UnsupportedEncodingContainer(codingPath: codingPath) } func superEncoder() -> Encoder { diff --git a/Sources/PerfectXML/XMLDOM.swift b/Sources/PerfectXML/XMLDOM.swift index aec6698..28b3136 100644 --- a/Sources/PerfectXML/XMLDOM.swift +++ b/Sources/PerfectXML/XMLDOM.swift @@ -312,25 +312,53 @@ public class XDocument: XNode, @unchecked Sendable { /// Parse the XML source string and create the document, if possible. /// - /// Uses `xmlReadMemory` with an explicit options bitmask (not the - /// flag-less `xmlParseDoc`) — `XML_PARSE_NONET` blocks network-based - /// external entity/DTD resolution, the classic XXE/SSRF vector, since - /// this may parse third-party/customer-supplied XML. Entity - /// substitution (`XML_PARSE_NOENT`) is deliberately left off by - /// default to avoid billion-laughs-style expansion DoS. + /// External entities (``) are + /// never loaded or substituted, whatever libxml2's global defaults + /// are. libxml2 seeds each parser context's entity substitution from + /// a libxml2 global (`xmlSubstituteEntitiesDefault`; per-thread in + /// threaded builds), and `xmlReadMemory` keeps it: if anything turned + /// that global on for the thread a task happens to run on, `xmlReadMemory` loads `file://` entities even without + /// `XML_PARSE_NOENT`. So this builds the context itself and forces + /// substitution, `XML_PARSE_NOENT` and external DTD loading off before + /// parsing. `XML_PARSE_NONET` is kept as defence in depth against + /// network fetches. Leaving substitution off also avoids + /// billion-laughs-style expansion. public init?(fromSource: String) { _ = XDocument.initialize let bytes = Array(fromSource.utf8) guard let doc = bytes.withUnsafeBufferPointer({ buffer -> xmlDocPtr? in guard let base = buffer.baseAddress else { return nil } return base.withMemoryRebound(to: Int8.self, capacity: buffer.count) { ptr in - xmlReadMemory(ptr, Int32(buffer.count), nil, nil, Int32(XML_PARSE_NONET.rawValue)) + XDocument.parseWithoutEntitySubstitution(ptr, count: Int32(buffer.count)) } }) else { return nil } super.init(toNodePtr(doc), document: nil) } + + /// Parses `count` bytes with entity substitution and external DTD + /// loading forced off. Returns nil if the document isn't well formed, + /// as `xmlReadMemory` does. + static func parseWithoutEntitySubstitution(_ ptr: UnsafePointer, count: Int32) -> xmlDocPtr? { + guard let ctxt = xmlCreateMemoryParserCtxt(ptr, count) else { + return nil + } + defer { xmlFreeParserCtxt(ctxt) } + _ = xmlCtxtUseOptions(ctxt, Int32(XML_PARSE_NONET.rawValue)) + ctxt.pointee.replaceEntities = 0 + ctxt.pointee.loadsubset = 0 + ctxt.pointee.options &= ~Int32(XML_PARSE_NOENT.rawValue | XML_PARSE_DTDLOAD.rawValue + | XML_PARSE_DTDATTR.rawValue | XML_PARSE_DTDVALID.rawValue) + _ = xmlParseDocument(ctxt) + let doc = ctxt.pointee.myDoc + ctxt.pointee.myDoc = nil + guard ctxt.pointee.wellFormed != 0 else { + if let doc { xmlFreeDoc(doc) } + return nil + } + return doc + } init(_ ptr: xmlDocPtr) { super.init(toNodePtr(ptr), document: nil) @@ -365,10 +393,9 @@ public class XDocument: XNode, @unchecked Sendable { public class HTMLDocument: XDocument, @unchecked Sendable { /// Parse the HTML source string and create the document, if possible. /// - /// Uses `htmlReadMemory` with an explicit options bitmask (not the - /// flag-less `htmlParseDoc`) — see `XDocument.init?(fromSource:)`'s - /// doc comment for the XXE-hardening rationale, which applies - /// identically here. + /// Uses `htmlReadMemory` with `XML_PARSE_NONET` (defence in depth). + /// The HTML parser doesn't load external entities; there's a test + /// for that with the libxml2 substitution global turned on. public init?(fromSource: String, encoding: String = "UTF-8") { _ = XDocument.initialize let bytes = Array(fromSource.utf8) diff --git a/Sources/PerfectXML/XMLStream.swift b/Sources/PerfectXML/XMLStream.swift index 8eba506..65c422c 100644 --- a/Sources/PerfectXML/XMLStream.swift +++ b/Sources/PerfectXML/XMLStream.swift @@ -149,11 +149,12 @@ public class XMLStream: @unchecked Sendable { // xmlTextReaderSetParserProp), and its raw value (4) collides // with XML_PARSE_DTDLOAD in the actual xmlParserOption enum — // i.e. it was accidentally enabling external DTD loading, not - // hardening anything. XML_PARSE_NONET blocks network-based - // external entity/DTD resolution (the classic XXE/SSRF vector); - // entity substitution (XML_PARSE_NOENT) is deliberately left at - // its default-off behavior to avoid billion-laughs-style - // expansion DoS. + // hardening anything. XML_PARSE_NONET is defence in depth against + // network fetches. What keeps external entities out is entity + // substitution being off. libxml2 seeds that from a libxml2 global + // (xmlSubstituteEntitiesDefault; per-thread in threaded builds); the reader didn't load entities in + // testing with the global on, but substitution and external DTD + // loading are forced off below anyway, as XDocument does. guard let reader = xmlReaderForIO(readCallback, closeCallback, asContext(self), @@ -162,6 +163,8 @@ public class XMLStream: @unchecked Sendable { Int32(XML_PARSE_NONET.rawValue | XML_PARSE_NOCDATA.rawValue)) else { throw XMLStreamError("Unable to allocate XML reader.") } + _ = xmlTextReaderSetParserProp(reader, Int32(XML_PARSER_SUBST_ENTITIES.rawValue), 0) + _ = xmlTextReaderSetParserProp(reader, Int32(XML_PARSER_LOADDTD.rawValue), 0) readerPtr = reader return reader } diff --git a/Sources/libxml2/libxml2.h b/Sources/libxml2/libxml2.h index 99ca456..15187d1 100644 --- a/Sources/libxml2/libxml2.h +++ b/Sources/libxml2/libxml2.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include diff --git a/Tests/PerfectXMLTests/PerfectXMLTests.swift b/Tests/PerfectXMLTests/PerfectXMLTests.swift index 098a6ac..c5c716f 100644 --- a/Tests/PerfectXMLTests/PerfectXMLTests.swift +++ b/Tests/PerfectXMLTests/PerfectXMLTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing @testable import PerfectXML +import perfectxml2 @Test func docParse1() { let docSrc = "\n
HI\n" @@ -483,9 +484,46 @@ import Testing } } -// `XMLEncoder` only supports a flat keyed container of primitive values -// (see Codable.swift's doc comment) — no test for the array/nested case -// here, matching that deliberate, documented scope limit. +// MARK: - XMLEncoder (a flat keyed container of primitive values only) + +private struct FlatModel: Encodable { + let name: String + let count: Int + let ratio: Double + let flag: Bool +} + +@Test func xmlEncoderEncodesFlatStruct() throws { + let data = try XMLEncoder().encode(FlatModel(name: "aa<b & c31.5true") + let doc = try #require(XDocument(fromSource: xml)) + #expect(doc.documentElement?.getElementsByTagName("name").first?.nodeValue == "a")) + let doc = try #require(XDocument(fromSource: xml)) + let root = try #require(doc.documentElement) + #expect(root.localName == "model") + #expect(root.namespaceURI == "urn:example") + #expect(root.getElementsByTagNameNS(namespaceURI: "urn:example", localName: "count").first?.nodeValue == "1") +} + +@Test func xmlEncoderThrowsForUnsupportedShapes() { + struct Nested: Encodable { let inner: FlatModel } + struct WithArray: Encodable { let items: [Int] } + struct WithEmptyArray: Encodable { let items: [Int] } + #expect(throws: XMLEncoderError.self) { try XMLEncoder().encode(Nested(inner: FlatModel(name: "", count: 0, ratio: 0, flag: false)), rootName: "r") } + #expect(throws: XMLEncoderError.self) { try XMLEncoder().encode(WithArray(items: [1]), rootName: "r") } + #expect(throws: XMLEncoderError.self) { try XMLEncoder().encode(WithEmptyArray(items: []), rootName: "r") } + #expect(throws: XMLEncoderError.self) { try XMLEncoder().encode([1, 2], rootName: "r") } + // An empty top-level array used to encode silently as ``. + #expect(throws: XMLEncoderError.self) { try XMLEncoder().encode([Int](), rootName: "r") } + #expect(throws: XMLEncoderError.self) { try XMLEncoder().encode(42, rootName: "r") } +} @Test func xmlStream() throws { struct ChunkyProvider: XMLStreamDataProvider { @@ -584,33 +622,79 @@ import Testing #expect(d.collected == value) } -// MARK: - XXE hardening regression (see XMLDOM.swift's doc comments on -// XDocument.init?(fromSource:)/HTMLDocument.init?(fromSource:encoding:)) +// MARK: - External entities (XXE) // -// Points an external SYSTEM entity/DTD reference at a local fixture file -// containing a unique marker string, parses through the hardened DOM -// path, and asserts the marker string is absent from the resulting tree. -// This is portable across libxml2 versions/builds (some already disable -// external-entity fetching by default independent of the options -// bitmask) — asserting "no substitution occurred" is correct either way, -// unlike asserting on network-fetch behavior, which would pass for the -// wrong reason in a network-isolated CI container. - -@Test func xxeExternalEntityIsNotSubstituted() throws { +// These point an external SYSTEM entity at a local file holding a unique +// marker and check the marker never reaches the parsed output. They test +// that entities aren't loaded at all; `XML_PARSE_NONET` (network fetches) +// isn't what they exercise. + +private func withEntityFile(_ body: (_ marker: String, _ source: String) throws -> Void) throws { let marker = "XXE-MARKER-\(UUID().uuidString)" let entityFile = FileManager.default.temporaryDirectory .appendingPathComponent("perfectxml-xxe-\(UUID().uuidString).txt") try marker.write(to: entityFile, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: entityFile) } - - let docSrc = """ + let source = """ ]> &xxe; """ - let doc = XDocument(fromSource: docSrc) - let output = doc?.string(pretty: false) ?? "" - #expect(!output.contains(marker)) + try body(marker, source) +} + +private struct StringProvider: XMLStreamDataProvider { + let source: [UInt8] + var offset = 0 + init(_ s: String) { source = Array(s.utf8) } + mutating func getData(maxCount: Int) throws -> Data? { + guard offset < source.count else { return nil } + let end = min(source.count, offset + maxCount) + defer { offset = end } + return Data(source[offset.. String { + var out = "" + let doc = XDocument(fromSource: source) + out += doc?.string(pretty: false) ?? "" + out += doc?.documentElement?.nodeValue ?? "" + let stream = XMLStream(provider: StringProvider(source)) + while let node = try stream.next() { + out += node.value ?? "" + out += node.content ?? "" + } + return out +} + +@Test func externalEntityIsNotLoaded() throws { + try withEntityFile { marker, source in + let doc = XDocument(fromSource: source) + #expect(doc != nil) + let text = try parsedText(source) + #expect(!text.contains(marker)) + } +} + +// libxml2 seeds entity substitution from a libxml2 global (per-thread in +// threaded builds, so this test sets and restores it on its own thread, +// and the parse runs synchronously on that thread). Plain +// `xmlReadMemory` (what 4.0 development builds used) returned the file's +// contents through `nodeValue` once the global was on; the parsers must +// force substitution off themselves. +@Test func externalEntityIsNotLoadedWhenSubstitutionGlobalIsOn() throws { + try withEntityFile { marker, source in + let previous = xmlSubstituteEntitiesDefault(1) + defer { _ = xmlSubstituteEntitiesDefault(previous) } + let text = try parsedText(source) + #expect(!text.contains(marker)) + let html = HTMLDocument(fromSource: source) + let htmlText = (html?.string(pretty: false) ?? "") + (html?.documentElement?.nodeValue ?? "") + #expect(!htmlText.contains(marker)) + } }