@capgo/cordova-updater 8.1.4 → 8.2.1

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.
Files changed (36) hide show
  1. package/dist/esm/definitions.d.ts +116 -86
  2. package/dist/esm/definitions.js +2 -2
  3. package/dist/esm/definitions.js.map +1 -1
  4. package/dist/esm/exec.js +11 -1
  5. package/dist/esm/exec.js.map +1 -1
  6. package/dist/esm/history.js +7 -7
  7. package/dist/esm/history.js.map +1 -1
  8. package/dist/plugin.cjs.js +13 -3
  9. package/dist/plugin.cjs.js.map +1 -1
  10. package/dist/plugin.js +13 -3
  11. package/dist/plugin.js.map +1 -1
  12. package/package.json +5 -3
  13. package/plugin.xml +9 -9
  14. package/src/android/CordovaUpdaterPlugin.java +173 -5
  15. package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +313 -55
  16. package/src/android/app/capgo/cordova/updater/CryptoCipher.java +51 -18
  17. package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +0 -1
  18. package/src/android/app/capgo/cordova/updater/DownloadService.java +402 -89
  19. package/src/android/app/capgo/cordova/updater/Version.java +312 -0
  20. package/src/android/capgo-cordova-updater.gradle +3 -5
  21. package/src/android/test/app/capgo/cordova/updater/BackgroundDownloadSettlementTest.java +29 -0
  22. package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +2 -12
  23. package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +0 -1
  24. package/src/android/test/app/capgo/cordova/updater/RsaContractTest.java +137 -0
  25. package/src/android/test/app/capgo/cordova/updater/SecurityHardeningTest.java +197 -0
  26. package/src/android/test/app/capgo/cordova/updater/SessionKeyRequiredTest.java +344 -0
  27. package/src/ios/CapgoSemanticVersion.swift +181 -0
  28. package/src/ios/CapgoUpdater.swift +427 -114
  29. package/src/ios/CordovaUpdaterPlugin.swift +171 -40
  30. package/src/ios/CryptoCipher.swift +41 -38
  31. package/src/ios/DelayUpdateUtils.swift +19 -45
  32. package/src/ios/Logger.swift +81 -5
  33. package/src/ios/RedirectPolicyDelegate.swift +53 -0
  34. package/src/ios/WebViewStatsReporter.swift +5 -0
  35. package/src/ios/ZipArchiveReader.swift +263 -0
  36. package/src/ios/ZipCentralDirectory.swift +283 -0
@@ -5,8 +5,6 @@
5
5
  */
6
6
 
7
7
  import Foundation
8
- import ZIPFoundation
9
- import Alamofire
10
8
  import Compression
11
9
  import UIKit
12
10
 
@@ -74,10 +72,11 @@ import UIKit
74
72
  private var statsQueue: [QueuedStatsEvent] = []
75
73
  private var statsInFlight: [QueuedStatsEvent] = []
76
74
  private let statsQueueLock = NSLock()
77
- private let statsPersistLock = NSLock()
78
75
  private let statsStateLock = NSLock()
76
+ private let statsPersistLock = NSLock()
79
77
  private var statsFlushTimer: Timer?
80
78
  private var _statsStopped = false
79
+
81
80
  private var statsStopped: Bool {
82
81
  get {
83
82
  statsStateLock.lock()
@@ -133,7 +132,7 @@ import UIKit
133
132
  let timedOut: Bool
134
133
  }
135
134
 
136
- private struct DownloadRequestResult {
135
+ struct DownloadRequestResult {
137
136
  let fileURL: URL?
138
137
  let response: HTTPURLResponse?
139
138
  let error: Error?
@@ -147,6 +146,10 @@ import UIKit
147
146
  case pathTraversal
148
147
  }
149
148
 
149
+ static func containsPathTraversalSegment(_ relativePath: String) -> Bool {
150
+ return relativePath.split(separator: "/").contains(where: { $0 == ".." })
151
+ }
152
+
150
153
  static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
151
154
  if relativePath.isEmpty {
152
155
  throw SecurePathError.emptyPath
@@ -154,21 +157,35 @@ import UIKit
154
157
  if relativePath.contains("\\") || relativePath.contains("\0") {
155
158
  throw SecurePathError.windowsPath
156
159
  }
160
+ if containsPathTraversalSegment(relativePath) {
161
+ throw SecurePathError.pathTraversal
162
+ }
157
163
  if (relativePath as NSString).isAbsolutePath {
158
164
  throw SecurePathError.absolutePath
159
165
  }
160
166
 
161
- let canonicalBase = baseDirectory.standardizedFileURL
167
+ let canonicalBase = baseDirectory.standardizedFileURL.resolvingSymlinksInPath()
162
168
  let canonicalBasePath = canonicalBase.path
163
169
  let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
164
- let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
165
- let canonicalTargetPath = canonicalTarget.path
170
+ let targetURL = canonicalBase.appendingPathComponent(relativePath, isDirectory: false).standardizedFileURL
171
+ let canonicalTargetPath: String
172
+ if FileManager.default.fileExists(atPath: targetURL.path) {
173
+ canonicalTargetPath = targetURL.resolvingSymlinksInPath().path
174
+ } else {
175
+ canonicalTargetPath = targetURL.path
176
+ }
166
177
 
167
- if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
178
+ // Require a strict child of the base. Equality would accept "." and wipe/write the root.
179
+ if !canonicalTargetPath.hasPrefix(normalizedBasePath) {
168
180
  throw SecurePathError.pathTraversal
169
181
  }
170
182
 
171
- return canonicalTarget
183
+ return URL(fileURLWithPath: canonicalTargetPath, isDirectory: false)
184
+ }
185
+
186
+ static func resolveBundleDirectory(libraryDir: URL, bundleId: String) throws -> URL {
187
+ let bundleRoot = libraryDir.appendingPathComponent("NoCloud/ionic_built_snapshots")
188
+ return try resolvePathInsideDirectory(baseDirectory: bundleRoot, relativePath: bundleId)
172
189
  }
173
190
 
174
191
  static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
@@ -177,7 +194,26 @@ import UIKit
177
194
  return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
178
195
  }
179
196
 
197
+ static func rememberManifestTarget(_ seenTargets: inout Set<String>, targetFile: URL) -> Bool {
198
+ return seenTargets.insert(targetFile.standardizedFileURL.path).inserted
199
+ }
200
+
201
+ private struct ManifestDownloadTask {
202
+ let fileName: String
203
+ let downloadUrl: String
204
+ let finalFileHash: String
205
+ let isBrotli: Bool
206
+ let destFileName: String
207
+ let destFilePath: URL
208
+ let builtinFilePath: URL
209
+ let cacheFilePath: URL?
210
+ let legacyCacheFilePath: URL?
211
+ }
212
+
180
213
  private func isTimedOutError(_ error: Error?) -> Bool {
214
+ if case let .sessionTaskFailed(underlying)? = error as? NetworkError {
215
+ return isTimedOutError(underlying)
216
+ }
181
217
  guard let nsError = error as NSError? else {
182
218
  return false
183
219
  }
@@ -185,7 +221,51 @@ import UIKit
185
221
  return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
186
222
  }
187
223
 
188
- private lazy var alamofireSession: Session = {
224
+ /// Errors produced by the URLSession transport. Descriptions intentionally match the messages the
225
+ /// previous Alamofire transport surfaced so logs and JS-facing error strings stay the same.
226
+ enum NetworkError: LocalizedError {
227
+ case sessionTaskFailed(Error)
228
+ case emptyResponse
229
+ case invalidURL(String)
230
+ case bodyEncodingFailed(Error?)
231
+ case downloadedFileMoveFailed(Error, source: URL, destination: URL)
232
+
233
+ var errorDescription: String? {
234
+ switch self {
235
+ case let .sessionTaskFailed(error):
236
+ return "URLSessionTask failed with error: \(error.localizedDescription)"
237
+ case .emptyResponse:
238
+ return "Response could not be serialized, input data was nil or zero length."
239
+ case let .invalidURL(url):
240
+ return "URL is not valid: \(url)"
241
+ case let .bodyEncodingFailed(error):
242
+ return "JSON could not be encoded because of error:\n\(error?.localizedDescription ?? "Invalid JSON object provided for parameter or object encoding.")"
243
+ case let .downloadedFileMoveFailed(error, source, destination):
244
+ return "Moving downloaded file from: \(source) to: \(destination) failed with error: \(error.localizedDescription)"
245
+ }
246
+ }
247
+ }
248
+
249
+ /// Mirrors Alamofire's `responseData` serializer: an empty body is an error unless the status allows it.
250
+ static func emptyResponseError(data: Data?, response: HTTPURLResponse?, method: String?) -> Error? {
251
+ if let data, !data.isEmpty {
252
+ return nil
253
+ }
254
+ if method?.uppercased() == "HEAD" || [204, 205].contains(response?.statusCode ?? 0) {
255
+ return nil
256
+ }
257
+ return NetworkError.emptyResponse
258
+ }
259
+
260
+ // lazy var is not thread-safe; concurrent manifest downloads can race first access when statsUrl is empty.
261
+ private var cachedUrlSession: URLSession?
262
+ private let urlSessionLock = NSLock()
263
+ private var urlSession: URLSession {
264
+ urlSessionLock.lock()
265
+ defer { urlSessionLock.unlock() }
266
+ if let session = cachedUrlSession {
267
+ return session
268
+ }
189
269
  let configuration = URLSessionConfiguration.ephemeral
190
270
  configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
191
271
  configuration.httpCookieStorage = nil
@@ -193,8 +273,84 @@ import UIKit
193
273
  configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
194
274
  configuration.urlCache = nil
195
275
  configuration.httpMaximumConnectionsPerHost = Self.manifestMaxConcurrentFiles
196
- return Session(configuration: configuration)
197
- }()
276
+ redirectPolicy.onBlockedRedirect = { [weak self] source, target in
277
+ self?.logger?.error("Blocked HTTPS to HTTP redirect; set allowHttpsToHttpRedirect to true to allow it")
278
+ self?.logger?.debug("Redirect from \(source?.absoluteString ?? "") to \(target?.absoluteString ?? "")")
279
+ }
280
+ let session = URLSession(configuration: configuration, delegate: redirectPolicy, delegateQueue: nil)
281
+ cachedUrlSession = session
282
+ return session
283
+ }
284
+
285
+ /// Owned separately from `CapgoUpdater` because URLSession retains its delegate strongly.
286
+ private let redirectPolicy = RedirectPolicyDelegate()
287
+
288
+ /// Follow redirects from HTTPS to plain HTTP. Off by default so a redirect can never downgrade updater traffic.
289
+ public var allowHttpsToHttpRedirect: Bool {
290
+ get { redirectPolicy.allowHttpsToHttpRedirect }
291
+ set { redirectPolicy.allowHttpsToHttpRedirect = newValue }
292
+ }
293
+
294
+ /// Runs a data task and reports `(data, response, error)` like Alamofire's `responseData` did:
295
+ /// `data` is nil when no bytes were received, transport errors are wrapped, and an empty body is an error
296
+ /// unless the status code is 204/205 (or the request is HEAD).
297
+ @discardableResult
298
+ private func startDataTask(
299
+ _ request: URLRequest,
300
+ completionQueue: DispatchQueue,
301
+ completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void
302
+ ) -> URLSessionDataTask {
303
+ let task = self.urlSession.dataTask(with: request) { data, response, error in
304
+ let httpResponse = response as? HTTPURLResponse
305
+ let body = (data?.isEmpty ?? true) ? nil : data
306
+ let resultError: Error?
307
+ if let error {
308
+ resultError = NetworkError.sessionTaskFailed(error)
309
+ } else {
310
+ resultError = Self.emptyResponseError(data: body, response: httpResponse, method: request.httpMethod)
311
+ }
312
+ completionQueue.async {
313
+ completion(body, httpResponse, resultError)
314
+ }
315
+ }
316
+ task.resume()
317
+ return task
318
+ }
319
+
320
+ /// Builds a POST request with a JSON body (same headers as Alamofire's JSON encoders).
321
+ func makeJSONPostRequest(urlString: String, body: () throws -> Data) -> Result<URLRequest, Error> {
322
+ guard let url = URL(string: urlString) else {
323
+ return .failure(NetworkError.invalidURL(urlString))
324
+ }
325
+ var request = URLRequest(url: url)
326
+ request.httpMethod = "POST"
327
+ request.timeoutInterval = self.timeout
328
+ do {
329
+ request.httpBody = try body()
330
+ } catch let error as NetworkError {
331
+ return .failure(error)
332
+ } catch {
333
+ return .failure(NetworkError.bodyEncodingFailed(error))
334
+ }
335
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
336
+ return .success(request)
337
+ }
338
+
339
+ /// Fire-and-forget JSON POST whose completion runs on the main queue, like Alamofire's default `responseData`.
340
+ private func sendJSONPost(
341
+ urlString: String,
342
+ body: () throws -> Data,
343
+ completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void
344
+ ) {
345
+ switch makeJSONPostRequest(urlString: urlString, body: body) {
346
+ case let .success(request):
347
+ self.startDataTask(request, completionQueue: .main, completion: completion)
348
+ case let .failure(error):
349
+ DispatchQueue.main.async {
350
+ completion(nil, nil, error)
351
+ }
352
+ }
353
+ }
198
354
  private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
199
355
 
200
356
  public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
@@ -252,43 +408,69 @@ import UIKit
252
408
  var responseData: Data?
253
409
  var httpResponse: HTTPURLResponse?
254
410
  var requestError: Error?
255
- let dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
256
- responseData = response.data
257
- httpResponse = response.response
258
- requestError = response.error
411
+ let dataTask = self.startDataTask(request, completionQueue: self.networkResponseQueue) { data, response, error in
412
+ responseData = data
413
+ httpResponse = response
414
+ requestError = error
259
415
  semaphore.signal()
260
416
  }
261
- dataRequest.resume()
262
417
 
263
418
  if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
264
- dataRequest.cancel()
419
+ dataTask.cancel()
265
420
  logger.error("\(label) timed out after \(Int(waitTimeout))s")
266
421
  return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
267
422
  }
268
423
 
269
- return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
424
+ // URLSession can report its own timeout (NSURLErrorTimedOut) before the semaphore deadline.
425
+ let timedOut = isTimedOutError(requestError)
426
+ if timedOut {
427
+ logger.error("\(label) timed out after \(Int(request.timeoutInterval))s")
428
+ }
429
+ return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: timedOut)
270
430
  }
271
431
 
272
- private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
432
+ func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
273
433
  let waitTimeout = max(self.timeout + 5, 10)
274
434
  let semaphore = DispatchSemaphore(value: 0)
275
435
  var tempFileURL: URL?
276
436
  var httpResponse: HTTPURLResponse?
277
437
  var requestError: Error?
278
438
  let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
279
- let destination: DownloadRequest.Destination = { _, _ in
280
- (temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
281
- }
282
- let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
283
- tempFileURL = response.fileURL
284
- httpResponse = response.response
285
- requestError = response.error
286
- semaphore.signal()
439
+ let downloadTask = self.urlSession.downloadTask(with: request) { location, response, error in
440
+ // URLSession deletes `location` once this handler returns, so move it synchronously here.
441
+ var movedFileURL: URL?
442
+ var resultError: Error?
443
+ if let error {
444
+ resultError = NetworkError.sessionTaskFailed(error)
445
+ } else if let location {
446
+ do {
447
+ let fileManager = FileManager.default
448
+ try fileManager.createDirectory(
449
+ at: temporaryDownloadURL.deletingLastPathComponent(),
450
+ withIntermediateDirectories: true,
451
+ attributes: nil
452
+ )
453
+ if fileManager.fileExists(atPath: temporaryDownloadURL.path) {
454
+ try fileManager.removeItem(at: temporaryDownloadURL)
455
+ }
456
+ try fileManager.moveItem(at: location, to: temporaryDownloadURL)
457
+ movedFileURL = temporaryDownloadURL
458
+ } catch {
459
+ resultError = NetworkError.downloadedFileMoveFailed(error, source: location, destination: temporaryDownloadURL)
460
+ }
461
+ }
462
+ let taskResponse = response as? HTTPURLResponse
463
+ self.networkResponseQueue.async {
464
+ tempFileURL = movedFileURL
465
+ httpResponse = taskResponse
466
+ requestError = resultError
467
+ semaphore.signal()
468
+ }
287
469
  }
288
- downloadRequest.resume()
470
+ downloadTask.resume()
289
471
 
290
472
  if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
291
- downloadRequest.cancel()
473
+ downloadTask.cancel()
292
474
  logger.error("\(label) timed out after \(Int(waitTimeout))s")
293
475
  return DownloadRequestResult(
294
476
  fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
@@ -415,10 +597,14 @@ import UIKit
415
597
 
416
598
  deinit {
417
599
  shutdown()
600
+ // Alamofire's Session invalidated its URLSession on deinit; keep releasing the session the same way.
601
+ cachedUrlSession?.invalidateAndCancel()
418
602
  }
419
603
 
420
604
  public func shutdown() {
605
+ statsPersistLock.lock()
421
606
  statsStopped = true
607
+ statsPersistLock.unlock()
422
608
  statsFlushTimer?.invalidate()
423
609
  statsFlushTimer = nil
424
610
  persistStatsQueue(force: true)
@@ -433,6 +619,18 @@ import UIKit
433
619
  return String((0..<length).map { _ in letters.randomElement()! })
434
620
  }
435
621
 
622
+ private func requireSessionKeyForEncryptedUpdate(sessionKey: String, versionName: String? = nil) throws {
623
+ if !self.publicKey.isEmpty && !CryptoCipher.isValidSessionKey(sessionKey) {
624
+ logger.error("Public key present but no valid session key provided")
625
+ self.sendStats(action: "session_key_required", versionName: versionName)
626
+ throw NSError(
627
+ domain: "CapgoUpdater",
628
+ code: 1,
629
+ userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
630
+ )
631
+ }
632
+ }
633
+
436
634
  public func setPublicKey(_ publicKey: String) {
437
635
  // Empty string means no encryption - proceed normally
438
636
  if publicKey.isEmpty {
@@ -679,28 +877,27 @@ import UIKit
679
877
 
680
878
  // Send synchronously using semaphore (safe because we're on a background queue)
681
879
  let semaphore = DispatchSemaphore(value: 0)
682
- self.alamofireSession.request(
683
- self.statsUrl,
684
- method: .post,
685
- parameters: parameters.toParameters(),
686
- encoding: JSONEncoding.default,
687
- requestModifier: { $0.timeoutInterval = self.timeout }
688
- ).responseData { response in
689
- let statusCode = response.response?.statusCode
690
- switch response.result {
691
- case .success where (200...299).contains(statusCode ?? 0):
692
- self.logger.info("Rate limit statistic sent")
693
- case .success:
880
+ let parameterValues = parameters.toParameters()
881
+ self.sendJSONPost(urlString: self.statsUrl, body: {
882
+ guard JSONSerialization.isValidJSONObject(parameterValues) else {
883
+ throw NetworkError.bodyEncodingFailed(nil)
884
+ }
885
+ return try JSONSerialization.data(withJSONObject: parameterValues)
886
+ }, completion: { _, response, error in
887
+ let statusCode = response?.statusCode
888
+ if let error {
694
889
  CapgoUpdater.releaseRateLimitStatisticClaim()
695
890
  self.logger.error("Error sending rate limit statistic")
696
- self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
697
- case let .failure(error):
891
+ self.logger.debug("Error: \(error.localizedDescription)")
892
+ } else if (200...299).contains(statusCode ?? 0) {
893
+ self.logger.info("Rate limit statistic sent")
894
+ } else {
698
895
  CapgoUpdater.releaseRateLimitStatisticClaim()
699
896
  self.logger.error("Error sending rate limit statistic")
700
- self.logger.debug("Error: \(error.localizedDescription)")
897
+ self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
701
898
  }
702
899
  semaphore.signal()
703
- }
900
+ })
704
901
  semaphore.wait()
705
902
  }
706
903
 
@@ -792,7 +989,10 @@ import UIKit
792
989
  }
793
990
  }
794
991
 
795
- private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
992
+ // Symlink targets are paths; anything bigger than this is not a legitimate link.
993
+ private static let maxZipSymlinkTargetBytes = 64 * 1024
994
+
995
+ private func extractZipEntry(_ archive: ZipArchiveReader, entry: ZipEntry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
796
996
  let fileManager = FileManager.default
797
997
 
798
998
  switch entry.type {
@@ -815,16 +1015,13 @@ import UIKit
815
1015
  fileHandle.closeFile()
816
1016
  }
817
1017
 
818
- _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
1018
+ try archive.extract(entry, bufferSize: bufferSize) { data in
819
1019
  if !data.isEmpty {
820
1020
  fileHandle.write(data)
821
1021
  }
822
1022
  }
823
1023
  case .symlink:
824
- var linkData = Data()
825
- _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
826
- linkData.append(data)
827
- }
1024
+ let linkData = try archive.readSmallEntry(entry, maxBytes: Self.maxZipSymlinkTargetBytes, bufferSize: bufferSize)
828
1025
 
829
1026
  guard let linkPath = String(data: linkData, encoding: .utf8) else {
830
1027
  throw CustomError.cannotUnzip
@@ -860,9 +1057,9 @@ import UIKit
860
1057
  self.notifyDownload(id: id, percent: 75)
861
1058
 
862
1059
  // Open the archive
863
- let archive: Archive
1060
+ let archive: ZipArchiveReader
864
1061
  do {
865
- archive = try Archive(url: sourceZip, accessMode: .read)
1062
+ archive = try ZipArchiveReader(url: sourceZip)
866
1063
  } catch {
867
1064
  self.sendStats(action: "unzip_fail")
868
1065
  throw CustomError.cannotUnzip
@@ -872,11 +1069,11 @@ import UIKit
872
1069
  try FileManager.default.createDirectory(at: destUnZip, withIntermediateDirectories: true, attributes: nil)
873
1070
 
874
1071
  // Count total entries for progress
875
- let totalEntries = archive.reduce(0) { count, _ in count + 1 }
1072
+ let totalEntries = archive.entries.count
876
1073
  var processedEntries = 0
877
1074
 
878
1075
  do {
879
- for entry in archive {
1076
+ for entry in archive.entries {
880
1077
  let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
881
1078
 
882
1079
  if entry.type == .directory {
@@ -939,10 +1136,7 @@ import UIKit
939
1136
 
940
1137
  struct ManifestLookupEntry {
941
1138
  let hash: String
942
- /// The manifest's own file name, `.br` suffix included when present. The
943
- /// built-in bundle stores files under this exact name (see
944
- /// isManifestEntryAvailableLocally), unlike the extracted/cached copy which
945
- /// is always named without the suffix.
1139
+ /// The manifest's own file name, `.br` suffix included when present.
946
1140
  let originalFileName: String
947
1141
  }
948
1142
 
@@ -967,7 +1161,13 @@ import UIKit
967
1161
  /// `manifest` must only contain entries the caller already checksum-verified
968
1162
  /// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
969
1163
  func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
970
- let bundleDir = self.getBundleDirectory(id: id)
1164
+ let bundleDir: URL
1165
+ do {
1166
+ bundleDir = try self.getBundleDirectory(id: id)
1167
+ } catch {
1168
+ logger.debug("Skip delta cache population: invalid bundle id")
1169
+ return
1170
+ }
971
1171
  let fileManager = FileManager.default
972
1172
 
973
1173
  guard fileManager.fileExists(atPath: bundleDir.path) else {
@@ -1005,10 +1205,14 @@ import UIKit
1005
1205
 
1006
1206
  // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
1007
1207
  // so there's no need to also duplicate this file into the delta cache
1008
- let builtinRelativePath = knownEntry?.originalFileName ?? relativePath
1009
- let builtinFilePath = builtinFolder.appendingPathComponent(builtinRelativePath)
1010
- let isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
1011
- verifyChecksum(file: builtinFilePath, expectedHash: checksum)
1208
+ let builtinLookupName = knownEntry?.originalFileName ?? relativePath
1209
+ let isBuiltinOrigin: Bool
1210
+ if let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: builtinLookupName) {
1211
+ isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
1212
+ verifyChecksum(file: builtinFilePath, expectedHash: checksum)
1213
+ } else {
1214
+ isBuiltinOrigin = false
1215
+ }
1012
1216
  if isBuiltinOrigin {
1013
1217
  continue
1014
1218
  }
@@ -1223,7 +1427,10 @@ import UIKit
1223
1427
  guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
1224
1428
  return nil
1225
1429
  }
1226
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1430
+ if !self.publicKey.isEmpty {
1431
+ if !CryptoCipher.isValidSessionKey(sessionKey) {
1432
+ return nil
1433
+ }
1227
1434
  do {
1228
1435
  fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1229
1436
  } catch {
@@ -1242,7 +1449,10 @@ import UIKit
1242
1449
  }
1243
1450
 
1244
1451
  let builtinFolder = self.builtinFolderURL()
1245
- let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
1452
+ // The .br suffix describes transport; builtin assets are uncompressed.
1453
+ guard let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName) else {
1454
+ return false
1455
+ }
1246
1456
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
1247
1457
  return true
1248
1458
  }
@@ -1381,10 +1591,11 @@ import UIKit
1381
1591
  }
1382
1592
 
1383
1593
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1594
+ try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
1384
1595
  try self.runBeforeDownload()
1385
1596
  let id = self.randomString(length: 10)
1386
1597
  logger.info("downloadManifest start \(id)")
1387
- let destFolder = self.getBundleDirectory(id: id)
1598
+ let destFolder = try self.getBundleDirectory(id: id)
1388
1599
  let builtinFolder = self.builtinFolderURL()
1389
1600
 
1390
1601
  // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
@@ -1413,8 +1624,8 @@ import UIKit
1413
1624
  var downloadError: Error?
1414
1625
  let errorLock = NSLock()
1415
1626
 
1416
- // Create operations for each file
1417
- var operations: [Operation] = []
1627
+ var tasks: [ManifestDownloadTask] = []
1628
+ var seenTargets = Set<String>()
1418
1629
 
1419
1630
  for entry in manifest {
1420
1631
  guard let fileName = entry.file_name,
@@ -1455,7 +1666,23 @@ import UIKit
1455
1666
  var fileHash = entryFileHash
1456
1667
 
1457
1668
  // Decrypt checksum if needed (done before creating operation)
1458
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1669
+ if !self.publicKey.isEmpty {
1670
+ if !CryptoCipher.isValidSessionKey(sessionKey) {
1671
+ let error = NSError(
1672
+ domain: "CapgoUpdater",
1673
+ code: 1,
1674
+ userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
1675
+ )
1676
+ self.sendStats(action: "session_key_required", versionName: version)
1677
+ errorLock.lock()
1678
+ if downloadError == nil {
1679
+ downloadError = error
1680
+ }
1681
+ errorLock.unlock()
1682
+ hasError.value = true
1683
+ logger.error("Public key present but no valid session key provided")
1684
+ continue
1685
+ }
1459
1686
  do {
1460
1687
  fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1461
1688
  } catch {
@@ -1485,7 +1712,25 @@ import UIKit
1485
1712
  let builtinFilePath: URL
1486
1713
  do {
1487
1714
  destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1488
- builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
1715
+ builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
1716
+ if !Self.rememberManifestTarget(&seenTargets, targetFile: destFilePath) {
1717
+ logger.error("Duplicate manifest target path: \(fileName)")
1718
+ self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
1719
+ let error = NSError(
1720
+ domain: "ManifestEntryError",
1721
+ code: 3,
1722
+ userInfo: [
1723
+ NSLocalizedDescriptionKey: "Duplicate manifest target path for \(fileName)"
1724
+ ]
1725
+ )
1726
+ errorLock.lock()
1727
+ if downloadError == nil {
1728
+ downloadError = error
1729
+ }
1730
+ errorLock.unlock()
1731
+ hasError.value = true
1732
+ continue
1733
+ }
1489
1734
  } catch {
1490
1735
  logger.error("Invalid manifest file path: \(fileName)")
1491
1736
  self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
@@ -1498,8 +1743,40 @@ import UIKit
1498
1743
  continue
1499
1744
  }
1500
1745
 
1501
- // Create parent directories synchronously (before operations start)
1502
- try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1746
+ tasks.append(
1747
+ ManifestDownloadTask(
1748
+ fileName: fileName,
1749
+ downloadUrl: downloadUrl,
1750
+ finalFileHash: finalFileHash,
1751
+ isBrotli: isBrotli,
1752
+ destFileName: destFileName,
1753
+ destFilePath: destFilePath,
1754
+ builtinFilePath: builtinFilePath,
1755
+ cacheFilePath: cacheFilePath,
1756
+ legacyCacheFilePath: legacyCacheFilePath
1757
+ )
1758
+ )
1759
+ }
1760
+
1761
+ if hasError.value {
1762
+ let resolvedError = downloadError ?? NSError(
1763
+ domain: "ManifestDownloadError",
1764
+ code: 1,
1765
+ userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
1766
+ )
1767
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
1768
+ self.saveBundleInfo(id: id, bundle: errorBundle)
1769
+ throw resolvedError
1770
+ }
1771
+
1772
+ var operations: [Operation] = []
1773
+
1774
+ for task in tasks {
1775
+ try FileManager.default.createDirectory(
1776
+ at: task.destFilePath.deletingLastPathComponent(),
1777
+ withIntermediateDirectories: true,
1778
+ attributes: nil
1779
+ )
1503
1780
 
1504
1781
  let operation = BlockOperation { [weak self] in
1505
1782
  guard let self = self else { return }
@@ -1507,26 +1784,29 @@ import UIKit
1507
1784
 
1508
1785
  do {
1509
1786
  // Try builtin first
1510
- if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
1511
- try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
1512
- self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
1787
+ if FileManager.default.fileExists(atPath: task.builtinFilePath.path) &&
1788
+ self.verifyChecksum(file: task.builtinFilePath, expectedHash: task.finalFileHash) {
1789
+ try self.copyItemReplacing(from: task.builtinFilePath, to: task.destFilePath)
1790
+ self.logger.info("downloadManifest \(task.fileName) using builtin file \(id)")
1513
1791
  }
1514
1792
  // Try cache
1515
1793
  else if
1516
- (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1517
- (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1518
- self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1794
+ (task.cacheFilePath != nil &&
1795
+ self.tryCopyFromCache(from: task.cacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) ||
1796
+ (task.legacyCacheFilePath != nil &&
1797
+ self.tryCopyFromCache(from: task.legacyCacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) {
1798
+ self.logger.info("downloadManifest \(task.fileName) copy from cache \(id)")
1519
1799
  }
1520
1800
  // Download
1521
1801
  else {
1522
1802
  try self.downloadManifestFile(
1523
- downloadUrl: downloadUrl,
1524
- destFilePath: destFilePath,
1525
- cacheFilePath: cacheFilePath,
1526
- fileHash: finalFileHash,
1527
- fileName: fileName,
1528
- destFileName: destFileName,
1529
- isBrotli: isBrotli,
1803
+ downloadUrl: task.downloadUrl,
1804
+ destFilePath: task.destFilePath,
1805
+ cacheFilePath: task.cacheFilePath,
1806
+ fileHash: task.finalFileHash,
1807
+ fileName: task.fileName,
1808
+ destFileName: task.destFileName,
1809
+ isBrotli: task.isBrotli,
1530
1810
  sessionKey: sessionKey,
1531
1811
  version: version,
1532
1812
  bundleId: id
@@ -1544,8 +1824,8 @@ import UIKit
1544
1824
  }
1545
1825
  errorLock.unlock()
1546
1826
  hasError.value = true
1547
- self.logger.error("Manifest file download failed: \(fileName)")
1548
- self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
1827
+ self.logger.error("Manifest file download failed: \(task.fileName)")
1828
+ self.logger.debug("Bundle: \(id), File: \(task.fileName), Error: \(error.localizedDescription)")
1549
1829
  }
1550
1830
  }
1551
1831
 
@@ -1682,7 +1962,7 @@ import UIKit
1682
1962
 
1683
1963
  do {
1684
1964
  var source = partialURL
1685
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1965
+ if !self.publicKey.isEmpty && CryptoCipher.isValidSessionKey(sessionKey) {
1686
1966
  let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1687
1967
  try FileManager.default.copyItem(at: partialURL, to: work)
1688
1968
  workURL = work
@@ -1984,6 +2264,7 @@ import UIKit
1984
2264
  }
1985
2265
 
1986
2266
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
2267
+ try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
1987
2268
  try self.runBeforeDownload()
1988
2269
  let id: String = self.randomString(length: 10)
1989
2270
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
@@ -2026,6 +2307,12 @@ import UIKit
2026
2307
  }
2027
2308
 
2028
2309
  let downloadResult = performDownloadRequest(request, label: "download \(version)")
2310
+ // Error responses (e.g. an HTTP 404 body) are also saved to a temp file; never leave them behind.
2311
+ defer {
2312
+ if let fileURL = downloadResult.fileURL {
2313
+ try? FileManager.default.removeItem(at: fileURL)
2314
+ }
2315
+ }
2029
2316
 
2030
2317
  if downloadResult.timedOut {
2031
2318
  persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
@@ -2264,6 +2551,15 @@ import UIKit
2264
2551
  self.deleteLock.lock()
2265
2552
  defer { self.deleteLock.unlock() }
2266
2553
 
2554
+ let destPersist: URL
2555
+ do {
2556
+ destPersist = try self.getBundleDirectory(id: id)
2557
+ } catch {
2558
+ logger.error("Cannot delete bundle with invalid id")
2559
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2560
+ return false
2561
+ }
2562
+
2267
2563
  let deleted: BundleInfo = self.getBundleInfo(id: id)
2268
2564
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
2269
2565
  logger.info("Cannot delete current or builtin bundle")
@@ -2292,7 +2588,6 @@ import UIKit
2292
2588
  return false
2293
2589
  }
2294
2590
 
2295
- let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
2296
2591
  let hadRegistry = self.hasStoredBundleInfo(id: id)
2297
2592
  let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
2298
2593
  if !hadRegistry && !hadFolder {
@@ -2596,8 +2891,8 @@ import UIKit
2596
2891
  }
2597
2892
  }
2598
2893
 
2599
- public func getBundleDirectory(id: String) -> URL {
2600
- return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
2894
+ public func getBundleDirectory(id: String) throws -> URL {
2895
+ return try Self.resolveBundleDirectory(libraryDir: libraryDir, bundleId: id)
2601
2896
  }
2602
2897
 
2603
2898
  struct ResetState {
@@ -2651,7 +2946,12 @@ import UIKit
2651
2946
  }
2652
2947
 
2653
2948
  private func bundleExists(id: String) -> Bool {
2654
- let destPersist: URL = self.getBundleDirectory(id: id)
2949
+ let destPersist: URL
2950
+ do {
2951
+ destPersist = try self.getBundleDirectory(id: id)
2952
+ } catch {
2953
+ return false
2954
+ }
2655
2955
  let indexPersist: URL = destPersist.appendingPathComponent("index.html")
2656
2956
  let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
2657
2957
  if
@@ -2674,7 +2974,12 @@ import UIKit
2674
2974
  }
2675
2975
  if bundleExists(id: id) {
2676
2976
  let currentBundleName = self.getCurrentBundle().getVersionName()
2677
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
2977
+ guard let bundleDir = try? self.getBundleDirectory(id: id) else {
2978
+ self.setBundleStatus(id: id, status: BundleStatus.ERROR)
2979
+ self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
2980
+ return false
2981
+ }
2982
+ self.setCurrentBundle(bundle: bundleDir.path)
2678
2983
  self.setBundleStatus(id: id, status: BundleStatus.PENDING)
2679
2984
  self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
2680
2985
  return true
@@ -2688,7 +2993,10 @@ import UIKit
2688
2993
  guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
2689
2994
  return false
2690
2995
  }
2691
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2996
+ guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
2997
+ return false
2998
+ }
2999
+ self.setCurrentBundle(bundle: bundleDir.path)
2692
3000
  return true
2693
3001
  }
2694
3002
 
@@ -2703,7 +3011,10 @@ import UIKit
2703
3011
  guard bundleExists(id: bundle.getId()) else {
2704
3012
  return false
2705
3013
  }
2706
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
3014
+ guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
3015
+ return false
3016
+ }
3017
+ self.setCurrentBundle(bundle: bundleDir.path)
2707
3018
  return true
2708
3019
  }
2709
3020
 
@@ -2789,9 +3100,16 @@ import UIKit
2789
3100
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
2790
3101
  }
2791
3102
 
2792
- func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
3103
+ func unsetChannel(defaultChannelKey: String, configDefaultChannel: String, allowSetDefaultChannel: Bool) -> SetChannel {
2793
3104
  let setChannel: SetChannel = SetChannel()
2794
3105
 
3106
+ if !allowSetDefaultChannel {
3107
+ logger.error("unsetChannel is disabled by allowSetDefaultChannel config")
3108
+ setChannel.message = "unsetChannel is disabled by configuration"
3109
+ setChannel.error = "disabled_by_config"
3110
+ return setChannel
3111
+ }
3112
+
2795
3113
  // Clear persisted defaultChannel and revert to config value
2796
3114
  UserDefaults.standard.removeObject(forKey: defaultChannelKey)
2797
3115
  UserDefaults.standard.synchronize()
@@ -3312,24 +3630,20 @@ import UIKit
3312
3630
 
3313
3631
  let operation = BlockOperation {
3314
3632
  let semaphore = DispatchSemaphore(value: 0)
3315
- self.alamofireSession.request(
3316
- self.statsUrl,
3317
- method: .post,
3318
- parameters: eventsToSend,
3319
- encoder: JSONParameterEncoder.default,
3320
- requestModifier: { $0.timeoutInterval = self.timeout }
3321
- ).responseData { response in
3633
+ self.sendJSONPost(urlString: self.statsUrl, body: {
3634
+ try JSONEncoder().encode(eventsToSend)
3635
+ }, completion: { responseData, httpResponse, responseError in
3322
3636
  if self.abandonStoppedStatsFlush() {
3323
3637
  semaphore.signal()
3324
3638
  return
3325
3639
  }
3326
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
3640
+ if self.checkAndHandleRateLimitResponse(statusCode: httpResponse?.statusCode, data: responseData, response: httpResponse).blocked {
3327
3641
  self.requeueStatsEvents(queuedEvents)
3328
3642
  semaphore.signal()
3329
3643
  return
3330
3644
  }
3331
3645
 
3332
- if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
3646
+ if let statusCode = httpResponse?.statusCode, !(200...299).contains(statusCode) {
3333
3647
  if CapgoUpdater.isTransientStatsFailure(statusCode) {
3334
3648
  self.requeueStatsEvents(queuedEvents)
3335
3649
  self.logger.error("Error sending stats batch")
@@ -3343,19 +3657,18 @@ import UIKit
3343
3657
  return
3344
3658
  }
3345
3659
 
3346
- switch response.result {
3347
- case .success:
3660
+ if let error = responseError {
3661
+ self.requeueStatsEvents(queuedEvents)
3662
+ self.logger.error("Error sending stats batch")
3663
+ self.logger.debug("Response: nil, Error: \(error.localizedDescription)")
3664
+ } else {
3348
3665
  self.clearStatsInFlight()
3349
3666
  self.logger.info("Stats batch sent successfully")
3350
3667
  self.logger.debug("Sent \(eventsToSend.count) events")
3351
3668
  self.runStatsCallbacks(queuedEvents)
3352
- case let .failure(error):
3353
- self.requeueStatsEvents(queuedEvents)
3354
- self.logger.error("Error sending stats batch")
3355
- self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
3356
3669
  }
3357
3670
  semaphore.signal()
3358
- }
3671
+ })
3359
3672
  semaphore.wait()
3360
3673
  if !self.statsStopped {
3361
3674
  self.persistStatsQueue()