diff --git a/.swiftlint.yml b/.swiftlint.yml index 1b53e12..1397026 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fe63b84..1537e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/API.swift b/Sources/API.swift index f4520cc..d81f33e 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -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( diff --git a/Sources/Parsing/ByteDecoder.swift b/Sources/Parsing/ByteDecoder.swift new file mode 100644 index 0000000..843a2bd --- /dev/null +++ b/Sources/Parsing/ByteDecoder.swift @@ -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, 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) + } + } +} diff --git a/Sources/Parsing/InputEncoding.swift b/Sources/Parsing/InputEncoding.swift new file mode 100644 index 0000000..b4288dc --- /dev/null +++ b/Sources/Parsing/InputEncoding.swift @@ -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 ` 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 "= 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) + } + } +} diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index fecea42..ec72f62 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -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)" } diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift index 5158cf6..72d2ad9 100644 --- a/Sources/Parsing/Parser.swift +++ b/Sources/Parsing/Parser.swift @@ -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. diff --git a/Tests/EncodingTests.swift b/Tests/EncodingTests.swift new file mode 100644 index 0000000..36a5ddc --- /dev/null +++ b/Tests/EncodingTests.swift @@ -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("".utf8)) == .utf8) + } + + @Test("Parses UTF-8 bytes, with and without a BOM") + func test_parseUTF8Bytes() throws { + let plain = try PureXML.parse(bytes: Array("hi".utf8)) + #expect(rootText(plain) == "hi") + let withBOM = try PureXML.parse(bytes: [0xEF, 0xBB, 0xBF] + Array("hi".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("hi", bigEndian: true)) + #expect(rootText(bigEndianNode) == "hi") + let littleEndianNode = try PureXML.parse(bytes: utf16("hi", 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 + } +}