diff --git a/Sources/CBOREncoder.swift b/Sources/CBOREncoder.swift index 8ba85fd..d33a355 100644 --- a/Sources/CBOREncoder.swift +++ b/Sources/CBOREncoder.swift @@ -136,9 +136,26 @@ extension CBOR { res.reserveCapacity(1 + map.count * (MemoryLayout.size + MemoryLayout.size + 2)) res = map.count.encode(options: options) res[0] = res[0] | 0b101_00000 - for (k, v) in map { - res.append(contentsOf: k.encode(options: options)) - res.append(contentsOf: v.encode(options: options)) + + if options.shouldSortMapKeys { + let sortedKeysWithEncodedKeys = map.keys.map { + (encoded: $0.encode(options: options), key: $0) + }.sorted(by: { + $0.encoded.lexicographicallyPrecedes($1.encoded) + }) + + sortedKeysWithEncodedKeys.forEach { keyTuple in + res.append(contentsOf: keyTuple.encoded) + guard let value = map[keyTuple.key] else { + return + } + res.append(contentsOf: value.encode(options: options)) + } + } else { + for (k, v) in map { + res.append(contentsOf: k.encode(options: options)) + res.append(contentsOf: v.encode(options: options)) + } } return res } @@ -442,16 +459,24 @@ extension CBOR { if options.forbidNonStringMapKeys { try ensureStringKey(A.self) } - let sortedKeysWithEncodedKeys = map.keys.map { - (encoded: $0.encode(options: options), key: $0) - }.sorted(by: { - $0.encoded.lexicographicallyPrecedes($1.encoded) - }) - - try sortedKeysWithEncodedKeys.forEach { keyTuple in - res.append(contentsOf: keyTuple.encoded) - let encodedVal = try encodeAny(map[keyTuple.key]!, options: options) - res.append(contentsOf: encodedVal) + if options.shouldSortMapKeys { + let sortedKeysWithEncodedKeys = map.keys.map { + (encoded: $0.encode(options: options), key: $0) + }.sorted(by: { + $0.encoded.lexicographicallyPrecedes($1.encoded) + }) + + try sortedKeysWithEncodedKeys.forEach { keyTuple in + res.append(contentsOf: keyTuple.encoded) + let encodedVal = try encodeAny(map[keyTuple.key]!, options: options) + res.append(contentsOf: encodedVal) + } + } else { + for (k, v) in map { + res.append(contentsOf: k.encode(options: options)) + let encodedVal = try encodeAny(v, options: options) + res.append(contentsOf: encodedVal) + } } } } diff --git a/Sources/CBOROptions.swift b/Sources/CBOROptions.swift index f422857..a0b6244 100644 --- a/Sources/CBOROptions.swift +++ b/Sources/CBOROptions.swift @@ -4,17 +4,20 @@ public struct CBOROptions { let forbidNonStringMapKeys: Bool /// The maximum number of nested items, inclusive, to decode. A maximum set to 0 dissallows anything other than top-level primitives. let maximumDepth: Int + let shouldSortMapKeys: Bool public init( useStringKeys: Bool = false, dateStrategy: DateStrategy = .taggedAsEpochTimestamp, forbidNonStringMapKeys: Bool = false, - maximumDepth: Int = .max + maximumDepth: Int = .max, + shouldShortMapKeys: Bool = true ) { self.useStringKeys = useStringKeys self.dateStrategy = dateStrategy self.forbidNonStringMapKeys = forbidNonStringMapKeys self.maximumDepth = maximumDepth + self.shouldSortMapKeys = shouldShortMapKeys } func toCodableEncoderOptions() -> CodableCBOREncoder._Options { diff --git a/Tests/CBOREncoderTests.swift b/Tests/CBOREncoderTests.swift index 79f6bc4..266949e 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) + let encodedMapToAny = try! CBOR.encodeMap(mapToAny, options: .init(shouldShortMapKeys: true)) XCTAssertEqual(encodedMapToAny, [0xa2, 0x61, 0x61, 0x01, 0x61, 0x62, 0x82, 0x02, 0x03]) let mapToAnyWithIntKeys: [Int: Any] = [ @@ -114,6 +114,70 @@ class CBOREncoderTests: XCTestCase { } } + func testEncodeSortedMaps() { + XCTAssertEqual(CBOR.encode(Dictionary()), [0xa0]) + + let encoded = CBOR.encode([3: 4, 1: 2]) + XCTAssert(encoded == [0xa2, 0x01, 0x02, 0x03, 0x04]) + + let arr1: CBOR = [1] + let arr2: CBOR = [2,3] + let nestedEnc: [UInt8] = CBOR.encode(["b": arr2, "a": arr1]) + let encodedAFirst: [UInt8] = [0xa2, 0x61, 0x61, 0x81, 0x01, 0x61, 0x62, 0x82, 0x02, 0x03] + XCTAssertEqual(nestedEnc, encodedAFirst) + + // Test comprehensive deterministic encoding with tricky edge cases + var keyValuePairs: [(CBOR, CBOR)] = [ + (CBOR.unsignedInt(1), CBOR.unsignedInt(1)), + (CBOR.unsignedInt(10), CBOR.unsignedInt(0)), + (CBOR.unsignedInt(100), CBOR.unsignedInt(2)), + (CBOR.negativeInt(0), CBOR.unsignedInt(3)), + (CBOR.utf8String("a"), CBOR.unsignedInt(4)), + (CBOR.utf8String("aa"), CBOR.unsignedInt(5)), + (CBOR.utf8String("z"), CBOR.unsignedInt(9)), + (CBOR.array([CBOR.unsignedInt(100)]), CBOR.unsignedInt(6)), + (CBOR.array([CBOR.negativeInt(0)]), CBOR.unsignedInt(7)), + (CBOR.boolean(false), CBOR.unsignedInt(8)), + ] + + keyValuePairs.shuffle() + + var mixedMap: [CBOR: CBOR] = [:] + for (key, value) in keyValuePairs { + mixedMap[key] = value + } + + let encodedMixed = mixedMap.encode() + + // Expected canonical order per RFC 8949 Section 4.2.1: + // 1. 1 (0x01) + // 2. 10 (0x0a) + // 3. 100 (0x1864) + // 4. -1 (0x20) + // 5. "a" (0x6161) + // 6. "z" (0x617a) + // 7. "aa" (0x626161) + // 8. [100] (0x811864) + // 9. [-1] (0x8120) + // 10. false (0xf4) + let expectedCanonical: [UInt8] = [ + 0xaa, // map(10) + 0x01, 0x01, // 1: 1 + 0x0a, 0x00, // 10: 0 + 0x18, 0x64, 0x02, // 100: 2 + 0x20, 0x03, // -1: 3 + 0x61, 0x61, 0x04, // "a": 4 + 0x61, 0x7a, 0x09, // "z": 9 + 0x62, 0x61, 0x61, 0x05, // "aa": 5 + 0x81, 0x18, 0x64, 0x06, // [100]: 6 + 0x81, 0x20, 0x07, // [-1]: 7 + 0xf4, 0x08 // false: 8 + ] + + XCTAssertEqual(encodedMixed, expectedCanonical, + "Map keys must be sorted in bytewise lexicographic order of their CBOR encodings per RFC 8949") + } + func testEncodeTagged() { let bignum: [UInt8] = [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] // 2**64 let bignumCBOR = CBOR.byteString(bignum)