Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
90 changes: 59 additions & 31 deletions Sources/ICNativeClient/Candid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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<T: CandidConvertible>(_ value: T) throws {
Expand Down Expand Up @@ -253,65 +261,72 @@ 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)")
}
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(
_ value: CandidValue,
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),
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Loading