diff --git a/CHANGELOG.md b/CHANGELOG.md index 90aa84a..1d27db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes are documented here. +## [0.7.6] - 2026-09-08 + +### Fixed + +- Bound Candid decoding work across shared-type resolution, normalization, and validation to prevent excessive expansion from small replies. Cached types also retain their nesting depth for limit checks. +- Decode Candid from `Data` slices without assuming a zero-based index. +- Accept padded Candid LEB128 encodings while retaining integer overflow, termination, and length checks. Encoded output remains unchanged. +- Use sorted hash-tree label boundaries to recognize certified absence despite pruned sibling branches, allowing polling to continue and certified rejects without `error_code` to be returned. + +### Compatibility + +- Public APIs are unchanged. Candid inputs exceeding the fixed internal budget of 1,000,000 work units now throw `ICClientError.invalidCandid`, even if their encoded size is small. + ## [0.7.5] - 2026-09-06 ### Added diff --git a/README.md b/README.md index 313c98e..cf1dca4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ICNativeClient -ICNativeClient is a Swift package for calling Internet Computer canisters from native Apple applications. Version 0.7.5 fixes target-scoped management queries and aligns typed reply decoding with Candid subtyping and tuple evolution. +ICNativeClient is a Swift package for calling Internet Computer canisters from native Apple applications. Version 0.7.6 bounds Candid decoding work, fixes decoding from Data slices, accepts padded LEB128 encodings, and corrects certified absence handling during polling. It includes principal/account helpers, a Candid DIDL codec, explicit Swift model conversion, and raw Candid-byte transport. @@ -155,6 +155,10 @@ Use `CandidNull()` for a typed Candid `null`, including payload-free variant cas `Data` and `[UInt8]` both map to Candid `vec nat8`. Recursive wire types are retained with `CandidType.recursive` and `CandidType.reference`; finite values can be decoded and re-encoded, while the configured nesting limit still rejects excessively deep values. +Candid decoding uses a fixed internal budget of 1,000,000 work units shared across type resolution, normalization, and value validation. It throws `ICClientError.invalidCandid` when that budget is exhausted, including for small inputs with excessive shared-type expansion. Existing depth, type-table, and collection limits still apply; blob and text bytes use their byte-length limits rather than one work unit per byte. Previously accepted inputs that exceed the work budget are now rejected. + +The Candid decoder accepts padded LEB128 encodings within the existing integer and length limits, and accepts `Data` slices with nonzero starting indices. Encoding continues to produce the same minimal LEB128 representation. + Use `queryRaw` and `callRaw` when integrating generated bindings or Candid types not represented by this value API. `unsafeQueryRaw` remains the explicit unverified opt-out; there is intentionally no typed unsafe wrapper. ### Raw transport @@ -289,6 +293,10 @@ let sharedStore = ICIdentityStore( Every participating target must include that access group in its Keychain Sharing entitlement. ICNativeClient does not migrate items between access groups or from application-specific storage formats. If the shared item is initially absent, authenticate and save the session from the main application before an extension attempts to load it. An invalid access group or missing entitlement is reported as `ICClientError.keychainFailure`. +## New in 0.7.6 + +0.7.6 fixes Candid decoding and certificate lookup without changing public APIs. A fixed internal decoding budget rejects excessive type expansion and value processing, including in small inputs. Candid decoding now accepts Data slices and padded LEB128 encodings; encoded output remains unchanged. Certified absence is recognized using sorted hash-tree boundaries, so pruned sibling branches no longer prevent polling from continuing or certified rejects without an error code from being returned. The bundled generator remains version 0.1.3. + ## New in 0.7.5 0.7.5 is a backward-compatible patch release. Query APIs and generated query wrappers can explicitly separate the delegation target from the signed and effective canister IDs. Typed reply decoding now follows Candid record, optional, vector, recursive, tuple, and `nat`-to-`int` subtyping without accepting fixed-width numeric widening. The bundled generator is version 0.1.3. diff --git a/Sources/ICNativeClient/Candid.swift b/Sources/ICNativeClient/Candid.swift index 0940a78..cc412d6 100644 --- a/Sources/ICNativeClient/Candid.swift +++ b/Sources/ICNativeClient/Candid.swift @@ -80,7 +80,11 @@ public struct CandidVariant: Equatable, Sendable { public let value: CandidValue public init(fields: [CandidField], tag: UInt32, value: CandidValue) throws { - self.fields = try Candid.normalized(fields, context: "variant") + try self.init(fields: fields, tag: tag, value: value, budget: nil) + } + + init(fields: [CandidField], tag: UInt32, value: CandidValue, budget: CandidDecodingBudget?) throws { + self.fields = try Candid.normalized(fields, context: "variant", budget: budget) guard self.fields.contains(where: { $0.id == tag }) else { throw ICClientError.invalidCandid("variant tag \(tag) is not declared") } @@ -115,9 +119,13 @@ public struct CandidTypedValue: Equatable, Sendable { public let value: CandidValue public init(type: CandidType, value: CandidValue) throws { - try Candid.validate(value, as: type, context: "value") - self.type = try Candid.normalized(type) - self.value = try Candid.normalized(value) + try self.init(type: type, value: value, budget: nil) + } + + init(type: CandidType, value: CandidValue, budget: CandidDecodingBudget?) throws { + try Candid.validate(value, as: type, context: "value", budget: budget) + self.type = try Candid.normalized(type, budget: budget) + self.value = try Candid.normalized(value, budget: budget) } public init(_ value: T) throws { @@ -253,23 +261,25 @@ public enum Candid { name.utf8.reduce(UInt32(0)) { $0 &* 223 &+ UInt32($1) } } - static func normalized(_ type: CandidType) throws -> CandidType { + static func normalized(_ type: CandidType, budget: CandidDecodingBudget? = nil) throws -> CandidType { + try budget?.consume() switch type { - case .optional(let child): return .optional(try normalized(child)) - case .vector(let child): return .vector(try normalized(child)) - case .record(let fields): return .record(try normalized(fields, context: "record")) - case .variant(let fields): return .variant(try normalized(fields, context: "variant")) - case .recursive(let id, let body): return .recursive(id: id, body: try normalized(body)) + case .optional(let child): return .optional(try normalized(child, budget: budget)) + case .vector(let child): return .vector(try normalized(child, budget: budget)) + case .record(let fields): return .record(try normalized(fields, context: "record", budget: budget)) + case .variant(let fields): return .variant(try normalized(fields, context: "variant", budget: budget)) + case .recursive(let id, let body): return .recursive(id: id, body: try normalized(body, budget: budget)) case .reference: return type default: return type } } - static func normalized(_ fields: [CandidField], context: String) throws -> [CandidField] { + static func normalized(_ fields: [CandidField], context: String, budget: CandidDecodingBudget? = nil) throws -> [CandidField] { guard fields.count <= maximumCollectionElements else { throw ICClientError.invalidCandid("\(context) fields exceed limit") } - let result = try fields.map { CandidField(id: $0.id, type: try normalized($0.type)) } + try budget?.consume(fields.count) + let result = try fields.map { CandidField(id: $0.id, type: try normalized($0.type, budget: budget)) } .sorted { $0.id < $1.id } for pair in zip(result, result.dropFirst()) where pair.0.id == pair.1.id { throw ICClientError.invalidCandid("duplicate \(context) field ID \(pair.0.id)") @@ -277,30 +287,33 @@ public enum Candid { return result } - static func normalized(_ value: CandidValue) throws -> CandidValue { + static func normalized(_ value: CandidValue, budget: CandidDecodingBudget? = nil) throws -> CandidValue { + try budget?.consume() switch value { case .optional(let type, let item): - return .optional(try normalized(type), try item.map(normalized)) + return .optional(try normalized(type, budget: budget), try item.map { try normalized($0, budget: budget) }) case .vector(let type, let items): - return .vector(try normalized(type), try items.map(normalized)) + try budget?.consume(items.count) + return .vector(try normalized(type, budget: budget), try items.map { try normalized($0, budget: budget) }) case .record(let fields, let values): - let fields = try normalized(fields, context: "record") + let fields = try normalized(fields, context: "record", budget: budget) var result: [UInt32: CandidValue] = [:] - for (id, value) in values { result[id] = try normalized(value) } + for (id, value) in values { result[id] = try normalized(value, budget: budget) } return .record(fields, result) case .variant(let variant): return .variant(try CandidVariant( fields: variant.fields, tag: variant.tag, - value: normalized(variant.value) + value: normalized(variant.value, budget: budget), + budget: budget )) default: return value } } - static func validate(_ value: CandidValue, as type: CandidType, context: String) throws { - try validate(value, as: type, context: context, bindings: [:], depth: 0) + static func validate(_ value: CandidValue, as type: CandidType, context: String, budget: CandidDecodingBudget? = nil) throws { + try validate(value, as: type, context: context, bindings: [:], depth: 0, budget: budget) } private static func validate( @@ -308,10 +321,12 @@ public enum Candid { as type: CandidType, context: String, bindings: [UInt32: CandidType], - depth: Int + depth: Int, + budget: CandidDecodingBudget? ) throws { + try budget?.consume() guard depth <= 100 else { throw ICClientError.invalidCandid("\(context): value nesting exceeds limit") } - let type = try normalized(type) + let type = try normalized(type, budget: budget) switch (type, value) { case (.null, .null), (.bool, .bool), (.nat, .nat), (.int, .int), (.nat8, .nat8), (.nat16, .nat16), (.nat32, .nat32), (.nat64, .nat64), @@ -320,44 +335,44 @@ public enum Candid { (.principal, .principal), (.vector(.nat8), .blob): return case (.optional(let expected), .optional(let declared, let item)): - guard try normalized(declared) == expected else { break } - if let item { try validate(item, as: expected, context: context, bindings: bindings, depth: depth + 1) } + guard try normalized(declared, budget: budget) == expected else { break } + if let item { try validate(item, as: expected, context: context, bindings: bindings, depth: depth + 1, budget: budget) } return case (.vector(let expected), .vector(let declared, let items)): - guard try normalized(declared) == expected else { break } + guard try normalized(declared, budget: budget) == expected else { break } guard items.count <= maximumCollectionElements else { throw ICClientError.invalidCandid("\(context): vector exceeds limit") } for (index, item) in items.enumerated() { - try validate(item, as: expected, context: "\(context) vector element \(index)", bindings: bindings, depth: depth + 1) + try validate(item, as: expected, context: "\(context) vector element \(index)", bindings: bindings, depth: depth + 1, budget: budget) } return case (.record(let expected), .record(let declared, let values)): - let actual = try normalized(declared, context: "record") + let actual = try normalized(declared, context: "record", budget: budget) guard actual == expected else { break } let expectedIDs = Set(expected.map(\.id)) guard Set(values.keys) == expectedIDs else { throw ICClientError.invalidCandid("\(context): record values do not match declared fields") } for field in expected { - try validate(values[field.id]!, as: field.type, context: "\(context) record field \(field.id)", bindings: bindings, depth: depth + 1) + try validate(values[field.id]!, as: field.type, context: "\(context) record field \(field.id)", bindings: bindings, depth: depth + 1, budget: budget) } return case (.variant(let expected), .variant(let variant)): guard variant.fields == expected, let field = expected.first(where: { $0.id == variant.tag }) else { break } - try validate(variant.value, as: field.type, context: "\(context) variant tag \(variant.tag)", bindings: bindings, depth: depth + 1) + try validate(variant.value, as: field.type, context: "\(context) variant tag \(variant.tag)", bindings: bindings, depth: depth + 1, budget: budget) return case (.recursive(let id, let body), _): var nestedBindings = bindings nestedBindings[id] = body - try validate(value, as: body, context: context, bindings: nestedBindings, depth: depth) + try validate(value, as: body, context: context, bindings: nestedBindings, depth: depth, budget: budget) return case (.reference(let id), _): guard let body = bindings[id] else { throw ICClientError.invalidCandid("\(context): unbound recursive type reference \(id)") } - try validate(value, as: body, context: context, bindings: bindings, depth: depth + 1) + try validate(value, as: body, context: context, bindings: bindings, depth: depth + 1, budget: budget) return default: break @@ -518,3 +533,16 @@ extension Array: CandidConvertible where Element: CandidConvertible { } public var candidValue: CandidValue { .vector(Element.candidType, map(\.candidValue)) } } + +// Shared by every stage of one decode, including normalization and validation. +final class CandidDecodingBudget { + static let maximumWork = 1_000_000 + private(set) var remaining = maximumWork + + func consume(_ amount: Int = 1) throws { + guard amount >= 0, amount <= remaining else { + throw ICClientError.invalidCandid("decoding work limit exceeded") + } + remaining -= amount + } +} diff --git a/Sources/ICNativeClient/CandidCodec.swift b/Sources/ICNativeClient/CandidCodec.swift index e8a5235..f2c4a60 100644 --- a/Sources/ICNativeClient/CandidCodec.swift +++ b/Sources/ICNativeClient/CandidCodec.swift @@ -202,7 +202,8 @@ public struct CandidDecoder: Sendable { public init() {} public func decode(_ data: Data) throws -> CandidReply { - var reader = Binary.Reader(data) + let budget = CandidDecodingBudget() + var reader = Binary.Reader(data, budget: budget) guard try reader.readData(count: 4) == Data("DIDL".utf8) else { throw ICClientError.invalidCandid("missing DIDL header") } @@ -210,26 +211,28 @@ public struct CandidDecoder: Sendable { guard tableCount <= CandidLimits.maximumTypeTableEntries else { throw ICClientError.invalidCandid("type table exceeds limit") } + try budget.consume(tableCount) var wireTable: [WireType] = [] wireTable.reserveCapacity(tableCount) for index in 0.. [(UInt32, Int64)] { let count = try reader.readCount(context: context) try Binary.checkCollection(count) + try reader.budget.consume(count) var fields: [(UInt32, Int64)] = [] fields.reserveCapacity(count) var previous: UInt32? @@ -265,9 +269,11 @@ public struct CandidDecoder: Sendable { return fields } - private func validateReferences(in table: [WireType]) throws { + private func validateReferences(in table: [WireType], budget: CandidDecodingBudget) throws { for definition in table { + try budget.consume() for reference in definition.references { + try budget.consume() if reference < 0 { guard CandidType(primitiveCode: reference) != nil else { throw ICClientError.invalidCandid("unknown primitive type code \(reference)") @@ -279,48 +285,75 @@ public struct CandidDecoder: Sendable { } } + private struct ResolvedType { + let type: CandidType + let freeReferences: Set + let structuralDepth: Int + } + private func resolve( _ reference: Int64, table: [WireType], - cache: inout [Int: CandidType], + cache: inout [Int: ResolvedType], stack: Set, - depth: Int - ) throws -> CandidType { + depth: Int, + budget: CandidDecodingBudget + ) throws -> ResolvedType { + try budget.consume() guard depth <= CandidLimits.maximumDepth else { throw ICClientError.invalidCandid("type nesting exceeds limit") } if reference < 0 { guard let type = CandidType(primitiveCode: reference) else { throw ICClientError.invalidCandid("unknown primitive type code \(reference)") } - return type + return ResolvedType(type: type, freeReferences: [], structuralDepth: 0) } guard reference <= Int64(Int.max), table.indices.contains(Int(reference)) else { throw ICClientError.invalidCandid("type reference \(reference) is out of range") } let index = Int(reference) if stack.contains(index) { - return .reference(UInt32(index)) + return ResolvedType(type: .reference(UInt32(index)), freeReferences: [UInt32(index)], structuralDepth: 0) + } + if let cached = cache[index] { + guard depth + cached.structuralDepth <= CandidLimits.maximumDepth else { + throw ICClientError.invalidCandid("type nesting exceeds limit") + } + return cached } - if let cached = cache[index] { return cached } var nextStack = stack nextStack.insert(index) + var freeReferences = Set() + var structuralDepth = 0 + func child(_ reference: Int64) throws -> CandidType { + let resolved = try resolve(reference, table: table, cache: &cache, stack: nextStack, depth: depth + 1, budget: budget) + try budget.consume(resolved.freeReferences.count) + freeReferences.formUnion(resolved.freeReferences) + structuralDepth = max(structuralDepth, 1 + resolved.structuralDepth) + return resolved.type + } let result: CandidType switch table[index] { - case .optional(let child): - result = .optional(try resolve(child, table: table, cache: &cache, stack: nextStack, depth: depth + 1)) - case .vector(let child): - result = .vector(try resolve(child, table: table, cache: &cache, stack: nextStack, depth: depth + 1)) + case .optional(let reference): result = .optional(try child(reference)) + case .vector(let reference): result = .vector(try child(reference)) case .record(let fields): - result = .record(try fields.map { CandidField(id: $0.0, type: try resolve($0.1, table: table, cache: &cache, stack: nextStack, depth: depth + 1)) }) + try budget.consume(fields.count) + result = .record(try fields.map { CandidField(id: $0.0, type: try child($0.1)) }) case .variant(let fields): - result = .variant(try fields.map { CandidField(id: $0.0, type: try resolve($0.1, table: table, cache: &cache, stack: nextStack, depth: depth + 1)) }) + try budget.consume(fields.count) + result = .variant(try fields.map { CandidField(id: $0.0, type: try child($0.1)) }) } - let resolved: CandidType - if result.freeRecursiveReferences.contains(UInt32(index)) { - resolved = .recursive(id: UInt32(index), body: result) + let type: CandidType + if freeReferences.remove(UInt32(index)) != nil { + type = .recursive(id: UInt32(index), body: result) + structuralDepth += 1 } else { - resolved = result + type = result + } + guard depth + structuralDepth <= CandidLimits.maximumDepth else { + throw ICClientError.invalidCandid("type nesting exceeds limit") } - if resolved.freeRecursiveReferences.isEmpty { cache[index] = resolved } + let resolved = ResolvedType(type: type, freeReferences: freeReferences, structuralDepth: structuralDepth) + if freeReferences.isEmpty { cache[index] = resolved } return resolved } @@ -330,6 +363,7 @@ public struct CandidDecoder: Sendable { depth: Int, bindings: [UInt32: CandidType] = [:] ) throws -> CandidValue { + try reader.budget.consume() guard depth <= CandidLimits.maximumDepth else { throw ICClientError.invalidCandid("value nesting exceeds limit") } switch type { case .null: return .null @@ -366,6 +400,7 @@ public struct CandidDecoder: Sendable { return .blob(try reader.readData(count: reader.readCount(context: "blob"))) case .vector(let child): let count = try reader.readCount(context: "vector") + try reader.budget.consume(count) var values: [CandidValue] = [] values.reserveCapacity(count) for index in 0.. UInt8 { guard offset < data.count else { throw ICClientError.invalidCandid("unexpected end of input") } defer { offset += 1 } - return data[offset] + return data[data.index(data.startIndex, offsetBy: offset)] } mutating func readData(count: Int) throws -> Data { guard count >= 0, count <= CandidLimits.maximumCollectionElements, offset <= data.count - count else { throw ICClientError.invalidCandid("truncated or oversized value") } defer { offset += count } - return data.subdata(in: offset..<(offset + count)) + let start = data.index(data.startIndex, offsetBy: offset) + return Data(data[start.. [UInt8] { @@ -537,8 +580,6 @@ private enum Binary { guard index < 10, index < 9 || byte & 0x7e == 0 else { throw ICClientError.invalidCandid("ULEB128 overflows uint64") } value |= UInt64(byte & 0x7f) << (7 * index) } - var canonical = Data(); Binary.appendULEB(value, to: &canonical) - guard Array(canonical) == bytes else { throw ICClientError.invalidCandid("non-canonical ULEB128") } return value } @@ -556,8 +597,6 @@ private enum Binary { } else if let last = bytes.last, last & 0x40 != 0 { value |= -1 << (7 * bytes.count) } - var canonical = Data(); Binary.appendSLEB(value, to: &canonical) - guard Array(canonical) == bytes else { throw ICClientError.invalidCandid("non-canonical SLEB128") } return value } @@ -694,7 +733,6 @@ private enum BigLEB { static func decodeUnsigned(_ bytes: [UInt8]) throws -> String { var value = BigUnsigned() for byte in bytes.reversed() { value.multiply(by: 128); value.add(UInt32(byte & 0x7f)) } - guard unsigned(value.decimal) == bytes else { throw ICClientError.invalidCandid("non-canonical unsigned LEB128") } return value.decimal } @@ -712,7 +750,6 @@ private enum BigLEB { } else { decimal = unsignedValue.decimal } - guard signed(decimal) == bytes else { throw ICClientError.invalidCandid("non-canonical signed LEB128") } return decimal } } diff --git a/Sources/ICNativeClient/Certificate.swift b/Sources/ICNativeClient/Certificate.swift index 1f5540a..3c08578 100644 --- a/Sources/ICNativeClient/Certificate.swift +++ b/Sources/ICNativeClient/Certificate.swift @@ -83,19 +83,49 @@ indirect enum ICHashTree: Sendable { } } - func lookup(_ path: [Data]) -> Lookup { - if path.isEmpty { - if case .leaf(let value) = self { return .found(value) } - if case .pruned = self { return .unknown } - return .error - } + private enum LabelLookup { + case before, after, absent, unknown + case found(ICHashTree) + } + + // IC hash-tree labels are sorted. A visible boundary can prove absence even + // when another branch is pruned; an unconstrained pruned branch cannot. + private func lookupLabel(_ target: Data) -> LabelLookup { switch self { - case .empty, .leaf: return .absent - case .pruned: return .unknown case .labeled(let label, let child): - return label == path[0] ? child.lookup(Array(path.dropFirst())) : .absent + if target.lexicographicallyPrecedes(label) { return .before } + if label.lexicographicallyPrecedes(target) { return .after } + return .found(child) case .fork(let left, let right): - return Self.merge(left.lookup(path), right.lookup(path)) + switch left.lookupLabel(target) { + case .after: + let result = right.lookupLabel(target) + if case .before = result { return .absent } + return result + case .unknown: + let result = right.lookupLabel(target) + if case .before = result { return .unknown } + return result + case let result: return result + } + case .pruned: return .unknown + case .empty, .leaf: return .absent + } + } + + func lookup(_ path: [Data]) -> Lookup { + guard let first = path.first else { + switch self { + case .leaf(let value): return .found(value) + case .empty: return .absent + case .pruned: return .unknown + default: return .error + } + } + switch lookupLabel(first) { + case .found(let child): return child.lookup(Array(path.dropFirst())) + case .unknown: return .unknown + case .before, .after, .absent: return .absent } } @@ -117,15 +147,6 @@ indirect enum ICHashTree: Sendable { } } - private static func merge(_ left: Lookup, _ right: Lookup) -> Lookup { - switch (left, right) { - case (.error, _), (_, .error), (.found, .found): .error - case (.found(let value), _), (_, .found(let value)): .found(value) - case (.unknown, _), (_, .unknown): .unknown - default: .absent - } - } - private static func hash(domain: String, values: [Data]) -> Data { var input = Data([UInt8(domain.utf8.count)]) input.append(Data(domain.utf8)) diff --git a/Tests/ICNativeClientTests/CandidTests.swift b/Tests/ICNativeClientTests/CandidTests.swift index 98ae177..5cd0bae 100644 --- a/Tests/ICNativeClientTests/CandidTests.swift +++ b/Tests/ICNativeClientTests/CandidTests.swift @@ -221,10 +221,9 @@ final class CandidTests: XCTestCase { XCTAssertEqual(try CandidEncoder().encode(CandidArguments(oneItemList.values)), oneItemFixture) } - func testRejectsMalformedCanonicalReferenceFieldVariantAndLimits() throws { + func testRejectsMalformedReferenceFieldVariantAndLimits() throws { for hex in [ "5849444c0000", // malformed header - "4449444c00017d8000", // non-canonical nat zero "4449444c000100", // type reference outside an empty table "4449444c016c02017b017b00", // duplicate record field ID "4449444c016c02027b017b00", // descending record field ID @@ -263,6 +262,107 @@ final class CandidTests: XCTestCase { } } + func testDecodesDataSlicesAndRejectsTruncatedSlices() throws { + let encoded = try CandidArguments("hello").encode() + let framed = Data([0xaa, 0xbb]) + encoded + Data([0xcc]) + let slice = framed.dropFirst(2).dropLast() + XCTAssertEqual(slice.startIndex, 2) + XCTAssertEqual(try CandidDecoder().decode(slice).decode(String.self), "hello") + XCTAssertThrowsError(try CandidDecoder().decode(slice.dropLast())) + XCTAssertThrowsError(try CandidDecoder().decode(slice.prefix(3))) + XCTAssertThrowsError(try CandidDecoder().decode(slice.dropFirst(slice.count))) + } + + func testAcceptsPaddedLEB128AndReencodesCanonically() throws { + // Cross-checked with candid 0.10.35. Padding is allowed on the wire. + let fixtures = [ + ("4449444c80008000", "4449444c0000"), + ("4449444c0001fd7f8000", "4449444c00017d00"), + ("4449444c00017d8100", "4449444c00017d01"), + ("4449444c00017cff7f", "4449444c00017c7f"), + ("4449444c00017c8000", "4449444c00017c00"), + ("4449444c016c0180007f018000", "4449444c016c01007f0100"), + ("4449444c000171810061", "4449444c0001710161"), + ] + for (padded, canonical) in fixtures { + let reply = try CandidDecoder().decode(data(padded)) + XCTAssertEqual(try CandidArguments(reply.values).encode(), data(canonical)) + } + for hex in [ + "4449444c80", // Unterminated structural integer. + "4449444c00017d80", // Unterminated nat. + "4449444c" + String(repeating: "80", count: 9) + "02", // UInt64 overflow. + "4449444c0001" + String(repeating: "80", count: 9) + "01", // Int64 overflow. + "4449444c" + String(repeating: "80", count: 10) + "00", // Structural length limit. + "4449444c00017d" + String(repeating: "80", count: 5_000) + "00", + ] { + XCTAssertThrowsError(try CandidDecoder().decode(data(hex))) + } + for value in [Int64.min, Int64.max] { + let reply = try CandidDecoder().decode(CandidArguments(CandidInt(String(value))).encode()) + XCTAssertEqual(try reply.decode(CandidInt.self).decimal, String(value)) + } + XCTAssertEqual( + try CandidDecoder().decode(CandidArguments(CandidNat(String(UInt64.max))).encode()).decode(CandidNat.self).decimal, + String(UInt64.max) + ) + } + + func testDecodingBudgetStopsSharedTypeExpansion() throws { + XCTAssertNoThrow(try CandidDecoder().decode(sharedTypeFixture(depth: 10))) + for depth in [19, 40] { + XCTAssertThrowsError(try CandidDecoder().decode(sharedTypeFixture(depth: depth))) { error in + XCTAssertTrue(String(describing: error).contains("decoding work limit exceeded")) + } + } + } + + func testDecodingBudgetIsSharedAcrossReplyValues() throws { + // Each null costs six units across resolution, reading and normalization, + // plus its slot in the reply; the final value crosses the shared limit. + func nulls(_ count: UInt64) -> Data { + Data("DIDL".utf8) + Data([0]) + ICRequestID.leb128(count) + + Data(repeating: 0x7f, count: Int(count)) + } + XCTAssertEqual(try CandidDecoder().decode(nulls(142_857)).values.count, 142_857) + XCTAssertThrowsError(try CandidDecoder().decode(nulls(142_858))) { error in + XCTAssertTrue(String(describing: error).contains("decoding work limit exceeded")) + } + let blob = Data(repeating: 0x55, count: 1_000_000) + XCTAssertEqual(try CandidDecoder().decode(CandidArguments(blob).encode()).decode(Data.self), blob) + } + + func testCachedTypesStillEnforceDepthLimit() throws { + func nestedTypes(_ count: Int) -> Data { + func reference(_ value: Int) -> Data { + value < 64 ? Data([UInt8(value)]) : Data([UInt8(value) | 0x80, 0]) + } + var bytes = Data("DIDL".utf8) + ICRequestID.leb128(UInt64(count)) + for index in 0.. Data { + var bytes = Data("DIDL".utf8) + bytes.append(contentsOf: [UInt8(depth + 2), 0x6e, 1]) + for index in 1...depth { + bytes.append(contentsOf: [0x6c, 2, 0, UInt8(index + 1), 1, UInt8(index + 1)]) + } + bytes.append(contentsOf: [0x6c, 0, 1, 0, 0]) + return bytes + } + private func typed(_ type: CandidType, _ value: CandidValue) throws -> CandidTypedValue { try CandidTypedValue(type: type, value: value) } diff --git a/Tests/ICNativeClientTests/ICNativeClientTests.swift b/Tests/ICNativeClientTests/ICNativeClientTests.swift index f77734f..9032506 100644 --- a/Tests/ICNativeClientTests/ICNativeClientTests.swift +++ b/Tests/ICNativeClientTests/ICNativeClientTests.swift @@ -309,6 +309,81 @@ final class ICNativeClientTests: XCTestCase { XCTAssertEqual(try ICCertificateVerifier.status(in: done, requestID: requestID), .done) } + func testHashTreeLookupUsesVisibleBoundariesAroundPrunedBranches() throws { + let pruned = ICHashTree.pruned(Data(repeating: 0, count: 32)) + let a = ICHashTree.labeled(Data("a".utf8), .leaf(Data([1]))) + let c = ICHashTree.labeled(Data("c".utf8), .leaf(Data([3]))) + XCTAssertEqual(ICHashTree.fork(pruned, c).lookup([Data("z".utf8)]), .absent) + XCTAssertEqual(ICHashTree.fork(a, pruned).lookup([Data("0".utf8)]), .absent) + XCTAssertEqual(ICHashTree.fork(a, c).lookup([Data("b".utf8)]), .absent) + XCTAssertEqual(ICHashTree.fork(pruned, c).lookup([Data("b".utf8)]), .unknown) + XCTAssertEqual(ICHashTree.fork(a, pruned).lookup([Data("b".utf8)]), .unknown) + XCTAssertEqual(ICHashTree.fork(pruned, c).lookup([Data("c".utf8)]), .found(Data([3]))) + XCTAssertEqual(ICHashTree.empty.lookup([]), .absent) + XCTAssertEqual(pruned.lookup([]), .unknown) + XCTAssertEqual(a.lookup([]), .error) + } + + func testCertifiedRejectWithoutErrorCodeWithPrunedSibling() throws { + let root = BLSTKey(seed: 71) + let requestID = Data(repeating: 1, count: 32) + let base = [Data("request_status".utf8), requestID] + let visible = hashTree([ + ([Data("time".utf8)], ICRequestID.leb128(nanoseconds(Date()))), + (base + [Data("status".utf8)], Data("rejected".utf8)), + (base + [Data("reject_code".utf8)], ICRequestID.leb128(4)), + (base + [Data("reject_message".utf8)], Data("denied".utf8)), + ]) + let tree: ICCBOR.Value = .array([.unsigned(1), .array([.unsigned(4), .bytes(Data(repeating: 0, count: 32))]), visible]) + let certificate = try ICCertificateVerifier.verify( + certificateData: makeCertificate(treeValue: tree, key: root), + effectiveCanisterID: Data(), trustRoot: .custom(root.derPublicKey) + ) + XCTAssertEqual(try ICCertificateVerifier.status(in: certificate, requestID: requestID), + .rejected(ICReject(code: 4, message: "denied", errorCode: nil, isCertified: true))) + } + + func testPollContinuesAfterCertifiedAbsenceWithPrunedBranches() async throws { + let root = BLSTKey(seed: 72) + let config = try configuration(root: root.derPublicKey) + let identity = try makeAuthSession(config: config) + let requestID = Data(repeating: 1, count: 32) + let lock = NSLock() + var reads = 0 + URLProtocolStub.handler = { request in + let attempt = lock.withLock { reads += 1; return reads } + let certificate: Data + if attempt == 1 { + let pruned: ICCBOR.Value = .array([.unsigned(4), .bytes(Data(repeating: 0, count: 32))]) + let neighbors: ICCBOR.Value = .array([.unsigned(1), + .array([.unsigned(2), .bytes(Data(repeating: 0, count: 32)), pruned]), + .array([.unsigned(2), .bytes(Data(repeating: 255, count: 32)), pruned]), + ]) + let tree: ICCBOR.Value = .array([.unsigned(1), pruned, + .array([.unsigned(1), + .array([.unsigned(2), .bytes(Data("request_status".utf8)), neighbors]), + self.hashTree([([Data("time".utf8)], ICRequestID.leb128(self.nanoseconds(Date())))]), + ]), + ]) + certificate = try self.makeCertificate(treeValue: tree, key: root) + } else { + let base = [Data("request_status".utf8), requestID] + certificate = try self.makeCertificate(leaves: [ + ([Data("time".utf8)], ICRequestID.leb128(self.nanoseconds(Date()))), + (base + [Data("status".utf8)], Data("replied".utf8)), + (base + [Data("reply".utf8)], Data([42])), + ], key: root) + } + return response(request, status: 200, body: readStateResponse(certificate)) + } + let transport = URLSessionConfiguration.ephemeral + transport.protocolClasses = [URLProtocolStub.self] + let agent = ICClient(configuration: config, session: URLSession(configuration: transport), sleep: { _ in }) + let reply = try await agent.poll(requestId: requestID, identity: identity, attempts: 2) + XCTAssertEqual(reply, Data([42])) + XCTAssertEqual(lock.withLock { reads }, 2) + } + func testDelegationValidatesSignaturesBindingTargetsPermissionsAndLimits() throws { let bls = BLSTKey(seed: 8) let config = try configuration(root: bls.derPublicKey) @@ -1792,7 +1867,14 @@ final class ICNativeClientTests: XCTestCase { key: BLSTKey, delegation: (Data, Data)? = nil ) throws -> Data { - let treeValue = hashTree(leaves) + try makeCertificate(treeValue: hashTree(leaves), key: key, delegation: delegation) + } + + private func makeCertificate( + treeValue: ICCBOR.Value, + key: BLSTKey, + delegation: (Data, Data)? = nil + ) throws -> Data { let digest = try ICHashTree(value: treeValue).digest let signature = key.sign(Data([0x0d]) + Data("ic-state-root".utf8) + digest) var fields: [(ICCBOR.Value, ICCBOR.Value)] = [