From 9c74774cd4e747d1e25b08168119935571151bc4 Mon Sep 17 00:00:00 2001
From: Tim Taplin
Date: Tue, 6 Oct 2026 08:45:30 -0600
Subject: [PATCH 1/5] Declare iOS 15 and add CI
The package builds for iOS with the SDK's libxml2 and all 26 tests pass on
an iPhone 17 simulator with Xcode 27, so declare .iOS(.v15) alongside
macOS 12. With the declaration, Xcode 27 builds the library at iOS 15; it
builds the test bundle itself at iOS 17. The manifest comment pointed at a
Documentation/ file that never existed; it now explains the Apple-platform
pkg-config choice inline.
CI is modeled on Perfect-CRUD's: Linux Swift 6.2 and 6.4 (with
libxml2-dev and pkg-config from apt) and macOS Xcode 26.0.1 and 26.6, each
in debug and release with warnings as errors, plus the tests on an iOS
Simulator after waiting for it to appear. The workflow itself has not run
on GitHub yet.
Verified locally: warning-free and 26/26 tests in debug and release on
Xcode 27 (Swift 6.4), Xcode 26.3 (Swift 6.2.4), and Linux swift:6.2-noble
and swift:6.4-noble containers.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/ci.yml | 115 +++++++++++++++++++++++++++++++++++++++
Package.swift | 8 ++-
2 files changed, 120 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..410b9ae
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,115 @@
+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 test target builds at the package's iOS 15 deployment target, so this also
+ # catches APIs that need a newer iOS. 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/Package.swift b/Package.swift
index cf6b206..a98742b 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")
@@ -28,6 +29,7 @@ let package = Package(
name: "PerfectXML",
platforms: [
.macOS(.v12),
+ .iOS(.v15),
],
products: [
.library(name: "PerfectXML", targets: ["PerfectXML"])
From 03465f1d9660c5cddcaecd599d09556edec24251 Mon Sep 17 00:00:00 2001
From: Tim Taplin
Date: Tue, 6 Oct 2026 07:25:12 -0600
Subject: [PATCH 2/5] README for 4.0.0: install, platforms, migration, factual
fixes
- Install snippet uses from: "4.0.0"; requirements list macOS 12+, iOS 15+
and Linux.
- The macOS notes told readers to brew install and force-link libxml2,
which is the setup the manifest avoids (the SDK copy is used, and mixing
Homebrew headers with the SDK's breaks the build). Replaced.
- The Codable section claimed XML decoding into Codable types; the package
only has a flat-struct XMLEncoder. Corrected.
- Adds a "Migrating from 3.x" section.
Co-Authored-By: Claude Opus 5.5
---
README.md | 46 +++++++++++++++++++++-------------------------
1 file changed, 21 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index 428a295..e112997 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -13,11 +13,11 @@
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.
@@ -27,32 +27,17 @@ The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tre
## 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,21 @@ 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. Nested values and unkeyed or single-value containers 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)`).
+* **Parsing never reaches the network.** `XDocument`, `HTMLDocument` and `XMLStream` parse with `XML_PARSE_NONET`, so libxml2 never fetches external entities or DTDs over the network. `XMLStream` also no longer loads external DTDs (3.x enabled DTD loading by mistake).
+* **`XMLEncoder` throws instead of crashing** when asked to encode an unsupported shape.
+
## Testing
Run the test suite with:
From 329d9778ec1890394019d60af9674310c2a224b2 Mon Sep 17 00:00:00 2001
From: Tim Taplin
Date: Tue, 6 Oct 2026 08:45:15 -0600
Subject: [PATCH 3/5] Keep external entities out regardless of libxml2 globals;
fix XMLEncoder
Entities: libxml2 seeds a parser context's entity substitution from the
process-wide xmlSubstituteEntitiesDefault global, and xmlReadMemory keeps
it. With the global on, XDocument returned a file:// entity's contents
through nodeValue even though XML_PARSE_NOENT wasn't passed (reproduced on
macOS libxml2 2.9.13 and Ubuntu noble 2.9.14). XDocument now creates the
parser context itself and forces substitution, NOENT and external DTD
loading off before parsing; XML_PARSE_NONET stays as defence in depth.
XMLStream sets the reader's SUBST_ENTITIES and LOADDTD properties to 0 as
well (it didn't leak in testing, so that part is belt and braces). The
module header now includes parserInternals.h for
xmlCreateMemoryParserCtxt.
The old "XXE regression" test used a file:// entity, so it never tested
NONET, and it passed with the global off whatever the code did. It is
replaced by two tests: one with default globals, and one that turns
xmlSubstituteEntitiesDefault on (restoring it afterwards) and checks
XDocument (tree and nodeValue), XMLStream and HTMLDocument. The second
fails with the previous xmlReadMemory code.
XMLEncoder: namespace: wrote "...", which isn't
XML. It now writes a default namespace declaration,
. A top-level array (even an empty one, which
encoded silently as ) and nested containers now throw
XMLEncoderError. The unused public XMLDecoderError is removed (there is no
decoder). Adds the first XMLEncoder tests.
Co-Authored-By: Claude Opus 5.5
---
Sources/PerfectXML/Codable.swift | 55 ++++++---
Sources/PerfectXML/XMLDOM.swift | 49 ++++++--
Sources/PerfectXML/XMLStream.swift | 13 ++-
Sources/libxml2/libxml2.h | 1 +
Tests/PerfectXMLTests/PerfectXMLTests.swift | 122 ++++++++++++++++----
5 files changed, 188 insertions(+), 52 deletions(-)
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..1b528b5 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 the process-wide libxml2
+ /// defaults are. libxml2 seeds each parser context's entity
+ /// substitution from a global (`xmlSubstituteEntitiesDefault`), and
+ /// `xmlReadMemory` keeps it: if anything in the process turned that
+ /// global 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..4191fc4 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 process global
+ // (xmlSubstituteEntitiesDefault); 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..ba70595 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,77 @@ 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 process-wide global. 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))
+ }
}
From aa0cdb808428a7142167a612b1768e29dbc8f251 Mon Sep 17 00:00:00 2001
From: Tim Taplin
Date: Tue, 6 Oct 2026 08:45:15 -0600
Subject: [PATCH 4/5] Package and README nits; accurate CI iOS comment
- Adds a .yum(["libxml2-devel"]) provider for the Linux libxml2 target.
- Removes the stale Perfect Assistant PADockerfile.
- README: the security note no longer credits XML_PARSE_NONET with
stopping XXE (file:// entities aren't network fetches); it describes
forced-off entity substitution, with NONET as defence in depth. Only the
document, node and parser classes are Sendable, not XMLEncoder. The
encoder's namespace and array behaviour and the removed XMLDecoderError
are documented, and the migration list is corrected.
- CI: Xcode builds the test bundle above the package's iOS 15 target
(iOS 17 with Xcode 27); only the library is built at iOS 15.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/ci.yml | 5 +++--
PADockerfile | 1 -
Package.swift | 1 +
README.md | 11 ++++++-----
4 files changed, 10 insertions(+), 8 deletions(-)
delete mode 100644 PADockerfile
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 410b9ae..57501d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -104,8 +104,9 @@ jobs:
xcrun simctl list devices available
exit 1
- # The test target builds at the package's iOS 15 deployment target, so this also
- # catches APIs that need a newer iOS. The package has no dependencies, so treating
+ # 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: |
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 a98742b..b4f9011 100644
--- a/Package.swift
+++ b/Package.swift
@@ -21,6 +21,7 @@ let libxml2Target: Target = .systemLibrary(
pkgConfig: "libxml-2.0",
providers: [
.apt(["libxml2-dev"]),
+ .yum(["libxml2-devel"]),
]
)
#endif
diff --git a/README.md b/README.md
index e112997..9799b58 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,9 @@ It implements most of the DOM Core level 2 *read-only* APIs plus XPath support,
**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 process-wide global (`xmlSubstituteEntitiesDefault`), so `XDocument` and `XMLStream` force substitution off on their own parser contexts; another library turning the global on 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.
@@ -503,7 +503,7 @@ 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.
-- **XML encoding** (`Codable.swift`) — `XMLEncoder().encode(value, rootName:namespace:)` encodes a flat `Encodable` struct of primitive values as an XML document. Nested values and unkeyed or single-value containers aren't supported and throw `XMLEncoderError`. There is no XML decoder.
+- **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.
@@ -515,8 +515,9 @@ These aren't yet covered by worked examples in this README; see the source files
* **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)`).
-* **Parsing never reaches the network.** `XDocument`, `HTMLDocument` and `XMLStream` parse with `XML_PARSE_NONET`, so libxml2 never fetches external entities or DTDs over the network. `XMLStream` also no longer loads external DTDs (3.x enabled DTD loading by mistake).
-* **`XMLEncoder` throws instead of crashing** when asked to encode an unsupported shape.
+* **`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 in a process that 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
From 48d46fcea9d63dfb754346245e466b1322c85b7d Mon Sep 17 00:00:00 2001
From: Tim Taplin
Date: Tue, 13 Oct 2026 08:57:53 -0500
Subject: [PATCH 5/5] Docs: xmlSubstituteEntitiesDefault is per-thread in
threaded libxml2 builds
The comments, test comment and README called it process-wide. In threaded
libxml2 builds (macOS 2.9.13, Ubuntu noble 2.9.14) it is per-thread. The
fix is unchanged: Swift tasks can run on any thread, so the parsers still
force substitution off on their own contexts.
Co-Authored-By: Claude Opus 5.5
---
README.md | 4 ++--
Sources/PerfectXML/XMLDOM.swift | 10 +++++-----
Sources/PerfectXML/XMLStream.swift | 4 ++--
Tests/PerfectXMLTests/PerfectXMLTests.swift | 4 +++-
4 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 9799b58..7578252 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ It implements most of the DOM Core level 2 *read-only* APIs plus XPath support,
**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:** 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 process-wide global (`xmlSubstituteEntitiesDefault`), so `XDocument` and `XMLStream` force substitution off on their own parser contexts; another library turning the global on 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.
+**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.
@@ -516,7 +516,7 @@ These aren't yet covered by worked examples in this README; see the source files
* **`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 in a process that called `xmlSubstituteEntitiesDefault(1)`, `file://` entity contents don't appear in `nodeValue`. 3.x's `xmlParseDoc` honoured the global.
+* **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
diff --git a/Sources/PerfectXML/XMLDOM.swift b/Sources/PerfectXML/XMLDOM.swift
index 1b528b5..28b3136 100644
--- a/Sources/PerfectXML/XMLDOM.swift
+++ b/Sources/PerfectXML/XMLDOM.swift
@@ -313,11 +313,11 @@ public class XDocument: XNode, @unchecked Sendable {
/// Parse the XML source string and create the document, if possible.
///
/// External entities (``) are
- /// never loaded or substituted, whatever the process-wide libxml2
- /// defaults are. libxml2 seeds each parser context's entity
- /// substitution from a global (`xmlSubstituteEntitiesDefault`), and
- /// `xmlReadMemory` keeps it: if anything in the process turned that
- /// global on, `xmlReadMemory` loads `file://` entities even without
+ /// 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
diff --git a/Sources/PerfectXML/XMLStream.swift b/Sources/PerfectXML/XMLStream.swift
index 4191fc4..65c422c 100644
--- a/Sources/PerfectXML/XMLStream.swift
+++ b/Sources/PerfectXML/XMLStream.swift
@@ -151,8 +151,8 @@ public class XMLStream: @unchecked Sendable {
// i.e. it was accidentally enabling external DTD loading, not
// 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 process global
- // (xmlSubstituteEntitiesDefault); the reader didn't load entities in
+ // 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,
diff --git a/Tests/PerfectXMLTests/PerfectXMLTests.swift b/Tests/PerfectXMLTests/PerfectXMLTests.swift
index ba70595..c5c716f 100644
--- a/Tests/PerfectXMLTests/PerfectXMLTests.swift
+++ b/Tests/PerfectXMLTests/PerfectXMLTests.swift
@@ -681,7 +681,9 @@ private func parsedText(_ source: String) throws -> String {
}
}
-// libxml2 seeds entity substitution from a process-wide global. Plain
+// 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.