From fd23bd5c736501f2a8ebcd6fb38fc2082b11326e Mon Sep 17 00:00:00 2001 From: Hamilton Chapman Date: Mon, 2 Feb 2026 17:03:46 +0000 Subject: [PATCH 1/5] fix: unsafe error handling and allocation crashes These changes address multiple critical safety issues: **Issue #118 - Large allocation crash:** - Replace `.map()` with progressive array building in `readN()` - Prevents memory allocation crashes from malformed CBOR with huge declared lengths - Add capacity reservation capped at reasonable size (1024) **Issue #69 - Array bounds checking:** - Add bounds validation before accessing data ranges in `UnkeyedDecodingContainer` - Prevents crashes from corrupted data with out-of-bounds ranges - Throw proper `DecodingError` instead of crashing **Unsafe `try!` replacements:** - AnyCodingKey: Replace `try!` with proper error handling for invalid key types - KeyedDecodingContainer: Replace `try!` with proper error propagation - KeyedDecodingContainer: Replace `fatalError()` with throwing DecodingError - UnkeyedDecodingContainer: Remove `fatalError()`, allow errors to propagate - AnyCodingKey.encode(): Replace `fatalError()` with throwing EncodingError - AnyCodingKey.key(): Replace `fatalError()` with preconditionFailure (better messages) **Encoder changes:** - Make `encodeMap()` and `encodeMapChunk()` throw instead of using `try!` - Update call sites with proper error handling - Add `preconditionFailure` for programming errors (`forbidNonStringMapKeys` violations) --- Sources/AnyCodingKey.swift | 32 +++++-- Sources/CBORDecoder.swift | 16 ++-- Sources/CBOREncodable.swift | 10 ++- Sources/CBOREncoder.swift | 20 +++-- Sources/CBOROptions.swift | 3 +- Sources/Decoder/KeyedDecodingContainer.swift | 11 ++- .../Decoder/UnkeyedDecodingContainer.swift | 20 ++++- Tests/CBORDecoderTests.swift | 89 +++++++++++++++++-- Tests/CBOREncoderTests.swift | 2 +- 9 files changed, 168 insertions(+), 35 deletions(-) diff --git a/Sources/AnyCodingKey.swift b/Sources/AnyCodingKey.swift index a97bbff..484678e 100644 --- a/Sources/AnyCodingKey.swift +++ b/Sources/AnyCodingKey.swift @@ -26,11 +26,17 @@ struct AnyCodingKey: CodingKey, Equatable { func key() -> K { if let intValue = self.intValue { - return K(intValue: intValue)! + guard let key = K(intValue: intValue) else { + preconditionFailure("CodingKey \(K.self) failed to initialize with intValue: \(intValue)") + } + return key } else if let stringValue = self._stringValue { - return K(stringValue: stringValue)! + guard let key = K(stringValue: stringValue) else { + preconditionFailure("CodingKey \(K.self) failed to initialize with stringValue: \(stringValue)") + } + return key } else { - fatalError("AnyCodingKey created without a string or int value") + preconditionFailure("AnyCodingKey created without a string or int value") } } } @@ -49,7 +55,13 @@ extension AnyCodingKey: Encodable { } else if let stringValue = self._stringValue { try container.encode(stringValue) } else { - fatalError("AnyCodingKey created without a string or int value") + throw EncodingError.invalidValue( + self, + EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "AnyCodingKey created without a string or int value" + ) + ) } } } @@ -60,9 +72,17 @@ extension AnyCodingKey: Decodable { if let intValue = try? value.decode(Int.self) { self._stringValue = nil self.intValue = intValue - } else { - self._stringValue = try! value.decode(String.self) + } else if let stringValue = try? value.decode(String.self) { + self._stringValue = stringValue self.intValue = nil + } else { + throw DecodingError.typeMismatch( + AnyCodingKey.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected Int or String key" + ) + ) } } } diff --git a/Sources/CBORDecoder.swift b/Sources/CBORDecoder.swift index 4e26806..67d16cc 100644 --- a/Sources/CBORDecoder.swift +++ b/Sources/CBORDecoder.swift @@ -64,10 +64,13 @@ public class CBORDecoder { } private func readN(_ n: Int) throws -> [CBOR] { - return try (0.. [CBOR] { @@ -110,9 +113,10 @@ public class CBORDecoder { } public func decodeItem() throws -> CBOR? { - guard currentDepth <= options.maximumDepth - else { throw CBORError.maximumDepthExceeded } - + guard currentDepth <= options.maximumDepth else { + throw CBORError.maximumDepthExceeded + } + currentDepth += 1 defer { currentDepth -= 1 } let b = try istream.popByte() diff --git a/Sources/CBOREncodable.swift b/Sources/CBOREncodable.swift index 50cff6b..70afda8 100644 --- a/Sources/CBOREncodable.swift +++ b/Sources/CBOREncodable.swift @@ -25,7 +25,7 @@ extension CBOR: CBOREncodable { case let .byteString(bs): return CBOR.encodeByteString(bs, options: options) case let .utf8String(str): return str.encode(options: options) case let .array(a): return CBOR.encodeArray(a, options: options) - case let .map(m): return CBOR.encodeMap(m, options: options) + case let .map(m): return try! CBOR.encodeMap(m, options: options) #if canImport(Foundation) case let .date(d): return CBOR.encodeDate(d, options: options) #endif @@ -206,7 +206,13 @@ extension Array where Element: CBOREncodable { extension Dictionary where Key: CBOREncodable, Value: CBOREncodable { public func encode(options: CBOROptions = CBOROptions()) -> [UInt8] { - return CBOR.encodeMap(self, options: options) + do { + return try CBOR.encodeMap(self, options: options) + } catch { + // This can only fail if forbidNonStringMapKeys is true and Key is not a String. + // This is a programming error, not a data error. + preconditionFailure("Failed to encode dictionary with key type \(Key.self): \(error)") + } } public func toCBOR(options: CBOROptions = CBOROptions()) -> CBOR { diff --git a/Sources/CBOREncoder.swift b/Sources/CBOREncoder.swift index d33a355..f3f9ac8 100644 --- a/Sources/CBOREncoder.swift +++ b/Sources/CBOREncoder.swift @@ -37,7 +37,8 @@ extension CBOR { array.withUnsafeBytes { bufferPtr in guard let ptr = bufferPtr.baseAddress?.bindMemory(to: UInt8.self, capacity: bytelength) else { - fatalError("Invalid pointer") + // This should never happen with valid Swift arrays, but handle it gracefully + preconditionFailure("Failed to get pointer to array memory") } var j = 0 for i in 0..(_ dict: [A: B], options: CBOROptions = CBOROptions()) -> [UInt8] { - return encodeMap(dict, options: options) + do { + return try encodeMap(dict, options: options) + } catch { + // This can only fail if forbidNonStringMapKeys is true and A is not a String. + // This is a programming error, not a data error. + preconditionFailure("Failed to encode dictionary with key type \(A.self): \(error)") + } } // MARK: - major 0: unsigned integer @@ -128,9 +135,9 @@ extension CBOR { // MARK: - major 5: a map of pairs of data items - public static func encodeMap(_ map: [A: B], options: CBOROptions = CBOROptions()) -> [UInt8] { + public static func encodeMap(_ map: [A: B], options: CBOROptions = CBOROptions()) throws -> [UInt8] { if options.forbidNonStringMapKeys { - try! ensureStringKey(A.self) + try ensureStringKey(A.self) } var res: [UInt8] = [] res.reserveCapacity(1 + map.count * (MemoryLayout.size + MemoryLayout.size + 2)) @@ -253,9 +260,9 @@ extension CBOR { return res } - public static func encodeMapChunk(_ map: [A: B], options: CBOROptions = CBOROptions()) -> [UInt8] { + public static func encodeMapChunk(_ map: [A: B], options: CBOROptions = CBOROptions()) throws -> [UInt8] { if options.forbidNonStringMapKeys { - try! ensureStringKey(A.self) + try ensureStringKey(A.self) } var res: [UInt8] = [] let count = map.count @@ -292,6 +299,7 @@ extension CBOR { AnnotatedMapDateStrategy.typeKey: AnnotatedMapDateStrategy.typeValue, AnnotatedMapDateStrategy.valueKey: dateCBOR ] + // String keys are guaranteed, so this should never throw return try! CBOR.encodeMap(map, options: options) case .taggedAsEpochTimestamp: var res: [UInt8] = [0b110_00001] // Epoch timestamp tag is 1 diff --git a/Sources/CBOROptions.swift b/Sources/CBOROptions.swift index a0b6244..49d057f 100644 --- a/Sources/CBOROptions.swift +++ b/Sources/CBOROptions.swift @@ -2,7 +2,8 @@ public struct CBOROptions { let useStringKeys: Bool let dateStrategy: DateStrategy let forbidNonStringMapKeys: Bool - /// The maximum number of nested items, inclusive, to decode. A maximum set to 0 dissallows anything other than top-level primitives. + // The maximum number of nested items, inclusive, to decode. A maximum set to 0 disallows + // anything other than top-level primitives. let maximumDepth: Int let shouldSortMapKeys: Bool diff --git a/Sources/Decoder/KeyedDecodingContainer.swift b/Sources/Decoder/KeyedDecodingContainer.swift index 6f0f3dc..8847f37 100644 --- a/Sources/Decoder/KeyedDecodingContainer.swift +++ b/Sources/Decoder/KeyedDecodingContainer.swift @@ -49,15 +49,20 @@ extension _CBORDecoder { for _ in 0.. = startIndex..= self.data.startIndex && range.endIndex <= self.data.endIndex else { + throw DecodingError.dataCorruptedError( + in: self, + debugDescription: "Data range \(range) is out of bounds for data with range \(self.data.startIndex)..<\(self.data.endIndex)" + ) + } + + self.index = range.upperBound + let container = _CBORDecoder.SingleValueContainer( + data: self.data[range.startIndex..<(range.endIndex)], + codingPath: self.codingPath, + userInfo: self.userInfo, + options: self.options + ) return container } diff --git a/Tests/CBORDecoderTests.swift b/Tests/CBORDecoderTests.swift index 502f8d1..d1679bf 100644 --- a/Tests/CBORDecoderTests.swift +++ b/Tests/CBORDecoderTests.swift @@ -168,7 +168,7 @@ class CBORDecoderTests: XCTestCase { XCTAssertEqual(decoded, expected) } - + func testDecodeFailsForExtremelyDeepStructures() { let justOverTags: [UInt8] = Array(repeating: 202, count: 1025) + [0] XCTAssertThrowsError(try CBOR.decode(justOverTags, options: CBOROptions(maximumDepth: 1024))) { error in @@ -179,21 +179,21 @@ class CBORDecoderTests: XCTestCase { XCTAssertEqual(error as? CBORError, CBORError.maximumDepthExceeded) } } - + func testDecodeFailsForSillyMaximumDepths() { let singleItem: [UInt8] = [0] XCTAssertThrowsError(try CBOR.decode(singleItem, options: CBOROptions(maximumDepth: -1))) { error in XCTAssertEqual(error as? CBORError, CBORError.maximumDepthExceeded) } } - + func testDecodeSucceedsForAllowedDeepStructures() { let singleItem: [UInt8] = [0] XCTAssertNoThrow(try CBOR.decode(singleItem, options: CBOROptions(maximumDepth: 0))) let endlessTags: [UInt8] = Array(repeating: 202, count: 1024) + [0] XCTAssertNoThrow(try CBOR.decode(endlessTags, options: CBOROptions(maximumDepth: 1024))) } - + func testRandomInputDoesNotHitStackLimits() { for _ in 1...50 { let length = Int.random(in: 1...1_000_000) @@ -201,21 +201,96 @@ class CBORDecoderTests: XCTestCase { _ = try? CBOR.decode(randomData, options: CBOROptions(maximumDepth: 512)) } } + + /// Test for issue #118: Large array length should not cause crash + /// https://github.com/valpackett/SwiftCBOR/issues/118 + func testLargeArrayLengthShouldNotCrash() throws { + let bytes: [UInt8] = [ + 0x9b, // Array with 8-byte length + 0x54, 0x68, 0x47, 0x9e, 0x98, 0x41, 0xd5, 0xed, // Huge length value + 0xae, 0x42, 0x4b, 0x9e, 0x68, 0x68, 0x47, + 0xa0, 0xf0, 0x41, 0xe2, 0xc4, 0x73, 0x42, 0x4f, 0x8c, 0x48, 0x68, 0x47, 0xa3, 0x48, 0x41, 0xe2, + 0x6c, 0xf2, 0x42, 0x3f, 0x1b, 0x3c, 0x68, 0x47, 0xa5, 0xa0, 0x41, 0xe1, 0xce, 0x5a, 0x42, 0x3d, + 0x71, 0x74, 0x68, 0x47, 0xa7, 0xf8, 0x41, 0xe2, 0x57, 0x12, 0x42, 0x3b, 0x54, 0x6c, 0x68, 0x47, + 0xaa, 0x50, 0x41, 0xe4, 0x17, 0x84, 0x42, 0x40, 0x6d, 0x24, 0x68, 0x47, 0xac, 0xa8, 0x41, 0xe0, + 0xa1, 0x91, 0x42, 0x41, 0xef, 0xdc, 0x68, 0x47, 0xaf, 0x0, 0x41, 0xd8, 0x93, 0xd0, 0x42, 0x48, + 0xfa, 0xa0, 0x68, 0x47, 0xb1, 0x58, 0x41, 0xd5, 0x5a, 0x5, 0x42, 0x4e, 0xfd, 0xb4, 0x68, 0x47, + 0xb3, 0xb0, 0x41, 0xd4, 0x43, 0x1c, 0x42, 0x50, 0x57, 0x6c, 0x68, 0x47, 0xb6, 0x8, 0x41, 0xd3, + 0xc5, 0x54, 0x42, 0x52, 0x37, 0xe4, 0x68, 0x47, 0xb8, 0x60, 0x41, 0xd4, 0x38, 0x2c, 0x42, 0x5f, + 0x84, 0x3c, 0x68, 0x47, 0xba, 0xb8, 0x41, 0xd3, 0x94, 0x1b, 0x42, 0x63, 0x6c, 0x40, 0x68, 0x47, + 0xbd, 0x10, 0x41, 0xd3, 0x5, 0xeb, 0x42, 0x61, 0x5e, 0xdc, 0x68, 0x47 + ] + // Should throw an error, not crash + XCTAssertThrowsError(try CBOR.decode(bytes)) { error in + // Should be an unfinished sequence or incorrectUTF8String error + XCTAssertTrue(error is CBORError) + } + } + + /// Test for issue #69: Corrupted data with out-of-bounds range should not crash + /// https://github.com/valpackett/SwiftCBOR/issues/69 + func testCorruptedDataRangeShouldNotCrash() throws { + // Array with declared length that exceeds actual data + let bytes: [UInt8] = [ + 0x84, // Array of 4 items + 0x01, // Item 1 + 0x02, // Item 2 + // Missing items 3 and 4 + ] + XCTAssertThrowsError(try CBOR.decode(bytes)) { error in + XCTAssertTrue(error is CBORError || error is DecodingError) + } + } + + /// Test that AnyCodingKey properly handles invalid key types + func testAnyCodingKeyInvalidTypeThrows() throws { + struct TestKey: Codable { + let key: AnyCodingKey + } + + // Create CBOR with a boolean key (invalid) + let bytes = CBOR.encode(["key": true]) + + let decoder = CodableCBORDecoder() + XCTAssertThrowsError(try decoder.decode(TestKey.self, from: Data(bytes))) { error in + // Should be a decoding error, not a crash + XCTAssertTrue(error is DecodingError) + } + } + + /// Test that malformed map data throws instead of crashing + func testMalformedMapDataThrows() throws { + // Map with declared count but missing values + let bytes: [UInt8] = [ + 0xa2, // Map with 2 entries + 0x01, // Key 1 + 0x02, // Value 1 + 0x03, // Key 2 + // Missing value 2 + ] + + struct TestStruct: Codable { + let value: [Int: Int] + } + + let decoder = CodableCBORDecoder() + XCTAssertThrowsError(try decoder.decode(TestStruct.self, from: Data(bytes))) { error in + XCTAssertTrue(error is CBORError || error is DecodingError) + } + } } #if os(Android) - extension XCTestCase { /// XCTestCase.measure on Android is problematic because /// the emulator on a virtualized runner can be quite slow /// but there is no way to set the standard deviation threshold /// for failure, so we override it to simply run the block /// and not perform any measurement. - /// + /// /// See: https://github.com/swiftlang/swift-corelibs-xctest/pull/506 func measure(_ count: Int = 0, _ block: () -> ()) { block() } } #endif - diff --git a/Tests/CBOREncoderTests.swift b/Tests/CBOREncoderTests.swift index 266949e..e359157 100644 --- a/Tests/CBOREncoderTests.swift +++ b/Tests/CBOREncoderTests.swift @@ -250,7 +250,7 @@ class CBOREncoderTests: XCTestCase { let map2 = ["B": 2] let a1_enc: [UInt8] = [0x61, 0x61, 0x01] let b2_enc: [UInt8] = [0x61, 0x42, 0x02] - let final: [UInt8] = CBOR.encodeMapStreamStart() + CBOR.encodeMapChunk(map) + CBOR.encodeMapChunk(map2) + CBOR.encodeStreamEnd() + let final: [UInt8] = CBOR.encodeMapStreamStart() + (try! CBOR.encodeMapChunk(map)) + (try! CBOR.encodeMapChunk(map2)) + CBOR.encodeStreamEnd() XCTAssertEqual(final, [0xbf] + a1_enc + b2_enc + [0xff]) } From 6b33977a9fe3f8b135b5b89f79a89056390110f0 Mon Sep 17 00:00:00 2001 From: Hamilton Chapman Date: Mon, 2 Feb 2026 17:23:25 +0000 Subject: [PATCH 2/5] fix: implement `superEncoder` methods for Codable class hierarchies Previously, `superEncoder()` and `superEncoder(forKey:)` methods in both `KeyedEncodingContainer` and `UnkeyedEncodingContainer` would crash with `fatalError("Unimplemented")`. This commit implements these methods to properly support Swift class inheritance with Codable, allowing subclasses to encode their superclass properties into nested containers. **Changes:** - `KeyedEncodingContainer.superEncoder()`: Creates encoder under "super" key - `KeyedEncodingContainer.superEncoder(forKey:)`: Creates encoder under custom key - `UnkeyedEncodingContainer.superEncoder()`: Appends encoder as array element - Make `_CBOREncoder` conform to `CBOREncodingContainer` protocol --- Sources/Encoder/CodableCBOREncoder.swift | 2 + Sources/Encoder/KeyedEncodingContainer.swift | 14 ++- .../Encoder/UnkeyedEncodingContainer.swift | 6 +- Tests/CodableCBOREncoderTests.swift | 114 ++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/Sources/Encoder/CodableCBOREncoder.swift b/Sources/Encoder/CodableCBOREncoder.swift index 027e013..21c479c 100644 --- a/Sources/Encoder/CodableCBOREncoder.swift +++ b/Sources/Encoder/CodableCBOREncoder.swift @@ -123,3 +123,5 @@ extension _CBOREncoder: Encoder { protocol CBOREncodingContainer: AnyObject { var data: Data { get } } + +extension _CBOREncoder: CBOREncodingContainer {} diff --git a/Sources/Encoder/KeyedEncodingContainer.swift b/Sources/Encoder/KeyedEncodingContainer.swift index 2b0bbfe..17870d8 100644 --- a/Sources/Encoder/KeyedEncodingContainer.swift +++ b/Sources/Encoder/KeyedEncodingContainer.swift @@ -67,11 +67,21 @@ extension _CBOREncoder.KeyedContainer: KeyedEncodingContainerProtocol { } func superEncoder() -> Encoder { - fatalError("Unimplemented") // FIXME + // Use a special "super" key for encoding class hierarchies + let superKey = AnyCodingKey(stringValue: "super") + let encoder = _CBOREncoder(options: self.options) + encoder.codingPath = self.codingPath + [superKey] + encoder.userInfo = self.userInfo + self.storage[superKey] = encoder + return encoder } func superEncoder(forKey key: Key) -> Encoder { - fatalError("Unimplemented") // FIXME + let encoder = _CBOREncoder(options: self.options) + encoder.codingPath = self.nestedCodingPath(forKey: key) + encoder.userInfo = self.userInfo + self.storage[anyCodingKeyForKey(key)] = encoder + return encoder } } diff --git a/Sources/Encoder/UnkeyedEncodingContainer.swift b/Sources/Encoder/UnkeyedEncodingContainer.swift index a3bd21e..5400f24 100644 --- a/Sources/Encoder/UnkeyedEncodingContainer.swift +++ b/Sources/Encoder/UnkeyedEncodingContainer.swift @@ -59,7 +59,11 @@ extension _CBOREncoder.UnkeyedContainer: UnkeyedEncodingContainer { } func superEncoder() -> Encoder { - fatalError("Unimplemented") // FIXME + let encoder = _CBOREncoder(options: self.options) + encoder.codingPath = self.nestedCodingPath + encoder.userInfo = self.userInfo + self.storage.append(encoder) + return encoder } } diff --git a/Tests/CodableCBOREncoderTests.swift b/Tests/CodableCBOREncoderTests.swift index eff44a1..136fc9a 100644 --- a/Tests/CodableCBOREncoderTests.swift +++ b/Tests/CodableCBOREncoderTests.swift @@ -130,6 +130,120 @@ class CodableCBOREncoderTests: XCTestCase { || encoded == [0xa2, 0x64, 0x6e, 0x61, 0x6d, 0x65, 0x63, 0x48, 0x61, 0x6d, 0x63, 0x61, 0x67, 0x65, 0x18, 0x1b] ) } + + /// Test that superEncoder() works in KeyedEncodingContainer + func testSuperEncoderInKeyedContainer() throws { + // Create a simple class hierarchy to test super encoding + class Base: Encodable { + let baseValue: Int + + init(baseValue: Int) { + self.baseValue = baseValue + } + } + + class Derived: Base { + let derivedValue: String + + init(baseValue: Int, derivedValue: String) { + self.derivedValue = derivedValue + super.init(baseValue: baseValue) + } + + enum CodingKeys: String, CodingKey { + case derivedValue + } + + override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(derivedValue, forKey: .derivedValue) + // Use superEncoder to encode the base class + try super.encode(to: container.superEncoder()) + } + } + + let derived = Derived(baseValue: 42, derivedValue: "test") + let encoded = try CodableCBOREncoder().encode(derived) + + // Should be encodable and decodable + XCTAssertNotNil(encoded) + XCTAssertGreaterThan(encoded.count, 0) + + // Decode to verify structure + let decoded = try CBOR.decode([UInt8](encoded)) + XCTAssertNotNil(decoded) + } + + /// Test that superEncoder() works in UnkeyedEncodingContainer + func testSuperEncoderInUnkeyedContainer() throws { + struct TestStruct: Encodable { + let value: Int + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(value) + // Use superEncoder in an array context + let superEncoder = container.superEncoder() + var nestedContainer = superEncoder.singleValueContainer() + try nestedContainer.encode("nested") + } + } + + let test = TestStruct(value: 123) + let encoded = try CodableCBOREncoder().encode(test) + + // Should encode successfully + XCTAssertNotNil(encoded) + XCTAssertGreaterThan(encoded.count, 0) + + // Verify it's a valid CBOR array with 2 elements + let decoded = try CBOR.decode([UInt8](encoded)) + if case let .array(arr) = decoded { + XCTAssertEqual(arr.count, 2) + XCTAssertEqual(arr[0], CBOR.unsignedInt(123)) + XCTAssertEqual(arr[1], CBOR.utf8String("nested")) + } else { + XCTFail("Expected array, got \(decoded)") + } + } + + /// Test that superEncoder(forKey:) works correctly + func testSuperEncoderForKey() throws { + struct TestStruct: Encodable { + let value1: Int + let value2: String + + enum CodingKeys: String, CodingKey { + case value1 + case customSuper + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value1, forKey: .value1) + + // Use superEncoder with a custom key + let superEncoder = container.superEncoder(forKey: .customSuper) + var nestedContainer = superEncoder.singleValueContainer() + try nestedContainer.encode(value2) + } + } + + let test = TestStruct(value1: 42, value2: "hello") + let encoded = try CodableCBOREncoder().encode(test) + + XCTAssertNotNil(encoded) + XCTAssertGreaterThan(encoded.count, 0) + + // Verify structure + let decoded = try CBOR.decode([UInt8](encoded)) + if case let .map(dict) = decoded { + XCTAssertEqual(dict[CBOR.utf8String("value1")], CBOR.unsignedInt(42)) + XCTAssertEqual(dict[CBOR.utf8String("customSuper")], CBOR.utf8String("hello")) + } else { + XCTFail("Expected map, got \(decoded)") + } + } } extension Array { From 12757c5bef383df5a523847b33ba208e652a85f2 Mon Sep 17 00:00:00 2001 From: Hamilton Chapman Date: Mon, 2 Feb 2026 18:02:50 +0000 Subject: [PATCH 3/5] fix: full depth tracking across Codable container hierarchy This change includes 2 main changes. Previously, the `maximumDepth` option was defined but not actually used in `CodableCBORDecoder`, creating a DoS vulnerability where deeply nested structures could cause stack overflow. **Problem:** 1. `CodableCBORDecoder` had a `maximumDepth` property but it wasn't included in the options computed property, so it defaulted to `.max` 2. `SingleValueDecodingContainer` called `CBOR.decode()` without passing options 3. `KeyedDecodingContainer` and `UnkeyedDecodingContainer` created `CBORDecoder` instances without passing options 4. This meant depth checking was never enforced for Codable decoding **Fix:** - Add `maximumDepth` property to `CodableCBORDecoder` (public API) - Include `maximumDepth` in options computed property - Update `setOptions()` to set `maximumDepth` - Pass options to all `CBOR.decode()` calls in `SingleValueDecodingContainer` - Pass options to `CBORDecoder()` calls in `KeyedDecodingContainer` - Pass options to `CBORDecoder()` calls in `UnkeyedDecodingContainer` Changes: - Added `currentDepth` field to `_CBORDecoder` and all container classes (`KeyedContainer`, `UnkeyedContainer`, `SingleValueContainer`) - Thread `currentDepth` through all container creation and nested decoding - Check depth before creating keyed/unkeyed containers in `_CBORDecoder` - Increment depth when creating nested decoders in `decode()` methods - Pass incremented depth through `superDecoder()` methods Previously, each `CBOR.decode()` call would reset `currentDepth` to 0, allowing deeply nested structures to bypass the depth limit. For example, a 10-level nested array with `maximumDepth=5` would incorrectly succeed. Now depth is properly tracked across the entire container hierarchy. --- Sources/Decoder/CodableCBORDecoder.swift | 30 ++- Sources/Decoder/KeyedDecodingContainer.swift | 22 ++- .../SingleValueDecodingContainer.swift | 38 ++-- .../Decoder/UnkeyedDecodingContainer.swift | 27 +-- Tests/CodableCBORDecoderTests.swift | 173 ++++++++++++++++++ 5 files changed, 247 insertions(+), 43 deletions(-) diff --git a/Sources/Decoder/CodableCBORDecoder.swift b/Sources/Decoder/CodableCBORDecoder.swift index 4f77d43..39603d8 100644 --- a/Sources/Decoder/CodableCBORDecoder.swift +++ b/Sources/Decoder/CodableCBORDecoder.swift @@ -3,6 +3,7 @@ import Foundation final public class CodableCBORDecoder { public var useStringKeys: Bool = false public var dateStrategy: DateStrategy = .taggedAsEpochTimestamp + public var maximumDepth: Int = .max struct _Options { let useStringKeys: Bool @@ -29,7 +30,7 @@ final public class CodableCBORDecoder { } var options: _Options { - return _Options(useStringKeys: self.useStringKeys, dateStrategy: self.dateStrategy) + return _Options(useStringKeys: self.useStringKeys, dateStrategy: self.dateStrategy, maximumDepth: self.maximumDepth) } public init() {} @@ -66,6 +67,7 @@ final public class CodableCBORDecoder { func setOptions(_ newOptions: _Options) { self.useStringKeys = newOptions.useStringKeys self.dateStrategy = newOptions.dateStrategy + self.maximumDepth = newOptions.maximumDepth } } @@ -78,34 +80,52 @@ final class _CBORDecoder { fileprivate var data: ArraySlice let options: CodableCBORDecoder._Options + var currentDepth: Int - init(data: ArraySlice, options: CodableCBORDecoder._Options) { + init(data: ArraySlice, options: CodableCBORDecoder._Options, currentDepth: Int = 0) { self.data = data self.options = options + self.currentDepth = currentDepth } } extension _CBORDecoder: Decoder { func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { + guard self.currentDepth < self.options.maximumDepth else { + let context = DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Maximum decoding depth of \(self.options.maximumDepth) exceeded" + ) + throw DecodingError.dataCorrupted(context) + } + try ensureMap(self.data.first, keyType: Key.self) - let container = KeyedContainer(data: self.data, codingPath: self.codingPath, userInfo: self.userInfo, options: self.options) + let container = KeyedContainer(data: self.data, codingPath: self.codingPath, userInfo: self.userInfo, options: self.options, currentDepth: self.currentDepth) self.container = container return KeyedDecodingContainer(container) } func unkeyedContainer() throws -> UnkeyedDecodingContainer { + guard self.currentDepth < self.options.maximumDepth else { + let context = DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Maximum decoding depth of \(self.options.maximumDepth) exceeded" + ) + throw DecodingError.dataCorrupted(context) + } + try ensureArray(self.data.first) - let container = UnkeyedContainer(data: self.data, codingPath: self.codingPath, userInfo: self.userInfo, options: self.options) + let container = UnkeyedContainer(data: self.data, codingPath: self.codingPath, userInfo: self.userInfo, options: self.options, currentDepth: self.currentDepth) self.container = container return container } func singleValueContainer() throws -> SingleValueDecodingContainer { - let container = SingleValueContainer(data: self.data, codingPath: self.codingPath, userInfo: self.userInfo, options: self.options) + let container = SingleValueContainer(data: self.data, codingPath: self.codingPath, userInfo: self.userInfo, options: self.options, currentDepth: self.currentDepth) self.container = container return container diff --git a/Sources/Decoder/KeyedDecodingContainer.swift b/Sources/Decoder/KeyedDecodingContainer.swift index 8847f37..c0636bb 100644 --- a/Sources/Decoder/KeyedDecodingContainer.swift +++ b/Sources/Decoder/KeyedDecodingContainer.swift @@ -14,13 +14,15 @@ extension _CBORDecoder { var codingPath: [CodingKey] var userInfo: [CodingUserInfoKey: Any] let options: CodableCBORDecoder._Options + let currentDepth: Int - init(data: ArraySlice, codingPath: [CodingKey], userInfo: [CodingUserInfoKey : Any], options: CodableCBORDecoder._Options) { + init(data: ArraySlice, codingPath: [CodingKey], userInfo: [CodingUserInfoKey : Any], options: CodableCBORDecoder._Options, currentDepth: Int = 0) { self.codingPath = codingPath self.userInfo = userInfo self.data = data self.index = self.data.startIndex self.options = options + self.currentDepth = currentDepth } func checkCanDecodeValue(forKey key: Key) throws { @@ -41,7 +43,7 @@ extension _CBORDecoder { var nestedContainers: [AnyCodingKey: CBORDecodingContainer] = [:] - let unkeyedContainer = UnkeyedContainer(data: self.data.suffix(from: self.index), codingPath: self.codingPath, userInfo: self.userInfo, options: self.options) + let unkeyedContainer = UnkeyedContainer(data: self.data.suffix(from: self.index), codingPath: self.codingPath, userInfo: self.userInfo, options: self.options, currentDepth: self.currentDepth) unkeyedContainer.count = count * 2 var iterator = unkeyedContainer.nestedContainers.makeIterator() @@ -94,7 +96,7 @@ extension _CBORDecoder { // each key-value pair in the map. let nextIndex = self.data.startIndex.advanced(by: 1) let remainingData = self.data.suffix(from: nextIndex) - count = try? CBORDecoder(input: remainingData.map { $0 }).readPairsUntilBreak().keys.count + count = try? CBORDecoder(input: remainingData.map { $0 }, options: self.options.toCBOROptions()).readPairsUntilBreak().keys.count default: let context = DecodingError.Context( codingPath: self.codingPath, @@ -145,9 +147,10 @@ extension _CBORDecoder.KeyedContainer: KeyedDecodingContainerProtocol { try checkCanDecodeValue(forKey: key) let container = try self.nestedContainers()[anyCodingKeyForKey(key)]! - let decoder = CodableCBORDecoder() - decoder.setOptions(self.options) - return try decoder.decode(T.self, from: container.data) + let innerDecoder = _CBORDecoder(data: container.data, options: self.options, currentDepth: self.currentDepth + 1) + innerDecoder.codingPath = self.codingPath + [key] + innerDecoder.userInfo = self.userInfo + return try T(from: innerDecoder) } func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { @@ -170,17 +173,18 @@ extension _CBORDecoder.KeyedContainer: KeyedDecodingContainerProtocol { data: anyCodingKeyedContainer.data, codingPath: anyCodingKeyedContainer.codingPath, userInfo: anyCodingKeyedContainer.userInfo, - options: anyCodingKeyedContainer.options + options: anyCodingKeyedContainer.options, + currentDepth: anyCodingKeyedContainer.currentDepth ) return KeyedDecodingContainer(container) } func superDecoder() throws -> Decoder { - return _CBORDecoder(data: self.data, options: self.options) + return _CBORDecoder(data: self.data, options: self.options, currentDepth: self.currentDepth + 1) } func superDecoder(forKey key: Key) throws -> Decoder { - let decoder = _CBORDecoder(data: self.data, options: self.options) + let decoder = _CBORDecoder(data: self.data, options: self.options, currentDepth: self.currentDepth + 1) decoder.codingPath = [key] return decoder diff --git a/Sources/Decoder/SingleValueDecodingContainer.swift b/Sources/Decoder/SingleValueDecodingContainer.swift index f6b7446..91b0772 100644 --- a/Sources/Decoder/SingleValueDecodingContainer.swift +++ b/Sources/Decoder/SingleValueDecodingContainer.swift @@ -7,13 +7,15 @@ extension _CBORDecoder { var data: ArraySlice var index: Data.Index let options: CodableCBORDecoder._Options + let currentDepth: Int - init(data: ArraySlice, codingPath: [CodingKey], userInfo: [CodingUserInfoKey : Any], options: CodableCBORDecoder._Options) { + init(data: ArraySlice, codingPath: [CodingKey], userInfo: [CodingUserInfoKey : Any], options: CodableCBORDecoder._Options, currentDepth: Int = 0) { self.codingPath = codingPath self.userInfo = userInfo self.data = data self.index = self.data.startIndex self.options = options + self.currentDepth = currentDepth } func checkCanDecode(_ type: T.Type, format: UInt8) throws { @@ -32,7 +34,7 @@ extension _CBORDecoder { extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { func decodeNil() -> Bool { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { return false } switch cbor { @@ -42,7 +44,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Bool.Type) throws -> Bool { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -55,7 +57,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: String.Type) throws -> String { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -68,7 +70,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Double.Type) throws -> Double { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -83,7 +85,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Float.Type) throws -> Float { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -97,7 +99,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Int.Type) throws -> Int { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -111,7 +113,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Int8.Type) throws -> Int8 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -125,7 +127,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Int16.Type) throws -> Int16 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -139,7 +141,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Int32.Type) throws -> Int32 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -153,7 +155,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: Int64.Type) throws -> Int64 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -167,7 +169,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: UInt.Type) throws -> UInt { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -180,7 +182,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: UInt8.Type) throws -> UInt8 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -193,7 +195,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: UInt16.Type) throws -> UInt16 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -206,7 +208,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: UInt32.Type) throws -> UInt32 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -219,7 +221,7 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: UInt64.Type) throws -> UInt64 { - guard let cbor = try? CBOR.decode(self.data.map { $0 }) else { + guard let cbor = try? CBOR.decode(self.data.map { $0 }, options: self.options.toCBOROptions()) else { let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "Invalid format: \(self.data)") throw DecodingError.dataCorrupted(context) } @@ -232,7 +234,9 @@ extension _CBORDecoder.SingleValueContainer: SingleValueDecodingContainer { } func decode(_ type: T.Type) throws -> T { - let decoder = _CBORDecoder(data: self.data, options: self.options) + let decoder = _CBORDecoder(data: self.data, options: self.options, currentDepth: self.currentDepth + 1) + decoder.codingPath = self.codingPath + decoder.userInfo = self.userInfo let value = try T(from: decoder) if let nextIndex = decoder.container?.index { self.index = nextIndex diff --git a/Sources/Decoder/UnkeyedDecodingContainer.swift b/Sources/Decoder/UnkeyedDecodingContainer.swift index 8b7fb25..fcb56c5 100644 --- a/Sources/Decoder/UnkeyedDecodingContainer.swift +++ b/Sources/Decoder/UnkeyedDecodingContainer.swift @@ -14,6 +14,7 @@ extension _CBORDecoder { var index: Data.Index let options: CodableCBORDecoder._Options + let currentDepth: Int lazy var count: Int? = { do { @@ -37,7 +38,7 @@ extension _CBORDecoder { // decoding each item in the array. let nextIndex = self.data.startIndex.advanced(by: 1) let remainingData = self.data.suffix(from: nextIndex) - return try? CBORDecoder(input: remainingData).readUntilBreak().count + return try? CBORDecoder(input: remainingData, options: self.options.toCBOROptions()).readUntilBreak().count default: return nil } @@ -71,12 +72,13 @@ extension _CBORDecoder { return nestedContainers }() - init(data: ArraySlice, codingPath: [CodingKey], userInfo: [CodingUserInfoKey : Any], options: CodableCBORDecoder._Options) { + init(data: ArraySlice, codingPath: [CodingKey], userInfo: [CodingUserInfoKey : Any], options: CodableCBORDecoder._Options, currentDepth: Int = 0) { self.codingPath = codingPath self.userInfo = userInfo self.data = data self.index = self.data.startIndex self.options = options + self.currentDepth = currentDepth } var isAtEnd: Bool { @@ -128,11 +130,10 @@ extension _CBORDecoder.UnkeyedContainer: UnkeyedDecodingContainer { defer { self.currentIndex += 1 } let container = self.nestedContainers[self.currentIndex] - let decoder = CodableCBORDecoder() - decoder.setOptions(self.options) - let value = try decoder.decode(T.self, from: container.data) - - return value + let innerDecoder = _CBORDecoder(data: container.data, options: self.options, currentDepth: self.currentDepth + 1) + innerDecoder.codingPath = self.codingPath + [AnyCodingKey(intValue: self.currentIndex)] + innerDecoder.userInfo = self.userInfo + return try T(from: innerDecoder) } func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { @@ -154,13 +155,14 @@ extension _CBORDecoder.UnkeyedContainer: UnkeyedDecodingContainer { data: anyCodingKeyContainer.data, codingPath: anyCodingKeyContainer.codingPath, userInfo: anyCodingKeyContainer.userInfo, - options: anyCodingKeyContainer.options + options: anyCodingKeyContainer.options, + currentDepth: anyCodingKeyContainer.currentDepth ) return KeyedDecodingContainer(container) } func superDecoder() throws -> Decoder { - return _CBORDecoder(data: self.data, options: self.options) + return _CBORDecoder(data: self.data, options: self.options, currentDepth: self.currentDepth + 1) } } @@ -194,7 +196,7 @@ extension _CBORDecoder.UnkeyedContainer { throw DecodingError.dataCorruptedError(in: self, debugDescription: "Handling UTF8 strings with break bytes is not supported yet") // Arrays case 0x80...0x9f: - let container = _CBORDecoder.UnkeyedContainer(data: self.data.suffix(from: startIndex), codingPath: self.nestedCodingPath, userInfo: self.userInfo, options: self.options) + let container = _CBORDecoder.UnkeyedContainer(data: self.data.suffix(from: startIndex), codingPath: self.nestedCodingPath, userInfo: self.userInfo, options: self.options, currentDepth: self.currentDepth) _ = container.nestedContainers self.index = container.index @@ -208,7 +210,7 @@ extension _CBORDecoder.UnkeyedContainer { return container // Maps case 0xa0...0xbf: - let container = _CBORDecoder.KeyedContainer(data: self.data.suffix(from: startIndex), codingPath: self.nestedCodingPath, userInfo: self.userInfo, options: self.options) + let container = _CBORDecoder.KeyedContainer(data: self.data.suffix(from: startIndex), codingPath: self.nestedCodingPath, userInfo: self.userInfo, options: self.options, currentDepth: self.currentDepth) let _ = try container.nestedContainers() // FIXME self.index = container.index @@ -249,7 +251,8 @@ extension _CBORDecoder.UnkeyedContainer { data: self.data[range.startIndex..<(range.endIndex)], codingPath: self.codingPath, userInfo: self.userInfo, - options: self.options + options: self.options, + currentDepth: self.currentDepth ) return container } diff --git a/Tests/CodableCBORDecoderTests.swift b/Tests/CodableCBORDecoderTests.swift index 436ce6a..28ccada 100644 --- a/Tests/CodableCBORDecoderTests.swift +++ b/Tests/CodableCBORDecoderTests.swift @@ -125,4 +125,177 @@ class CodableCBORDecoderTests: XCTestCase { let dateTwo = try! CodableCBORDecoder().decode(Date.self, from: Data([0xc1, 0xfb, 0x41, 0xd4, 0x52, 0xd9, 0xec, 0x20, 0x00, 0x00])) XCTAssertEqual(dateTwo, expectedDateTwo) } + + /// Test that maximumDepth option is properly accessible and passed through + func testMaximumDepthOptionAccessible() throws { + // Test that maximumDepth is accessible and can be set + let decoder = CodableCBORDecoder() + XCTAssertEqual(decoder.maximumDepth, .max) // Default value + + decoder.maximumDepth = 100 + XCTAssertEqual(decoder.maximumDepth, 100) + + // Test that options are properly converted + let options = decoder.options + XCTAssertEqual(options.maximumDepth, 100) + + let cborOptions = options.toCBOROptions() + XCTAssertEqual(cborOptions.maximumDepth, 100) + + XCTAssertEqual(decoder.options.maximumDepth, decoder.maximumDepth) + } + + /// Test that depth is enforced across nested array structures + func testMaximumDepthEnforcedAcrossNestedArrays() throws { + // Create a deeply nested array: [[[[42]]]] (4 levels deep) + // Level 0: outer array + // Level 1: first nested array + // Level 2: second nested array + // Level 3: third nested array + // Level 4: innermost value (42) + let deeplyNested = try! CodableCBOREncoder().encode([[[[42]]]]) + + // Should succeed with depth limit of 5 or more + let decoder5 = CodableCBORDecoder() + decoder5.maximumDepth = 5 + XCTAssertNoThrow(try decoder5.decode([[[[Int]]]].self, from: deeplyNested)) + + // Should fail with depth limit of 3 (can't reach level 4) + let decoder3 = CodableCBORDecoder() + decoder3.maximumDepth = 3 + XCTAssertThrowsError(try decoder3.decode([[[[Int]]]].self, from: deeplyNested)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Expected dataCorrupted error, got \(error)") + return + } + XCTAssertTrue(context.debugDescription.contains("Maximum decoding depth")) + } + + // Should fail with depth limit of 0 (can't even decode top level) + let decoder0 = CodableCBORDecoder() + decoder0.maximumDepth = 0 + XCTAssertThrowsError(try decoder0.decode([[[[Int]]]].self, from: deeplyNested)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Expected dataCorrupted error, got \(error)") + return + } + XCTAssertTrue(context.debugDescription.contains("Maximum decoding depth")) + } + } + + /// Test that depth is enforced across nested map structures + func testMaximumDepthEnforcedAcrossNestedMaps() throws { + struct Level3: Codable, Equatable { let value: Int } + struct Level2: Codable, Equatable { let nested: Level3 } + struct Level1: Codable, Equatable { let nested: Level2 } + struct Level0: Codable, Equatable { let nested: Level1 } + + let deeply = Level0(nested: Level1(nested: Level2(nested: Level3(value: 42)))) + let encoded = try! CodableCBOREncoder().encode(deeply) + + // Should succeed with sufficient depth + let decoder5 = CodableCBORDecoder() + decoder5.maximumDepth = 5 + XCTAssertNoThrow(try decoder5.decode(Level0.self, from: encoded)) + + // Should fail with insufficient depth + let decoder2 = CodableCBORDecoder() + decoder2.maximumDepth = 2 + XCTAssertThrowsError(try decoder2.decode(Level0.self, from: encoded)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Expected dataCorrupted error, got \(error)") + return + } + XCTAssertTrue(context.debugDescription.contains("Maximum decoding depth")) + } + } + + /// Test that depth is enforced across mixed array and map structures + func testMaximumDepthEnforcedAcrossMixedStructures() throws { + struct Inner: Codable, Equatable { let values: [Int] } + struct Outer: Codable, Equatable { let items: [Inner] } + + let mixed = Outer(items: [Inner(values: [1, 2]), Inner(values: [3, 4])]) + let encoded = try! CodableCBOREncoder().encode(mixed) + + // Structure depth: + // Level 0: Outer keyed container + // Level 1: items array + // Level 2: Inner keyed container + // Level 3: values array + // Level 4: Int values + + // Should succeed with depth 5 + let decoder5 = CodableCBORDecoder() + decoder5.maximumDepth = 5 + let decoded = try! decoder5.decode(Outer.self, from: encoded) + XCTAssertEqual(decoded, mixed) + + // Should fail with depth 2 (can't reach Inner level) + let decoder2 = CodableCBORDecoder() + decoder2.maximumDepth = 2 + XCTAssertThrowsError(try decoder2.decode(Outer.self, from: encoded)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Expected dataCorrupted error, got \(error)") + return + } + XCTAssertTrue(context.debugDescription.contains("Maximum decoding depth")) + } + } + + /// Test the specific case from the bug report: 10-deep nested array with depth=5 should fail + func testDeepNestedArrayRespectDepthLimit() throws { + // Create 10-level deep nested array + typealias Level10 = [[[[[[[[[[Int]]]]]]]]]] + + let level1: [Int] = [42] + let level2: [[Int]] = [level1] + let level3: [[[Int]]] = [level2] + let level4: [[[[Int]]]] = [level3] + let level5: [[[[[Int]]]]] = [level4] + let level6: [[[[[[Int]]]]]] = [level5] + let level7: [[[[[[[Int]]]]]]] = [level6] + let level8: [[[[[[[[Int]]]]]]]] = [level7] + let level9: [[[[[[[[[Int]]]]]]]]] = [level8] + let level10: Level10 = [level9] + + let encoded = try! CodableCBOREncoder().encode(level10) + + // Should fail with depth limit of 5 + let decoder = CodableCBORDecoder() + decoder.maximumDepth = 5 + + XCTAssertThrowsError(try decoder.decode(Level10.self, from: encoded)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Expected dataCorrupted error, got \(error)") + return + } + XCTAssertTrue(context.debugDescription.contains("Maximum decoding depth")) + } + } + + /// Test that depth tracking works correctly when decoding array elements + func testDepthTrackingInArrayElements() throws { + // Array of arrays: [[1], [2], [3]] + // Each inner array is at depth 1 when decoded as an element + let arrayOfArrays = [[1], [2], [3]] + let encoded = try! CodableCBOREncoder().encode(arrayOfArrays) + + // Should succeed with depth 3 + let decoder3 = CodableCBORDecoder() + decoder3.maximumDepth = 3 + let decoded = try! decoder3.decode([[Int]].self, from: encoded) + XCTAssertEqual(decoded, arrayOfArrays) + + // Should fail with depth 1 (can't decode inner arrays) + let decoder1 = CodableCBORDecoder() + decoder1.maximumDepth = 1 + XCTAssertThrowsError(try decoder1.decode([[Int]].self, from: encoded)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Expected dataCorrupted error, got \(error)") + return + } + XCTAssertTrue(context.debugDescription.contains("Maximum decoding depth")) + } + } } From e4cbb36f99b195f502ea751d66c8370673f041fc Mon Sep 17 00:00:00 2001 From: Hamilton Chapman Date: Mon, 2 Feb 2026 18:52:10 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20typo=20in=20`CBOROptions`=20paramete?= =?UTF-8?q?r:=20`shouldShortMapKeys`=20=E2=86=92=20`shouldSortMapKeys`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter name in `CBOROptions.init()` was incorrectly spelled as `shouldShortMapKeys` when it should be `shouldSortMapKeys` to match the property name and convey the correct meaning (sorting keys, not shortening them). --- Sources/CBOROptions.swift | 4 ++-- Tests/CBOREncoderTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CBOROptions.swift b/Sources/CBOROptions.swift index 49d057f..256fb50 100644 --- a/Sources/CBOROptions.swift +++ b/Sources/CBOROptions.swift @@ -12,13 +12,13 @@ public struct CBOROptions { dateStrategy: DateStrategy = .taggedAsEpochTimestamp, forbidNonStringMapKeys: Bool = false, maximumDepth: Int = .max, - shouldShortMapKeys: Bool = true + shouldSortMapKeys: Bool = true ) { self.useStringKeys = useStringKeys self.dateStrategy = dateStrategy self.forbidNonStringMapKeys = forbidNonStringMapKeys self.maximumDepth = maximumDepth - self.shouldSortMapKeys = shouldShortMapKeys + self.shouldSortMapKeys = shouldSortMapKeys } func toCodableEncoderOptions() -> CodableCBOREncoder._Options { diff --git a/Tests/CBOREncoderTests.swift b/Tests/CBOREncoderTests.swift index e359157..b72a92d 100644 --- a/Tests/CBOREncoderTests.swift +++ b/Tests/CBOREncoderTests.swift @@ -102,7 +102,7 @@ class CBOREncoderTests: XCTestCase { "a": 1, "b": [2, 3] ] - let encodedMapToAny = try! CBOR.encodeMap(mapToAny, options: .init(shouldShortMapKeys: true)) + let encodedMapToAny = try! CBOR.encodeMap(mapToAny, options: .init(shouldSortMapKeys: true)) XCTAssertEqual(encodedMapToAny, [0xa2, 0x61, 0x61, 0x01, 0x61, 0x62, 0x82, 0x02, 0x03]) let mapToAnyWithIntKeys: [Int: Any] = [ From a1eb97d376d42e2644ee3544c19403adf6872a8b Mon Sep 17 00:00:00 2001 From: Hamilton Chapman Date: Mon, 2 Feb 2026 18:49:46 +0000 Subject: [PATCH 5/5] fix: CBOR subscript force unwrap that crashes on `nil` assignment The subscript setter had force unwraps that would crash when setting a value to `nil`. Now we don't force unwrap and when setting an array's value by index to `nil` the element will be updated to become a `CBOR.null`. --- Sources/CBOR.swift | 4 ++-- Tests/CBORTests.swift | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Sources/CBOR.swift b/Sources/CBOR.swift index e5270bc..5cb7f15 100644 --- a/Sources/CBOR.swift +++ b/Sources/CBOR.swift @@ -61,10 +61,10 @@ public indirect enum CBOR : Equatable, Hashable, set(x) { switch (self, position) { case (var .array(l), let .unsignedInt(i)): - l[Int(i)] = x! + l[Int(i)] = x ?? .null // nil becomes CBOR.null self = .array(l) case (var .map(l), let i): - l[i] = x! + l[i] = x // nil removes the key from dictionary self = .map(l) default: break } diff --git a/Tests/CBORTests.swift b/Tests/CBORTests.swift index e888f24..2440d1b 100644 --- a/Tests/CBORTests.swift +++ b/Tests/CBORTests.swift @@ -58,4 +58,40 @@ class CBORTests: XCTestCase { cbor["tags"]?[2] = CBOR.map(nestedMap) XCTAssertEqual(cbor["tags"]?[2], CBOR.map(nestedMap)) } + + func testSubscriptSetterWithNilOnMap() { + // Test that setting a map value to nil removes the key + var cbor = CBOR.map(["foo": CBOR.unsignedInt(1), "bar": CBOR.utf8String("test")]) + + XCTAssertEqual(cbor["foo"], CBOR.unsignedInt(1)) + + // Setting to nil should remove the key + cbor["foo"] = nil + + XCTAssertNil(cbor["foo"]) + XCTAssertEqual(cbor["bar"], CBOR.utf8String("test")) + } + + func testSubscriptSetterWithNilOnArray() { + // Test that setting an array element to nil sets it to CBOR.null + var cbor = CBOR.array([CBOR.unsignedInt(1), CBOR.unsignedInt(2), CBOR.unsignedInt(3)]) + + // Setting to nil should not crash and should set to CBOR.null + cbor[1] = nil + + // Element should be set to null, others unchanged + XCTAssertEqual(cbor[0], CBOR.unsignedInt(1)) + XCTAssertEqual(cbor[1], CBOR.null) + XCTAssertEqual(cbor[2], CBOR.unsignedInt(3)) + } + + func testSubscriptSetterWithValidValue() { + // Test that setting with a valid value still works + var cbor = CBOR.array([CBOR.unsignedInt(1), CBOR.unsignedInt(2)]) + + cbor[1] = CBOR.utf8String("changed") + + XCTAssertEqual(cbor[0], CBOR.unsignedInt(1)) + XCTAssertEqual(cbor[1], CBOR.utf8String("changed")) + } }