feat(parser): accept raw bytes with UTF-8/UTF-16 encoding detection

Add PureXML.parse(bytes:) and events(bytes:) for raw [UInt8] input. The encoding
is detected following the XML sniff order (BOM first, then the <? byte pattern,
defaulting to UTF-8) and decoded with the Swift standard library only, so the
package stays Foundation-free and portable. UTF-8 and UTF-16 (both byte orders,
with or without a BOM) are supported, the encoding set the XML spec requires.
Malformed input (odd-length UTF-16) raises ParseError.malformedEncoding.

PureXML.Parsing.InputEncoding.detect(_:) exposes the detector. The lint rule
optional_data_string_conversion is disabled because its suggested fix is a
Foundation API this package cannot use. Reimplemented from the documented XML
byte-detection algorithm; no upstream code copied.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 00:52:56 +02:00
parent 4856a3fcf4
commit e4b2e0127d
8 changed files with 163 additions and 0 deletions

View File

@ -5,6 +5,10 @@
# build plus test ships with the engine package.
opt_in_rules:
- force_unwrapping
disabled_rules:
# Suggests Foundation's String(bytes:encoding:); PureXML is Foundation-free, so
# String(decoding:as:) from the standard library is the correct conversion.
- optional_data_string_conversion
excluded:
- .build
- DerivedData

View File

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Byte input with encoding detection. `PureXML.parse(bytes:)` and
`PureXML.events(bytes:)` accept raw `[UInt8]` and detect the encoding (UTF-8 or
UTF-16, with or without a byte-order mark) following the XML sniff order, then
decode with the Swift standard library (no Foundation). `PureXML.Parsing.
InputEncoding.detect(_:)` exposes the detector. Malformed input (such as an
odd-length UTF-16 stream) raises `ParseError.malformedEncoding`.
- Configurable, bounded-by-default parser limits (`PureXML.Parsing.Limits`):
maximum nesting depth (default 256), name length, and content length, enforced
during scanning. Protects against pathological input and keeps the recursive

View File

@ -18,12 +18,24 @@ public extension PureXML {
try Parsing.Parser().parse(pulling: pull, limits: limits)
}
/// Parses an XML document from raw bytes, detecting the encoding (UTF-8 or
/// UTF-16, with or without a byte-order mark) before parsing.
static func parse(bytes: [UInt8], limits: Parsing.Limits = .default) throws -> Model.Node {
try Parsing.Parser().parse(bytes: bytes, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over an XML string. Pull events
/// one at a time with `next()` to process documents without building a tree.
static func events(_ xml: String, limits: Parsing.Limits = .default) -> Parsing.EventReader {
Parsing.EventReader(xml, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over raw bytes, detecting the
/// encoding before streaming events.
static func events(bytes: [UInt8], limits: Parsing.Limits = .default) throws -> Parsing.EventReader {
try Parsing.EventReader(Parsing.ByteDecoder.decode(bytes), limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over an incremental character
/// source, for processing arbitrarily large or chunked input.
static func events(

View File

@ -0,0 +1,35 @@
extension PureXML.Parsing {
/// Decodes raw bytes into a string using the detected encoding, stripping any
/// byte-order mark. UTF-8 and UTF-16 (both byte orders) are supported, which
/// is the encoding set the XML specification requires of every processor.
/// Uses only the Swift standard library (no Foundation), so it stays portable.
enum ByteDecoder {
static func decode(_ bytes: [UInt8]) throws -> String {
let (encoding, bomLength) = InputEncoding.detectWithBOM(bytes)
let body = bytes.dropFirst(bomLength)
switch encoding {
case .utf8:
return String(decoding: body, as: UTF8.self)
case .utf16BigEndian:
return try decodeUTF16(body, bigEndian: true)
case .utf16LittleEndian:
return try decodeUTF16(body, bigEndian: false)
}
}
private static func decodeUTF16(_ bytes: ArraySlice<UInt8>, bigEndian: Bool) throws -> String {
guard bytes.count.isMultiple(of: 2) else {
throw PureXML.Parsing.ParseError.malformedEncoding
}
var units: [UInt16] = []
units.reserveCapacity(bytes.count / 2)
var iterator = bytes.makeIterator()
while let first = iterator.next(), let second = iterator.next() {
let high = UInt16(first)
let low = UInt16(second)
units.append(bigEndian ? (high << 8 | low) : (low << 8 | high))
}
return String(decoding: units, as: UTF16.self)
}
}
}

View File

@ -0,0 +1,38 @@
public extension PureXML.Parsing {
/// A character encoding PureXML can decode from raw bytes. XML processors are
/// required to support UTF-8 and UTF-16; those are covered here. Other declared
/// encodings are out of scope (and reported as malformed rather than guessed).
enum InputEncoding: String, Equatable, Sendable {
case utf8
case utf16BigEndian
case utf16LittleEndian
/// Detects the encoding from the leading bytes, following the XML sniff
/// order: a byte-order mark first, then the `<?` byte pattern of the XML
/// declaration, defaulting to UTF-8 when neither is present.
public static func detect(_ bytes: [UInt8]) -> InputEncoding {
detectWithBOM(bytes).encoding
}
/// Detects the encoding and the length of any byte-order mark to strip.
static func detectWithBOM(_ bytes: [UInt8]) -> (encoding: InputEncoding, bomLength: Int) {
if bytes.count >= 3, bytes[0] == 0xEF, bytes[1] == 0xBB, bytes[2] == 0xBF {
return (.utf8, 3)
}
if bytes.count >= 2, bytes[0] == 0xFE, bytes[1] == 0xFF {
return (.utf16BigEndian, 2)
}
if bytes.count >= 2, bytes[0] == 0xFF, bytes[1] == 0xFE {
return (.utf16LittleEndian, 2)
}
// No BOM: sniff the "<?" pattern of an XML declaration.
if bytes.count >= 4, bytes[0] == 0x00, bytes[1] == 0x3C, bytes[2] == 0x00, bytes[3] == 0x3F {
return (.utf16BigEndian, 0)
}
if bytes.count >= 4, bytes[0] == 0x3C, bytes[1] == 0x00, bytes[2] == 0x3F, bytes[3] == 0x00 {
return (.utf16LittleEndian, 0)
}
return (.utf8, 0)
}
}
}

View File

@ -22,6 +22,9 @@ public extension PureXML.Parsing {
/// default as a security posture (XXE, entity-expansion DoS); enabling it
/// is a deliberate future opt-in, not silent behavior.
case unsupportedDoctype(Mark)
/// Raised when raw bytes cannot be decoded in the detected encoding (for
/// example an odd-length UTF-16 stream).
case malformedEncoding
/// Raised by entry points whose parsing path is not implemented yet.
case notImplemented(String)
@ -63,6 +66,8 @@ public extension PureXML.Parsing {
"content exceeds the length limit of \(limit) at \(mark)"
case let .unsupportedDoctype(mark):
"DTD processing is disabled (DOCTYPE at \(mark))"
case .malformedEncoding:
"input bytes are malformed for the detected encoding"
case let .notImplemented(detail):
"not implemented: \(detail)"
}

View File

@ -20,6 +20,12 @@ public extension PureXML.Parsing {
try build(EventReader(xml, limits: limits))
}
/// Parses a single XML document from raw bytes, detecting the encoding
/// (UTF-8 or UTF-16, with or without a byte-order mark) before parsing.
public func parse(bytes: [UInt8], limits: Limits = .default) throws -> PureXML.Model.Node {
try parse(ByteDecoder.decode(bytes), limits: limits)
}
/// Parses a single XML document from an incremental character source. The
/// closure returns the next character or nil at end of input, so the
/// document can arrive in chunks and is never held whole.

57
Tests/EncodingTests.swift Normal file
View File

@ -0,0 +1,57 @@
@testable import PureXML
import Testing
@Suite("Encoding")
struct EncodingTests {
@Test("Detects encodings from the leading bytes")
func test_detect() {
let detect = PureXML.Parsing.InputEncoding.detect
#expect(detect([0xEF, 0xBB, 0xBF, 0x3C]) == .utf8)
#expect(detect([0xFE, 0xFF, 0x00, 0x3C]) == .utf16BigEndian)
#expect(detect([0xFF, 0xFE, 0x3C, 0x00]) == .utf16LittleEndian)
#expect(detect([0x00, 0x3C, 0x00, 0x3F]) == .utf16BigEndian)
#expect(detect([0x3C, 0x00, 0x3F, 0x00]) == .utf16LittleEndian)
#expect(detect(Array("<r/>".utf8)) == .utf8)
}
@Test("Parses UTF-8 bytes, with and without a BOM")
func test_parseUTF8Bytes() throws {
let plain = try PureXML.parse(bytes: Array("<r>hi</r>".utf8))
#expect(rootText(plain) == "hi")
let withBOM = try PureXML.parse(bytes: [0xEF, 0xBB, 0xBF] + Array("<r>hi</r>".utf8))
#expect(rootText(withBOM) == "hi")
}
@Test("Parses UTF-16 bytes in both byte orders")
func test_parseUTF16Bytes() throws {
let bigEndianNode = try PureXML.parse(bytes: utf16("<r>hi</r>", bigEndian: true))
#expect(rootText(bigEndianNode) == "hi")
let littleEndianNode = try PureXML.parse(bytes: utf16("<r>hi</r>", bigEndian: false))
#expect(rootText(littleEndianNode) == "hi")
}
@Test("Odd-length UTF-16 input is rejected as malformed")
func test_malformedUTF16() {
#expect(throws: PureXML.Parsing.ParseError.malformedEncoding) {
_ = try PureXML.parse(bytes: [0xFE, 0xFF, 0x00])
}
}
private func utf16(_ string: String, bigEndian: Bool) -> [UInt8] {
var bytes: [UInt8] = bigEndian ? [0xFE, 0xFF] : [0xFF, 0xFE]
for unit in string.utf16 {
let high = UInt8(unit >> 8)
let low = UInt8(unit & 0xFF)
bytes += bigEndian ? [high, low] : [low, high]
}
return bytes
}
private func rootText(_ node: PureXML.Model.Node) -> String? {
guard case let .document(children) = node else { return nil }
for child in children {
if case let .element(element) = child { return element.text }
}
return nil
}
}