@otakit/capacitor-updater 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ enum DeltaAssemblerError: Error, LocalizedError {
5
+ case missingFiles
6
+ case invalidPath(String)
7
+ case duplicatePath(String)
8
+ case fileCountExceeded(Int)
9
+ case totalSizeExceeded(UInt64)
10
+ case filesHashMismatch
11
+ case fileHashMismatch(String)
12
+ case missingIndexHtml
13
+ case invalidFileURL(String)
14
+
15
+ var errorDescription: String? {
16
+ switch self {
17
+ case .missingFiles:
18
+ return "Delta manifest has no files"
19
+ case let .invalidPath(path):
20
+ return "Invalid file path in delta manifest: \(path)"
21
+ case let .duplicatePath(path):
22
+ return "Duplicate file path in delta manifest: \(path)"
23
+ case let .fileCountExceeded(count):
24
+ return "Delta manifest exceeds file count limit: \(count)"
25
+ case let .totalSizeExceeded(size):
26
+ return "Delta manifest exceeds total size limit: \(size)"
27
+ case .filesHashMismatch:
28
+ return "Delta file list does not match the signed filesHash"
29
+ case let .fileHashMismatch(path):
30
+ return "Downloaded file hash mismatch: \(path)"
31
+ case .missingIndexHtml:
32
+ return "Delta bundle does not contain index.html"
33
+ case let .invalidFileURL(url):
34
+ return "Invalid file download URL: \(url)"
35
+ }
36
+ }
37
+ }
38
+
39
+ /// Assembles a delta-strategy bundle from per-file content-addressed objects.
40
+ ///
41
+ /// The content cache (`otakit_files/<sha256>`) is the device-side state:
42
+ /// previous bundles and the builtin seed populate it, and assembling a new
43
+ /// bundle downloads only the cache misses.
44
+ final class DeltaAssembler {
45
+ // Mirror ZipUtils' extraction limits.
46
+ private let maxFiles = 10_000
47
+ private let maxTotalSize: UInt64 = 500_000_000 // 500 MB
48
+
49
+ private let cacheDirectory: URL
50
+ private let downloader: Downloader
51
+ private let fileManager = FileManager.default
52
+
53
+ private static let builtinSeedMarkerName = "builtin_seed.json"
54
+
55
+ init(cacheDirectory: URL, downloader: Downloader) {
56
+ self.cacheDirectory = cacheDirectory
57
+ self.downloader = downloader
58
+ }
59
+
60
+ // MARK: - Canonical file list
61
+
62
+ /// Canonical file list hash — must match the server's computeFilesHash
63
+ /// (console/lib/delta-files.ts) and the Android mirror byte-for-byte:
64
+ /// entries sorted by UTF-8 bytes of path, lines "<path>:<sha256 lowercase>",
65
+ /// joined with "\n", hashed with SHA-256 (hex).
66
+ static func computeFilesHash(_ entries: [ManifestFileEntry]) -> String {
67
+ let sorted = entries.sorted { lhs, rhs in
68
+ let lhsBytes = Array(lhs.path.utf8)
69
+ let rhsBytes = Array(rhs.path.utf8)
70
+ for index in 0..<min(lhsBytes.count, rhsBytes.count) {
71
+ if lhsBytes[index] != rhsBytes[index] {
72
+ return lhsBytes[index] < rhsBytes[index]
73
+ }
74
+ }
75
+ return lhsBytes.count < rhsBytes.count
76
+ }
77
+ let canonical = sorted
78
+ .map { "\($0.path):\($0.sha256.lowercased())" }
79
+ .joined(separator: "\n")
80
+ let digest = SHA256.hash(data: Data(canonical.utf8))
81
+ return digest.map { String(format: "%02x", $0) }.joined()
82
+ }
83
+
84
+ // MARK: - Validation
85
+
86
+ private func isValidEntryPath(_ path: String) -> Bool {
87
+ if path.isEmpty || path.count > 512 {
88
+ return false
89
+ }
90
+ if path.hasPrefix("/") || path.contains("\\") {
91
+ return false
92
+ }
93
+ for segment in path.split(separator: "/", omittingEmptySubsequences: false) {
94
+ if segment.isEmpty || segment == "." || segment == ".." {
95
+ return false
96
+ }
97
+ }
98
+ for scalar in path.unicodeScalars {
99
+ if scalar.value < 0x20 || scalar.value == 0x7f {
100
+ return false
101
+ }
102
+ }
103
+ // Metadata files the plugin writes into the bundle directory; an app
104
+ // file with the same root-level name would be overwritten.
105
+ if path == "bundle.json" || path == "otakit_files.json" {
106
+ return false
107
+ }
108
+ return true
109
+ }
110
+
111
+ func validate(_ entries: [ManifestFileEntry], expectedFilesHash: String) throws {
112
+ guard !entries.isEmpty else {
113
+ throw DeltaAssemblerError.missingFiles
114
+ }
115
+ guard entries.count <= maxFiles else {
116
+ throw DeltaAssemblerError.fileCountExceeded(entries.count)
117
+ }
118
+
119
+ // Key on UTF-8 bytes, not String: Swift compares canonically-equivalent
120
+ // strings (NFC vs NFD) as equal, which would reject a manifest the
121
+ // server and Android both accept.
122
+ var seenPaths = Set<Data>()
123
+ var totalSize: UInt64 = 0
124
+ for entry in entries {
125
+ guard isValidEntryPath(entry.path) else {
126
+ throw DeltaAssemblerError.invalidPath(entry.path)
127
+ }
128
+ guard seenPaths.insert(Data(entry.path.utf8)).inserted else {
129
+ throw DeltaAssemblerError.duplicatePath(entry.path)
130
+ }
131
+ if let size = entry.size, size > 0 {
132
+ totalSize += UInt64(size)
133
+ if totalSize > maxTotalSize {
134
+ throw DeltaAssemblerError.totalSizeExceeded(totalSize)
135
+ }
136
+ }
137
+ }
138
+
139
+ guard seenPaths.contains(Data("index.html".utf8)) else {
140
+ throw DeltaAssemblerError.missingIndexHtml
141
+ }
142
+
143
+ // The signed manifest sha256 is the filesHash; recomputing it here is what
144
+ // extends signature coverage to every (path, sha256) pair.
145
+ guard DeltaAssembler.computeFilesHash(entries) == expectedFilesHash.lowercased() else {
146
+ throw DeltaAssemblerError.filesHashMismatch
147
+ }
148
+ }
149
+
150
+ // MARK: - Cache
151
+
152
+ private func cachePath(for sha256: String) -> URL {
153
+ cacheDirectory.appendingPathComponent(sha256.lowercased(), isDirectory: false)
154
+ }
155
+
156
+ private func isCached(_ sha256: String) -> Bool {
157
+ fileManager.fileExists(atPath: cachePath(for: sha256).path)
158
+ }
159
+
160
+ private func ensureCached(_ entry: ManifestFileEntry) async throws {
161
+ if isCached(entry.sha256) {
162
+ return
163
+ }
164
+
165
+ guard let url = URL(string: entry.url) else {
166
+ throw DeltaAssemblerError.invalidFileURL(entry.url)
167
+ }
168
+
169
+ let temporary = try await downloader.download(from: url)
170
+ defer { try? fileManager.removeItem(at: temporary) }
171
+
172
+ guard try HashUtils.verify(fileURL: temporary, expectedSha256: entry.sha256) else {
173
+ throw DeltaAssemblerError.fileHashMismatch(entry.path)
174
+ }
175
+
176
+ let destination = cachePath(for: entry.sha256)
177
+ if fileManager.fileExists(atPath: destination.path) {
178
+ return
179
+ }
180
+ // Write via temp + rename so a crash mid-copy can never leave a
181
+ // truncated file at a content-addressed path (exists() implies valid).
182
+ let staging = cacheDirectory.appendingPathComponent(
183
+ ".tmp-\(UUID().uuidString)",
184
+ isDirectory: false
185
+ )
186
+ try fileManager.copyItem(at: temporary, to: staging)
187
+ do {
188
+ try fileManager.moveItem(at: staging, to: destination)
189
+ } catch {
190
+ try? fileManager.removeItem(at: staging)
191
+ // A concurrent writer may have won the rename; that's fine.
192
+ if !fileManager.fileExists(atPath: destination.path) {
193
+ throw error
194
+ }
195
+ }
196
+ }
197
+
198
+ // MARK: - Assembly
199
+
200
+ /// Fill cache misses and lay out the bundle directory from the cache.
201
+ /// `destination` must be an empty/absent directory; entries must be
202
+ /// validated first.
203
+ func assemble(entries: [ManifestFileEntry], into destination: URL) async throws {
204
+ if fileManager.fileExists(atPath: destination.path) {
205
+ try fileManager.removeItem(at: destination)
206
+ }
207
+ try fileManager.createDirectory(at: destination, withIntermediateDirectories: true)
208
+
209
+ let destinationPrefix = destination.standardizedFileURL.path.hasSuffix("/")
210
+ ? destination.standardizedFileURL.path
211
+ : destination.standardizedFileURL.path + "/"
212
+
213
+ for entry in entries {
214
+ try await ensureCached(entry)
215
+
216
+ let target = destination.appendingPathComponent(entry.path, isDirectory: false)
217
+ // Defense in depth alongside isValidEntryPath (mirrors ZipUtils).
218
+ guard target.standardizedFileURL.path.hasPrefix(destinationPrefix) else {
219
+ throw DeltaAssemblerError.invalidPath(entry.path)
220
+ }
221
+ let parent = target.deletingLastPathComponent()
222
+ try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
223
+ try fileManager.copyItem(at: cachePath(for: entry.sha256), to: target)
224
+ }
225
+ }
226
+
227
+ // MARK: - Builtin seeding
228
+
229
+ private struct BuiltinSeed: Codable {
230
+ let nativeBuild: String
231
+ let hashes: [String]
232
+ }
233
+
234
+ private var builtinSeedURL: URL {
235
+ cacheDirectory.appendingPathComponent(DeltaAssembler.builtinSeedMarkerName, isDirectory: false)
236
+ }
237
+
238
+ private func readBuiltinSeed() -> BuiltinSeed? {
239
+ guard let data = try? Data(contentsOf: builtinSeedURL) else {
240
+ return nil
241
+ }
242
+ return try? JSONDecoder().decode(BuiltinSeed.self, from: data)
243
+ }
244
+
245
+ /// Hash the store-build web assets into the cache once per native build, so
246
+ /// the first OTA only downloads what changed relative to the binary.
247
+ /// Best-effort: failures only cost extra downloads.
248
+ func seedFromBuiltinIfNeeded(builtinDirectory: URL, nativeBuild: String) {
249
+ if let seed = readBuiltinSeed(), seed.nativeBuild == nativeBuild {
250
+ return
251
+ }
252
+
253
+ var isDirectory: ObjCBool = false
254
+ guard fileManager.fileExists(atPath: builtinDirectory.path, isDirectory: &isDirectory),
255
+ isDirectory.boolValue else {
256
+ return
257
+ }
258
+
259
+ var hashes: [String] = []
260
+ let enumerator = fileManager.enumerator(
261
+ at: builtinDirectory,
262
+ includingPropertiesForKeys: [.isRegularFileKey],
263
+ options: [.skipsHiddenFiles]
264
+ )
265
+ while let item = enumerator?.nextObject() as? URL {
266
+ guard let isRegular = try? item.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile,
267
+ isRegular == true else {
268
+ continue
269
+ }
270
+ guard let sha256 = try? HashUtils.sha256(fileURL: item) else {
271
+ continue
272
+ }
273
+ let destination = cachePath(for: sha256)
274
+ if !fileManager.fileExists(atPath: destination.path) {
275
+ let staging = cacheDirectory.appendingPathComponent(
276
+ ".tmp-\(UUID().uuidString)",
277
+ isDirectory: false
278
+ )
279
+ if (try? fileManager.copyItem(at: item, to: staging)) != nil {
280
+ if (try? fileManager.moveItem(at: staging, to: destination)) == nil {
281
+ try? fileManager.removeItem(at: staging)
282
+ }
283
+ }
284
+ }
285
+ hashes.append(sha256)
286
+ }
287
+
288
+ let seed = BuiltinSeed(nativeBuild: nativeBuild, hashes: hashes)
289
+ if let data = try? JSONEncoder().encode(seed) {
290
+ try? data.write(to: builtinSeedURL, options: .atomic)
291
+ }
292
+ }
293
+
294
+ // MARK: - Eviction
295
+
296
+ /// Remove cache entries not referenced by any live bundle and not part of
297
+ /// the builtin seed. Best-effort.
298
+ func pruneCache(referencedHashes: Set<String>) {
299
+ var keep = Set(referencedHashes.map { $0.lowercased() })
300
+ if let seed = readBuiltinSeed() {
301
+ keep.formUnion(seed.hashes.map { $0.lowercased() })
302
+ }
303
+
304
+ guard let items = try? fileManager.contentsOfDirectory(atPath: cacheDirectory.path) else {
305
+ return
306
+ }
307
+ for item in items {
308
+ if item == DeltaAssembler.builtinSeedMarkerName || item.hasPrefix(".tmp-") {
309
+ continue
310
+ }
311
+ if !keep.contains(item.lowercased()) {
312
+ try? fileManager.removeItem(at: cacheDirectory.appendingPathComponent(item))
313
+ }
314
+ }
315
+ }
316
+ }
@@ -3,13 +3,35 @@ import Foundation
3
3
  private let baseChannelKey = "__base__"
4
4
  private let defaultRuntimeKey = "__default__"
5
5
 
6
+ struct ManifestEncryption {
7
+ let alg: String
8
+ let kid: String
9
+ let wrapNonce: String
10
+ let wrappedDek: String
11
+ let nonce: String
12
+ }
13
+
14
+ struct ManifestFileEntry {
15
+ let path: String
16
+ let sha256: String
17
+ let size: Int?
18
+ let url: String
19
+ }
20
+
6
21
  struct LatestManifest {
7
22
  let version: String
8
- let url: String
23
+ /// Bundle zip URL. Present for the zip strategy; nil for deltas.
24
+ let url: String?
25
+ /// Zip hash for the zip strategy; canonical filesHash for deltas.
9
26
  let sha256: String
10
27
  let size: Int
11
28
  let runtimeVersion: String?
12
29
  let releaseId: String
30
+ let strategy: String
31
+ let forceImmediate: Bool
32
+ let encryption: ManifestEncryption?
33
+ /// Per-file entries for the deltas strategy; nil for zip.
34
+ let files: [ManifestFileEntry]?
13
35
  }
14
36
 
15
37
  struct ManifestSignature {
@@ -94,7 +116,6 @@ enum ManifestClient {
94
116
  guard
95
117
  let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
96
118
  let version = object["version"] as? String,
97
- let downloadUrl = object["url"] as? String,
98
119
  let sha256 = object["sha256"] as? String,
99
120
  let size = object["size"] as? Int
100
121
  else {
@@ -112,10 +133,23 @@ enum ManifestClient {
112
133
  throw ManifestClientError.invalidResponse
113
134
  }
114
135
 
115
- guard let dlURL = URL(string: downloadUrl) else {
116
- throw ManifestClientError.invalidURL
136
+ let strategy = (object["strategy"] as? String)?
137
+ .trimmingCharacters(in: .whitespacesAndNewlines)
138
+ .nilIfEmpty ?? "zip"
139
+ let forceImmediate = object["forceImmediate"] as? Bool ?? false
140
+ let encryption = try parseEncryption(object["encryption"])
141
+
142
+ let downloadUrl = (object["url"] as? String)?.nilIfEmpty
143
+ var files: [ManifestFileEntry]?
144
+
145
+ if strategy == "deltas" {
146
+ files = try parseFiles(object["files"], allowInsecureUrls: allowInsecureUrls)
147
+ } else {
148
+ guard let downloadUrl, let dlURL = URL(string: downloadUrl) else {
149
+ throw ManifestClientError.invalidResponse
150
+ }
151
+ try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
117
152
  }
118
- try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
119
153
 
120
154
  if manifestKeys.isEmpty {
121
155
  print("[OtaKit] WARNING: No manifest signing keys configured — signature verification is disabled for this request.")
@@ -133,6 +167,9 @@ enum ManifestClient {
133
167
  sha256: sha256,
134
168
  size: size,
135
169
  runtimeVersion: runtimeVersion,
170
+ strategy: strategy,
171
+ forceImmediate: forceImmediate,
172
+ encryption: encryption,
136
173
  signature: signature,
137
174
  trustedKeys: manifestKeys
138
175
  )
@@ -144,7 +181,62 @@ enum ManifestClient {
144
181
  sha256: sha256,
145
182
  size: size,
146
183
  runtimeVersion: runtimeVersion,
147
- releaseId: releaseId
184
+ releaseId: releaseId,
185
+ strategy: strategy,
186
+ forceImmediate: forceImmediate,
187
+ encryption: encryption,
188
+ files: files
189
+ )
190
+ }
191
+
192
+ private static func parseFiles(
193
+ _ rawValue: Any?,
194
+ allowInsecureUrls: Bool
195
+ ) throws -> [ManifestFileEntry] {
196
+ guard let rawFiles = rawValue as? [[String: Any]], !rawFiles.isEmpty else {
197
+ throw ManifestClientError.invalidResponse
198
+ }
199
+
200
+ var entries: [ManifestFileEntry] = []
201
+ entries.reserveCapacity(rawFiles.count)
202
+ for rawFile in rawFiles {
203
+ guard let path = rawFile["path"] as? String,
204
+ let sha256 = rawFile["sha256"] as? String,
205
+ let fileUrl = rawFile["url"] as? String,
206
+ let parsedUrl = URL(string: fileUrl) else {
207
+ throw ManifestClientError.invalidResponse
208
+ }
209
+ try requireHTTPS(url: parsedUrl, allowInsecure: allowInsecureUrls)
210
+ entries.append(
211
+ ManifestFileEntry(
212
+ path: path,
213
+ sha256: sha256,
214
+ size: rawFile["size"] as? Int,
215
+ url: fileUrl
216
+ )
217
+ )
218
+ }
219
+ return entries
220
+ }
221
+
222
+ private static func parseEncryption(_ rawValue: Any?) throws -> ManifestEncryption? {
223
+ guard let rawValue, !(rawValue is NSNull) else {
224
+ return nil
225
+ }
226
+ guard let object = rawValue as? [String: Any],
227
+ let alg = object["alg"] as? String,
228
+ let kid = object["kid"] as? String,
229
+ let wrapNonce = object["wrapNonce"] as? String,
230
+ let wrappedDek = object["wrappedDek"] as? String,
231
+ let nonce = object["nonce"] as? String else {
232
+ throw ManifestClientError.invalidResponse
233
+ }
234
+ return ManifestEncryption(
235
+ alg: alg,
236
+ kid: kid,
237
+ wrapNonce: wrapNonce,
238
+ wrappedDek: wrappedDek,
239
+ nonce: nonce
148
240
  )
149
241
  }
150
242
 
@@ -31,6 +31,9 @@ enum ManifestVerifier {
31
31
  sha256: String,
32
32
  size: Int,
33
33
  runtimeVersion: String?,
34
+ strategy: String,
35
+ forceImmediate: Bool,
36
+ encryption: ManifestEncryption?,
34
37
  signature: ManifestSignature,
35
38
  trustedKeys: [ManifestKey]
36
39
  ) throws {
@@ -41,6 +44,9 @@ enum ManifestVerifier {
41
44
  sha256: sha256,
42
45
  size: size,
43
46
  runtimeVersion: runtimeVersion,
47
+ strategy: strategy,
48
+ forceImmediate: forceImmediate,
49
+ encryption: encryption,
44
50
  kid: signature.kid,
45
51
  iat: signature.iat,
46
52
  exp: signature.exp
@@ -78,6 +84,23 @@ enum ManifestVerifier {
78
84
  }
79
85
  }
80
86
 
87
+ /// Encode the encryption block for the canonical payload.
88
+ /// Must match the server's `encodeEncryptionForPayload` exactly.
89
+ private static func encodeEncryptionForPayload(_ encryption: ManifestEncryption?) -> String {
90
+ guard let encryption else {
91
+ return "null"
92
+ }
93
+ return [
94
+ encryption.alg,
95
+ encryption.kid,
96
+ encryption.wrapNonce,
97
+ encryption.wrappedDek,
98
+ encryption.nonce,
99
+ ].joined(separator: "|")
100
+ }
101
+
102
+ /// Canonical payload v2 — must match the server's `buildCanonicalPayload`
103
+ /// (console/lib/manifest-signing.ts) and the Android mirror byte-for-byte.
81
104
  private static func buildCanonicalPayload(
82
105
  appId: String,
83
106
  channel: String?,
@@ -85,6 +108,9 @@ enum ManifestVerifier {
85
108
  sha256: String,
86
109
  size: Int,
87
110
  runtimeVersion: String?,
111
+ strategy: String,
112
+ forceImmediate: Bool,
113
+ encryption: ManifestEncryption?,
88
114
  kid: String,
89
115
  iat: Int,
90
116
  exp: Int
@@ -97,6 +123,9 @@ enum ManifestVerifier {
97
123
  "sha256:\(sha256)",
98
124
  "size:\(size)",
99
125
  "runtimeVersion:\(runtimeVersion ?? "null")",
126
+ "strategy:\(strategy)",
127
+ "forceImmediate:\(forceImmediate ? "true" : "false")",
128
+ "encryption:\(encodeEncryptionForPayload(encryption))",
100
129
  "kid:\(kid)",
101
130
  "iat:\(iat)",
102
131
  "exp:\(exp)",