Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion PADockerfile

This file was deleted.

9 changes: 6 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -20,6 +21,7 @@ let libxml2Target: Target = .systemLibrary(
pkgConfig: "libxml-2.0",
providers: [
.apt(["libxml2-dev"]),
.yum(["libxml2-devel"]),
]
)
#endif
Expand All @@ -28,6 +30,7 @@ let package = Package(
name: "PerfectXML",
platforms: [
.macOS(.v12),
.iOS(.v15),
],
products: [
.library(name: "PerfectXML", targets: ["PerfectXML"])
Expand Down
51 changes: 24 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,39 @@
<img src="https://img.shields.io/badge/Swift-6.2-orange.svg?style=flat" alt="Swift 6.2">
</a>
<a href="https://developer.apple.com/swift/" target="_blank">
<img src="https://img.shields.io/badge/Platforms-macOS%2012%2B-lightgray.svg?style=flat" alt="Platforms macOS 12+">
<img src="https://img.shields.io/badge/Platforms-macOS%2012%2B%20%7C%20iOS%2015%2B%20%7C%20Linux-lightgray.svg?style=flat" alt="Platforms macOS 12+ | iOS 15+ | Linux">
</a>
<a href="./LICENSE" target="_blank">
<img src="https://img.shields.io/badge/License-Apache%202.0-lightgrey.svg?style=flat" alt="License Apache 2.0">
</a>
</p>

XML &amp; 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 (`<!ENTITY x SYSTEM "file:///etc/passwd">`, 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)
<module-includes>:1:9: note: in file included from <module-includes>: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

Expand Down Expand Up @@ -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 (`<root xmlns="…">`). 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 `<root/>`). `namespace:` now writes `<root xmlns="…">`; 3.x wrote the malformed `<root:namespace>…</root>`. `XMLDecoderError`, which nothing threw, is removed.

## Testing

Run the test suite with:
Expand Down
55 changes: 39 additions & 16 deletions Sources/PerfectXML/Codable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)]
Expand All @@ -153,19 +155,30 @@ public class XMLEncoder: Encoder {
userInfo = [:]
}

/// Encodes `value` as `<rootName>…</rootName>`. If `namespace` is
/// given, it becomes the default namespace of the root element and
/// its children (`<rootName xmlns="namespace">`).
///
/// 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<A: Encodable>(_ 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.")
Expand All @@ -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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}

class XMLEncodingContainer<K : CodingKey>: KeyedEncodingContainerProtocol {
typealias Key = K
let codingPath: [CodingKey]
Expand Down Expand Up @@ -275,11 +296,13 @@ class XMLEncodingContainer<K : CodingKey>: KeyedEncodingContainerProtocol {
}

func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: K) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
KeyedEncodingContainer(UnsupportedKeyedEncodingContainer<NestedKey>(codingPath: codingPath))
parent.vendedUnsupportedContainer = true
return KeyedEncodingContainer(UnsupportedKeyedEncodingContainer<NestedKey>(codingPath: codingPath))
}

func nestedUnkeyedContainer(forKey key: K) -> UnkeyedEncodingContainer {
UnsupportedEncodingContainer(codingPath: codingPath)
parent.vendedUnsupportedContainer = true
return UnsupportedEncodingContainer(codingPath: codingPath)
}

func superEncoder() -> Encoder {
Expand Down
Loading
Loading