diff --git a/Sources/CBOR/Decoder/CBORDecoder.swift b/Sources/CBOR/Decoder/CBORDecoder.swift index 5571e9f..b5351e8 100644 --- a/Sources/CBOR/Decoder/CBORDecoder.swift +++ b/Sources/CBOR/Decoder/CBORDecoder.swift @@ -7,13 +7,31 @@ import Foundation +/// Decodes ``Decodable`` objects from CBOR data. +/// +/// This type can be reused efficiently for multiple deserialization operations. Use the ``decode(_:from:)`` method +/// to decode data. +/// +/// To configure decoding behavior, pass options to the ``init(rejectIndeterminateLengths:)`` method or modify +/// the ``options`` variable. public struct CBORDecoder { - var options: DecodingOptions + /// The options that determine decoding behavior. + public var options: DecodingOptions - public init(options: DecodingOptions = DecodingOptions()) { - self.options = options + /// Creates a new decoder. + /// - Parameter rejectIndeterminateLengths: Set to `false` to allow indeterminate length objects to be decoded. + /// Defaults to *rejecting* indeterminate length items (strings, bytes, + /// maps, and arrays). + public init(rejectIndeterminateLengths: Bool = true) { + self.options = DecodingOptions(rejectIndeterminateLengths: rejectIndeterminateLengths) } + /// Decodes the given type from CBOR binary data. + /// - Parameters: + /// - type: The decodable type to deserialize. + /// - data: The CBOR data to decode from. + /// - Returns: An instance of the decoded type. + /// - Throws: A ``DecodingError`` with context and a debug description for a failed deserialization operation. public func decode(_ type: T.Type, from data: Data) throws -> T { do { return try data.withUnsafeBytes { @@ -31,28 +49,30 @@ public struct CBORDecoder { if let error = error as? CBORScanner.ScanError { switch error { case .unexpectedEndOfData: - throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Unexpected end of data.")) - case .invalidMajorType(let byte, let offset): + throw DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Unexpected end of data.") + ) + case let .invalidMajorType(byte, offset): throw DecodingError.dataCorrupted(.init( codingPath: [], debugDescription: "Unexpected major type: \(String(byte, radix: 2)) at offset \(offset)" )) - case .invalidSize(let byte, let offset): + case let .invalidSize(byte, offset): throw DecodingError.dataCorrupted(.init( codingPath: [], debugDescription: "Unexpected size argument: \(String(byte, radix: 2)) at offset \(offset)" )) - case .expectedMajorType(let offset): + case let .expectedMajorType(offset): throw DecodingError.dataCorrupted(.init( codingPath: [], debugDescription: "Expected major type at offset \(offset)" )) - case .typeInIndeterminateString(let type, let offset): + case let .typeInIndeterminateString(type, offset): throw DecodingError.dataCorrupted(.init( codingPath: [], debugDescription: "Unexpected major type in indeterminate \(type) at offset \(offset)" )) - case .rejectedIndeterminateLength(let type, let offset): + case let .rejectedIndeterminateLength(type, offset): throw DecodingError.dataCorrupted(.init( codingPath: [], debugDescription: "Rejected indeterminate length type \(type) at offset \(offset)" diff --git a/Sources/CBOR/Decoder/Containers/KeyedCBORDecodingContainer.swift b/Sources/CBOR/Decoder/Containers/KeyedCBORDecodingContainer.swift index 57805f7..8e49f88 100644 --- a/Sources/CBOR/Decoder/Containers/KeyedCBORDecodingContainer.swift +++ b/Sources/CBOR/Decoder/Containers/KeyedCBORDecodingContainer.swift @@ -99,7 +99,7 @@ struct KeyedCBORDecodingContainer: DecodingContextContainer, Key func nestedContainer( keyedBy type: NestedKey.Type, forKey key: Key - ) throws -> KeyedDecodingContainer{ + ) throws -> KeyedDecodingContainer { let region = try getRegion(forKey: key) let container = try KeyedCBORDecodingContainer(context: context.appending(key), data: region) return KeyedDecodingContainer(container) diff --git a/Sources/CBOR/Decoder/Containers/SingleValueCBORDecodingContainer.swift b/Sources/CBOR/Decoder/Containers/SingleValueCBORDecodingContainer.swift index 4adddc3..22392db 100644 --- a/Sources/CBOR/Decoder/Containers/SingleValueCBORDecodingContainer.swift +++ b/Sources/CBOR/Decoder/Containers/SingleValueCBORDecodingContainer.swift @@ -13,7 +13,7 @@ struct SingleValueCBORDecodingContainer: DecodingContextContainer { } extension SingleValueCBORDecodingContainer: Decoder { - func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { try KeyedDecodingContainer(KeyedCBORDecodingContainer(context: context, data: data)) } @@ -215,6 +215,7 @@ extension SingleValueCBORDecodingContainer: SingleValueDecodingContainer { } func decode(_ type: T.Type) throws -> T { + // swiftlint:disable force_cast return if T.self == Date.self { try _decode(Date.self) as! T // Unfortunate force unwrap, but necessary } else if T.self == UUID.self { @@ -224,5 +225,6 @@ extension SingleValueCBORDecodingContainer: SingleValueDecodingContainer { } else { try T(from: self) } + // swiftlint:enable force_cast } } diff --git a/Sources/CBOR/Decoder/Containers/UnkeyedCBORDecodingContainer.swift b/Sources/CBOR/Decoder/Containers/UnkeyedCBORDecodingContainer.swift index 93d3d02..afdcef1 100644 --- a/Sources/CBOR/Decoder/Containers/UnkeyedCBORDecodingContainer.swift +++ b/Sources/CBOR/Decoder/Containers/UnkeyedCBORDecodingContainer.swift @@ -55,11 +55,11 @@ struct UnkeyedCBORDecodingContainer: DecodingContextContainer, UnkeyedDecodingCo ) throws -> KeyedDecodingContainer { try consumeDecoder().container(keyedBy: type) } - + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { try consumeDecoder().unkeyedContainer() } - + mutating func superDecoder() throws -> Decoder { try consumeDecoder() } diff --git a/Sources/CBOR/Decoder/DecodingOptions.swift b/Sources/CBOR/Decoder/DecodingOptions.swift index 45221af..ab4d903 100644 --- a/Sources/CBOR/Decoder/DecodingOptions.swift +++ b/Sources/CBOR/Decoder/DecodingOptions.swift @@ -5,21 +5,16 @@ // Created by Khan Winter on 8/23/25. // +/// Options that determine the behavior of ``CBORDecoder``. public struct DecodingOptions { - public var rejectIndeterminateLengthData: Bool - public var rejectIndeterminateLengthStrings: Bool - public var rejectIndeterminateLengthArrays: Bool - public var rejectIndeterminateLengthMaps: Bool + /// Set to `false` to allow indeterminate length objects to be decoded. + /// `true` by default. + /// + /// For deterministic encoding, this **must** be enabled. + public var rejectIndeterminateLengths: Bool - public init( - rejectIndeterminateLengthData: Bool = true, - rejectIndeterminateLengthStrings: Bool = true, - rejectIndeterminateLengthArrays: Bool = true, - rejectIndeterminateLengthMaps: Bool = true - ) { - self.rejectIndeterminateLengthData = rejectIndeterminateLengthData - self.rejectIndeterminateLengthStrings = rejectIndeterminateLengthStrings - self.rejectIndeterminateLengthArrays = rejectIndeterminateLengthArrays - self.rejectIndeterminateLengthMaps = rejectIndeterminateLengthMaps + /// Create a new options object. + public init(rejectIndeterminateLengths: Bool = true) { + self.rejectIndeterminateLengths = rejectIndeterminateLengths } } diff --git a/Sources/CBOR/Decoder/Scanner/CBORScanner.swift b/Sources/CBOR/Decoder/Scanner/CBORScanner.swift index af846eb..a3ff7ef 100644 --- a/Sources/CBOR/Decoder/Scanner/CBORScanner.swift +++ b/Sources/CBOR/Decoder/Scanner/CBORScanner.swift @@ -7,12 +7,18 @@ import Foundation -/// # Why? -/// I'd have loved to use a 'pop' method for this, where we only decode as data is requested. However, the way Swift's -/// decoding APIs work forces us to be able to be able to do random access for keys in maps, which requires scanning. +/// # Why Scan? +/// I'd have loved to use a 'pop' method for decoding, where we only decode as data is requested. However, the way +/// Swift's decoding APIs work forces us to be able to be able to do random access for keys in maps, which requires +/// scanning. /// /// Here we build a map of byte offsets and types to be able to quickly scan through a CBOR blob to find specific /// indices and keys. +/// +/// # Dev Notes +/// +/// - This is where we do any indeterminate length validation and rejection. The decoder containers themselves will +/// take either indeterminate or specific lengths and decode them. @usableFromInline final class CBORScanner { @usableFromInline @@ -25,17 +31,23 @@ final class CBORScanner { case rejectedIndeterminateLength(type: MajorType, offset: Int) } -// enum ScanItem: Int { -// case map // (childCount: Int, mapCount: Int, offset: Int, byteCount: Int) -// case array // (childCount: Int, mapCount: Int, offset: Int, byteCount: Int) -// -// case int // (offset: Int, byteCount: Int) -// case string -// case byteString -// case tagged -// case simple (byteCount: Int) -// } - + // MARK: - Results + + /// After the scanner scans, this contains a map that allows the CBOR data to be scanned for values at arbitrary + /// positions, keys, etc. The map contents are represented literally as ints for performance but uses the + /// following map: + /// ``` + /// enum ScanItem: Int { + /// case map // (childCount: Int, mapCount: Int, offset: Int, byteCount: Int) + /// case array // (childCount: Int, mapCount: Int, offset: Int, byteCount: Int) + /// + /// case int // (offset: Int, byteCount: Int) + /// case string + /// case byteString + /// case tagged + /// case simple (byteCount: Int) + /// } + /// ``` struct Results { var map: [Int] = [] @@ -126,6 +138,8 @@ final class CBORScanner { } } + // MARK: - Map Navigation + func firstChildIndex(_ mapIndex: Int) -> Int { let byte = UInt8(results.map[mapIndex]) guard let type = MajorType(rawValue: byte) else { @@ -173,20 +187,9 @@ final class CBORScanner { } } - switch type { - case .uint: - let size = try popByteCount() - let offset = reader.index - results.recordType(raw, currentByteIndex: offset, length: size) - guard reader.canRead(size) else { throw ScanError.unexpectedEndOfData } - reader.pop(size) - case .nint: - let size = try popByteCount() - let offset = reader.index - results.recordType(raw, currentByteIndex: offset, length: size) - guard reader.canRead(size) else { throw ScanError.unexpectedEndOfData } - reader.pop(size) + case .uint, .nint: + try scanInt(raw: raw) case .bytes: try scanBytesOrString(.bytes) case .string: @@ -196,14 +199,30 @@ final class CBORScanner { case .map: try scanMap() case .simple: - let idx = reader.index - results.recordSimple(reader.pop(), currentByteIndex: idx) - reader.pop(simpleLength(raw)) + scanSimple(raw: raw) case .tagged: fatalError() } } + // MARK: - Scan Int + + private func scanInt(raw: UInt8) throws { + let size = try popByteCount() + let offset = reader.index + results.recordType(raw, currentByteIndex: offset, length: size) + guard reader.canRead(size) else { throw ScanError.unexpectedEndOfData } + reader.pop(size) + } + + // MARK: - Scan Simple + + private func scanSimple(raw: UInt8) { + let idx = reader.index + results.recordSimple(reader.pop(), currentByteIndex: idx) + reader.pop(simpleLength(raw)) + } + private func simpleLength(_ arg: UInt8) -> Int { switch arg & 0b11111 { case 25: @@ -217,6 +236,8 @@ final class CBORScanner { } } + // MARK: - Scan String/Bytes + private func scanBytesOrString(_ type: MajorType) throws { let raw = reader._peek() // already checked previously @@ -229,8 +250,7 @@ final class CBORScanner { return } - if (type == .string && options.rejectIndeterminateLengthStrings) - || (type == .bytes && options.rejectIndeterminateLengthData) { + if (type == .string || type == .bytes) && options.rejectIndeterminateLengths { throw ScanError.rejectedIndeterminateLength(type: type, offset: reader.index) } @@ -255,6 +275,8 @@ final class CBORScanner { results.recordType(raw, currentByteIndex: start, length: reader.index - start) } + // MARK: - Scan Array + private func scanArray() throws { guard peekIsIndeterminate() else { let size = try reader.readNextInt(as: Int.self) @@ -266,7 +288,7 @@ final class CBORScanner { return } - if options.rejectIndeterminateLengthArrays { + if options.rejectIndeterminateLengths { throw ScanError.rejectedIndeterminateLength(type: .array, offset: reader.index) } @@ -282,6 +304,8 @@ final class CBORScanner { results.recordEnd(childCount: count, resultLocation: mapIdx, currentByteIndex: reader.index) } + // MARK: - Scan Map + private func scanMap() throws { guard peekIsIndeterminate() else { let size = try reader.readNextInt(as: Int.self) * 2 @@ -293,7 +317,7 @@ final class CBORScanner { return } - if options.rejectIndeterminateLengthMaps { + if options.rejectIndeterminateLengths { throw ScanError.rejectedIndeterminateLength(type: .map, offset: reader.index) } @@ -311,6 +335,8 @@ final class CBORScanner { } } +// MARK: - Utils + extension CBORScanner { func popByteCount() throws -> Int { let byteCount = reader.popArgument() @@ -330,10 +356,11 @@ extension CBORScanner { } } +// MARK: - Debug Description + #if DEBUG extension CBORScanner: CustomDebugStringConvertible { - @usableFromInline - var debugDescription: String { + @usableFromInline var debugDescription: String { var string = "" func indent(_ other: String, d: Int) { string += String(repeating: " ", count: d * 2) + other + "\n" } diff --git a/Sources/CBOR/Decoder/Scanner/DataReader.swift b/Sources/CBOR/Decoder/Scanner/DataReader.swift index 65b7ec0..dac3ce0 100644 --- a/Sources/CBOR/Decoder/Scanner/DataReader.swift +++ b/Sources/CBOR/Decoder/Scanner/DataReader.swift @@ -7,6 +7,11 @@ import Foundation +/// A mutable struct used by the `CBORScanner` to iteratively scan a CBOR blob. +/// Since this isn't passed by reference, this represents the *entire* blob instead of a single value +/// like `DataRegion`. +/// +/// This results in some duplicated code. I'd love to remove it but it works for now I suppose. struct DataReader { private let data: Slice private(set) var index = 0 diff --git a/Sources/CBOR/Encoder/CBOREncoder.swift b/Sources/CBOR/Encoder/CBOREncoder.swift index d651d76..fceefdf 100644 --- a/Sources/CBOR/Encoder/CBOREncoder.swift +++ b/Sources/CBOR/Encoder/CBOREncoder.swift @@ -11,7 +11,7 @@ import FoundationEssentials import Foundation #endif -/// An object that can serialize ``Codable`` objects into the CBOR serialization format. +/// Serializes ``Encodable`` objects using the CBOR serialization format. /// /// To perform serialization, use the ``encode(_:)-6zhmp`` method to convert a Codable object to ``Data``. To /// configure encoding behavior, either pass customization options in with @@ -20,7 +20,7 @@ public struct CBOREncoder { /// Options that determine the behavior of ``CBOREncoder``. public var options: EncodingOptions - /// Create a new CBOR encoder object. + /// Create a new CBOR encoder. /// - Parameters: /// - forceStringKeys: See ``EncodingOptions/forceStringKeys``. /// - useStringDates: See ``EncodingOptions/useStringDates``. diff --git a/Sources/CBOR/Encoder/Optimizers/IntOptimizer.swift b/Sources/CBOR/Encoder/Optimizers/IntOptimizer.swift index 4a37d92..bb88ba3 100644 --- a/Sources/CBOR/Encoder/Optimizers/IntOptimizer.swift +++ b/Sources/CBOR/Encoder/Optimizers/IntOptimizer.swift @@ -5,7 +5,7 @@ // Created by Khan Winter on 8/17/25. // -@inlinable +@inlinable // swiftlint:disable:next cyclomatic_complexity func IntOptimizer(value: IntType) -> EncodingOptimizer { let encodingValue: UInt if value < 0 { diff --git a/Tests/CBORTests/DecodableTests.swift b/Tests/CBORTests/DecodableTests.swift index 8bc7ca7..335d731 100644 --- a/Tests/CBORTests/DecodableTests.swift +++ b/Tests/CBORTests/DecodableTests.swift @@ -9,12 +9,13 @@ import Testing import Foundation @testable import CBOR -//@_optimize(none) -//public func blackHole(_: some Any) {} +// @_optimize(none) +// public func blackHole(_: some Any) {} @Suite struct DecodableTests { - @Test func uint8() throws { + @Test + func uint8() throws { var value = try CBORDecoder().decode(UInt8.self, from: [0]) #expect(value == 0) value = try CBORDecoder().decode(UInt8.self, from: [1]) @@ -32,7 +33,8 @@ struct DecodableTests { #expect(throws: DecodingError.self) { try CBORDecoder().decode(UInt8.self, from: [128]) } } - @Test func uint16() throws { + @Test + func uint16() throws { var value = try CBORDecoder().decode(UInt16.self, from: [0]) #expect(value == 0) value = try CBORDecoder().decode(UInt16.self, from: [1]) @@ -57,7 +59,8 @@ struct DecodableTests { #expect(throws: DecodingError.self) { try CBORDecoder().decode(UInt16.self, from: [25, 0]) } } - @Test func uint32() throws { + @Test + func uint32() throws { var value: UInt32 = try CBORDecoder().decode(UInt32.self, from: [0]) #expect(value == 0) value = try CBORDecoder().decode(UInt32.self, from: [1]) @@ -94,7 +97,8 @@ struct DecodableTests { #expect(throws: DecodingError.self) { try CBORDecoder().decode(UInt32.self, from: [26, 0, 0, 0]) } } - @Test func uint64() throws { + @Test + func uint64() throws { var value: UInt64 = try CBORDecoder().decode(UInt64.self, from: [0]) #expect(value == 0) value = try CBORDecoder().decode(UInt64.self, from: [1]) @@ -163,6 +167,7 @@ struct DecodableTests { ("6b68656c6c6f20776f726c64", "hello world"), ("60", ""), ("66e29da4efb88f", "❤️"), + // swiftlint:disable:next line_length ("782F68656C6C6F20776F726C642068656C6C6F20776F726C642068656C6C6F20776F726C642068656C6C6F20776F726C64", "hello world hello world hello world hello world") ]) func string(data: String, expected: String) throws { @@ -182,19 +187,22 @@ struct DecodableTests { #expect(string == expected) } - @Test func emptyMap() throws { + @Test + func emptyMap() throws { let data = "A0".asHexData() let dictionary = try CBORDecoder().decode([String: Int].self, from: data) #expect(dictionary.isEmpty) } - @Test func simpleMap() throws { + @Test + func simpleMap() throws { let data = "A262414201614102".asHexData() let dictionary = try CBORDecoder().decode([String: Int].self, from: data) #expect(dictionary == ["AB": 1, "A": 2]) } - @Test func unkeyedContainerHasCountForIndeterminate() throws { + @Test + func unkeyedContainerHasCountForIndeterminate() throws { let data = "9F0203FF".asHexData() try data.withUnsafeBytes { let data = $0[...] @@ -223,12 +231,14 @@ struct DecodableTests { } } - @Test func array() throws { + @Test + func array() throws { let twentyItems = "940101010101010101010101010101010101010101".asHexData() #expect(try CBORDecoder().decode([Int].self, from: twentyItems) == Array(repeating: 1, count: 20)) } - @Test func indeterminateArray() throws { + @Test + func indeterminateArray() throws { // let array = "9F0203FF".asHexData() let options = DecodingOptions(rejectIndeterminateLengthArrays: false) // #expect(try CBORDecoder(options: options).decode([Int].self, from: array) == [2, 3]) @@ -246,7 +256,8 @@ struct DecodableTests { #expect(result == [[2, 3], [4, 5]]) } - @Test func rejectsIndeterminateArrayWhenConfigured() throws { + @Test + func rejectsIndeterminateArrayWhenConfigured() throws { let array = "9FFF".asHexData() let options = DecodingOptions(rejectIndeterminateLengthArrays: true) #expect(throws: DecodingError.self) { diff --git a/Tests/CBORTests/EncodableTests.swift b/Tests/CBORTests/EncodableTests.swift index ed643bd..ea4aa32 100644 --- a/Tests/CBORTests/EncodableTests.swift +++ b/Tests/CBORTests/EncodableTests.swift @@ -218,6 +218,7 @@ struct EncodableTests { let encoder = CBOREncoder() let data = try encoder.encode(Company.mock) + // swiftlint:disable:next line_length #expect(data == "A469656D706C6F796565738AA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726BA563616765181E65656D61696C71616C696365406578616D706C652E636F6D686973416374697665F5646E616D6565416C6963656474616773836573776966746463626F726962656E63686D61726B67666F756E6465641907CF686D65746164617461A268696E6475737472796474656368686C6F636174696F6E6672656D6F7465646E616D656941636D6520436F7270".asHexData()) } diff --git a/Tests/CBORTests/ScannerTests.swift b/Tests/CBORTests/ScannerTests.swift index 021ba4b..d9fa087 100644 --- a/Tests/CBORTests/ScannerTests.swift +++ b/Tests/CBORTests/ScannerTests.swift @@ -108,7 +108,8 @@ struct ScannerTests { } } - @Test func indeterminateNestedArray() throws { + @Test + func indeterminateNestedArray() throws { let expectedMap = [128, 2, 22, 0, 10, 128, 2, 6, 1, 4, 2, 3, 0, 3, 4, 0, 128, 2, 6, 5, 4, 4, 7, 0, 5, 8, 0] let data = "9F9F0203FF9F0405FFFF".asHexData() try data.withUnsafeBytes {