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 @@
-
+
@@ -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)\(rootName)>"
+ encoded = "<\(rootName)\(namespaceAttribute)>\(encoded)\(rootName)>"
}
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 = "\nHI\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))
+ }
}