diff --git a/.changeset/json-parser-depth.md b/.changeset/json-parser-depth.md new file mode 100644 index 0000000000..1a3271c1e6 --- /dev/null +++ b/.changeset/json-parser-depth.md @@ -0,0 +1,5 @@ +--- +'zero-swift': patch +--- + +JSON is parsed, written, compared, and hashed (also as a custom query's arguments) without recursion, so deeply nested JSON no longer overflows the stack. Before, a debug build overflowed at about 300 levels on a thread with a 512 KB stack, such as a test or cooperative thread on macOS. The parser's depth limit is lowered from 5,000 to 1,500 levels, so freeing a value, which still recurses, fits in a 512 KB stack in a release build. JS has no such limit: a row whose JSON column is nested deeper than about 1,495 levels is rejected, as described in JSParity.md. A debug build can still overflow a 512 KB stack when it frees objects nested more than about 1,000 deep. diff --git a/swift/Sources/Zero/Hash/QueryHash.swift b/swift/Sources/Zero/Hash/QueryHash.swift index 0fdf1da4d4..a7de50217f 100644 --- a/swift/Sources/Zero/Hash/QueryHash.swift +++ b/swift/Sources/Zero/Hash/QueryHash.swift @@ -133,7 +133,64 @@ package enum QueryHash { extension QueryHash.State { typealias H = QueryHash + /// Visits `v` without recursion: the arrays and objects it is in are on + /// a stack, so nesting takes heap, not stack. mutating func visitValue(_ v: JSONValue) { + if visitScalar(v) { return } + // An array, or an object's entries in `Object.keys(v).sort()` order, + // and the index of the next value. + var containers: [(entries: Entries, next: Int)] = [] + var value = v + nested: while true { + switch value { + case .array(let a): + mix(H.tagArr) + containers.append((.array(a), 0)) + case .object(let o): + mix(H.tagObj) + // By UTF-16 code unit, whatever the order. + containers.append((.object(o.sorted { StringCompare.utf16Less($0.key, $1.key) }), 0)) + default: + preconditionFailure("not an array or object") + } + // Visits values until one is an array or object, ending the + // containers that are done. + while var container = containers.popLast() { + while container.next < container.entries.count { + let e: JSONValue + switch container.entries { + case .array(let a): + e = a[container.next] + case .object(let entries): + mixString(entries[container.next].key) + e = entries[container.next].value + } + container.next += 1 + if visitScalar(e) { continue } + containers.append(container) + value = e + continue nested + } + mix(H.tagEnd) + } + return + } + } + + enum Entries { + case array([JSONValue]) + case object([(key: String, value: JSONValue)]) + + var count: Int { + switch self { + case .array(let a): a.count + case .object(let o): o.count + } + } + } + + /// Visits `v` unless it is an array or object; returns whether it did. + private mutating func visitScalar(_ v: JSONValue) -> Bool { switch v { case .string(let s): mixString(s) @@ -141,27 +198,18 @@ extension QueryHash.State { // NaN and Infinity are null on the wire (JSON.stringify writes null). if !n.isFinite { mix(H.tagNull) - return + } else { + mix(JSNumber.isInt32(n) ? H.tagInt : H.tagFloat) + mixNumber(n) } - mix(JSNumber.isInt32(n) ? H.tagInt : H.tagFloat) - mixNumber(n) case .bool(let b): mix(b ? H.tagTrue : H.tagFalse) case .null: mix(H.tagNull) - case .array(let a): - mix(H.tagArr) - for e in a { visitValue(e) } - mix(H.tagEnd) - case .object(let o): - mix(H.tagObj) - // `Object.keys(v).sort()`: by UTF-16 code unit, whatever the order. - for (k, value) in o.sorted(by: { StringCompare.utf16Less($0.key, $1.key) }) { - mixString(k) - visitValue(value) - } - mix(H.tagEnd) + case .array, .object: + return false } + return true } mutating func visitOptionalString(_ s: String?) { diff --git a/swift/Sources/Zero/JSON/JSONObject.swift b/swift/Sources/Zero/JSON/JSONObject.swift index b3097e7a2f..1f163c5d13 100644 --- a/swift/Sources/Zero/JSON/JSONObject.swift +++ b/swift/Sources/Zero/JSON/JSONObject.swift @@ -403,33 +403,19 @@ extension JSONObject: ExpressibleByDictionaryLiteral { extension JSONObject: Hashable { /// The same keys, by code unit, with equal values, in any order. public static func == (a: JSONObject, b: JSONObject) -> Bool { - if a.storage === b.storage { return true } - guard a.count == b.count else { return false } - for i in 0.. Bool { storage === other.storage } + + func key(at i: Int) -> String { storage.key(at: i) } + + func value(at i: Int) -> JSONValue { storage.value(at: i) } + /// Independent of the order, as `==` is. public func hash(into hasher: inout Hasher) { - var acc = 0 - for (key, value) in self { - var h = Hasher() - h.combine(JSKey(key)) - h.combine(value) - acc ^= h.finalize() - } - hasher.combine(count) - hasher.combine(acc) + JSONValue.object(self).hash(into: &hasher) } } diff --git a/swift/Sources/Zero/JSON/JSONParser.swift b/swift/Sources/Zero/JSON/JSONParser.swift index 02707d93c0..2c8076eb7e 100644 --- a/swift/Sources/Zero/JSON/JSONParser.swift +++ b/swift/Sources/Zero/JSON/JSONParser.swift @@ -9,8 +9,14 @@ package struct JSONParser { package var description: String { "\(message) at offset \(offset)" } } - /// Nesting deeper than this is rejected instead of overflowing the stack. - package static let maxDepth = 5_000 + /// Nesting deeper than this is rejected with "JSON nested too deeply". + /// + /// Parsing, writing, comparing, and hashing don't recurse, but freeing a + /// value does (in `Array`'s and `JSONObject`'s storage), so the limit + /// keeps freeing within a 512 KB stack (a macOS test or cooperative + /// thread) in a release build. JS has no limit short of its own stack + /// (zero-cache writes about 4,100 levels); see JSParity.md. + package static let maxDepth = 1_500 package static func parse(_ text: String) throws(Error) -> JSONValue { var text = text @@ -30,7 +36,7 @@ package struct JSONParser { package static func parse(_ buffer: UnsafeBufferPointer) throws(Error) -> JSONValue { var p = JSONParser(buffer) p.skipWhitespace() - let value = try p.parseValue(depth: 0) + let value = try p.parseValue() p.skipWhitespace() if p.i != p.end { throw p.error("Unexpected non-whitespace character after JSON") } return value @@ -57,89 +63,128 @@ package struct JSONParser { } } - private mutating func parseValue(depth: Int) throws(Error) -> JSONValue { - guard i < end else { throw error("Unexpected end of JSON input") } - switch b[i] { - case UInt8(ascii: "{"): return try parseObject(depth: depth) - case UInt8(ascii: "["): return try parseArray(depth: depth) - case UInt8(ascii: "\""): return .string(try parseString()) - case UInt8(ascii: "t"): try expectLiteral("true"); return .bool(true) - case UInt8(ascii: "f"): try expectLiteral("false"); return .bool(false) - case UInt8(ascii: "n"): try expectLiteral("null"); return .null - case UInt8(ascii: "-"), UInt8(ascii: "0")...UInt8(ascii: "9"): return .number(try parseNumber()) - default: throw error("Unexpected token") - } + /// An array or object that a value being parsed is in. + private enum Container { + case array([JSONValue]) + /// An object and the key of the value being parsed. + case object(JSONObject, key: String) } - private mutating func expectLiteral(_ literal: StaticString) throws(Error) { - let n = literal.utf8CodeUnitCount - guard end - i >= n else { throw error("Unexpected end of JSON input") } - let p = literal.utf8Start - for k in 0.. JSONValue { - if depth >= Self.maxDepth { throw error("JSON nested too deeply") } - i += 1 // { - var object = JSONObject() - skipWhitespace() - if i < end && b[i] == UInt8(ascii: "}") { - i += 1 - return .object(object) - } + /// Parses a value without recursion, so nesting takes heap, not stack. + /// The innermost open array or object is `array` or `object` (with + /// `key`); the ones it is in are on `parents`. They move between the two + /// (`take()`), so each stays uniquely referenced and adding to it doesn't + /// copy it. + private mutating func parseValue() throws(Error) -> JSONValue { + var parents: [Container] = [] + var array: [JSONValue]? + var object: JSONObject? + var key = "" while true { - skipWhitespace() guard i < end else { throw error("Unexpected end of JSON input") } - guard b[i] == UInt8(ascii: "\"") else { throw error("Expected property name") } - let key = try parseString() - skipWhitespace() - guard i < end, b[i] == UInt8(ascii: ":") else { - throw error("Expected ':' after property name") - } - i += 1 - skipWhitespace() - object[key] = try parseValue(depth: depth + 1) - skipWhitespace() - guard i < end else { throw error("Unexpected end of JSON input") } - if b[i] == UInt8(ascii: ",") { + var value: JSONValue + switch b[i] { + case UInt8(ascii: "{"), UInt8(ascii: "["): + let isObject = b[i] == UInt8(ascii: "{") + if parents.count + (array == nil && object == nil ? 0 : 1) >= Self.maxDepth { + throw error("JSON nested too deeply") + } i += 1 + skipWhitespace() + if i < end && b[i] == (isObject ? UInt8(ascii: "}") : UInt8(ascii: "]")) { + i += 1 + value = isObject ? .object(JSONObject()) : .array([]) + break + } + if let a = array.take() { + parents.append(.array(a)) + } else if let o = object.take() { + parents.append(.object(o, key: key)) + } + if isObject { + object = JSONObject() + key = try parseKey() + } else { + array = [] + } continue + case UInt8(ascii: "\""): value = .string(try parseString()) + case UInt8(ascii: "t"): try expectLiteral("true"); value = .bool(true) + case UInt8(ascii: "f"): try expectLiteral("false"); value = .bool(false) + case UInt8(ascii: "n"): try expectLiteral("null"); value = .null + case UInt8(ascii: "-"), UInt8(ascii: "0")...UInt8(ascii: "9"): + value = .number(try parseNumber()) + default: throw error("Unexpected token") } - if b[i] == UInt8(ascii: "}") { - i += 1 - return .object(object) + // Adds the value to its container, closing the containers that end + // after it, until one has another value. + while true { + skipWhitespace() + if array != nil { + array!.append(value) + guard i < end else { throw error("Unexpected end of JSON input") } + if b[i] == UInt8(ascii: ",") { + i += 1 + skipWhitespace() + break + } + guard b[i] == UInt8(ascii: "]") else { + throw error("Expected ',' or ']' after array element") + } + i += 1 + value = .array(array.take()!) + } else if object != nil { + // A repeated key keeps its first place and its last value. + object![key] = value + guard i < end else { throw error("Unexpected end of JSON input") } + if b[i] == UInt8(ascii: ",") { + i += 1 + key = try parseKey() + break + } + guard b[i] == UInt8(ascii: "}") else { + throw error("Expected ',' or '}' after property value") + } + i += 1 + value = .object(object.take()!) + } else { + return value + } + guard let parent = parents.popLast() else { continue } + switch consume parent { + case .array(let a): + array = consume a + case .object(let o, let k): + object = consume o + key = consume k + } } - throw error("Expected ',' or '}' after property value") } } - private mutating func parseArray(depth: Int) throws(Error) -> JSONValue { - if depth >= Self.maxDepth { throw error("JSON nested too deeply") } - i += 1 // [ - var array: [JSONValue] = [] + /// A property name and its colon, and the whitespace around them. + private mutating func parseKey() throws(Error) -> String { skipWhitespace() - if i < end && b[i] == UInt8(ascii: "]") { - i += 1 - return .array(array) + guard i < end else { throw error("Unexpected end of JSON input") } + guard b[i] == UInt8(ascii: "\"") else { throw error("Expected property name") } + let key = try parseString() + skipWhitespace() + guard i < end, b[i] == UInt8(ascii: ":") else { + throw error("Expected ':' after property name") } - while true { - skipWhitespace() - array.append(try parseValue(depth: depth + 1)) - skipWhitespace() - guard i < end else { throw error("Unexpected end of JSON input") } - if b[i] == UInt8(ascii: ",") { - i += 1 - continue - } - if b[i] == UInt8(ascii: "]") { - i += 1 - return .array(array) - } - throw error("Expected ',' or ']' after array element") + i += 1 + skipWhitespace() + return key + } + + private mutating func expectLiteral(_ literal: StaticString) throws(Error) { + let n = literal.utf8CodeUnitCount + guard end - i >= n else { throw error("Unexpected end of JSON input") } + let p = literal.utf8Start + for k in 0.. String { diff --git a/swift/Sources/Zero/JSON/JSONSerializer.swift b/swift/Sources/Zero/JSON/JSONSerializer.swift index 8edd2fb3d8..96507b1d47 100644 --- a/swift/Sources/Zero/JSON/JSONSerializer.swift +++ b/swift/Sources/Zero/JSON/JSONSerializer.swift @@ -14,35 +14,73 @@ package enum JSONSerializer { Array(stringify(value).utf8) } + /// An array or object being written, and the index of its next value. + private enum Container { + case array([JSONValue], next: Int) + case object(JSONObject, next: Int) + } + + /// Writes `value` without recursion: the arrays and objects it is in are + /// on `containers`, so nesting takes heap, not stack. package static func write(_ value: JSONValue, into out: inout String) { - switch value { - case .null: - out += "null" - case .bool(let b): - out += b ? "true" : "false" - case .number(let n): - out += n.isFinite ? JSNumber.format(n) : "null" - case .string(let s): - writeString(s, into: &out) - case .array(let a): - out += "[" - for (i, e) in a.enumerated() { - if i > 0 { out += "," } - write(e, into: &out) + if writeScalar(value, into: &out) { return } + var containers: [Container] = [] + var value = value + nested: while true { + switch value { + case .array(let a): + out += "[" + containers.append(.array(a, next: 0)) + case .object(let o): + out += "{" + containers.append(.object(o, next: 0)) + default: + preconditionFailure("not an array or object") } - out += "]" - case .object(let o): - out += "{" - var first = true - for (key, value) in o { - if !first { out += "," } - first = false - writeString(key, into: &out) - out += ":" - write(value, into: &out) + // Writes values until one is an array or object, closing the + // containers that are done. + while let container = containers.popLast() { + switch container { + case .array(let a, var i): + while i < a.count { + if i > 0 { out += "," } + let e = a[i] + i += 1 + if writeScalar(e, into: &out) { continue } + containers.append(.array(a, next: i)) + value = e + continue nested + } + out += "]" + case .object(let o, var i): + while i < o.count { + if i > 0 { out += "," } + let (key, v) = o[i] + i += 1 + writeString(key, into: &out) + out += ":" + if writeScalar(v, into: &out) { continue } + containers.append(.object(o, next: i)) + value = v + continue nested + } + out += "}" + } } - out += "}" + return + } + } + + /// Writes `value` unless it is an array or object; returns whether it did. + private static func writeScalar(_ value: JSONValue, into out: inout String) -> Bool { + switch value { + case .null: out += "null" + case .bool(let b): out += b ? "true" : "false" + case .number(let n): out += n.isFinite ? JSNumber.format(n) : "null" + case .string(let s): writeString(s, into: &out) + case .array, .object: return false } + return true } static let hex: [Character] = Array("0123456789abcdef") diff --git a/swift/Sources/Zero/JSON/JSONValue.swift b/swift/Sources/Zero/JSON/JSONValue.swift index d8820e7f6c..0eeda686e4 100644 --- a/swift/Sources/Zero/JSON/JSONValue.swift +++ b/swift/Sources/Zero/JSON/JSONValue.swift @@ -20,19 +20,144 @@ package typealias Value = JSONValue extension JSONValue: Hashable { public static func == (lhs: JSONValue, rhs: JSONValue) -> Bool { + equal(lhs, rhs, depth: 0) + } + + /// How deep `==` recurses before it goes on with a stack of its own. + /// Recursion is faster, and this deep takes little of a thread's stack. + static let maxRecursion = 64 + + private static func equal(_ lhs: JSONValue, _ rhs: JSONValue, depth: Int) -> Bool { + switch (lhs, rhs) { + case (.null, .null): return true + case (.bool(let a), .bool(let b)): return a == b + case (.number(let a), .number(let b)): return a == b + case (.string(let a), .string(let b)): return StringCompare.equal(a, b) + case (.array(let a), .array(let b)): + guard a.count == b.count else { return false } + if depth >= maxRecursion { return deepEqual(lhs, rhs) } + return a.elementsEqual(b) { equal($0, $1, depth: depth + 1) } + case (.object(let a), .object(let b)): + if depth >= maxRecursion { return deepEqual(lhs, rhs) } + if a.hasSameStorage(as: b) { return true } + guard a.count == b.count else { return false } + for i in 0.. Bool { + var pending: [(JSONValue, JSONValue)] = [] + var pair = (lhs, rhs) + while true { + switch pair { + case (.array(let a), .array(let b)): + guard a.count == b.count else { return false } + for i in a.indices.reversed() { pending.append((a[i], b[i])) } + case (.object(let a), .object(let b)): + guard a.count == b.count else { return false } + for i in 0.. Bool { switch (lhs, rhs) { case (.null, .null): true case (.bool(let a), .bool(let b)): a == b case (.number(let a), .number(let b)): a == b case (.string(let a), .string(let b)): StringCompare.equal(a, b) - case (.array(let a), .array(let b)): a == b - case (.object(let a), .object(let b)): a == b default: false } } + /// Hashes without recursion, as `==` compares. An object's hash doesn't + /// depend on the order of its keys, as its equality doesn't. public func hash(into hasher: inout Hasher) { - switch self { + if Self.hashScalar(self, into: &hasher) { return } + // The hasher values go into: the caller's, then each object's hasher + // of the entry being hashed. + var hashers = [hasher] + defer { hasher = hashers[0] } + var frames: [HashFrame] = [] + var value = self + nested: while true { + switch value { + case .array(let a): + hashers[hashers.count - 1].combine(4) + hashers[hashers.count - 1].combine(a.count) + frames.append(.array(a, next: 0)) + case .object(let o): + frames.append(.object(o, next: 0, combined: 0, inEntry: false)) + default: + preconditionFailure("not an array or object") + } + // Hashes values until one is an array or object, finishing the + // containers that are done. + while let frame = frames.popLast() { + switch frame { + case .array(let a, var i): + while i < a.count { + let e = a[i] + i += 1 + if Self.hashScalar(e, into: &hashers[hashers.count - 1]) { continue } + frames.append(.array(a, next: i)) + value = e + continue nested + } + case .object(let o, var i, var combined, let inEntry): + // Each entry is hashed alone, and the entries combined in any order. + if inEntry { combined ^= hashers.removeLast().finalize() } + while i < o.count { + let (key, v) = o[i] + i += 1 + var h = Hasher() + h.combine(JSKey(key)) + if Self.hashScalar(v, into: &h) { + combined ^= h.finalize() + continue + } + hashers.append(h) + frames.append(.object(o, next: i, combined: combined, inEntry: true)) + value = v + continue nested + } + hashers[hashers.count - 1].combine(5) + hashers[hashers.count - 1].combine(o.count) + hashers[hashers.count - 1].combine(combined) + } + } + return + } + } + + /// Hashes `value` unless it is an array or object; returns whether it did. + private static func hashScalar(_ value: JSONValue, into hasher: inout Hasher) -> Bool { + switch value { case .null: hasher.combine(0) case .bool(let b): @@ -45,16 +170,21 @@ extension JSONValue: Hashable { hasher.combine(3) hasher.combine(s.utf8.count) for byte in s.utf8 { hasher.combine(byte) } - case .array(let a): - hasher.combine(4) - hasher.combine(a) - case .object(let o): - hasher.combine(5) - hasher.combine(o) + case .array, .object: + return false } + return true } } +/// An array or object being hashed, and the index of its next value. +private enum HashFrame { + case array([JSONValue], next: Int) + /// `combined` is the entries hashed so far; `inEntry`, whether the + /// hasher of an entry whose value is an array or object is on `hashers`. + case object(JSONObject, next: Int, combined: Int, inEntry: Bool) +} + extension JSONValue { public var isNull: Bool { if case .null = self { return true } diff --git a/swift/Sources/Zero/Zero.docc/JSParity.md b/swift/Sources/Zero/Zero.docc/JSParity.md index 2ea2bd8a13..49a285de07 100644 --- a/swift/Sources/Zero/Zero.docc/JSParity.md +++ b/swift/Sources/Zero/Zero.docc/JSParity.md @@ -114,6 +114,17 @@ three-argument form compares as JS compares `undefined`, so then the others in the order they were set. Keys are compared by UTF-16 code unit, so `"é"` and `"e\u{301}"` are two keys, as they are in JS. JSON text round-trips as `JSON.stringify` writes it. +- **JSON nesting depth.** JSON nested more than 1,500 arrays and objects + deep is rejected ("JSON nested too deeply"); JS accepts whatever fits its + stack, and zero-cache writes up to about 4,100 levels. A poke is about 5 + levels before a row's values, and real data, such as a rich-text document + in a JSON column, rarely passes a few hundred, but a row with a JSON + column nested deeper than about 1,495 levels makes the client reject the + message, disconnect, and fail the same way after it reconnects. Values + that deep are parsed, written, compared, and hashed without recursion. + Freeing one still recurses: in a release build 1,500 levels fit in a + 512 KB stack (a macOS test or cooperative thread), but a debug build + overflows one when it frees objects nested more than about 1,000 deep. - **Storage.** The replica is a SQLite database per user and URL, not IndexedDB. One process uses it at a time; there are no tabs to share it with. diff --git a/swift/Tests/ConformanceTests/SweepWireTests.swift b/swift/Tests/ConformanceTests/SweepWireTests.swift index 1b308c500b..5f52155a3d 100644 --- a/swift/Tests/ConformanceTests/SweepWireTests.swift +++ b/swift/Tests/ConformanceTests/SweepWireTests.swift @@ -36,29 +36,25 @@ struct SweepWireTests { /// `JSON.parse` acceptance, and `JSON.stringify` of what it parsed, keys /// in JS property order, for each input. - /// On an 8 MB stack, as `deepRowValue`: a vector nests 1,000 deep. - @Test func json() async throws { - let cases = try Vectors.cases("sweep-json") - try await onLargeStack { - for c in cases { - let input = c["input"]!.stringValue! - let ok = c["ok"]!.boolValue! - let value: JSONValue - do { - value = try JSONParser.parse(input) - } catch { - #expect(!ok, "rejected \(String(reflecting: input)): \(error)") - continue - } - #expect(ok, "accepted \(String(reflecting: input))") - guard ok else { continue } - let expected = c["stringify"]!.stringValue! - let actual = JSONSerializer.stringify(value) - #expect( - Self.same(actual, expected), - "\(String(reflecting: input)): Swift \(actual.debugDescription), JS \(expected.debugDescription)" - ) + @Test func json() throws { + for c in try Vectors.cases("sweep-json") { + let input = c["input"]!.stringValue! + let ok = c["ok"]!.boolValue! + let value: JSONValue + do { + value = try JSONParser.parse(input) + } catch { + #expect(!ok, "rejected \(String(reflecting: input)): \(error)") + continue } + #expect(ok, "accepted \(String(reflecting: input))") + guard ok else { continue } + let expected = c["stringify"]!.stringValue! + let actual = JSONSerializer.stringify(value) + #expect( + Self.same(actual, expected), + "\(String(reflecting: input)): Swift \(actual.debugDescription), JS \(expected.debugDescription)" + ) } } @@ -73,35 +69,51 @@ struct SweepWireTests { #expect(JSONValue.object(["\u{e9}": 1]) != JSONValue.object(["e\u{301}": 1])) } - /// A row value nested 4,000 deep, which `JSON.stringify` on zero-cache - /// writes (Node's default stack takes about 4,100 levels) and the parser - /// accepts (its limit is 5,000). Parsing, encoding, hashing, comparing, - /// and freeing it must not overflow the stack of the thread it is on. - /// It runs on a thread with an 8 MB stack, the size of the macOS main - /// thread's: test threads get 512 KB there, and the debug build's frames - /// are larger than release's. + /// A row value nested as deep as the parser accepts: the poke takes 5 + /// levels, the value the rest of `JSONParser.maxDepth`. Parsing, encoding, + /// hashing, and comparing it must not overflow the stack of the thread it + /// is on: here one with 512 KB, the size of a test or cooperative thread + /// on macOS. One level more is rejected. (zero-cache writes about 4,100 + /// levels; see JSParity.md.) @Test func deepRowValue() async throws { - try await onLargeStack { - let depth = 4_000 - let text = - #"["pokePart",{"pokeID":"p","rowsPatch":[{"op":"put","tableName":"t","value":{"id":"x","j":"# + func poke(_ depth: Int) -> String { + #"["pokePart",{"pokeID":"p","rowsPatch":[{"op":"put","tableName":"t","value":{"id":"x","j":"# + String(repeating: "[", count: depth) + String(repeating: "]", count: depth) + "}}]}]" + } + let text = poke(JSONParser.maxDepth - 5) + #expect(throws: (any Error).self) { try Downstream(text: poke(JSONParser.maxDepth - 4)) } + let messages = try await onThread(stackSize: 512 << 10) { let message = try Downstream(text: text) let encoded = JSONSerializer.stringify(message.json) - #expect(encoded.utf8.count == text.utf8.count) - #expect(message == (try Downstream(text: encoded))) + let decoded = try Downstream(text: encoded) var hasher = Hasher() message.hash(into: &hasher) _ = hasher.finalize() + return Messages([message, decoded], encodedLength: encoded.utf8.count) } + #expect(messages.encodedLength == text.utf8.count) + #expect(messages.all[0] == messages.all[1]) + // Freeing still recurses, in `Array`'s deinit, and fits as well. + try await onThread(stackSize: 512 << 10) { messages.all = [] } } - private func onLargeStack(_ body: @escaping @Sendable () throws -> Void) async throws { - try await withCheckedThrowingContinuation { (done: CheckedContinuation) in - let thread = Thread { - done.resume(with: Result { try body() }) - } - thread.stackSize = 8 << 20 + final class Messages: @unchecked Sendable { + var all: [Downstream] + let encodedLength: Int + + init(_ all: [Downstream], encodedLength: Int) { + self.all = all + self.encodedLength = encodedLength + } + } + + /// Runs `body` on a thread with a stack of `stackSize` bytes. + private func onThread( + stackSize: Int, _ body: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { (done: CheckedContinuation) in + let thread = Thread { done.resume(with: Result { try body() }) } + thread.stackSize = stackSize thread.start() } } diff --git a/swift/Tests/ZeroTests/JSONDepthTests.swift b/swift/Tests/ZeroTests/JSONDepthTests.swift new file mode 100644 index 0000000000..5a200d754f --- /dev/null +++ b/swift/Tests/ZeroTests/JSONDepthTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing + +@testable import Zero + +/// JSON nested as deep as the parser accepts doesn't overflow the stack of a +/// thread with 512 KB, the size of a test or cooperative thread on macOS +/// (the iPhone main thread has 1 MB). +struct JSONDepthTests { + @Test func deepestValuesOnSmallStack() async throws { + let depth = JSONParser.maxDepth + let array = String(repeating: "[", count: depth) + String(repeating: "]", count: depth) + let object = + String(repeating: #"{"a":"#, count: depth - 1) + "{}" + + String(repeating: "}", count: depth - 1) + // The same, but for the innermost value. + let otherArray = + String(repeating: "[", count: depth - 1) + "[0]" + String(repeating: "]", count: depth - 1) + let otherObject = + String(repeating: #"{"a":"#, count: depth - 1) + #"{"a":0}"# + + String(repeating: "}", count: depth - 1) + let deep = try await onThread(stackSize: 512 << 10) { + var values: [JSONValue] = [] + var results: [Bool] = [] + for (text, otherText) in [(array, otherArray), (object, otherObject)] { + let value = try JSONParser.parse(text) + let copy = try JSONParser.parse(text) + let other = try JSONParser.parse(otherText) + results.append(JSONSerializer.stringify(value) == text) + results.append(value == copy) + results.append(value != other) + results.append(value.hashValue == copy.hashValue) + results.append( + QueryHash.hashOfNameAndArgs("q", [value]) == QueryHash.hashOfNameAndArgs("q", [copy])) + values += [value, copy, other] + } + // One more level is rejected. + var errors: [JSONParser.Error?] = [] + for text in ["[" + array + "]", #"{"a":"# + object + "}"] { + do throws(JSONParser.Error) { + _ = try JSONParser.parse(text) + errors.append(nil) + } catch { + errors.append(error) + } + } + return Deep(values: values, results: results, errors: errors) + } + #expect(deep.results == Array(repeating: true, count: 10)) + #expect( + deep.errors == [ + JSONParser.Error(message: "JSON nested too deeply", offset: depth), + JSONParser.Error(message: "JSON nested too deeply", offset: depth * 5), + ]) + // Freeing still recurses, in `Array`'s and `JSONObject`'s deinit. That + // fits in 512 KB in a release build, but not for the object in a debug + // build (about 1,000 levels fit), so the values are freed on a larger + // stack. + try await onThread(stackSize: 8 << 20) { deep.values = [] } + } + + final class Deep: @unchecked Sendable { + var values: [JSONValue] + let results: [Bool] + let errors: [JSONParser.Error?] + + init(values: [JSONValue], results: [Bool], errors: [JSONParser.Error?]) { + self.values = values + self.results = results + self.errors = errors + } + } + + /// Runs `body` on a thread with a stack of `stackSize` bytes. + func onThread( + stackSize: Int, _ body: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { (done: CheckedContinuation) in + let thread = Thread { done.resume(with: Result { try body() }) } + thread.stackSize = stackSize + thread.start() + } + } +}