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:
parent
4856a3fcf4
commit
e4b2e0127d
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
35
Sources/Parsing/ByteDecoder.swift
Normal file
35
Sources/Parsing/ByteDecoder.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Sources/Parsing/InputEncoding.swift
Normal file
38
Sources/Parsing/InputEncoding.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)"
|
||||
}
|
||||
|
||||
@ -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
57
Tests/EncodingTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user