From f00452f13791768992f42a760800b8826d483f19 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:26:14 +0200 Subject: [PATCH 1/5] Add guarded cumulative scoped reads and native value conveniences --- .../Architecture/Release1.1/Reading.md | 52 ++++ Scripts/ci.py | 3 +- Scripts/qualify_release_1_1.py | 12 +- .../JSON/Coding/JSONCodingStrategies.swift | 17 +- .../JSON/Coding/JSONValueConveniences.swift | 28 ++ Sources/JSON/Coding/JSONValueDecoder.swift | 2 +- Sources/JSON/Coding/JSONValueEncoder.swift | 2 +- Sources/JSON/Diagnostics/JSONDiagnostic.swift | 1 + Sources/JSON/Document/JSONSourceMap.swift | 3 + Sources/JSON/Documentation.docc/JSON.md | 6 + .../JSON/Documentation.docc/ScopedReads.md | 39 +++ Sources/JSON/Errors/JSONAccessFailure.swift | 13 + Sources/JSON/Limits/JSONWorkMeter.swift | 36 ++- .../JSON/Number/JSONNumberConversions.swift | 6 + Sources/JSON/Reading/JSONReadCursor.swift | 291 ++++++++++++++++++ Sources/JSON/Reading/JSONReadScope.swift | 155 ++++++++++ .../Release1_1/ReadAccessTests.swift | 246 +++++++++++++++ .../PublicReaderConsumer.swift | 20 ++ Tools/Release1_1Probes/PublicTaskEscape.swift | 6 + .../Release1_1Probes/PublicTaskTransfer.swift | 6 + 20 files changed, 934 insertions(+), 10 deletions(-) create mode 100644 Documentation/Architecture/Release1.1/Reading.md create mode 100644 Sources/JSON/Coding/JSONValueConveniences.swift create mode 100644 Sources/JSON/Documentation.docc/ScopedReads.md create mode 100644 Sources/JSON/Errors/JSONAccessFailure.swift create mode 100644 Sources/JSON/Reading/JSONReadCursor.swift create mode 100644 Sources/JSON/Reading/JSONReadScope.swift create mode 100644 Tests/JSONTests/Release1_1/ReadAccessTests.swift create mode 100644 Tools/Release1_1Probes/PublicReaderConsumer.swift create mode 100644 Tools/Release1_1Probes/PublicTaskEscape.swift create mode 100644 Tools/Release1_1Probes/PublicTaskTransfer.swift diff --git a/Documentation/Architecture/Release1.1/Reading.md b/Documentation/Architecture/Release1.1/Reading.md new file mode 100644 index 0000000..173d029 --- /dev/null +++ b/Documentation/Architecture/Release1.1/Reading.md @@ -0,0 +1,52 @@ +# Scoped reads and native coding + +The additive public surface is JSON/JSONDocument `read(limits:_:)` and +`readWithReport(limits:_:)`, JSONReadCursor and JSONAccessFailure. Throwing read-only +String/JSONKey/Int brackets preserve typed selection. Required value/string/Boolean/ +number/object/array access never coerces, defaults or manufactures null. Integer +conversion is exact; Double/Decimal require an explicit `.exact` or `.rounded` +choice. Both key overloads provide raw/scalar/model typed presence. + +The supported compiler requires experimental features for the intended returned +nonescapable shape. The public implementation uses the qualified guarded fallback. +A handle owns only its bounded typed path and a synchronous non-Sendable scope. +Scope exit invalidates it and releases root/document/source/coding-state references. +A caller-thread guard runs before accessing mutable state; a cross-thread attempt +fails with configuration/invalidOperation. This introduces no actor isolation or +Sendable escape hatch. Public compiler probes and guarded misuse tests are required. + +One noncopyable core-domain meter admits the full root once. Navigation, equivalent +Pointer path text, overlapping path copies, text extraction, numeric work, source +lookup and native model projection use that ledger. Dead path ownership is refunded +at the next owned boundary; work is never refunded. Paths retain literal scalar keys +and integer indices; missing intermediate parents fail at their first missing hop. +Grouping calls amortizes root admission. It does not selectively parse the input or +make all deep navigation, root admission, edits or arbitrary caller work constant-time. + +Coding options temporarily select native strategies and userInfo while their limits +permanently tighten the scope's ceilings. Prior cumulative usage and already admitted +capacities must fit that restriction; live retention is checked against current live +ownership. No state/meter copy or new decoder facade resets the budget. Numeric +temporary storage is released after primitive projection. Returned values and caller +model computation retain the ordinary caller-work and retention boundaries. + +Resource/cancellation failure is latched at the owned meter/scope. Even a caller +Decodable that catches an owned conversion refusal cannot certify successful scope +or native facade completion. Ordinary access/type/conversion errors can be caught +explicitly. JSONAccessFailure holds a complete bounded typed path and structured +cause, with no rejected value or document. Mapped missing members use their parent +span; replaced/unmapped roots have no invented location. Diagnostic classification +uses the cause and never renders arbitrary caller descriptions. + +JSON `init(encoding:coding:)`, `decode(_:coding:)` and `decodeWithReport(_:coding:)` +delegate to the existing native facades. Natural foreign JSON Codable keeps its +representation and fidelity limitations. Exact persistence uses a separate explicit +Version 1 wrapper. No new kind, dictionary authority, defaulting, dynamic-member +lookup, mutation bracket or projection parser is introduced. + +The new modern-date compatibility case exposed an existing ISO-8601 strategy bug: +the formatter selected only time fields. Both native writer and reader now explicitly +select year/month/day plus fractional UTC time. Baseline epoch-only tests could not +detect losing the calendar date. Record this as a real affected coding qualification +failure and refresh its native/safety/consumer/DocC evidence; do not redefine it as +permitted rounding or silently reduce the assertion. diff --git a/Scripts/ci.py b/Scripts/ci.py index b44a6c9..a609790 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -23,6 +23,7 @@ def commands(lane, output): ['swift', 'build', '-c', 'release'], ['swift', 'test'], ['swift', 'test', '-c', 'release'], ['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'ownership', '--output', str(output / 'ownership.json')], + ['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'reader', '--output', str(output / 'reader.json')], ['swift', 'run', '-c', 'release', '--package-path', 'Examples/Consumer', 'Consumer'], ['swift', 'test', '--package-path', 'Tools/MCPReadiness'], ['swift', 'run', '-c', 'release', '--package-path', 'Tools/MCPReadiness', 'MCPReadiness', str(output / 'consumer.json')], @@ -68,7 +69,7 @@ def check_result(command, code, log): counts = [int(n) for n in re.findall(r'Test run with (\d+) tests? in[^\n]*passed', log)] passed = code == 0 if command[:2] == ['swift', 'test']: - expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command else [1] if 'Tools/Qualification' in command else [1, 1, 1] if 'thread' in command else [8, 11, 12, 37, 67] + expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command else [1] if 'Tools/Qualification' in command else [1, 1, 1] if 'thread' in command else [9, 11, 12, 37, 77] passed = passed and sorted(counts) == expected if 'Tools/Qualification' in command: probes = re.findall(r'^CAP_PROBE (\w+) (\S+)$', log, re.MULTILINE) diff --git a/Scripts/qualify_release_1_1.py b/Scripts/qualify_release_1_1.py index 696fc01..2022260 100644 --- a/Scripts/qualify_release_1_1.py +++ b/Scripts/qualify_release_1_1.py @@ -41,7 +41,7 @@ def verify_index_samples(samples): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--mode', choices=['ownership', 'sdk', 'mobile', 'index'], required=True) + parser.add_argument('--mode', choices=['ownership', 'reader', 'sdk', 'mobile', 'index'], required=True) parser.add_argument('--output', default='.build/ci/r0/probe-receipt.json') args = parser.parse_args() output = ROOT / args.output @@ -94,6 +94,16 @@ def run(name, command, expected=0, diagnostic=None): run('lifetime-feature-boundary', swift + ['-typecheck', str(PROBES / 'LifetimeAnnotated.swift')], 1, "'@_lifetime' attribute is only valid when experimental feature Lifetimes is enabled") receipt['positive_commands'] = 3; receipt['negative_compiler_cases'] = 4 + elif args.mode == 'reader': + binary = directory / 'public-reader-consumer' + imports = ['-I', str(products)] + run('public-consumer-compile', swift + imports + ['-L', str(products), '-lJSON', str(PROBES / 'PublicReaderConsumer.swift'), '-o', str(binary)]) + result = run('public-consumer-run', [str(binary)]) + if 'PUBLIC_READER_CONSUMER passed' not in result: raise RuntimeError('Missing public reader outcomes') + run('public-sendable-negative', swift + imports + ['-typecheck', str(PROBES / 'PublicTaskEscape.swift')], 1, "capture of 'cursor' with non-Sendable type") + run('public-task-transfer-negative', swift + imports + ['-c', str(PROBES / 'PublicTaskTransfer.swift'), '-o', str(directory / 'illegal-public.o')], 1, + "passing closure as a 'sending' parameter risks causing data races") + receipt['positive_commands'] = 2; receipt['negative_compiler_cases'] = 2 elif args.mode == 'sdk': for sdk, target in [('macosx', 'arm64-apple-macosx27.0'), ('iphoneos', 'arm64-apple-ios27.0'), ('iphonesimulator', 'arm64-apple-ios27.0-simulator')]: path = run(sdk + '-path', ['xcrun', '--sdk', sdk, '--show-sdk-path']).strip() diff --git a/Sources/JSON/Coding/JSONCodingStrategies.swift b/Sources/JSON/Coding/JSONCodingStrategies.swift index d65478c..17a4fd0 100644 --- a/Sources/JSON/Coding/JSONCodingStrategies.swift +++ b/Sources/JSON/Coding/JSONCodingStrategies.swift @@ -43,10 +43,11 @@ public struct JSONCodingOptions: Sendable { } package final class JSONCodingState { package var meter: JSONWorkMeter - package let options: JSONCodingOptions + package private(set) var options: JSONCodingOptions package var failure: (any Error)? package private(set) var bridgedUserInfo: [CodingUserInfoKey: Any] = [:] private var prepared = false + private var preparedBytes: UInt64 = 0 package init(options: JSONCodingOptions, meter: consuming JSONWorkMeter) { self.options = options; self.meter = meter } @@ -55,13 +56,27 @@ package final class JSONCodingState { if !prepared { try meter.require(.objectMembers, UInt64(options.userInfo.count)) for (key, value) in options.userInfo { + let before = meter.used(.retainedBytes) try JSONAdmission.text(key.rawValue, key: true, meter: &meter, owned: true) try meter.work(); try meter.retain(JSONLogicalWeights.member) bridgedUserInfo[key] = value + preparedBytes += meter.used(.retainedBytes) - before } prepared = true } } + package func withOptions(_ requested: JSONCodingOptions, _ body: () throws -> T) throws -> T { + try meter.restrict(to: requested.limits) + let oldOptions = options, oldInfo = bridgedUserInfo, oldPrepared = prepared, oldBytes = preparedBytes + options = requested; bridgedUserInfo = [:]; prepared = false; preparedBytes = 0 + func restore() throws { + try meter.release(preparedBytes) + options = oldOptions; bridgedUserInfo = oldInfo; prepared = oldPrepared; preparedBytes = oldBytes + } + do { + try check(); let result = try body(); try check(); try restore(); return result + } catch { try restore(); throw error } + } package func latch(_ action: () throws -> Void) { do { try action() } catch { if failure == nil { failure = error } } } package func path(_ path: [any CodingKey], appending key: any CodingKey) throws -> [any CodingKey] { try check(); try meter.require(.maxDepth, UInt64(path.count) + 1) diff --git a/Sources/JSON/Coding/JSONValueConveniences.swift b/Sources/JSON/Coding/JSONValueConveniences.swift new file mode 100644 index 0000000..56b66ab --- /dev/null +++ b/Sources/JSON/Coding/JSONValueConveniences.swift @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +extension JSON { + /// Encodes a Swift model directly through the existing native value coder. + /// No Foundation serialization intermediate or persistence envelope is introduced. + /// - Parameters: + /// - value: The Encodable model; caller callback errors propagate. + /// - coding: Native Date/Data strategies, Sendable context and finite limits. + /// - Throws: Native/caller coding errors, resource/configuration failure or cancellation. + public init(encoding value: T, coding: JSONCodingOptions = .init()) throws { + self = try JSONValueEncoder(options: coding).encode(value) + } + /// Decodes a Swift model with the existing native strategies and exact integers. + /// - Parameters: + /// - type: The target Decodable model. + /// - coding: Native strategies, Sendable context and finite limits. + /// - Throws: Native/caller coding errors, resource/configuration failure or cancellation. + public func decode(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> T { + try JSONValueDecoder(options: coding).decode(type, from: self) + } + /// Decodes a Swift model and reports successful owned native coding work. + /// - Parameters: + /// - type: The target Decodable model. + /// - coding: Native strategies, Sendable context and finite limits. + /// - Throws: Native/caller coding errors, resource/configuration failure or cancellation. + public func decodeWithReport(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> JSONOperationReport { + try JSONValueDecoder(options: coding).decodeWithReport(type, from: self) + } +} diff --git a/Sources/JSON/Coding/JSONValueDecoder.swift b/Sources/JSON/Coding/JSONValueDecoder.swift index cabbe2f..323ff74 100644 --- a/Sources/JSON/Coding/JSONValueDecoder.swift +++ b/Sources/JSON/Coding/JSONValueDecoder.swift @@ -87,7 +87,7 @@ package final class JSONNativeDecoder: Decoder { guard length <= 128 else { throw DecodingError.dataCorrupted(.init(codingPath: codingPath,debugDescription: "ISO-8601 text exceeds fixed format capacity")) } } try state.meter.checkpoint() - let strategy = Date.ISO8601FormatStyle().timeZone(separator: .colon).time(includingFractionalSeconds: true) + let strategy = Date.ISO8601FormatStyle().year().month().day().timeZone(separator: .colon).time(includingFractionalSeconds: true) guard let date = try? strategy.parse(string) else { throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "Invalid ISO-8601 date")) } try state.meter.checkpoint(); return date as! T } diff --git a/Sources/JSON/Coding/JSONValueEncoder.swift b/Sources/JSON/Coding/JSONValueEncoder.swift index 412da71..c215974 100644 --- a/Sources/JSON/Coding/JSONValueEncoder.swift +++ b/Sources/JSON/Coding/JSONValueEncoder.swift @@ -159,7 +159,7 @@ package final class JSONNativeEncoder: Encoder { throw EncodingError.invalidValue(date,.init(codingPath: codingPath,debugDescription: "ISO-8601 year must be 0000...9999")) } try state.meter.retain(128); try state.meter.checkpoint() - let text = date.ISO8601Format(.iso8601.timeZone(separator: .colon).time(includingFractionalSeconds: true)) + let text = date.ISO8601Format(.iso8601.year().month().day().timeZone(separator: .colon).time(includingFractionalSeconds: true)) try state.meter.checkpoint(); try set(.string(text)); try state.meter.release(128) } } else { try value.encode(to: self) } diff --git a/Sources/JSON/Diagnostics/JSONDiagnostic.swift b/Sources/JSON/Diagnostics/JSONDiagnostic.swift index 4385b7d..38a5acf 100644 --- a/Sources/JSON/Diagnostics/JSONDiagnostic.swift +++ b/Sources/JSON/Diagnostics/JSONDiagnostic.swift @@ -22,6 +22,7 @@ public struct JSONDiagnostic: Sendable, Equatable { /// - error: The error to classify without rendering its arbitrary description. public init(_ error: any Error) { if let error = error as? JSONFailure { kind = .failure; failure = error } + else if let error = error as? JSONAccessFailure { kind = .failure; failure = error.cause } else if error is CancellationError { kind = .cancellation; failure = nil } else { kind = .callerError; failure = nil } } diff --git a/Sources/JSON/Document/JSONSourceMap.swift b/Sources/JSON/Document/JSONSourceMap.swift index 2c1d783..3df9aac 100644 --- a/Sources/JSON/Document/JSONSourceMap.swift +++ b/Sources/JSON/Document/JSONSourceMap.swift @@ -39,6 +39,9 @@ public struct JSONSourceMap: Sendable { /// - limits: Finite inclusive capacities and work quotas for this call. public func span(at path: [JSONLocationComponent], kind: Entry.Kind = .value, limits: JSONLimits = .balanced) throws -> JSONSourceSpan? { var meter = try JSONWorkMeter(limits: limits, domain: .core) + return try span(at: path, kind: kind, meter: &meter) + } + package func span(at path: [JSONLocationComponent], kind: Entry.Kind = .value, meter: inout JSONWorkMeter) throws -> JSONSourceSpan? { for entry in entries { try meter.work() if entry.kind != kind || entry.path.count != path.count { continue } diff --git a/Sources/JSON/Documentation.docc/JSON.md b/Sources/JSON/Documentation.docc/JSON.md index 20ae563..f5b82b5 100644 --- a/Sources/JSON/Documentation.docc/JSON.md +++ b/Sources/JSON/Documentation.docc/JSON.md @@ -83,3 +83,9 @@ belongs exclusively to each invocation. - ``JSONResourceMetric`` - ``JSONOperationStatistics`` - ``JSONOperationReport`` + +### Scoped reads + +- +- ``JSONReadCursor`` +- ``JSONAccessFailure`` diff --git a/Sources/JSON/Documentation.docc/ScopedReads.md b/Sources/JSON/Documentation.docc/ScopedReads.md new file mode 100644 index 0000000..e0783e6 --- /dev/null +++ b/Sources/JSON/Documentation.docc/ScopedReads.md @@ -0,0 +1,39 @@ +# Navigate and decode within one read scope + +Group related field reads under one finite cumulative budget. + +```swift +import JSON + +let response = try JSONParser().parse( + #"{"users":[{"name":"Ada","age":9007199254740993,"nickname":null}]}"#) +let report = try response.readWithReport { root in + let user = try root["users"][0] + let name = try user["name"].requiredString() + let age = try user["age"].requiredInteger(as: UInt64.self) + return (name, age, try user.stringPresence(of: "nickname")) +} +``` + +``JSONReadCursor`` brackets require a present member or valid array element. An +integer bracket never means a numeric object key. Keys are literal decoded scalar +sequences: slash, tilde, empty and dotted keys need no special bracket syntax. +Use parent presence to distinguish absence, explicit null and a typed non-null +value. Wrong kinds and incompatible models throw; they do not select a default. + +Required integer conversion preserves values above 2^53 and checks the target range. +For floating point, choose the fidelity explicitly: decimal 0.1 rejects +`requiredDouble(policy: .exact)` and permits finite `.rounded` conversion. +Native model projection uses `decode(_:coding:)`; native Float/Double models keep +their existing finite-rounding semantics. + +Root admission runs once. Owned path/copy/search/conversion/model work shares one +ledger, including prior work when coding limits tighten it. The report's core domain +and numeric counters describe owned operations, not caller instructions or RSS. +Catching resource failure or cancellation inside the callback cannot produce success. + +Cursors are synchronous and non-Sendable. Escaped handles become unusable, and the +scope releases its root and source references. Return ordinary values or models from +the callback. Later direct collection operations follow their existing contracts. +``JSONAccessFailure`` describes ordinary failures with a complete bounded typed path; +``JSONDocument`` reads can add coherent source locations. Replaced roots have none. diff --git a/Sources/JSON/Errors/JSONAccessFailure.swift b/Sources/JSON/Errors/JSONAccessFailure.swift new file mode 100644 index 0000000..b2ceed8 --- /dev/null +++ b/Sources/JSON/Errors/JSONAccessFailure.swift @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +/// An ordinary read failure with a complete bounded typed path and no JSON payload. +/// +/// Resource failures, limit configuration errors and cancellation keep their own +/// types. A mapped read can attach the selected value or missing member's parent +/// location to the cause. Replaced/unmapped documents never invent a location. +public struct JSONAccessFailure: Error, Sendable { + /// The stable navigation, kind or conversion failure, with an optional source location. + public let cause: JSONFailure + /// The exact attempted key/index path, including the failing hop. + public let path: [JSONLocationComponent] + package init(cause: JSONFailure, path: [JSONLocationComponent]) { self.cause = cause; self.path = path } +} diff --git a/Sources/JSON/Limits/JSONWorkMeter.swift b/Sources/JSON/Limits/JSONWorkMeter.swift index 2366971..d4b81a7 100644 --- a/Sources/JSON/Limits/JSONWorkMeter.swift +++ b/Sources/JSON/Limits/JSONWorkMeter.swift @@ -1,7 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 /// Noncopyable so a nested helper cannot accidentally fork an invocation's budget. +private final class JSONMeterHealth { + var failure: (any Error)? + var observed = Array(repeating: UInt64(0), count: JSONResourceMetric.allCases.count) +} package struct JSONWorkMeter: ~Copyable { - package let limits: JSONLimits + package private(set) var limits: JSONLimits + private let health = JSONMeterHealth() package let domain: JSONOperationDomain private var counters: [UInt64] private var retired: [UInt64] @@ -21,12 +26,19 @@ package struct JSONWorkMeter: ~Copyable { retired = Array(repeating: 0, count: JSONResourceMetric.allCases.count) } package func checkpoint() throws { - checkpointObserver?(used(.workUnits)) - try Task.checkCancellation() + if let failure = health.failure { throw failure } + do { + checkpointObserver?(used(.workUnits)) + try Task.checkCancellation() + } catch { health.failure = error; throw error } } package func used(_ metric: JSONResourceMetric) -> UInt64 { counters[metric.index] } package func require(_ metric: JSONResourceMetric, _ attempted: UInt64) throws { - guard attempted <= limits[metric] else { throw violation(metric, attempted) } + if let failure = health.failure { throw failure } + guard attempted <= limits[metric] else { + let failure = violation(metric, attempted); health.failure = failure; throw failure + } + health.observed[metric.index] = max(health.observed[metric.index], attempted) } private func violation(_ metric: JSONResourceMetric, _ attempted: UInt64?, overflow: Bool = false) -> JSONFailure { JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, @@ -35,7 +47,9 @@ package struct JSONWorkMeter: ~Copyable { } package func adding(_ metric: JSONResourceMetric, _ current: UInt64, _ amount: UInt64) throws -> UInt64 { let (next, overflow) = current.addingReportingOverflow(amount) - guard !overflow else { throw violation(metric, nil, overflow: true) } + guard !overflow else { + let failure = violation(metric, nil, overflow: true); health.failure = failure; throw failure + } try require(metric, next) return next } @@ -83,6 +97,18 @@ package struct JSONWorkMeter: ~Copyable { if let plan { counters[JSONResourceMetric.compiledPlanBytes.index] = plan } counters[JSONResourceMetric.retainedBytes.index] = retained; peak = max(peak,retained) } + /// Tightening keeps the same ledger. Earlier accepted capacities and consumed + /// work must fit the new ceiling; failures stay terminal even if caught. + package mutating func restrict(to requested: JSONLimits) throws { + try checkpoint() + let restricted = try limits.intersection(requested) + limits = restricted + for metric in JSONResourceMetric.allCases where metric != .cancellationQuantum { + let live = metric == .retainedBytes || metric == .regexRetainedBytes || metric == .annotationBytes || metric == .compiledPlanBytes + try require(metric, live ? used(metric) : max(used(metric), health.observed[metric.index])) + } + sinceCancellationCheck = 0 + } /// Only live ownership is refunded; work and lifetime admission remain monotonic. package mutating func release(_ bytes: UInt64) throws { guard bytes <= used(.retainedBytes) else { throw JSONFailure(.configuration, code: .invalidLimits) } diff --git a/Sources/JSON/Number/JSONNumberConversions.swift b/Sources/JSON/Number/JSONNumberConversions.swift index 0990214..46e6a3e 100644 --- a/Sources/JSON/Number/JSONNumberConversions.swift +++ b/Sources/JSON/Number/JSONNumberConversions.swift @@ -147,6 +147,9 @@ extension JSONNumber { /// - limits: Finite inclusive capacities and work quotas for this call. public func doubleValue(policy: JSONNumberConversionPolicy = .exact, limits: JSONLimits = .balanced) throws -> Double { var meter = try JSONWorkMeter(limits: limits, domain: .core) + return try doubleValue(policy: policy, meter: &meter) + } + package func doubleValue(policy: JSONNumberConversionPolicy, meter: inout JSONWorkMeter) throws -> Double { let text = try binaryConversionText(meter: &meter) try meter.checkpoint() guard let value = Double(text), value.isFinite else { throw JSONFailure(.conversion, code: .inexactConversion) } @@ -170,6 +173,9 @@ extension JSONNumber { /// - limits: Finite inclusive capacities and work quotas for this call. public func decimalValue(policy: JSONNumberConversionPolicy = .exact, limits: JSONLimits = .balanced) throws -> Decimal { var meter = try JSONWorkMeter(limits: limits, domain: .core) + return try decimalValue(policy: policy, meter: &meter) + } + package func decimalValue(policy: JSONNumberConversionPolicy, meter: inout JSONWorkMeter) throws -> Decimal { let c = components guard c.digits.count <= 39, c.exponent >= -128, c.exponent <= 165 else { throw JSONFailure(.conversion, code: .inexactConversion) diff --git a/Sources/JSON/Reading/JSONReadCursor.swift b/Sources/JSON/Reading/JSONReadCursor.swift new file mode 100644 index 0000000..b494f2f --- /dev/null +++ b/Sources/JSON/Reading/JSONReadCursor.swift @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +/// Synchronous typed access to one admitted immutable root under a shared budget. +/// +/// A cursor is deliberately not Sendable. Escaped handles become unusable when +/// their read scope exits; they retain no selected value or source snapshot. +/// Ordinary failures carry JSONAccessFailure's complete typed path. Resource +/// failure and cancellation are terminal even if caught inside the callback. +public struct JSONReadCursor { + private let scope: JSONReadScope + private let selection: JSONReadPath + private var path: [JSONLocationComponent] { selection.components } + package init(scope: JSONReadScope, selection: JSONReadPath) { self.scope = scope; self.selection = selection } + /// Requires an exactly matching object member; keys are literal decoded scalars. + /// - Parameter key: The member name; empty, slash, tilde and dotted keys are literal. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public subscript(_ key: String) -> Self { + get throws { + try scope.run(path) { state in + try JSONAdmission.text(key, key: true, meter: &state.meter) + return try child(.key(JSONKey(key, meter: &state.meter)), state: state) + } + } + } + /// Requires an exactly matching object member using an existing scalar key. + /// - Parameter key: The exact member key, without canonical normalization. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public subscript(_ key: JSONKey) -> Self { + get throws { + try scope.run(path) { state in + try JSONAdmission.text(key.string, key: true, meter: &state.meter) + return try child(.key(key), state: state) + } + } + } + /// Requires an array and a nonnegative in-range element; numeric keys stay keys. + /// - Parameter index: The zero-based element index, without negative indexing. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public subscript(_ index: Int) -> Self { + get throws { try scope.run(path) { try child(.index(index), state: $0) } } + } + private func child(_ part: JSONLocationComponent, state: JSONCodingState) throws -> Self { + let attempted = try scope.append(part, to: path, state: state) + return try scope.run(attempted.components) { state in + _ = try scope.select(attempted.components, state: state) + return Self(scope: scope, selection: attempted) + } + } + private func selected(_ body: (JSON, JSONCodingState) throws -> T) throws -> T { + try scope.run(path) { state in try body(scope.select(path, state: state), state) } + } + /// Returns the selected value, including JSON null, without coercion. + /// Subsequent caller collection operations retain their ordinary accounting contracts. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public func requiredValue() throws -> JSON { try selected { value, state in try state.meter.work(); return value } } + /// Requires a JSON string and admits its returned text under the active meter. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredString() throws -> String { + try selected { value, state in + guard case .string(let text) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + try JSONAdmission.text(text, meter: &state.meter, owned: true) + try state.meter.release(state.meter.used(.retainedBytes) - before) + return text + } + } + /// Requires a JSON Boolean; numbers and strings are not coerced. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredBoolean() throws -> Bool { + try selected { value, _ in + guard case .boolean(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an exact JSON number, retaining its admitted original lexeme. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredNumber() throws -> JSONNumber { + try selected { value, _ in + guard case .number(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an ordered exact-key object; later direct lookup is caller work. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredObject() throws -> JSONObject { + try selected { value, _ in + guard case .object(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an array; later direct collection traversal is caller work. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredArray() throws -> [JSON] { + try selected { value, _ in + guard case .array(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an integral exact number within the target's signed/unsigned range. + /// - Parameter type: The requested fixed-width integer type. + /// - Throws: Kind/conversion access failure, resource/configuration failure or cancellation. + public func requiredInteger(as type: T.Type) throws -> T { + try selected { value, state in + guard case .number(let number) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + return try number.integer(as: type, meter: &state.meter) + } + } + /// Projects an exact number to a finite Double with an explicit fidelity choice. + /// Decimal 0.1 rejects `.exact`; `.rounded` permits finite approximation. + /// - Parameter policy: Mathematical equality or explicitly permitted finite rounding. + /// - Throws: Kind/conversion access failure, resource/configuration failure or cancellation. + public func requiredDouble(policy: JSONNumberConversionPolicy) throws -> Double { + try selected { value, state in + guard case .number(let number) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + do { + let result = try number.doubleValue(policy: policy, meter: &state.meter) + try state.meter.release(state.meter.used(.retainedBytes) - before); return result + } catch { try state.meter.release(state.meter.used(.retainedBytes) - before); throw error } + } + } + /// Projects an exact number to finite Decimal with an explicit fidelity choice. + /// - Parameter policy: Mathematical equality or explicitly permitted finite rounding. + /// - Throws: Kind/conversion access failure, resource/configuration failure or cancellation. + public func requiredDecimal(policy: JSONNumberConversionPolicy) throws -> Decimal { + try selected { value, state in + guard case .number(let number) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + do { + let result = try number.decimalValue(policy: policy, meter: &state.meter) + try state.meter.release(state.meter.used(.retainedBytes) - before); return result + } catch { try state.meter.release(state.meter.used(.retainedBytes) - before); throw error } + } + } + private func field(_ key: JSONKey, _ body: (JSON, JSONCodingState) throws -> T) throws -> JSONPresence { + try scope.run(path) { state in + try JSONAdmission.text(key.string, key: true, meter: &state.meter) + let attempted = try scope.append(.key(key), to: path, state: state) + return try scope.run(attempted.components) { state in + guard case .object(let object) = try scope.select(path, state: state) else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + guard let value = try object.lookup(key.string, meter: &state.meter) else { return .absent } + if value.isNull { return .null } + return .value(try body(value, state)) + } + } + } + private func admitted(_ key: String, _ body: (JSONKey) throws -> JSONPresence) throws -> JSONPresence { + try scope.run(path) { state in + try JSONAdmission.text(key, key: true, meter: &state.meter) + return try body(JSONKey(key, meter: &state.meter)) + } + } + /// Distinguishes absence, explicit null and an existing member's exact value. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func presence(of key: JSONKey) throws -> JSONPresence { try field(key) { value, state in try state.meter.work(); return value } } + /// Distinguishes absence, explicit null and an existing member's exact value. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func presence(of key: String) throws -> JSONPresence { try admitted(key) { try presence(of: $0) } } + + /// Distinguishes absence and null while requiring a non-null JSON string. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func stringPresence(of key: JSONKey) throws -> JSONPresence { + try field(key) { value, state in + guard case .string(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + try JSONAdmission.text(result, meter: &state.meter, owned: true) + try state.meter.release(state.meter.used(.retainedBytes) - before) + return result + } + } + /// Distinguishes absence and null while requiring a non-null JSON string. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func stringPresence(of key: String) throws -> JSONPresence { try admitted(key) { try stringPresence(of: $0) } } + + /// Distinguishes absence and null while requiring a non-null JSON boolean. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func booleanPresence(of key: JSONKey) throws -> JSONPresence { + try field(key) { value, state in + guard case .boolean(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + try state.meter.work() + return result + } + } + /// Distinguishes absence and null while requiring a non-null JSON boolean. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func booleanPresence(of key: String) throws -> JSONPresence { try admitted(key) { try booleanPresence(of: $0) } } + + /// Distinguishes absence and null while requiring a non-null JSON number. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func numberPresence(of key: JSONKey) throws -> JSONPresence { + try field(key) { value, state in + guard case .number(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + try state.meter.work() + return result + } + } + /// Distinguishes absence and null while requiring a non-null JSON number. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func numberPresence(of key: String) throws -> JSONPresence { try admitted(key) { try numberPresence(of: $0) } } + private func project(_ type: T.Type, value: JSON, coding: JSONCodingOptions, state: JSONCodingState) throws -> T { + let before = state.meter.used(.retainedBytes) + do { + let result = try state.withOptions(coding) { try JSONNativeDecoder(value: value, state: state, codingPath: []).decode(type) } + try state.meter.release(state.meter.used(.retainedBytes) - before) + return result + } catch { try state.meter.release(state.meter.used(.retainedBytes) - before); throw error } + } + /// Projects the selected value through the existing native decoder and ledger. + /// Coding ceilings permanently tighten this scope against prior usage. Caller + /// Decodable computation remains caller work; its errors propagate. + /// - Parameters: + /// - type: The target Decodable model. + /// - coding: Native strategies/context and intersected finite limits. + /// - Throws: Native/caller decoding errors, resource/configuration failure or cancellation. + public func decode(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> T { + try selected { value, state in + try project(type, value: value, coding: coding, state: state) + } + } + /// Decodes an existing non-null member, without calling the model for absence/null. + /// - Parameters: + /// - key: The exact decoded-scalar member key. + /// - type: The target Decodable model. + /// - coding: Native strategies/context and intersected finite limits. + /// - Throws: Native/caller decoding errors, resource/configuration failure or cancellation. + public func presence(of key: JSONKey, as type: T.Type, coding: JSONCodingOptions = .init()) throws -> JSONPresence { + try field(key) { value, state in + try project(type, value: value, coding: coding, state: state) + } + } + /// Decodes an existing non-null member, without calling the model for absence/null. + /// - Parameters: + /// - key: The literal decoded-scalar member name. + /// - type: The target Decodable model. + /// - coding: Native strategies/context and intersected finite limits. + /// - Throws: Native/caller decoding errors, resource/configuration failure or cancellation. + public func presence(of key: String, as type: T.Type, coding: JSONCodingOptions = .init()) throws -> JSONPresence { + try scope.run(path) { state in + try JSONAdmission.text(key, key: true, meter: &state.meter) + return try presence(of: JSONKey(key, meter: &state.meter), as: type, coding: coding) + } + } +} + +extension JSON { + /// Runs related typed reads with one root admission and cumulative finite budget. + /// The synchronous callback receives a non-Sendable, scope-bound cursor. + /// - Parameters: + /// - limits: Inclusive root, path, work, numeric and logical retention ceilings. + /// - body: Caller computation; returned payloads retain ordinary value semantics. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func read(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> Result { + try readWithReport(limits: limits, body).result + } + /// Runs related reads and reports all successful owned work in the core domain. + /// Catching terminal failure in the callback cannot produce a successful report. + /// - Parameters: + /// - limits: Inclusive root, path, work, numeric and logical retention ceilings. + /// - body: Synchronous caller computation, outside owned instruction/RSS claims. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func readWithReport(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> JSONOperationReport { + try JSONDocument(root: self).readWithReport(limits: limits, body) + } +} +extension JSONDocument { + /// Runs related typed reads with optional coherent mapped source diagnostics. + /// - Parameters: + /// - limits: Inclusive root, source, path and cumulative work/retention ceilings. + /// - body: Synchronous caller computation using a scope-bound non-Sendable cursor. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func read(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> Result { + try readWithReport(limits: limits, body).result + } + /// Reports successful reads while preserving this document's source coherence. + /// Scope exit releases library root/source ownership and invalidates escaped handles. + /// - Parameters: + /// - limits: Inclusive root, source, path and cumulative work/retention ceilings. + /// - body: Synchronous caller computation, outside owned instruction/RSS claims. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func readWithReport(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> JSONOperationReport { + let scope = try JSONReadScope(self, limits: limits) + defer { scope.invalidate() } + let root = JSONReadCursor(scope: scope, selection: JSONReadPath(scope: scope, components: [], bytes: 0)) + return try scope.complete(body(root)) + } +} diff --git a/Sources/JSON/Reading/JSONReadScope.swift b/Sources/JSON/Reading/JSONReadScope.swift new file mode 100644 index 0000000..492c8ed --- /dev/null +++ b/Sources/JSON/Reading/JSONReadScope.swift @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +/// Guarded synchronous ownership; deliberately not Sendable. An escaped handle +/// stores only a bounded path and this invalidated scope, never a selected DOM. +package final class JSONReadScope { + private let owner = ObjectIdentifier(Thread.current) + package var document: JSONDocument? + package var state: JSONCodingState? + private var rootBytes: UInt64 = 0 + private var pathBytes: UInt64 = 0 + private var retiredPathBytes: UInt64 = 0 + package init(_ document: JSONDocument, limits: JSONLimits) throws { + let state = JSONCodingState(options: .init(limits: limits), meter: try JSONWorkMeter(limits: limits, domain: .core)) + try JSONAdmission.value(document.root, meter: &state.meter, owned: true) + if let source = document.source { try JSONAdmission.text(source, meter: &state.meter, owned: true) } + if let bytes = document.sourceBytes { + try state.meter.require(.inputBytes, UInt64(bytes.count)) + for _ in bytes { try state.meter.work(); try state.meter.retain(1) } + } + if let map = document.sourceMap { + for entry in map.entries { + try state.meter.work(); try state.meter.charge(.sourceMapEntries) + try state.meter.retain(JSONLogicalWeights.sourceMapEntry) + for part in entry.path { + try state.meter.work(); try state.meter.retain(JSONLogicalWeights.frame) + if case .key(let key) = part { try JSONAdmission.text(key.string, key: true, meter: &state.meter, owned: true) } + } + } + } + self.document = document; self.state = state + rootBytes = state.meter.used(.retainedBytes) + } + package func retirePath(_ bytes: UInt64) { if ObjectIdentifier(Thread.current) == owner, state != nil { retiredPathBytes += bytes } } + package func check() throws -> JSONCodingState { + guard ObjectIdentifier(Thread.current) == owner else { throw JSONFailure(.configuration, code: .invalidOperation) } + guard document != nil, let state else { throw JSONFailure(.configuration, code: .invalidOperation) } + if retiredPathBytes != 0 { + try state.meter.release(retiredPathBytes); pathBytes -= retiredPathBytes; retiredPathBytes = 0 + } + try state.check(); return state + } + package func invalidate() { document = nil; state = nil } + package func complete(_ result: T) throws -> JSONOperationReport { + let state = try check() + // Paths/root/source cease to be scope-owned. Any escaped path is caller + // storage and cannot access this invalidated root or ledger. + try state.meter.release(rootBytes + pathBytes) + let statistics = state.meter.statistics() + invalidate() + return .init(result: result, statistics: statistics) + } + package func run(_ path: [JSONLocationComponent], _ body: (JSONCodingState) throws -> T) throws -> T { + do { return try body(check()) } + catch is CancellationError { + let error = CancellationError(); if state?.failure == nil { state?.failure = error }; throw error + } + catch let failure as JSONFailure where failure.category == .resource { + if state?.failure == nil { state?.failure = failure }; throw failure + } + catch let failure as JSONFailure where failure.category == .typeMismatch || failure.category == .missingValue || failure.category == .conversion || failure.code == .invalidIndex { + guard let state else { throw failure } + let location = try location(for: path, missing: failure.category == .missingValue, state: state) + throw JSONAccessFailure(cause: .init(failure.category, code: failure.code, location: location ?? failure.location, resource: failure.resource), path: path) + } + } + private func location(for path: [JSONLocationComponent], missing: Bool, state: JSONCodingState) throws -> JSONSourceLocation? { + guard let document, let map = document.sourceMap else { return nil } + var selected: [JSONLocationComponent] = [] + for part in path.prefix(missing ? max(0, path.count - 1) : path.count) { + try state.meter.work(); try state.meter.retain(JSONLogicalWeights.frame); selected.append(part) + } + var span: JSONSourceSpan? + do { + span = try map.span(at: selected, meter: &state.meter) + if span == nil, !missing, !selected.isEmpty { + // A wrong-container or negative-index hop has no child span. + // Borrow the already bounded prefix; do not invent a location. + selected.removeLast(); try state.meter.release(JSONLogicalWeights.frame) + span = try map.span(at: selected, meter: &state.meter) + } + } + catch { try state.meter.release(UInt64(selected.count) * JSONLogicalWeights.frame); throw error } + try state.meter.release(UInt64(selected.count) * JSONLogicalWeights.frame) + guard let span else { return nil } + guard let bytes = document.sourceBytes else { return .init(byteOffset: span.start) } + var line: UInt64 = 1, column: UInt64 = 1, offset: UInt64 = 0 + for byte in bytes { + if offset == span.start { break } + try state.meter.work(); offset += 1 + if byte == 10 { line += 1; column = 1 } else { column += 1 } + } + return .init(byteOffset: span.start, line: line, column: column) + } + package func select(_ path: [JSONLocationComponent], state: JSONCodingState) throws -> JSON { + guard let document else { throw JSONFailure(.configuration, code: .invalidOperation) } + var value = document.root + for part in path { + try state.meter.work() + switch part { + case .key(let key): + guard case .object(let object) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + guard let child = try object.lookup(key.string, meter: &state.meter) else { throw JSONFailure(.missingValue, code: .missingMember) } + value = child + case .index(let index): + guard case .array(let array) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + guard index >= 0 else { throw JSONFailure(.configuration, code: .invalidIndex) } + guard index < array.count else { throw JSONFailure(.missingValue, code: .missingTarget) } + value = array[index] + } + } + return value + } + package func append(_ part: JSONLocationComponent, to path: [JSONLocationComponent], state: JSONCodingState) throws -> JSONReadPath { + try state.meter.require(.pointerTokens, UInt64(path.count) + 1) + var pointerBytes: UInt64 = 0, owned: UInt64 = 0 + for position in 0...path.count { + let component = position == path.count ? part : path[position] + pointerBytes = try state.meter.adding(.pointerBytes, pointerBytes, 1) + let text: String + switch component { + case .key(let key): text = key.string + case .index(let index): + // Preflight the bounded formatting bridge before it allocates. + try state.meter.retain(21); try state.meter.checkpoint() + text = String(index) + try state.meter.work(UInt64(text.utf8.count)); try state.meter.release(21) + } + try state.meter.work(); owned = try state.meter.adding(.retainedBytes, owned, JSONLogicalWeights.indexSlot) + for byte in text.utf8 { + try state.meter.work() + pointerBytes = try state.meter.adding(.pointerBytes, pointerBytes, byte == 47 || byte == 126 ? 2 : 1) + if case .key = component { owned = try state.meter.adding(.retainedBytes, owned, 1) } + } + } + try state.meter.require(.diagnosticBytes, pointerBytes) + try state.meter.retain(owned) + var result: [JSONLocationComponent] = [] + do { + for component in path { try state.meter.work(); result.append(component) } + try state.meter.work(); result.append(part) + } catch { try state.meter.release(owned); throw error } + pathBytes += owned + return JSONReadPath(scope: self, components: result, bytes: owned) + } +} +package final class JSONReadPath { + package let scope: JSONReadScope + package let components: [JSONLocationComponent] + private let bytes: UInt64 + package init(scope: JSONReadScope, components: [JSONLocationComponent], bytes: UInt64) { + self.scope = scope; self.components = components; self.bytes = bytes + } + deinit { scope.retirePath(bytes) } +} diff --git a/Tests/JSONTests/Release1_1/ReadAccessTests.swift b/Tests/JSONTests/Release1_1/ReadAccessTests.swift new file mode 100644 index 0000000..7c609df --- /dev/null +++ b/Tests/JSONTests/Release1_1/ReadAccessTests.swift @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing +@testable import JSON + +private struct ReadModel: Codable, Equatable { let count: UInt64; let name: String; let bytes: Data } +private struct MeterWitness: Decodable { + let identity: ObjectIdentifier + let work: UInt64 + init(from decoder: any Decoder) throws { + let native = try #require(decoder as? JSONNativeDecoder) + identity = ObjectIdentifier(native.state); work = native.state.meter.statistics()[.workUnits] + } +} +private struct UserInfoWitness: Decodable { + let marker: String + init(from decoder: any Decoder) throws { + marker = try #require(decoder.userInfo[CodingUserInfoKey(rawValue: "marker")!] as? String) + } +} +private enum ReadCallerFailure: Error { case expected } +private struct RefusingDecode: Decodable { init(from decoder: any Decoder) throws { throw ReadCallerFailure.expected } } +private struct CatchingOwnedFailure: Decodable { + init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + do { _ = try c.decode(UInt64.self) } catch {} + } +} +private func expectAccess(_ path: [JSONLocationComponent], _ code: JSONFailure.Code, _ body: () throws -> Void) { + do { try body(); Issue.record("Expected a typed access failure") } + catch let error as JSONAccessFailure { #expect(error.path == path); #expect(error.cause.code == code) } + catch { Issue.record("Unexpected error: \(type(of: error))") } +} + +@Test func readAccessChainedBracketsAndRequiredKinds() throws { + let value = try JSONParser().parse(#"{"users":[{"name":"Ada","age":9007199254740993}],"0":false,"empty":"","zero":0,"false":false,"null":null}"#) + try value.read { root in + let user = try root[JSONKey("users")][0] + #expect(try user["name"].requiredString() == "Ada") + #expect(try user["age"].requiredInteger(as: UInt64.self) == 9_007_199_254_740_993) + #expect(try root["0"].requiredBoolean() == false) + #expect(try root["users"].requiredArray().count == 1) + #expect(try user.requiredObject().count == 2) + #expect(try root["null"].requiredValue() == .null) + #expect(try root.stringPresence(of: "empty") == .value("")) + #expect(try root.numberPresence(of: "zero") == .value(JSONNumber(integer: 0))) + #expect(try root.booleanPresence(of: "false") == .value(false)) + #expect(try root.presence(of: JSONKey("missing")) == .absent) + #expect(try root.stringPresence(of: JSONKey("null")) == .null) + } +} + +@Test func readAccessExactKeysAndFirstFailingHop() throws { + let keys = ["", "0", "/", "~", "a.b", "\u{e9}", "e\u{301}", "🚀"] + let value = JSON.object(try JSONObject(members: keys.map { .init($0, value: .string($0)) })) + try value.read { root in + for key in keys { let text = try root[key].requiredString(); #expect(text.utf8.elementsEqual(key.utf8)) } + expectAccess([.key("missing")], .missingMember) { _ = try root["missing"]["later"] } + expectAccess([.index(0)], .wrongKind) { _ = try root[0] } + expectAccess([.key("0"), .key("later")], .wrongKind) { _ = try root["0"]["later"] } + expectAccess([.key("0")], .wrongKind) { _ = try root["0"].requiredBoolean() } + } + try JSON.array([.null]).read { root in + expectAccess([.index(-1)], .invalidIndex) { _ = try root[-1] } + expectAccess([.index(1)], .missingTarget) { _ = try root[1] } + expectAccess([.key("0")], .wrongKind) { _ = try root["0"] } + } +} + +@Test func readAccessExactNumericPoliciesAndBounds() throws { + for text in ["9007199254740993", "18446744073709551615"] { + let value = try JSONParser().parse(text) + #expect(try value.read { try $0.requiredInteger(as: UInt64.self) } == UInt64(text)) + } + #expect(try JSONParser().parse("-9223372036854775808").read { try $0.requiredInteger(as: Int64.self) } == Int64.min) + for text in ["1.5", "18446744073709551616", "-1"] { + let value = try JSONParser().parse(text) + expectAccess([], .inexactConversion) { _ = try value.read { try $0.requiredInteger(as: UInt64.self) } } + } + let tenth = try JSONParser().parse("0.1") + expectAccess([], .inexactConversion) { _ = try tenth.read { try $0.requiredDouble(policy: .exact) } } + #expect(try tenth.read { try $0.requiredDouble(policy: .rounded) } == 0.1) + #expect(try tenth.read { try $0.requiredDecimal(policy: .exact) } == Decimal(string: "0.1")) + let huge = try JSONParser().parse("1e100000") + expectAccess([], .inexactConversion) { _ = try huge.read { try $0.requiredDouble(policy: .rounded) } } +} + +@Test func readAccessMappedDiagnosticsAndReplacedRootCoherence() throws { + let bytes = Data("{\n \"user\": {\"age\": \"wrong\"}\n}".utf8) + let document = try JSONParser().parseDocument(bytes, source: "fixture") + do { + _ = try document.read { try $0["user"]["age"].requiredInteger(as: Int.self) } + Issue.record("Expected mapped kind failure") + } catch let error as JSONAccessFailure { + #expect(error.path == [.key("user"), .key("age")]) + #expect(error.cause.location?.byteOffset == 20) + #expect(error.cause.location?.line == 2) + #expect(error.cause.location?.column == 19) + #expect(JSONDiagnostic(error).failure == error.cause) + } + do { _ = try document.read { try $0["user"]["age"]["child"].requiredValue() } } + catch let error as JSONAccessFailure { #expect(error.cause.location?.byteOffset == 20) } + do { _ = try document.read { try $0["user"]["missing"].requiredValue() } } + catch let error as JSONAccessFailure { #expect(error.cause.location?.byteOffset == 12) } + do { _ = try document.replacingRoot(document.root).read { try $0["user"]["age"].requiredBoolean() } } + catch let error as JSONAccessFailure { #expect(error.cause.location == nil) } +} + +@Test func readAccessSharedProjectionLimitsAndTerminalAccounting() throws { + let model = ReadModel(count: .max, name: "e\u{301}", bytes: Data([0, 1, 255])) + let value = try JSON(encoding: model) + #expect(try value.decode(ReadModel.self) == model) + #expect(try value.read { try $0.decode(ReadModel.self) } == model) + let baseline = try value.readWithReport { _ in 0 } + let decoded = try value.readWithReport { try $0.decode(ReadModel.self) } + #expect(decoded.statistics.domain == .core) + #expect(decoded.statistics[.workUnits] > baseline.statistics[.workUnits]) + #expect(decoded.statistics[.numericWorkUnits] > baseline.statistics[.numericWorkUnits]) + try value.read { root in + let first = try root.decode(MeterWitness.self) + let second = try root["count"].decode(MeterWitness.self) + #expect(first.identity == second.identity && second.work > first.work) + let info = JSONCodingOptions(userInfo: [CodingUserInfoKey(rawValue: "marker")!: "qualified"]) + #expect(try root.decode(UserInfoWitness.self, coding: info).marker == "qualified") + } + var coding = JSONCodingOptions(); coding.limits[.workUnits] = baseline.statistics[.workUnits] - 1 + #expect(throws: JSONFailure.self) { + try value.read { root in + do { _ = try root.decode(ReadModel.self, coding: coding) } catch {} + return 0 + } + } + var limits = JSONLimits.balanced; limits[.workUnits] = decoded.statistics[.workUnits] + #expect(try value.read(limits: limits) { try $0.decode(ReadModel.self) } == model) + #expect(throws: JSONFailure.self) { + try value.read(limits: limits) { root in + _ = try root.decode(ReadModel.self) + return try root.decode(ReadModel.self) + } + } + #expect(throws: ReadCallerFailure.self) { try value.read { try $0.decode(RefusingDecode.self) } } + let text = try JSONParser().parse("18446744073709551615") + let admitted = try text.readWithReport { _ in 0 } + var tight = JSONCodingOptions(); tight.limits[.workUnits] = admitted.statistics[.workUnits] + 2 + #expect(throws: JSONFailure.self) { try text.read { try $0.decode(CatchingOwnedFailure.self, coding: tight) } } + #expect(throws: JSONFailure.self) { try text.decode(CatchingOwnedFailure.self, coding: tight) } +} + +@Test func readAccessPresenceAvoidsCallerDecodeAndPreservesNativeFidelity() throws { + let value = try JSONParser().parse(#"{"null":null,"wrong":"no","good":9007199254740993,"é":1,"e\u0301":2}"#) + try value.read { root in + switch try root.presence(of: "missing", as: RefusingDecode.self) { case .absent: break; default: Issue.record("Missing decoded") } + switch try root.presence(of: JSONKey("null"), as: RefusingDecode.self) { case .null: break; default: Issue.record("Null decoded") } + #expect(try root.presence(of: "good", as: UInt64.self) == .value(9_007_199_254_740_993)) + #expect(throws: DecodingError.self) { try root.presence(of: "wrong", as: UInt64.self) } + #expect(throws: ReadCallerFailure.self) { try root.presence(of: "good", as: RefusingDecode.self) } + #expect(try root.decode(JSON.self) == value) + #expect(throws: DecodingError.self) { try root.decode([String: JSON].self) } + } + #expect(try value.decode(JSON.self) == value) + #expect(try JSONParser().parse("9007199254740993").decode(JSONNumber.self) == JSONNumber(parsing: "9007199254740993")) + #expect(try value.decode(JSONObject.self).count == 5) +} + +@Test func readAccessEscapedHandlesInvalidateAndReleaseRootAndSource() throws { + let document = try JSONParser().parseDocument(Data(#"{"child":{"name":"value"}}"#.utf8)) + let escaped = try document.read { try $0["child"] } + #expect(throws: JSONFailure.self) { try escaped.requiredValue() } + let scope = try JSONReadScope(document, limits: .balanced) + weak let state = scope.state + let cursor = JSONReadCursor(scope: scope, selection: JSONReadPath(scope: scope, components: [], bytes: 0)) + _ = try scope.complete(try cursor["child"]) + #expect(scope.document == nil && scope.state == nil && state == nil) + #expect(throws: JSONFailure.self) { try cursor.requiredObject() } +} + +@Test func readAccessPathCopiesReleaseLiveRetentionAndFailCompletePaths() throws { + let value = try JSONParser().parse(#"{"child":{"name":"value"}}"#) + func attempt(_ root: JSONReadCursor) throws { + do { + _ = try root["child"]["name"].requiredBoolean() + Issue.record("Expected ordinary kind failure") + } catch let error as JSONAccessFailure { #expect(error.path == [.key("child"), .key("name")]) } + } + let one = try value.readWithReport { root in try attempt(root) } + var limits = JSONLimits.balanced; limits[.retainedBytes] = one.statistics.peakLogicalRetainedBytes + let repeated = try value.readWithReport(limits: limits) { root in + for _ in 0..<100 { try attempt(root) } + } + #expect(repeated.statistics.peakLogicalRetainedBytes == one.statistics.peakLogicalRetainedBytes) + #expect(repeated.statistics[.retainedBytes] == 0) + #expect(repeated.statistics[.workUnits] > one.statistics[.workUnits]) + let oneText = try value.readWithReport { try $0["child"]["name"].requiredString() } + limits = .balanced; limits[.retainedBytes] = oneText.statistics.peakLogicalRetainedBytes + let repeatedText = try value.readWithReport(limits: limits) { root in + for _ in 0..<100 { _ = try root["child"]["name"].requiredString() } + } + #expect(repeatedText.statistics[.retainedBytes] == 0) + #expect(repeatedText.statistics.peakLogicalRetainedBytes == oneText.statistics.peakLogicalRetainedBytes) + limits = .balanced; limits[.pointerBytes] = 5 + #expect(throws: JSONFailure.self) { try value.read(limits: limits) { try $0["child"] } } + limits = .balanced; limits[.diagnosticBytes] = 5 + #expect(throws: JSONFailure.self) { try value.read(limits: limits) { try $0["child"] } } + +} + +@Test func readAccessCaughtCancellationCannotCertifyCompletion() async throws { + let task = Task.detached { () throws -> Bool in + let value = try JSONParser().parse(#"{"name":"value"}"#) + do { + _ = try value.read { root in + withUnsafeCurrentTask { $0?.cancel() } + do { _ = try root["name"].requiredString() } catch is CancellationError {} + return 0 + } + return false + } catch is CancellationError { return true } + } + #expect(try await task.value) +} + +@Test func readAccessBridgeStrategiesAndForeignNaturalCodingRemainDistinct() throws { + let value = ReadModel(count: .max, name: "sensor", bytes: Data([1, 2, 255])) + for strategy in [JSONCodingOptions.DataStrategy.base64, .deferredToData] { + let coding = JSONCodingOptions(dataStrategy: strategy) + let encoded = try JSON(encoding: value, coding: coding) + #expect(try encoded == JSONValueEncoder(options: coding).encode(value)) + #expect(try encoded.decode(ReadModel.self, coding: coding) == value) + } + for strategy in [JSONCodingOptions.DateStrategy.deferredToDate, .secondsSince1970, .iso8601] { + let coding = JSONCodingOptions(dateStrategy: strategy) + let date = Date(timeIntervalSince1970: 1_600_000_000.25) + let encoded = try JSON(encoding: date, coding: coding) + let decoded = try encoded.decode(Date.self, coding: coding) + let reference = try JSONValueDecoder(options: coding).decode(Date.self, from: encoded) + #expect(decoded == reference) + #expect(decoded == date) + } + for text in ["-9223372036854775808", "18446744073709551615", "0.1"] { + let number = try JSONNumber(parsing: text) + let bytes = try Foundation.JSONEncoder().encode(number) + #expect(try Foundation.JSONDecoder().decode(JSONNumber.self, from: bytes) == number) + } + let huge = try JSONNumber(parsing: "1e100000") + #expect(throws: EncodingError.self) { try Foundation.JSONEncoder().encode(huge) } +} diff --git a/Tools/Release1_1Probes/PublicReaderConsumer.swift b/Tools/Release1_1Probes/PublicReaderConsumer.swift new file mode 100644 index 0000000..a9e1389 --- /dev/null +++ b/Tools/Release1_1Probes/PublicReaderConsumer.swift @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import JSON + +private struct Reading: Decodable { let count: UInt64; let name: String; let bytes: Data } +private let value = try JSONParser().parse(#"{"users":[{"count":18446744073709551615,"name":"sensor","bytes":"AAH/","nullable":null}]}"#) +private let report = try value.readWithReport { root in + let user = try root[JSONKey("users")][0] + let name = try user["name"].requiredString(); precondition(name == "sensor") + let count = try user["count"].requiredInteger(as: UInt64.self); precondition(count == UInt64.max) + let missing = try user.presence(of: "missing"); precondition(missing == .absent) + let nullable = try user.stringPresence(of: "nullable"); precondition(nullable == .null) + return try user.decode(Reading.self) +} +precondition(report.result.count == UInt64.max && report.result.bytes == Data([0, 1, 255])) +precondition(report.statistics.domain == .core && report.statistics[.numericWorkUnits] > 0) +private let escaped = try value.read { $0 } +do { _ = try escaped.requiredValue(); fatalError("Escaped cursor remained valid") } +catch let error as JSONFailure { precondition(error.code == .invalidOperation) } +print("PUBLIC_READER_CONSUMER passed work=\(report.statistics[.workUnits]) numeric=\(report.statistics[.numericWorkUnits])") diff --git a/Tools/Release1_1Probes/PublicTaskEscape.swift b/Tools/Release1_1Probes/PublicTaskEscape.swift new file mode 100644 index 0000000..1a298ee --- /dev/null +++ b/Tools/Release1_1Probes/PublicTaskEscape.swift @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON +func forbidden(_ cursor: JSONReadCursor) { + let closure: @Sendable () throws -> JSON = { try cursor.requiredValue() } + _ = closure +} diff --git a/Tools/Release1_1Probes/PublicTaskTransfer.swift b/Tools/Release1_1Probes/PublicTaskTransfer.swift new file mode 100644 index 0000000..17b339c --- /dev/null +++ b/Tools/Release1_1Probes/PublicTaskTransfer.swift @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON +func forbidden(_ cursor: JSONReadCursor) { + Task.detached { try cursor.requiredValue() } + _ = try? cursor.requiredValue() +} From 51b7c16cf09cc88dcb26dc75375b812c15a28d44 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:58:19 +0200 Subject: [PATCH 2/5] Fix mobile schema bundle layout and declare iOS 27 minima --- .../Architecture/Release1.1/Decisions.md | 5 +++-- .../Architecture/Release1.1/Mobile.md | 21 +++++++++++++++++++ Package.swift | 4 ++-- Scripts/ci.py | 2 +- .../Document/JSONSchemaBuiltins.swift | 2 +- 5 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 Documentation/Architecture/Release1.1/Mobile.md diff --git a/Documentation/Architecture/Release1.1/Decisions.md b/Documentation/Architecture/Release1.1/Decisions.md index 9307d07..4106480 100644 --- a/Documentation/Architecture/Release1.1/Decisions.md +++ b/Documentation/Architecture/Release1.1/Decisions.md @@ -79,8 +79,9 @@ fields. In-memory rereading is not evidence; no cloud-sync claim is intended. ## Mobile platform and CI -Propose `.macOS("27.0")` and `.iOS("27.0")`; iPadOS shares iOS. The root manifest -remains macOS-only pending the focused platform PR. All four products, private +The mobile feature manifest declares `.macOS("27.0")` and `.iOS("27.0")`; +iPadOS shares iOS. See [mobile qualification](Mobile.md) for the packaging fix +and the distinction between deployment minima and executed support evidence. All four products, private regex and schema resources compile for iPhoneOS with the default build engine. The simulator default engine fails while signing the schema resource bundle; its `Resources` directory layout needs qualification. The documented deprecated native diff --git a/Documentation/Architecture/Release1.1/Mobile.md b/Documentation/Architecture/Release1.1/Mobile.md new file mode 100644 index 0000000..5d669b3 --- /dev/null +++ b/Documentation/Architecture/Release1.1/Mobile.md @@ -0,0 +1,21 @@ +# Apple mobile qualification + +The 1.1 feature manifest declares macOS 27 and iOS 27 minimum deployment versions. +iPadOS uses the iOS declaration. The native host still requires macOS/SDK 27 and +Swift tools/compiler 6.4, language mode 6. SDK compilation alone does not qualify +mobile runtime or physical-device behavior. + +The schema resource rule copies `Resources/Draft2020-12` as `Draft2020-12` at the +resource root. It retains every pinned schema byte and provenance/license record. +The loader uses the same path relative to `Bundle.module.resourceURL` on each +platform. A flat iOS bundle with a root directory literally named `Resources` and +a root Info.plist failed native codesign. A temporary-copy experiment reproduced +the failure and signed successfully after removing that extra directory level. +No signing bypass, custom plist, resolver or dependency is introduced. + +The mobile probe builds a public consumer of all four products, the private regex +implementation and packaged schemas with the default Swift Build engine for both +iPhoneOS and iPhoneSimulator. Required subsequent evidence includes iPhone/iPad +simulator behavior/cancellation, physical-device smoke and measurements, and real +disk-backed SwiftData teardown/reopen using the exact payload. Keep every not-run +row explicit; this document does not certify those later runs. diff --git a/Package.swift b/Package.swift index c83a644..c372695 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "Swift-JSON", - platforms: [.macOS("27.0")], + platforms: [.macOS("27.0"), .iOS("27.0")], products: [ .library(name: "JSON", targets: ["JSON"]), .library(name: "JSONSchema", targets: ["JSONSchema"]), @@ -14,7 +14,7 @@ let package = Package( targets: [ .target(name: "JSON"), .target(name: "JSONRegexRuntime", dependencies: ["JSON"]), - .target(name: "JSONSchema", dependencies: ["JSON", "JSONRegexRuntime"], resources: [.copy("Resources")]), + .target(name: "JSONSchema", dependencies: ["JSON", "JSONRegexRuntime"], resources: [.copy("Resources/Draft2020-12")]), .target(name: "JSONStreaming", dependencies: ["JSON"]), .target(name: "JSONPatch", dependencies: ["JSON"]), .testTarget(name: "JSONTests", dependencies: ["JSON"], exclude: ["ImplementationPlan.md"]), diff --git a/Scripts/ci.py b/Scripts/ci.py index 24c6b8e..c32f7ed 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -78,7 +78,7 @@ def check_result(command, code, log): passed = passed and len(probes) == 49 and dict(probes) == expected_probes if command == ['swift', 'package', 'dump-package'] and passed: d = json.loads(log) - passed = sorted(p['name'] for p in d['products']) == ['JSON', 'JSONPatch', 'JSONSchema', 'JSONStreaming'] and not d['dependencies'] and d['swiftLanguageVersions'] == ['6'] and d['toolsVersion']['_version'] == '6.4.0' and d['platforms'] == [{'options': [], 'platformName': 'macos', 'version': '27.0'}] + passed = sorted(p['name'] for p in d['products']) == ['JSON', 'JSONPatch', 'JSONSchema', 'JSONStreaming'] and not d['dependencies'] and d['swiftLanguageVersions'] == ['6'] and d['toolsVersion']['_version'] == '6.4.0' and d['platforms'] == [{'options': [], 'platformName': 'macos', 'version': '27.0'}, {'options': [], 'platformName': 'ios', 'version': '27.0'}] if command == ['swift', 'package', 'show-dependencies', '--format', 'json'] and passed: passed = json.loads(log)['dependencies'] == [] return passed, counts diff --git a/Sources/JSONSchema/Document/JSONSchemaBuiltins.swift b/Sources/JSONSchema/Document/JSONSchemaBuiltins.swift index 3b1153a..e35e698 100644 --- a/Sources/JSONSchema/Document/JSONSchemaBuiltins.swift +++ b/Sources/JSONSchema/Document/JSONSchemaBuiltins.swift @@ -32,7 +32,7 @@ extension JSONSchemaRegistry { for (path, identifier, size, expectedHash) in files { try meter.checkpoint(); try meter.charge(.schemaDocuments); try meter.resetValueBounds() try meter.require(.inputBytes, size); try meter.charge(.schemaBytes, size); try meter.retain(size) - let url = root.appendingPathComponent("Resources/Draft2020-12").appendingPathComponent(path) + let url = root.appendingPathComponent("Draft2020-12").appendingPathComponent(path) let handle: FileHandle do { handle = try FileHandle(forReadingFrom: url) } catch { throw JSONSchemaCompilationFailure(.metaSchema) } From 30c1e69cdc25330280d76aa874d63a366080103a Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:45:14 +0200 Subject: [PATCH 3/5] Latch terminal overflow from lifetime counter retirement as well --- Sources/JSON/Limits/JSONWorkMeter.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/JSON/Limits/JSONWorkMeter.swift b/Sources/JSON/Limits/JSONWorkMeter.swift index d4b81a7..a0a5360 100644 --- a/Sources/JSON/Limits/JSONWorkMeter.swift +++ b/Sources/JSON/Limits/JSONWorkMeter.swift @@ -41,9 +41,11 @@ package struct JSONWorkMeter: ~Copyable { health.observed[metric.index] = max(health.observed[metric.index], attempted) } private func violation(_ metric: JSONResourceMetric, _ attempted: UInt64?, overflow: Bool = false) -> JSONFailure { - JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, + let failure = JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, resource: .init(domain: domain, metric: metric, limit: limits[metric], attemptedUsage: attempted, overflow: overflow)) + if health.failure == nil { health.failure = failure } + return failure } package func adding(_ metric: JSONResourceMetric, _ current: UInt64, _ amount: UInt64) throws -> UInt64 { let (next, overflow) = current.addingReportingOverflow(amount) From 69955815bab20bdfbaa993a6107586536393da09 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:46:23 +0200 Subject: [PATCH 4/5] Prove caught retired-counter overflow cannot resume the ledger --- Tests/JSONTests/Release1_1/ReadAccessTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tests/JSONTests/Release1_1/ReadAccessTests.swift b/Tests/JSONTests/Release1_1/ReadAccessTests.swift index 7c609df..9908c75 100644 --- a/Tests/JSONTests/Release1_1/ReadAccessTests.swift +++ b/Tests/JSONTests/Release1_1/ReadAccessTests.swift @@ -107,6 +107,13 @@ private func expectAccess(_ path: [JSONLocationComponent], _ code: JSONFailure.C } @Test func readAccessSharedProjectionLimitsAndTerminalAccounting() throws { + var huge = JSONLimits.balanced; huge[.inputBytes] = UInt64(Int.max) - 1 + var lifetime = try JSONWorkMeter(limits: huge, domain: .core) + for _ in 0..<2 { try lifetime.charge(.inputBytes, huge[.inputBytes]); try lifetime.resetValueBounds() } + #expect(throws: JSONFailure.self) { try lifetime.charge(.inputBytes, huge[.inputBytes]) } + let terminal = lifetime.statistics()[.inputBytes] + #expect(throws: JSONFailure.self) { try lifetime.checkpoint() } + #expect(lifetime.statistics()[.inputBytes] == terminal) let model = ReadModel(count: .max, name: "e\u{301}", bytes: Data([0, 1, 255])) let value = try JSON(encoding: model) #expect(try value.decode(ReadModel.self) == model) From 8efdf33a6dafb2279ea77beb11ed49c43d599317 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:22:45 +0200 Subject: [PATCH 5/5] Track tightening history only in coding and read scopes --- Sources/JSON/Limits/JSONWorkMeter.swift | 18 ++++++++++++++++-- Sources/JSON/Reading/JSONReadScope.swift | 2 +- .../JSONTests/Release1_1/ReadAccessTests.swift | 2 ++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Sources/JSON/Limits/JSONWorkMeter.swift b/Sources/JSON/Limits/JSONWorkMeter.swift index a0a5360..79b6332 100644 --- a/Sources/JSON/Limits/JSONWorkMeter.swift +++ b/Sources/JSON/Limits/JSONWorkMeter.swift @@ -7,6 +7,7 @@ private final class JSONMeterHealth { package struct JSONWorkMeter: ~Copyable { package private(set) var limits: JSONLimits private let health = JSONMeterHealth() + private let trackAcceptedCapacities: Bool package let domain: JSONOperationDomain private var counters: [UInt64] private var retired: [UInt64] @@ -18,10 +19,11 @@ package struct JSONWorkMeter: ~Copyable { /// Observer execution itself is trusted test code, outside owned work quotas. package var checkpointObserver: (@Sendable (UInt64) -> Void)? package mutating func schemaEvaluationPhase(_ active: Bool) { compilingEvaluation = active } - package init(limits: JSONLimits, domain: JSONOperationDomain) throws { + package init(limits: JSONLimits, domain: JSONOperationDomain, trackAcceptedCapacities: Bool = false) throws { try Task.checkCancellation() try limits.validate() self.limits = limits; self.domain = domain + self.trackAcceptedCapacities = trackAcceptedCapacities || domain == .coding counters = Array(repeating: 0, count: JSONResourceMetric.allCases.count) retired = Array(repeating: 0, count: JSONResourceMetric.allCases.count) } @@ -38,7 +40,18 @@ package struct JSONWorkMeter: ~Copyable { guard attempted <= limits[metric] else { let failure = violation(metric, attempted); health.failure = failure; throw failure } - health.observed[metric.index] = max(health.observed[metric.index], attempted) + // Fixed-policy operations never tighten after admission. Coding and read + // scopes retain the capacity history needed by restrict. Cumulative work + // already lives in counters; live ownership must use current, not peak. + if trackAcceptedCapacities { + switch metric { + case .workUnits, .numericWorkUnits, .compileWorkUnits, .evaluationWorkUnits, + .patchWorkUnits, .totalWorkUnits, .regexCompileWorkUnits, + .regexSearchWorkUnits, .aggregateRegexWorkUnits, .retainedBytes, + .regexRetainedBytes, .annotationBytes, .compiledPlanBytes: break + default: health.observed[metric.index] = max(health.observed[metric.index], attempted) + } + } } private func violation(_ metric: JSONResourceMetric, _ attempted: UInt64?, overflow: Bool = false) -> JSONFailure { let failure = JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, @@ -103,6 +116,7 @@ package struct JSONWorkMeter: ~Copyable { /// work must fit the new ceiling; failures stay terminal even if caught. package mutating func restrict(to requested: JSONLimits) throws { try checkpoint() + guard trackAcceptedCapacities else { throw JSONFailure(.configuration, code: .invalidOperation) } let restricted = try limits.intersection(requested) limits = restricted for metric in JSONResourceMetric.allCases where metric != .cancellationQuantum { diff --git a/Sources/JSON/Reading/JSONReadScope.swift b/Sources/JSON/Reading/JSONReadScope.swift index 492c8ed..1c9dbef 100644 --- a/Sources/JSON/Reading/JSONReadScope.swift +++ b/Sources/JSON/Reading/JSONReadScope.swift @@ -11,7 +11,7 @@ package final class JSONReadScope { private var pathBytes: UInt64 = 0 private var retiredPathBytes: UInt64 = 0 package init(_ document: JSONDocument, limits: JSONLimits) throws { - let state = JSONCodingState(options: .init(limits: limits), meter: try JSONWorkMeter(limits: limits, domain: .core)) + let state = JSONCodingState(options: .init(limits: limits), meter: try JSONWorkMeter(limits: limits, domain: .core, trackAcceptedCapacities: true)) try JSONAdmission.value(document.root, meter: &state.meter, owned: true) if let source = document.source { try JSONAdmission.text(source, meter: &state.meter, owned: true) } if let bytes = document.sourceBytes { diff --git a/Tests/JSONTests/Release1_1/ReadAccessTests.swift b/Tests/JSONTests/Release1_1/ReadAccessTests.swift index 9908c75..777e934 100644 --- a/Tests/JSONTests/Release1_1/ReadAccessTests.swift +++ b/Tests/JSONTests/Release1_1/ReadAccessTests.swift @@ -107,6 +107,8 @@ private func expectAccess(_ path: [JSONLocationComponent], _ code: JSONFailure.C } @Test func readAccessSharedProjectionLimitsAndTerminalAccounting() throws { + var history = JSONCodingOptions(); history.limits[.stringBytes] = 6 + #expect(throws: JSONFailure.self) { try JSON.string("history").read { try $0.decode(String.self, coding: history) } } var huge = JSONLimits.balanced; huge[.inputBytes] = UInt64(Int.max) - 1 var lifetime = try JSONWorkMeter(limits: huge, domain: .core) for _ in 0..<2 { try lifetime.charge(.inputBytes, huge[.inputBytes]); try lifetime.resetValueBounds() }