From dddbe8b12e72ab4a0efc0b537ad08b8574a23896 Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Sat, 3 Oct 2026 10:00:18 -0700 Subject: [PATCH] feat(json): add ordered bounded parsing and serialization --- README.md | 36 +- Sources/JSON/OrderedJSON.swift | 485 +++++++++++++++++++++++++ Tests/JSONTests/OrderedJSONTests.swift | 137 +++++++ 3 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 Sources/JSON/OrderedJSON.swift create mode 100644 Tests/JSONTests/OrderedJSONTests.swift diff --git a/README.md b/README.md index 3fc608a..ac3dc38 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Add the package to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/reidchatham/JSON.swift", from: "1.0.0"), + .package(url: "https://github.com/rchatham/JSON.swift", from: "1.0.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ @@ -115,6 +115,40 @@ let doubles = json.compactMap(\.doubleValue) --- +## Ordered, bounded JSON + +Use `OrderedJSON` when a protocol depends on the order of object members or +needs to reject duplicate keys. Unlike `JSON.object([String: JSON])`, objects +are arrays of `OrderedJSONMember`; numbers retain their original spelling. +Parsing and serialization both enforce configurable resource limits and throw +on duplicate decoded keys, malformed UTF-8/escapes, invalid numbers, or a +limit violation. + +```swift +import JSON + +let limits = OrderedJSONLimits( + maximumBodyBytes: 1_048_576, + maximumDepth: 32, + maximumNodes: 10_000, + maximumStringBytes: 65_536, + maximumContainerEntries: 256 +) +let value = try OrderedJSONParser(limits: limits).parse( + Data(#"{"second":2,"first":1}"#.utf8) +) +guard case .object(let members) = value else { fatalError("Expected an object") } +print(members.map(\.key)) // ["second", "first"] +let bytes = try OrderedJSONSerializer(limits: limits).serialize(value) +``` + +The caller must cap incoming bytes **before buffering** an untrusted request; +`maximumBodyBytes` validates the `Data` passed to `parse`, not a network stream. +The existing `JSON` API remains the simpler choice when member order and raw +numeric lexemes do not matter. + +--- + ## JSONSchema Define the shape of JSON values for validation, LLM structured output, and documentation. diff --git a/Sources/JSON/OrderedJSON.swift b/Sources/JSON/OrderedJSON.swift new file mode 100644 index 0000000..0bafaa8 --- /dev/null +++ b/Sources/JSON/OrderedJSON.swift @@ -0,0 +1,485 @@ +import Foundation + +/// An order-preserving JSON value with bounded parsing and serialization. +public enum OrderedJSON: Equatable, Sendable { + case null + case bool(Bool) + case number(OrderedJSONNumber) + case string(String) + case array([OrderedJSON]) + case object([OrderedJSONMember]) +} + +public struct OrderedJSONMember: Equatable, Sendable { + public let key: String + public let value: OrderedJSON + + public init(key: String, value: OrderedJSON) { + self.key = key + self.value = value + } +} + +/// A syntactically valid, finite JSON number. Its source spelling is retained. +public struct OrderedJSONNumber: Equatable, Sendable { + public let rawValue: String + + public init(rawValue: String) throws { + guard OrderedJSONParser.isValidNumber(rawValue) else { + throw OrderedJSONError.invalidNumber + } + if rawValue.contains(".") || rawValue.contains("e") || rawValue.contains("E") { + guard let value = Double(rawValue), value.isFinite else { + throw OrderedJSONError.nonFiniteNumber + } + } + self.rawValue = rawValue + } + + public init(_ value: Double) throws { + guard value.isFinite else { throw OrderedJSONError.nonFiniteNumber } + self.rawValue = String(value) + } + + public init(_ value: Int) { + self.rawValue = String(value) + } +} + +public struct OrderedJSONLimits: Equatable, Sendable { + public var maximumBodyBytes: Int + public var maximumDepth: Int + public var maximumNodes: Int + public var maximumStringBytes: Int + public var maximumContainerEntries: Int + + public init( + maximumBodyBytes: Int = 1_048_576, + maximumDepth: Int = 32, + maximumNodes: Int = 10_000, + maximumStringBytes: Int = 65_536, + maximumContainerEntries: Int = 256 + ) { + self.maximumBodyBytes = maximumBodyBytes + self.maximumDepth = maximumDepth + self.maximumNodes = maximumNodes + self.maximumStringBytes = maximumStringBytes + self.maximumContainerEntries = maximumContainerEntries + } + + public static let httpDefault = OrderedJSONLimits() +} + +public enum OrderedJSONError: Error, Equatable, Sendable { + case bodyTooLarge + case invalidUTF8 + case unexpectedEnd + case unexpectedToken + case trailingData + case invalidEscape + case invalidSurrogate + case invalidNumber + case nonFiniteNumber + case duplicateKey(String) + case depthLimitExceeded + case nodeLimitExceeded + case stringLimitExceeded + case containerLimitExceeded +} + +public struct OrderedJSONParser: Sendable { + public let limits: OrderedJSONLimits + + public init(limits: OrderedJSONLimits = .httpDefault) { + self.limits = limits + } + + public func parse(_ data: Data) throws -> OrderedJSON { + guard data.count <= limits.maximumBodyBytes else { throw OrderedJSONError.bodyTooLarge } + guard let text = String(data: data, encoding: .utf8) else { + throw OrderedJSONError.invalidUTF8 + } + var implementation = ParserImplementation(text: text, limits: limits) + return try implementation.parse() + } + + fileprivate static func isValidNumber(_ text: String) -> Bool { + var parser = NumberSyntax(bytes: Array(text.utf8)) + return parser.consumeNumber() && parser.index == parser.bytes.count + } +} + +public struct OrderedJSONSerializer: Sendable { + public let limits: OrderedJSONLimits + + public init(limits: OrderedJSONLimits = .httpDefault) { + self.limits = limits + } + + public func serialize(_ value: OrderedJSON) throws -> Data { + var output = "" + var byteCount = 0 + var nodeCount = 0 + try append(value, depth: 1, nodeCount: &nodeCount, byteCount: &byteCount, to: &output) + guard let data = output.data(using: .utf8) else { throw OrderedJSONError.invalidUTF8 } + return data + } + + private func append( + _ value: OrderedJSON, + depth: Int, + nodeCount: inout Int, + byteCount: inout Int, + to output: inout String + ) throws { + guard depth <= limits.maximumDepth else { throw OrderedJSONError.depthLimitExceeded } + nodeCount += 1 + guard nodeCount <= limits.maximumNodes else { throw OrderedJSONError.nodeLimitExceeded } + switch value { + case .null: + try appendText("null", byteCount: &byteCount, to: &output) + case .bool(let value): + try appendText(value ? "true" : "false", byteCount: &byteCount, to: &output) + case .number(let number): + guard OrderedJSONParser.isValidNumber(number.rawValue) else { + throw OrderedJSONError.invalidNumber + } + if number.rawValue.contains(".") || number.rawValue.contains("e") + || number.rawValue.contains("E") + { + guard let value = Double(number.rawValue), value.isFinite else { + throw OrderedJSONError.nonFiniteNumber + } + } + try appendText(number.rawValue, byteCount: &byteCount, to: &output) + case .string(let string): + try appendEscaped(string, byteCount: &byteCount, to: &output) + case .array(let values): + guard values.count <= limits.maximumContainerEntries else { + throw OrderedJSONError.containerLimitExceeded + } + try appendText("[", byteCount: &byteCount, to: &output) + for (index, item) in values.enumerated() { + if index > 0 { try appendText(",", byteCount: &byteCount, to: &output) } + try append( + item, depth: depth + 1, nodeCount: &nodeCount, byteCount: &byteCount, + to: &output) + } + try appendText("]", byteCount: &byteCount, to: &output) + case .object(let members): + guard members.count <= limits.maximumContainerEntries else { + throw OrderedJSONError.containerLimitExceeded + } + var keys = Set() + try appendText("{", byteCount: &byteCount, to: &output) + for (index, member) in members.enumerated() { + guard keys.insert(member.key).inserted else { + throw OrderedJSONError.duplicateKey(member.key) + } + if index > 0 { try appendText(",", byteCount: &byteCount, to: &output) } + try appendEscaped(member.key, byteCount: &byteCount, to: &output) + try appendText(":", byteCount: &byteCount, to: &output) + try append( + member.value, depth: depth + 1, nodeCount: &nodeCount, byteCount: &byteCount, + to: &output) + } + try appendText("}", byteCount: &byteCount, to: &output) + } + } + + private func appendEscaped( + _ string: String, + byteCount: inout Int, + to output: inout String + ) throws { + guard string.utf8.count <= limits.maximumStringBytes else { + throw OrderedJSONError.stringLimitExceeded + } + var escaped = "\"" + for scalar in string.unicodeScalars { + switch scalar.value { + case 0x22: escaped += "\\\"" + case 0x5C: escaped += "\\\\" + case 0x08: escaped += "\\b" + case 0x0C: escaped += "\\f" + case 0x0A: escaped += "\\n" + case 0x0D: escaped += "\\r" + case 0x09: escaped += "\\t" + case 0x00...0x1F: + escaped += String(format: "\\u%04x", scalar.value) + default: + escaped.unicodeScalars.append(scalar) + } + } + escaped += "\"" + try appendText(escaped, byteCount: &byteCount, to: &output) + } + + private func appendText( + _ text: String, + byteCount: inout Int, + to output: inout String + ) throws { + let (nextCount, overflow) = byteCount.addingReportingOverflow(text.utf8.count) + guard !overflow, nextCount <= limits.maximumBodyBytes else { + throw OrderedJSONError.bodyTooLarge + } + byteCount = nextCount + output += text + } +} + +private struct ParserImplementation { + let scalars: [Unicode.Scalar] + let limits: OrderedJSONLimits + var index = 0 + var nodes = 0 + + init(text: String, limits: OrderedJSONLimits) { + self.scalars = Array(text.unicodeScalars) + self.limits = limits + } + + mutating func parse() throws -> OrderedJSON { + skipWhitespace() + let value = try parseValue(depth: 1) + skipWhitespace() + guard index == scalars.count else { throw OrderedJSONError.trailingData } + return value + } + + mutating func parseValue(depth: Int) throws -> OrderedJSON { + guard depth <= limits.maximumDepth else { throw OrderedJSONError.depthLimitExceeded } + nodes += 1 + guard nodes <= limits.maximumNodes else { throw OrderedJSONError.nodeLimitExceeded } + guard let scalar = current else { throw OrderedJSONError.unexpectedEnd } + switch scalar.value { + case 0x6E: + try consume("null") + return .null + case 0x74: + try consume("true") + return .bool(true) + case 0x66: + try consume("false") + return .bool(false) + case 0x22: return .string(try parseString()) + case 0x5B: return try parseArray(depth: depth) + case 0x7B: return try parseObject(depth: depth) + case 0x2D, 0x30...0x39: return .number(try parseNumber()) + default: throw OrderedJSONError.unexpectedToken + } + } + + mutating func parseArray(depth: Int) throws -> OrderedJSON { + index += 1 + skipWhitespace() + var values: [OrderedJSON] = [] + if consumeIf(0x5D) { return .array(values) } + while true { + guard values.count < limits.maximumContainerEntries else { + throw OrderedJSONError.containerLimitExceeded + } + values.append(try parseValue(depth: depth + 1)) + skipWhitespace() + if consumeIf(0x5D) { return .array(values) } + guard consumeIf(0x2C) else { throw OrderedJSONError.unexpectedToken } + skipWhitespace() + } + } + + mutating func parseObject(depth: Int) throws -> OrderedJSON { + index += 1 + skipWhitespace() + var members: [OrderedJSONMember] = [] + var keys = Set() + if consumeIf(0x7D) { return .object(members) } + while true { + guard members.count < limits.maximumContainerEntries else { + throw OrderedJSONError.containerLimitExceeded + } + guard current?.value == 0x22 else { throw OrderedJSONError.unexpectedToken } + let key = try parseString() + guard keys.insert(key).inserted else { throw OrderedJSONError.duplicateKey(key) } + skipWhitespace() + guard consumeIf(0x3A) else { throw OrderedJSONError.unexpectedToken } + skipWhitespace() + members.append(OrderedJSONMember(key: key, value: try parseValue(depth: depth + 1))) + skipWhitespace() + if consumeIf(0x7D) { return .object(members) } + guard consumeIf(0x2C) else { throw OrderedJSONError.unexpectedToken } + skipWhitespace() + } + } + + mutating func parseString() throws -> String { + index += 1 + var result = String.UnicodeScalarView() + var decodedUTF8Bytes = 0 + while let scalar = current { + index += 1 + switch scalar.value { + case 0x22: + return String(result) + case 0x00...0x1F: + throw OrderedJSONError.unexpectedToken + case 0x5C: + try appendDecoded(try parseEscape(), bytes: &decodedUTF8Bytes, to: &result) + default: + try appendDecoded(scalar, bytes: &decodedUTF8Bytes, to: &result) + } + } + throw OrderedJSONError.unexpectedEnd + } + + mutating func parseEscape() throws -> Unicode.Scalar { + guard let escaped = current else { throw OrderedJSONError.unexpectedEnd } + index += 1 + switch escaped.value { + case 0x22, 0x2F, 0x5C: return escaped + case 0x62: return Unicode.Scalar(0x08)! + case 0x66: return Unicode.Scalar(0x0C)! + case 0x6E: return Unicode.Scalar(0x0A)! + case 0x72: return Unicode.Scalar(0x0D)! + case 0x74: return Unicode.Scalar(0x09)! + case 0x75: + let first = try parseHexQuad() + if (0xD800...0xDBFF).contains(first) { + guard consumeIf(0x5C), consumeIf(0x75) else { + throw OrderedJSONError.invalidSurrogate + } + let second = try parseHexQuad() + guard (0xDC00...0xDFFF).contains(second) else { + throw OrderedJSONError.invalidSurrogate + } + let value = 0x10000 + ((first - 0xD800) << 10) + second - 0xDC00 + guard let scalar = Unicode.Scalar(value) else { + throw OrderedJSONError.invalidSurrogate + } + return scalar + } + guard !(0xDC00...0xDFFF).contains(first), let scalar = Unicode.Scalar(first) else { + throw OrderedJSONError.invalidSurrogate + } + return scalar + default: throw OrderedJSONError.invalidEscape + } + } + + func appendDecoded( + _ scalar: Unicode.Scalar, + bytes: inout Int, + to result: inout String.UnicodeScalarView + ) throws { + let width: Int + switch scalar.value { + case 0...0x7F: width = 1 + case 0x80...0x7FF: width = 2 + case 0x800...0xFFFF: width = 3 + default: width = 4 + } + let (next, overflow) = bytes.addingReportingOverflow(width) + guard !overflow, next <= limits.maximumStringBytes else { + throw OrderedJSONError.stringLimitExceeded + } + bytes = next + result.append(scalar) + } + + mutating func parseHexQuad() throws -> UInt32 { + var value: UInt32 = 0 + for _ in 0..<4 { + guard let scalar = current else { throw OrderedJSONError.unexpectedEnd } + index += 1 + let digit: UInt32 + switch scalar.value { + case 0x30...0x39: digit = scalar.value - 0x30 + case 0x41...0x46: digit = scalar.value - 0x41 + 10 + case 0x61...0x66: digit = scalar.value - 0x61 + 10 + default: throw OrderedJSONError.invalidEscape + } + value = value * 16 + digit + } + return value + } + + mutating func parseNumber() throws -> OrderedJSONNumber { + let start = index + var bytes: [UInt8] = [] + while let scalar = current, scalar.isASCII, + scalar.value == 0x2D || scalar.value == 0x2B || scalar.value == 0x2E + || scalar.value == 0x45 || scalar.value == 0x65 + || (0x30...0x39).contains(scalar.value) + { + bytes.append(UInt8(scalar.value)) + index += 1 + } + let raw = String(decoding: bytes, as: UTF8.self) + guard index > start, OrderedJSONParser.isValidNumber(raw) else { + throw OrderedJSONError.invalidNumber + } + return try OrderedJSONNumber(rawValue: raw) + } + + mutating func consume(_ literal: String) throws { + for expected in literal.unicodeScalars { + guard current == expected else { throw OrderedJSONError.unexpectedToken } + index += 1 + } + } + + mutating func skipWhitespace() { + while let value = current?.value, + value == 0x20 || value == 0x09 || value == 0x0A || value == 0x0D + { + index += 1 + } + } + + mutating func consumeIf(_ value: UInt32) -> Bool { + guard current?.value == value else { return false } + index += 1 + return true + } + + var current: Unicode.Scalar? { index < scalars.count ? scalars[index] : nil } +} + +private struct NumberSyntax { + let bytes: [UInt8] + var index = 0 + + mutating func consumeNumber() -> Bool { + _ = consume(0x2D) + if consume(0x30) { + if isDigit(current) { return false } + } else { + guard consumeDigits(requireOne: true) else { return false } + } + if consume(0x2E), !consumeDigits(requireOne: true) { return false } + if consume(0x65) || consume(0x45) { + _ = consume(0x2B) || consume(0x2D) + guard consumeDigits(requireOne: true) else { return false } + } + return true + } + + mutating func consumeDigits(requireOne: Bool) -> Bool { + let start = index + while isDigit(current) { index += 1 } + return !requireOne || index > start + } + + mutating func consume(_ byte: UInt8) -> Bool { + guard current == byte else { return false } + index += 1 + return true + } + + func isDigit(_ byte: UInt8?) -> Bool { + guard let byte else { return false } + return (0x30...0x39).contains(byte) + } + + var current: UInt8? { index < bytes.count ? bytes[index] : nil } +} diff --git a/Tests/JSONTests/OrderedJSONTests.swift b/Tests/JSONTests/OrderedJSONTests.swift new file mode 100644 index 0000000..698ec94 --- /dev/null +++ b/Tests/JSONTests/OrderedJSONTests.swift @@ -0,0 +1,137 @@ +import Foundation +import XCTest + +@testable import JSON + +final class OrderedJSONTests: XCTestCase { + func testParserAndSerializerPreserveObjectOrderAndUnicode() throws { + let data = Data(#"{"z":"é","a":"\uD83D\uDE00","nested":{"b":1,"a":2}}"#.utf8) + let value = try OrderedJSONParser().parse(data) + guard case .object(let members) = value else { return XCTFail("expected object") } + XCTAssertEqual(members.map(\.key), ["z", "a", "nested"]) + XCTAssertEqual(members[1].value, .string("😀")) + guard case .object(let nested) = members[2].value else { + return XCTFail("expected nested object") + } + XCTAssertEqual(nested.map(\.key), ["b", "a"]) + XCTAssertEqual( + String(decoding: try OrderedJSONSerializer().serialize(value), as: UTF8.self), + #"{"z":"é","a":"😀","nested":{"b":1,"a":2}}"#) + } + + func testRejectsDuplicateKeysInvalidUTF8EscapesAndSurrogates() { + assertError(#"{"a":1,"a":2}"#, equals: .duplicateKey("a")) + assertError(#"{"a":1,"\u0061":2}"#, equals: .duplicateKey("a")) + assertError(#""\q""#, equals: .invalidEscape) + assertError(#""\uD800""#, equals: .invalidSurrogate) + assertError(#""\uDC00""#, equals: .invalidSurrogate) + XCTAssertThrowsError(try OrderedJSONParser().parse(Data([0x22, 0xFF, 0x22]))) { + XCTAssertEqual($0 as? OrderedJSONError, .invalidUTF8) + } + } + + func testRejectsMalformedAndNonFiniteNumbers() throws { + assertError("01", equals: .invalidNumber) + assertError("1.", equals: .invalidNumber) + assertError("1e400", equals: .nonFiniteNumber) + for source in ["-0", "5e-324", "1.7976931348623157e308", "123456789012345678901234567890"] { + let parsed = try OrderedJSONParser().parse(Data(source.utf8)) + XCTAssertEqual( + String(decoding: try OrderedJSONSerializer().serialize(parsed), as: UTF8.self), + source) + } + } + + func testSerializerEscapesAllRequiredCharacters() throws { + let encoded = try OrderedJSONSerializer().serialize( + .string("\"\\\u{08}\u{0C}\n\r\t\u{01}é")) + XCTAssertEqual(String(decoding: encoded, as: UTF8.self), #""\"\\\b\f\n\r\t\u0001é""#) + } + + func testBodyDepthNodeStringAndContainerLimits() throws { + XCTAssertThrowsError( + try OrderedJSONParser(limits: limits(body: 2)).parse(Data("null".utf8)) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .bodyTooLarge) + } + XCTAssertThrowsError( + try OrderedJSONParser(limits: limits(depth: 2)).parse(Data("[[0]]".utf8)) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .depthLimitExceeded) + } + XCTAssertThrowsError( + try OrderedJSONParser(limits: limits(nodes: 2)).parse(Data("[0,1]".utf8)) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .nodeLimitExceeded) + } + XCTAssertThrowsError( + try OrderedJSONParser(limits: limits(string: 3)).parse(Data(#""😀""#.utf8)) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .stringLimitExceeded) + } + XCTAssertThrowsError( + try OrderedJSONParser(limits: limits(container: 1)).parse(Data("[0,1]".utf8)) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .containerLimitExceeded) + } + } + + func testSerializerRejectsDuplicateKeys() { + let value = OrderedJSON.object([ + .init(key: "x", value: .null), + .init(key: "x", value: .bool(true)), + ]) + XCTAssertThrowsError(try OrderedJSONSerializer().serialize(value)) { + XCTAssertEqual($0 as? OrderedJSONError, .duplicateKey("x")) + } + } + + func testSerializerEnforcesAllStructuralLimits() { + XCTAssertThrowsError(try OrderedJSONSerializer(limits: limits(body: 3)).serialize(.null)) { + XCTAssertEqual($0 as? OrderedJSONError, .bodyTooLarge) + } + XCTAssertThrowsError( + try OrderedJSONSerializer(limits: limits(depth: 1)).serialize(.array([.null])) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .depthLimitExceeded) + } + XCTAssertThrowsError( + try OrderedJSONSerializer(limits: limits(nodes: 1)).serialize(.array([.null])) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .nodeLimitExceeded) + } + XCTAssertThrowsError( + try OrderedJSONSerializer(limits: limits(string: 1)).serialize(.string("é")) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .stringLimitExceeded) + } + XCTAssertThrowsError( + try OrderedJSONSerializer(limits: limits(container: 1)).serialize( + .array([.null, .null])) + ) { + XCTAssertEqual($0 as? OrderedJSONError, .containerLimitExceeded) + } + } + + private func assertError(_ source: String, equals expected: OrderedJSONError) { + XCTAssertThrowsError(try OrderedJSONParser().parse(Data(source.utf8))) { + XCTAssertEqual($0 as? OrderedJSONError, expected) + } + } + + private func limits( + body: Int = 1_048_576, + depth: Int = 32, + nodes: Int = 10_000, + string: Int = 65_536, + container: Int = 256 + ) -> OrderedJSONLimits { + OrderedJSONLimits( + maximumBodyBytes: body, + maximumDepth: depth, + maximumNodes: nodes, + maximumStringBytes: string, + maximumContainerEntries: container + ) + } +}