From e0492bdf1848908f0aba962ea541ad1e4554ffb2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:39:26 +0000 Subject: [PATCH] Encoding Implementation --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- Sources/CBOR/CommonTags.swift | 1 + Tests/CBORTests/DAGCBORTests.swift | 39 +++++++++++++++++++++++++++++++++++++++ Sources/CBOR/Encoder/CBOREncoder.swift | 13 +++++++++++-- Sources/CBOR/Encoder/CIDType.swift | 28 ++++++++++++++++++++++++++++ Sources/CBOR/Encoder/DAGCBOREncoder.swift | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Sources/CBOR/Encoder/EncodingContext.swift | 4 ++++ Sources/CBOR/Encoder/EncodingOptions.swift | 43 ++++++++++++++++++++++++++++++++++++++++++- Sources/CBOR/Encoder/Containers/SingleValueCBOREncodingContainer.swift | 53 ++++++++++++++++++++++++++++++++++++++++++++--------- Sources/CBOR/Encoder/Optimizers/CIDOptimizer.swift | 31 +++++++++++++++++++++++++++++++ 10 file(s) changed, 298 insertion(s)(+), 13 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -35,12 +35,17 @@ - Supports tagged items (will expand and add ability to inject your own tags in the future): - Dates - UUIDs + - [CIDs](https://github.com/multiformats/cid) (tag 42) - Flexible date parsing (tags `0` or `1` with support for any numeric value representation). - Decoding multiple top-level objects using `decodeMultiple(_:from:)`. +- *NEW* IPLD compatible DAG-CBOR encoder for content addressable data. +- *NEW* Flexible date decoding for untagged date items encoded as strings, floating point values, or integers. > Note: This is not a valid CBOR/CDE encoder, it merely always outputs countable collections. CBOR/CDE should be implemented in the future as it's quite similar. ## Usage + +### Standard CBOR This library utilizes Swift's `Codable` API for all (de)serialization operations. It intentionally doesn't support streaming CBOR blobs. After [installing](#installation) the package using Swift Package Manager, use the `CBOREncoder` and `CBORDecoder` types to encode to and decode from CBOR blobs, respectively. @@ -76,7 +81,45 @@ let decoder = CBORDecoder(options: options) ``` -[Documentation](https://swiftpackageindex.com/thecoolwinter/CBOR/1.0.1/documentation/cbor) is hosted on the Swift Package Index. +### DAG-CBOR + +This library also offers the ability to encode and decode DAG-CBOR data. DAG-CBOR is a superset of the CBOR spec, though very similar. This library provides a `DAGCBOREncoder` type that is automatically configured to produce compatible data. The flags it sets are also available through the `EncodingOptions` struct, but using the specialized type will ensure safety. + +```swift +// !! SEE NOTE !! +let dagEncoder = DAGCBOREncoder(dateEncodingStrategy: .double) +``` + +To use, conform your internal CID type to ``CIDType``. **Do not conform standard types like `String` or `Data` to ``CIDType``**, or the encoder will attempt to encode all of those data as tagged items. +```swift +struct CID: CIDType, Encodable { + let bytes: [UInt8] + func cidData() throws -> [UInt8] { + // Often you'll want to re-encode your CID from a human readable + // format to Base256. + return bytes + } +} + +// Now, any time the encoder finds a `CID` type it will encode it using the +// correct tag. +let cid = CID(bytes: [0,1,2,3,4,5,6,7,8]) +let data = try DAGCBOREncoder().encode(cid) + +print(data.hexString()) +// Output: +// D8 2A # tag(42) +// 4A # bytes(10) +// 00000102030405060708 # "\u0000\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b" +``` +You **do not** need to prefix your data with the `NULL` character once encoded. This library will handle that for you. It is invalid encoding to not include the prefixed byte, so the encoder handles it. + +> [!NOTE] +> DAG-CBOR does not allow tagged items (besides the CID item), and thus encoding dates must be done by encoding their 'raw' value directly. This is an application specific behavior, so ensure the encoder is using the correct date encoding behavior for compatibility. By default, the encoder will encode dates as an epoch `Double` timestamp. + +## Documentation + +[Documentation](https://swiftpackageindex.com/thecoolwinter/CBOR/1.1.0/documentation/cbor) is hosted on the Swift Package Index. ## Installation diff --git a/Sources/CBOR/CommonTags.swift b/Sources/CBOR/CommonTags.swift --- a/Sources/CBOR/CommonTags.swift +++ b/Sources/CBOR/CommonTags.swift @@ -10,4 +10,5 @@ case stringDate = 0 case epochDate = 1 case uuid = 37 + case cid = 42 } diff --git a/Tests/CBORTests/DAGCBORTests.swift b/Tests/CBORTests/DAGCBORTests.swift new file mode 100644 --- /dev/null +++ b/Tests/CBORTests/DAGCBORTests.swift @@ -0,0 +1,39 @@ +// +// DAGCBORTests.swift +// CBOR +// +// Created by Khan Winter on 10/14/25. +// + +import Foundation +import Testing +@testable import CBOR + +// swiftlint:disable:next private_over_fileprivate +fileprivate struct CID: CIDType { + let data: Data + + func cidData() throws -> Data { + data + } +} + +@Suite +struct DAGCBORTests { + @Test + func `Custom CID Type Encoded Correctly`() throws { + let cid = CID(data: [0, 1, 2, 3, 4, 5, 6, 7, 8]) + let data = try DAGCBOREncoder().encode(cid) + #expect(data.hexString() == "d82a4a00000102030405060708") + } + + @Test + func `Known valid base256 encoded ID`() throws { + let cid = CID(data: "017112209fe4ccc6de16724f3a30c7e8f254f3c6471986acb1f8d8cf8e96ce2ad7dbe7fb".asHexData()) + let data = try DAGCBOREncoder().encode(cid) + #expect( + data.hexString() == + "d82a582500017112209FE4CCC6DE16724F3A30C7E8F254F3C6471986ACB1F8D8CF8E96CE2AD7DBE7FB".lowercased() + ) + } +} diff --git a/Sources/CBOR/Encoder/CBOREncoder.swift b/Sources/CBOR/Encoder/CBOREncoder.swift --- a/Sources/CBOR/Encoder/CBOREncoder.swift +++ b/Sources/CBOR/Encoder/CBOREncoder.swift @@ -24,13 +24,22 @@ /// - Parameters: /// - forceStringKeys: See ``EncodingOptions/forceStringKeys``. /// - dateEncodingStrategy: See ``EncodingOptions/dateEncodingStrategy``. + /// - rejectTaggedItems: See ``EncodingOptions/rejectTaggedItems``. + /// - forceDoubleLengthEncoding: See ``EncodingOptions/forceDoubleLengthEncoding``. + /// - rejectInfAndNan: See ``EncodingOptions/rejectInfAndNan``. public init( forceStringKeys: Bool = false, - dateEncodingStrategy: EncodingOptions.DateStrategy = .double + dateEncodingStrategy: EncodingOptions.DateStrategy = .double, + taggedItemsStrategy: EncodingOptions.TagStrategy = .accept, + forceDoubleLengthEncoding: Bool = false, + rejectInfAndNan: Bool = false ) { options = EncodingOptions( forceStringKeys: forceStringKeys, - dateEncodingStrategy: dateEncodingStrategy + dateEncodingStrategy: dateEncodingStrategy, + taggedItemsStrategy: taggedItemsStrategy, + forceDoubleLengthEncoding: forceDoubleLengthEncoding, + rejectInfAndNan: rejectInfAndNan ) } diff --git a/Sources/CBOR/Encoder/CIDType.swift b/Sources/CBOR/Encoder/CIDType.swift new file mode 100644 --- /dev/null +++ b/Sources/CBOR/Encoder/CIDType.swift @@ -0,0 +1,28 @@ +// +// CIDType.swift +// CBOR +// +// Created by Khan Winter on 10/14/25. +// + +/// A type that represents a [CID](https://github.com/multiformats/cid). When encoded using ``CBOREncoder`` or +/// ``DAGCBOREncoder``, uses the tag `42`. This is the only allowed tagged data type when using ``DAGCBOREncoder``. +/// +/// To use, conform your internal CID type to ``CIDType``. Do not conform standard types like `String` or `Data` to +/// ``CIDType``, or the encoder will attempt to encode all of those data as tagged items. +/// ```swift +/// struct CID: CIDType, Encodable { +/// let bytes: [UInt8] +/// +/// func cidData() throws -> [UInt8] { +/// // Often you'll want to re-encode your CID from a human readable format to Base256. +/// return bytes +/// } +/// } +/// ``` +/// Note that you **do not** need to prefix your data with the `NULL` character once encoded. This library will +/// handle that for you. It is invalid DAG-CBOR encoding to not include the prefixed byte. +public protocol CIDType: Encodable { + associatedtype Bytes: Collection where Bytes.Element == UInt8 + func cidData() throws -> Bytes +} diff --git a/Sources/CBOR/Encoder/DAGCBOREncoder.swift b/Sources/CBOR/Encoder/DAGCBOREncoder.swift new file mode 100644 --- /dev/null +++ b/Sources/CBOR/Encoder/DAGCBOREncoder.swift @@ -0,0 +1,54 @@ +// +// DAGCBOREncoder.swift +// CBOR +// +// Created by Khan Winter on 10/14/25. +// + +import Foundation + +/// Serializes ``Encodable`` objects using the DAG-CBOR serialization format. +/// +/// To perform serialization, use the ``encode`` method to convert a Codable object to ``Data``. To +/// configure encoding behavior, either pass customization options in with +/// ``init(dateEncodingStrategy:)`` or modify ``dateEncodingStrategy``. +/// +/// This type has no performance differences from ``CBOREncoder``. Instead, it automatically configures the private +/// encoding options flags to generate always-valid DAG-CBOR encoded data. +/// +/// - Warning: DAG-CBOR requires that implementations ***do not*** use tags for values such as dates. Because of this, +/// depending on the ``DAGCBOREncoder/dateEncodingStrategy``, date values will +/// be encoded without tag information as a different type. +public struct DAGCBOREncoder { + /// Options that determine the behavior of ``DAGCBOREncoder``. + public var dateEncodingStrategy: EncodingOptions.DateStrategy + + /// Create a new CBOR encoder. + /// - Parameter dateEncodingStrategy: See ``EncodingOptions/dateEncodingStrategy``. + public init(dateEncodingStrategy: EncodingOptions.DateStrategy = .double) { + self.dateEncodingStrategy = dateEncodingStrategy + } + + /// Returns a DAG-CBOR-encoded representation of the value you supply. + /// - Parameter value: The value to encode as CBOR data. + /// - Returns: The encoded CBOR data. + public func encode(_ value: T) throws -> Data { + // Required overrides for valid DAG-CBOR encoding + let options = EncodingOptions.dag(dateEncodingStrategy: dateEncodingStrategy) + + let tempStorage = TopLevelTemporaryEncodingStorage() + + let encodingContext = EncodingContext(options: options) + let encoder = SingleValueCBOREncodingContainer(parent: tempStorage, context: encodingContext) + try encoder.encode(value) + + let dataSize = tempStorage.value.size + var data = Data(count: dataSize) + data.withUnsafeMutableBytes { ptr in + var slice = ptr[...] + tempStorage.value.write(to: &slice) + assert(slice.isEmpty) + } + return data + } +} diff --git a/Sources/CBOR/Encoder/EncodingContext.swift b/Sources/CBOR/Encoder/EncodingContext.swift --- a/Sources/CBOR/Encoder/EncodingContext.swift +++ b/Sources/CBOR/Encoder/EncodingContext.swift @@ -25,4 +25,8 @@ func appending(_ key: Key) -> EncodingContext { EncodingContext(options: options, path: .child(key: key, parent: path)) } + + func error(_ description: String = "", error: Error? = nil) -> EncodingError.Context { + .init(codingPath: codingPath, debugDescription: description, underlyingError: error) + } } diff --git a/Sources/CBOR/Encoder/EncodingOptions.swift b/Sources/CBOR/Encoder/EncodingOptions.swift --- a/Sources/CBOR/Encoder/EncodingOptions.swift +++ b/Sources/CBOR/Encoder/EncodingOptions.swift @@ -25,12 +25,53 @@ /// Determine how to encode dates. public let dateEncodingStrategy: DateStrategy + /// Different strategies for encoding tagged items. + public enum TagStrategy { + /// Encode all tagged items. Default. + case accept + /// DAG mode option. Reject all tagged items (UUIDs). If possible, encoder uses an alternative encoding + /// method. Otherwise, throws an encoding error. + case dagMode + } + + /// Determine how to encode tagged items. + public let taggedItemsStrategy: TagStrategy + + /// Force the encoder to encode all floating point numbers as 64-bit double values. + public let forceDoubleLengthEncoding: Bool + + /// DAG mode option. Reject all Infinity and NaN values for floating-point numbers (`Double`, `Float`). + public let rejectInfAndNan: Bool + /// Initialize new encoding options. /// - Parameters: /// - forceStringKeys: Force encoded maps to use string keys even when integer keys are available. /// - useStringDates: See ``dateEncodingStrategy`` and ``DateStrategy``. - public init(forceStringKeys: Bool, dateEncodingStrategy: DateStrategy) { + /// - taggedItemsStrategy: See ``taggedItemsStrategy`` and ``TagStrategy``. + /// - forceDoubleLengthEncoding: Encode all floating point numbers as 64-bit double values. + /// - rejectInfAndNan: DAG mode option. Reject all Infinity and NaN values for floating-point numbers (`Double`, + /// `Float`). + public init( + forceStringKeys: Bool, + dateEncodingStrategy: DateStrategy, + taggedItemsStrategy: TagStrategy, + forceDoubleLengthEncoding: Bool, + rejectInfAndNan: Bool + ) { self.forceStringKeys = forceStringKeys self.dateEncodingStrategy = dateEncodingStrategy + self.taggedItemsStrategy = taggedItemsStrategy + self.forceDoubleLengthEncoding = forceDoubleLengthEncoding + self.rejectInfAndNan = rejectInfAndNan + } + + static func dag(dateEncodingStrategy: DateStrategy) -> EncodingOptions { + EncodingOptions( + forceStringKeys: true, + dateEncodingStrategy: dateEncodingStrategy, + taggedItemsStrategy: .dagMode, + forceDoubleLengthEncoding: true, + rejectInfAndNan: true + ) } } diff --git a/Sources/CBOR/Encoder/Containers/SingleValueCBOREncodingContainer.swift b/Sources/CBOR/Encoder/Containers/SingleValueCBOREncodingContainer.swift --- a/Sources/CBOR/Encoder/Containers/SingleValueCBOREncodingContainer.swift +++ b/Sources/CBOR/Encoder/Containers/SingleValueCBOREncodingContainer.swift @@ -46,11 +46,21 @@ } func encode(_ value: Double) throws { + guard !options.rejectInfAndNan && value.isNormal else { + throw EncodingError.invalidValue( + value, + context.error("Configured to reject Inf and NaN values. Found Infinite or NaN floating point value.") + ) + } parent.register(DoubleOptimizer(value: value)) } func encode(_ value: Float) throws { - parent.register(FloatOptimizer(value: value)) + if options.forceDoubleLengthEncoding { + try encode(Double(value)) + } else { + parent.register(FloatOptimizer(value: value)) + } } func encode(_ value: T) throws where T: Encodable, T: FixedWidthInteger { @@ -62,16 +72,18 @@ // function for any type, only the standard library types. This is the same method Foundation uses to detect // special encoding cases. It's still lame. switch value { + case let value as any CIDType: + parent.register(try CIDOptimizer(value)) case let value as Date: - switch options.dateEncodingStrategy { - case .string: - parent.register(StringDateOptimizer(value: value)) - case .float: - parent.register(EpochFloatDateOptimizer(value: value)) - case .double: - parent.register(EpochDoubleDateOptimizer(value: value)) - } + try _encodeDate(value) case let value as UUID: + guard options.taggedItemsStrategy != .dagMode else { + throw EncodingError.invalidValue( + value, + // swiftlint:disable:next line_length + context.error("In DAG mode, all tagged items are rejected except tag 42. UUIDs are encoded as a tagged value by default. Override `encode` for your type and encode UUID with a different representation.") + ) + } parent.register(UUIDOptimizer(value: value)) case let value as Data: parent.register(ByteStringOptimizer(value: value)) @@ -81,6 +93,29 @@ // #endif default: try value.encode(to: self) + } + } + + func _encodeDate(_ value: Date) throws { + switch options.dateEncodingStrategy { + case .string: + if options.taggedItemsStrategy == .dagMode { + parent.register(StringDateOptimizer(value: value).optimizer) + } else { + parent.register(StringDateOptimizer(value: value)) + } + case .float: + if options.taggedItemsStrategy == .dagMode { + parent.register(EpochFloatDateOptimizer(value: value).optimizer) + } else { + parent.register(EpochFloatDateOptimizer(value: value)) + } + case .double: + if options.taggedItemsStrategy == .dagMode { + parent.register(EpochDoubleDateOptimizer(value: value).optimizer) + } else { + parent.register(EpochDoubleDateOptimizer(value: value)) + } } } } diff --git a/Sources/CBOR/Encoder/Optimizers/CIDOptimizer.swift b/Sources/CBOR/Encoder/Optimizers/CIDOptimizer.swift new file mode 100644 --- /dev/null +++ b/Sources/CBOR/Encoder/Optimizers/CIDOptimizer.swift @@ -0,0 +1,31 @@ +// +// CIDOptimizer.swift +// CBOR +// +// Created by Khan Winter on 10/14/25. +// + +import Foundation + +/// https://github.com/ipld/cid-cbor/ +struct CIDOptimizer: EncodingOptimizer { + var optimizer: EncodingOptimizer + + var type: MajorType { .tagged } + var argument: UInt8 { 24 } // Small int for tag ID + var headerSize: Int { 1 } + var contentSize: Int { optimizer.size } + + init(_ cid: T) throws { + optimizer = ByteStringOptimizer(value: [0] + (try cid.cidData())) + } + + mutating func writeHeader(to data: inout Slice) { + data[data.startIndex] = UInt8(CommonTags.cid.rawValue) + data.removeFirst() + } + + mutating func writePayload(to data: inout Slice) { + optimizer.write(to: &data) + } +} -- tangled.sh