@onekeyfe/react-native-bundle-update 6.7.0-1 → 6.7.0-alpha.311

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 (25) hide show
  1. package/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt +604 -230
  2. package/ios/ReactNativeBundleUpdate.swift +782 -198
  3. package/lib/typescript/src/ReactNativeBundleUpdate.nitro.d.ts +0 -5
  4. package/nitrogen/generated/android/c++/JBundleDownloadASCParams.hpp +1 -5
  5. package/nitrogen/generated/android/c++/JBundleDownloadResult.hpp +1 -5
  6. package/nitrogen/generated/android/c++/JBundleInstallParams.hpp +1 -5
  7. package/nitrogen/generated/android/c++/JBundleVerifyASCParams.hpp +1 -5
  8. package/nitrogen/generated/android/c++/JBundleVerifyParams.hpp +1 -5
  9. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativebundleupdate/BundleDownloadASCParams.kt +2 -7
  10. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativebundleupdate/BundleDownloadResult.kt +3 -8
  11. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativebundleupdate/BundleInstallParams.kt +3 -8
  12. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativebundleupdate/BundleVerifyASCParams.kt +3 -8
  13. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativebundleupdate/BundleVerifyParams.kt +3 -8
  14. package/nitrogen/generated/ios/swift/BundleDownloadASCParams.swift +2 -7
  15. package/nitrogen/generated/ios/swift/BundleDownloadResult.swift +2 -7
  16. package/nitrogen/generated/ios/swift/BundleInstallParams.swift +2 -7
  17. package/nitrogen/generated/ios/swift/BundleVerifyASCParams.swift +2 -7
  18. package/nitrogen/generated/ios/swift/BundleVerifyParams.swift +2 -7
  19. package/nitrogen/generated/shared/c++/BundleDownloadASCParams.hpp +1 -5
  20. package/nitrogen/generated/shared/c++/BundleDownloadResult.hpp +1 -5
  21. package/nitrogen/generated/shared/c++/BundleInstallParams.hpp +1 -5
  22. package/nitrogen/generated/shared/c++/BundleVerifyASCParams.hpp +1 -5
  23. package/nitrogen/generated/shared/c++/BundleVerifyParams.hpp +1 -5
  24. package/package.json +1 -1
  25. package/src/ReactNativeBundleUpdate.nitro.ts +0 -5
@@ -324,6 +324,16 @@ public class BundleUpdateStore: NSObject {
324
324
 
325
325
  private static var didProcessPreLaunchTask = false
326
326
 
327
+ private static func failPendingTask(mmkv: MMKV, taskDict: [String: Any], reason: String) {
328
+ var updatedTask = taskDict
329
+ updatedTask["status"] = "failed"
330
+ updatedTask["lastError"] = reason
331
+ if let jsonData = try? JSONSerialization.data(withJSONObject: updatedTask),
332
+ let jsonStr = String(data: jsonData, encoding: .utf8) {
333
+ mmkv.set(jsonStr, forKey: "onekey_pending_install_task")
334
+ }
335
+ }
336
+
327
337
  /// Checks MMKV for a pending bundle-switch task and applies it before JS runtime starts.
328
338
  /// Only handles the happy path (status=pending, bundle exists, env matches).
329
339
  /// All complex logic (retry, download, error handling) remains in JS layer.
@@ -366,9 +376,9 @@ public class BundleUpdateStore: NSObject {
366
376
  }
367
377
 
368
378
  let currentBundleVersionStr: String
369
- if let cbv = currentBundleVersion(),
370
- let dashRange = cbv.range(of: "-", options: .backwards) {
371
- currentBundleVersionStr = String(cbv[dashRange.upperBound...])
379
+ if let currentFolder = currentBundleVersion() {
380
+ guard let parsedVersion = bundleVersion(inFolder: currentFolder, appVersion: currentAppVersion) else { return }
381
+ currentBundleVersionStr = parsedVersion
372
382
  } else {
373
383
  currentBundleVersionStr = getBuiltinBundleVersion()
374
384
  }
@@ -379,26 +389,76 @@ public class BundleUpdateStore: NSObject {
379
389
  let appVersion = payload["appVersion"] as? String,
380
390
  let bundleVersion = payload["bundleVersion"] as? String,
381
391
  let signature = payload["signature"] as? String,
382
- !appVersion.isEmpty, !bundleVersion.isEmpty, !signature.isEmpty,
383
- appVersion.isSafeVersionString, bundleVersion.isSafeVersionString
392
+ !appVersion.isEmpty, !bundleVersion.isEmpty, !signature.isEmpty
384
393
  else { return }
394
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
395
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Invalid version string")
396
+ return
397
+ }
398
+ guard appVersion == currentAppVersion else {
399
+ OneKeyLog.warn("BundleUpdate", "processPreLaunchPendingTask: target appVersion (\(appVersion)) != currentAppVersion (\(currentAppVersion))")
400
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_ENV_CHANGED:Target appVersion mismatch")
401
+ return
402
+ }
385
403
 
386
- // 5. Verify bundle directory and entry file exist
404
+ // 5. Verify bundle directory, canonical path confinement, and required files
387
405
  let folderName = "\(appVersion)-\(bundleVersion)"
388
406
  let bundleDirPath = (bundleDir() as NSString).appendingPathComponent(folderName)
407
+ let resolvedBundleDir = (bundleDirPath as NSString).resolvingSymlinksInPath
408
+ let baseBundleDir = (bundleDir() as NSString).resolvingSymlinksInPath
409
+ guard resolvedBundleDir.hasPrefix(baseBundleDir + "/") else {
410
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: bundle path outside bundle directory: \(resolvedBundleDir)")
411
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Bundle path outside bundle directory")
412
+ return
413
+ }
414
+ let sigPath = (signatureFilePath(folderName) as NSString).resolvingSymlinksInPath
415
+ let baseAscDir = (ascDir() as NSString).resolvingSymlinksInPath
416
+ guard sigPath.hasPrefix(baseAscDir + "/") else {
417
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: signature path outside asc directory: \(sigPath)")
418
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Signature path outside asc directory")
419
+ return
420
+ }
389
421
  guard FileManager.default.fileExists(atPath: bundleDirPath) else { return }
390
422
  let entryFilePath = (bundleDirPath as NSString).appendingPathComponent("main.jsbundle.hbc")
391
- guard FileManager.default.fileExists(atPath: entryFilePath) else {
392
- OneKeyLog.warn("BundleUpdate", "processPreLaunchPendingTask: bundle dir exists but entry file missing: \(entryFilePath)")
423
+ let metadataFilePath = (bundleDirPath as NSString).appendingPathComponent("metadata.json")
424
+ guard FileManager.default.fileExists(atPath: entryFilePath),
425
+ FileManager.default.fileExists(atPath: metadataFilePath) else {
426
+ OneKeyLog.warn("BundleUpdate", "processPreLaunchPendingTask: bundle dir exists but required files missing: \(bundleDirPath)")
427
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Required bundle files missing")
393
428
  return
394
429
  }
395
430
 
396
- // 6. Apply: set currentBundleVersion (same as installBundle)
431
+ #if ALLOW_SKIP_GPG_VERIFICATION
432
+ let skipGPG = isDevSettingsEnabled() && isSkipGPGEnabled()
433
+ #else
434
+ let skipGPG = false
435
+ #endif
436
+ if !skipGPG && !validateMetadataFileSha256(folderName, signature: signature, expectedAppVersion: appVersion, expectedBundleVersion: bundleVersion) {
437
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: GPG signature verification failed for \(folderName)")
438
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Bundle signature verification failed")
439
+ return
440
+ }
441
+
442
+ guard let metadata = getMetadataFileContent(folderName) else {
443
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: failed to read metadata.json for \(folderName)")
444
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Failed to read metadata.json")
445
+ return
446
+ }
447
+ if !validateEntryBundlesSha256(bundleDirPath: bundleDirPath, metadata: metadata) {
448
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: entry bundle SHA256 verification failed for \(folderName)")
449
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Entry bundle SHA256 verification failed")
450
+ return
451
+ }
452
+ if !validateBundlePairCompatibility(bundleDirPath: bundleDirPath, metadata: metadata) {
453
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: bundle pair compatibility check failed for \(folderName)")
454
+ failPendingTask(mmkv: mmkv, taskDict: taskDict, reason: "ERR_VERIFY_FAILED:Bundle pair compatibility check failed")
455
+ return
456
+ }
457
+
458
+ // 6. Apply: write signature file BEFORE flipping currentBundleVersion
459
+ writeSignatureFile(folderName, signature: signature)
397
460
  let ud = UserDefaults.standard
398
461
  ud.set(folderName, forKey: bundlePrefsKey)
399
- if !signature.isEmpty {
400
- writeSignatureFile(folderName, signature: signature)
401
- }
402
462
  setNativeVersion(currentAppVersion)
403
463
  setNativeBuildNumber(getCurrentNativeBuildNumber())
404
464
  ud.synchronize()
@@ -525,14 +585,31 @@ public class BundleUpdateStore: NSObject {
525
585
  #endif
526
586
  }
527
587
 
528
- public static func readMetadataFileSha256(_ signature: String) -> String? {
588
+ public static func bundleVersion(inFolder folder: String, appVersion: String) -> String? {
589
+ let prefix = "\(appVersion)-"
590
+ guard appVersion.isSafeVersionString, folder.hasPrefix(prefix) else { return nil }
591
+ let bundleVersion = String(folder.dropFirst(prefix.count))
592
+ return bundleVersion.isSafeVersionString ? bundleVersion : nil
593
+ }
594
+
595
+ public static func readMetadataFileSha256(
596
+ _ signature: String,
597
+ expectedAppVersion: String? = nil,
598
+ expectedBundleVersion: String? = nil,
599
+ expectedAppType: String? = "ios"
600
+ ) -> String? {
529
601
  guard !signature.isEmpty else { return nil }
530
602
 
531
603
  // GPG cleartext signature verification is required. The actual PGP
532
604
  // verification + JSON-body sha256 extraction lives in the shared
533
605
  // BundleCryptoCore (react-native-bundle-crypto); preserve the previous
534
606
  // "failure => nil" semantics here.
535
- let r = BundleCryptoCore.verifyGpgCleartext(signature)
607
+ let r = BundleCryptoCore.verifyGpgCleartext(
608
+ signature,
609
+ expectedAppVersion: expectedAppVersion,
610
+ expectedBundleVersion: expectedBundleVersion,
611
+ expectedAppType: expectedAppType
612
+ )
536
613
  guard r.valid, let sha256 = r.sha256 else {
537
614
  OneKeyLog.error("BundleUpdate", "readMetadataFileSha256: GPG verification failed (\(r.reason ?? "unknown")), rejecting unsigned content")
538
615
  return nil
@@ -540,12 +617,20 @@ public class BundleUpdateStore: NSObject {
540
617
  return sha256
541
618
  }
542
619
 
543
- public static func validateMetadataFileSha256(_ currentBundleVersion: String, signature: String) -> Bool {
620
+ public static func validateMetadataFileSha256(_ currentBundleVersion: String, signature: String, expectedAppVersion: String, expectedBundleVersion: String) -> Bool {
544
621
  guard let metadataFilePath = getMetadataFilePath(currentBundleVersion) else {
545
622
  OneKeyLog.debug("BundleUpdate", "metadataFilePath is null")
546
623
  return false
547
624
  }
548
- guard let extractedSha256 = readMetadataFileSha256(signature), !extractedSha256.isEmpty else {
625
+ guard expectedAppVersion.isSafeVersionString,
626
+ expectedBundleVersion.isSafeVersionString,
627
+ currentBundleVersion == "\(expectedAppVersion)-\(expectedBundleVersion)" else { return false }
628
+ guard let extractedSha256 = readMetadataFileSha256(
629
+ signature,
630
+ expectedAppVersion: expectedAppVersion,
631
+ expectedBundleVersion: expectedBundleVersion,
632
+ expectedAppType: "ios"
633
+ ), !extractedSha256.isEmpty else {
549
634
  return false
550
635
  }
551
636
  guard let calculatedSha256 = calculateSHA256(metadataFilePath) else { return false }
@@ -716,14 +801,15 @@ public class BundleUpdateStore: NSObject {
716
801
  OneKeyLog.debug("BundleUpdate", "getJsBundlePath: signatureLength=\(signature.count)")
717
802
 
718
803
  #if ALLOW_SKIP_GPG_VERIFICATION
719
- let devSettingsEnabled = isDevSettingsEnabled()
720
- if devSettingsEnabled {
721
- OneKeyLog.warn("BundleUpdate", "Startup SHA256 validation skipped (DevSettings enabled)")
804
+ let skipGPG = isDevSettingsEnabled() && isSkipGPGEnabled()
805
+ if skipGPG {
806
+ OneKeyLog.warn("BundleUpdate", "Startup SHA256 validation skipped (DevSettings + skip-GPG enabled)")
722
807
  }
723
808
  #else
724
- let devSettingsEnabled = false
809
+ let skipGPG = false
725
810
  #endif
726
- if !devSettingsEnabled && !validateMetadataFileSha256(currentBundleVer, signature: signature) {
811
+ guard let currentBundleVersion = bundleVersion(inFolder: currentBundleVer, appVersion: currentAppVersion) else { return nil }
812
+ if !skipGPG && !validateMetadataFileSha256(currentBundleVer, signature: signature, expectedAppVersion: currentAppVersion, expectedBundleVersion: currentBundleVersion) {
727
813
  OneKeyLog.warn("BundleUpdate", "getJsBundlePath: validateMetadataFileSha256 failed, signatureLength=\(signature.count)")
728
814
  return nil
729
815
  }
@@ -851,6 +937,7 @@ public class BundleUpdateStore: NSObject {
851
937
  }
852
938
 
853
939
  public static func clearUpdateBundleData() {
940
+ didProcessPreLaunchTask = true
854
941
  invalidateValidatedBundleInfoCache()
855
942
  let bDir = bundleDir()
856
943
  let fm = FileManager.default
@@ -878,22 +965,83 @@ private extension String {
878
965
 
879
966
  /// URLSession delegate that handles download progress and HTTPS redirect validation
880
967
  private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
968
+ private static let maxDownloadBytes: Int64 = 512 * 1024 * 1024
969
+
881
970
  /// Called with progress percentage (0-100) during download
882
971
  var onProgress: ((Int) -> Void)?
883
972
 
884
973
  /// Continuation to bridge delegate callbacks → async/await
885
974
  private var continuation: CheckedContinuation<(URL, URLResponse), Error>?
886
975
  private var tempFileURL: URL?
976
+ private var exceededMaxBytes = false
887
977
  /// Resume data captured from the most recent failure so the caller can
888
978
  /// persist it for the next attempt. Populated in didCompleteWithError
889
979
  /// when the system supplies NSURLSessionDownloadTaskResumeData.
890
980
  private(set) var lastResumeData: Data?
891
981
  private let lock = NSLock()
982
+ private var cancelledByClear = false
983
+ private var rejectedInsecureRedirect = false
984
+ private var runCompleted = true
985
+ private var completionWaiters: [UUID: CheckedContinuation<Bool, Never>] = [:]
986
+
987
+ var wasCancelledByClear: Bool {
988
+ lock.lock()
989
+ defer { lock.unlock() }
990
+ return cancelledByClear
991
+ }
892
992
 
893
- func setContinuation(_ cont: CheckedContinuation<(URL, URLResponse), Error>) {
993
+ func markCancelledByClear() {
894
994
  lock.lock()
995
+ cancelledByClear = true
996
+ lock.unlock()
997
+ }
998
+
999
+ var wasInsecureRedirectRejected: Bool {
1000
+ lock.lock()
1001
+ defer { lock.unlock() }
1002
+ return rejectedInsecureRedirect
1003
+ }
1004
+
1005
+ func setContinuation(_ cont: CheckedContinuation<(URL, URLResponse), Error>) -> Bool {
1006
+ lock.lock()
1007
+ if cancelledByClear {
1008
+ lock.unlock()
1009
+ cont.resume(throwing: BundleUpdateError(.cancelled, .download))
1010
+ return false
1011
+ }
895
1012
  continuation = cont
896
1013
  lock.unlock()
1014
+ return true
1015
+ }
1016
+
1017
+ func finishRun() {
1018
+ lock.lock()
1019
+ runCompleted = true
1020
+ let waiters = Array(completionWaiters.values)
1021
+ completionWaiters.removeAll()
1022
+ lock.unlock()
1023
+ waiters.forEach { $0.resume(returning: true) }
1024
+ }
1025
+
1026
+ func waitForRunCompletion(timeout: TimeInterval) async -> Bool {
1027
+ await withCheckedContinuation { continuation in
1028
+ let id = UUID()
1029
+ lock.lock()
1030
+ if runCompleted {
1031
+ lock.unlock()
1032
+ continuation.resume(returning: true)
1033
+ return
1034
+ }
1035
+ completionWaiters[id] = continuation
1036
+ lock.unlock()
1037
+ DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
1038
+ guard let self else { return }
1039
+ self.lock.lock()
1040
+ let waiter = self.completionWaiters.removeValue(forKey: id)
1041
+ self.lock.unlock()
1042
+ waiter?.resume(returning: false)
1043
+ }
1044
+ }
897
1045
  }
898
1046
 
899
1047
  // MARK: - URLSessionDownloadDelegate
@@ -901,6 +1049,11 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
901
1049
  private var prevProgress: Int = -1
902
1050
 
903
1051
  func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
1052
+ if totalBytesWritten > Self.maxDownloadBytes || totalBytesExpectedToWrite > Self.maxDownloadBytes {
1053
+ exceededMaxBytes = true
1054
+ downloadTask.cancel()
1055
+ return
1056
+ }
904
1057
  guard totalBytesExpectedToWrite > 0 else { return }
905
1058
  let progress = Int((totalBytesWritten * 100) / totalBytesExpectedToWrite)
906
1059
  if progress != prevProgress {
@@ -911,6 +1064,13 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
911
1064
  }
912
1065
 
913
1066
  func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
1067
+ if exceededMaxBytes { return }
1068
+ if let attrs = try? FileManager.default.attributesOfItem(atPath: location.path),
1069
+ let fileSize = attrs[.size] as? Int64,
1070
+ fileSize > Self.maxDownloadBytes {
1071
+ exceededMaxBytes = true
1072
+ return
1073
+ }
914
1074
  // Copy to a temp location because the file at `location` is deleted after this method returns
915
1075
  let tempPath = NSTemporaryDirectory() + UUID().uuidString
916
1076
  let dest = URL(fileURLWithPath: tempPath)
@@ -930,6 +1090,12 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
930
1090
  continuation = nil
931
1091
  lock.unlock()
932
1092
 
1093
+ if exceededMaxBytes {
1094
+ self.lastResumeData = nil
1095
+ cont?.resume(throwing: BundleUpdateError(.security, .download))
1096
+ return
1097
+ }
1098
+
933
1099
  if let error = error {
934
1100
  // iOS attaches partial download bytes via userInfo so the next
935
1101
  // attempt can finish from the cut point instead of byte 0. Mixpanel
@@ -948,14 +1114,16 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
948
1114
  cont?.resume(returning: (tempURL, response))
949
1115
  } else {
950
1116
  self.lastResumeData = nil
951
- cont?.resume(throwing: NSError(domain: "BundleUpdate", code: -1,
952
- userInfo: [NSLocalizedDescriptionKey: "Download completed without file"]))
1117
+ cont?.resume(throwing: BundleUpdateError(.storage, .download))
953
1118
  }
954
1119
  }
955
1120
 
956
1121
  func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
957
1122
  if request.url?.scheme?.lowercased() != "https" {
958
1123
  OneKeyLog.error("BundleUpdate", "Blocked redirect to non-HTTPS URL")
1124
+ lock.lock()
1125
+ rejectedInsecureRedirect = true
1126
+ lock.unlock()
959
1127
  completionHandler(nil)
960
1128
  } else {
961
1129
  completionHandler(request)
@@ -966,8 +1134,12 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
966
1134
  func reset() {
967
1135
  lock.lock()
968
1136
  continuation = nil
1137
+ cancelledByClear = false
1138
+ rejectedInsecureRedirect = false
1139
+ runCompleted = false
969
1140
  lock.unlock()
970
1141
  tempFileURL = nil
1142
+ exceededMaxBytes = false
971
1143
  onProgress = nil
972
1144
  prevProgress = -1
973
1145
  lastResumeData = nil
@@ -980,13 +1152,101 @@ private struct BundleListener {
980
1152
  let callback: (BundleDownloadEvent) -> Void
981
1153
  }
982
1154
 
1155
+ private enum BundleUpdateCode: String {
1156
+ case network = "NETWORK_ERROR"
1157
+ case timeout = "TIMEOUT"
1158
+ case http = "HTTP_ERROR"
1159
+ case missing = "PACKAGE_MISSING"
1160
+ case storage = "STORAGE_ERROR"
1161
+ case integrity = "INTEGRITY_FAILED"
1162
+ case incompatible = "INCOMPATIBLE"
1163
+ case install = "INSTALL_FAILED"
1164
+ case cancelled = "CANCELLED"
1165
+ case unknown = "UNKNOWN"
1166
+ case invalidArgument = "INVALID_ARGUMENT"
1167
+ case security = "SECURITY_REJECTED"
1168
+ }
1169
+
1170
+ private enum BundleUpdateStep: String {
1171
+ case download, downloadASC, verifyASC, verifyPackage, install, cleanup
1172
+ }
1173
+
1174
+ private struct BundleUpdateError: Error, CustomStringConvertible {
1175
+ let code: BundleUpdateCode
1176
+ let step: BundleUpdateStep
1177
+ let httpStatus: Int?
1178
+
1179
+ init(_ code: BundleUpdateCode, _ step: BundleUpdateStep, httpStatus: Int? = nil) {
1180
+ self.code = code
1181
+ self.step = step
1182
+ self.httpStatus = code == .http && (100...599).contains(httpStatus ?? -1) ? httpStatus : nil
1183
+ }
1184
+
1185
+ var description: String {
1186
+ let status = httpStatus.map { ",\"httpStatus\":\($0)" } ?? ""
1187
+ return "ONEKEY_UPDATE_ERROR:{\"code\":\"\(code.rawValue)\",\"step\":\"\(step.rawValue)\"\(status)}"
1188
+ }
1189
+ }
1190
+
1191
+ private func bundleUpdateError(_ error: Error, step: BundleUpdateStep) -> BundleUpdateError {
1192
+ if let typed = error as? BundleUpdateError {
1193
+ return BundleUpdateError(typed.code, step, httpStatus: typed.httpStatus)
1194
+ }
1195
+ if let httpError = error as? RangeDownloader.HTTPStatusError {
1196
+ return BundleUpdateError(.http, step, httpStatus: httpError.statusCode)
1197
+ }
1198
+ let nsError = error as NSError
1199
+ if nsError.domain == NSURLErrorDomain {
1200
+ switch nsError.code {
1201
+ case URLError.timedOut.rawValue:
1202
+ return BundleUpdateError(.timeout, step)
1203
+ case URLError.cannotFindHost.rawValue,
1204
+ URLError.cannotConnectToHost.rawValue,
1205
+ URLError.networkConnectionLost.rawValue,
1206
+ URLError.notConnectedToInternet.rawValue,
1207
+ URLError.dnsLookupFailed.rawValue,
1208
+ URLError.cancelled.rawValue:
1209
+ return BundleUpdateError(.network, step)
1210
+ case URLError.secureConnectionFailed.rawValue,
1211
+ URLError.serverCertificateHasBadDate.rawValue,
1212
+ URLError.serverCertificateNotYetValid.rawValue,
1213
+ URLError.serverCertificateUntrusted.rawValue,
1214
+ URLError.serverCertificateHasUnknownRoot.rawValue,
1215
+ URLError.clientCertificateRejected.rawValue,
1216
+ URLError.clientCertificateRequired.rawValue:
1217
+ return BundleUpdateError(.security, step)
1218
+ case URLError.badURL.rawValue, URLError.unsupportedURL.rawValue:
1219
+ return BundleUpdateError(.invalidArgument, step)
1220
+ default:
1221
+ return BundleUpdateError(.unknown, step)
1222
+ }
1223
+ }
1224
+ if nsError.domain == NSCocoaErrorDomain || nsError.domain == NSPOSIXErrorDomain {
1225
+ return BundleUpdateError(.storage, step)
1226
+ }
1227
+ return BundleUpdateError(.unknown, step)
1228
+ }
1229
+
983
1230
  class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
984
1231
 
1232
+ private func updatePromise<T>(step: BundleUpdateStep, work: @escaping () async throws -> T) -> Promise<T> {
1233
+ return Promise.async {
1234
+ do {
1235
+ return try await work()
1236
+ } catch {
1237
+ OneKeyLog.error("BundleUpdate", "\(step.rawValue) failed: \(error)")
1238
+ throw bundleUpdateError(error, step: step)
1239
+ }
1240
+ }
1241
+ }
1242
+
985
1243
  // Serial queue protects mutable state (listeners, nextListenerId, isDownloading)
986
1244
  private let stateQueue = DispatchQueue(label: "so.onekey.bundleupdate.state")
987
1245
  private var listeners: [BundleListener] = []
988
1246
  private var nextListenerId: Double = 1
989
1247
  private var isDownloading = false
1248
+ private var clearingDownloads = false
1249
+ private var activeRunDelegate: DownloadDelegate?
990
1250
  private var urlSession: URLSession?
991
1251
  private var downloadDelegate: DownloadDelegate?
992
1252
  private var downloadFilePath: String?
@@ -1060,7 +1320,6 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1060
1320
  }
1061
1321
 
1062
1322
  private func snapshotResumeDataForBackgrounding() {
1063
- guard let session = self.urlSession else { return }
1064
1323
  // Read both isDownloading AND activeDownloadFilePath inside the
1065
1324
  // same stateQueue.sync block. A concurrent downloadBundle entry
1066
1325
  // flips isDownloading=true at the start of the Promise body but
@@ -1070,11 +1329,12 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1070
1329
  // activeDownloadFilePath is still nil from a previous run, or —
1071
1330
  // on the unwind via defer — see isDownloading=false alongside a
1072
1331
  // not-yet-cleared path.
1073
- let snapshot: (Bool, String?) = self.stateQueue.sync {
1074
- (self.isDownloading, self.activeDownloadFilePath)
1332
+ let snapshot: (URLSession?, DownloadDelegate?, String?) = self.stateQueue.sync {
1333
+ guard self.isDownloading, !self.clearingDownloads else { return (nil, nil, nil) }
1334
+ return (self.urlSession, self.activeRunDelegate, self.activeDownloadFilePath)
1075
1335
  }
1076
- let (stillDownloading, snapshotPath) = snapshot
1077
- guard stillDownloading, let filePath = snapshotPath else { return }
1336
+ guard let session = snapshot.0, let runDelegate = snapshot.1,
1337
+ let filePath = snapshot.2 else { return }
1078
1338
 
1079
1339
  let resumeDataPath = "\(filePath).resume"
1080
1340
  let bgTaskName = "BundleUpdateResumeSnapshot"
@@ -1109,22 +1369,27 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1109
1369
  bgTask.set(started)
1110
1370
  OneKeyLog.info("BundleUpdate", "didEnterBackground: snapshotting resumeData for \(filePath)")
1111
1371
 
1112
- session.getAllTasks { tasks in
1372
+ session.getAllTasks { [weak self] tasks in
1113
1373
  let group = DispatchGroup()
1114
1374
  for task in tasks {
1115
1375
  guard let dl = task as? URLSessionDownloadTask, dl.state == .running else { continue }
1116
1376
  group.enter()
1117
1377
  dl.cancel(byProducingResumeData: { data in
1118
1378
  if let data = data, data.count > 0 {
1119
- do {
1120
- let dir = (resumeDataPath as NSString).deletingLastPathComponent
1121
- if !FileManager.default.fileExists(atPath: dir) {
1122
- try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
1379
+ self?.stateQueue.sync {
1380
+ guard self?.activeRunDelegate === runDelegate,
1381
+ self?.clearingDownloads == false,
1382
+ !runDelegate.wasCancelledByClear else { return }
1383
+ do {
1384
+ let dir = (resumeDataPath as NSString).deletingLastPathComponent
1385
+ if !FileManager.default.fileExists(atPath: dir) {
1386
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
1387
+ }
1388
+ try data.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic)
1389
+ OneKeyLog.info("BundleUpdate", "didEnterBackground: persisted resumeData (\(data.count) bytes) at \(resumeDataPath)")
1390
+ } catch {
1391
+ OneKeyLog.warn("BundleUpdate", "didEnterBackground: failed to persist resumeData: \(error)")
1123
1392
  }
1124
- try data.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic)
1125
- OneKeyLog.info("BundleUpdate", "didEnterBackground: persisted resumeData (\(data.count) bytes) at \(resumeDataPath)")
1126
- } catch {
1127
- OneKeyLog.warn("BundleUpdate", "didEnterBackground: failed to persist resumeData: \(error)")
1128
1393
  }
1129
1394
  } else {
1130
1395
  OneKeyLog.info("BundleUpdate", "didEnterBackground: cancel produced no resumeData (task may have just completed)")
@@ -1168,17 +1433,22 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1168
1433
  }
1169
1434
 
1170
1435
  func downloadBundle(params: BundleDownloadParams) throws -> Promise<BundleDownloadResult> {
1171
- return Promise.async { [weak self] in
1172
- guard let self = self else { throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Module deallocated"]) }
1436
+ return updatePromise(step: .download) { [weak self] in
1437
+ guard let self = self else { throw BundleUpdateError(.unknown, .download) }
1173
1438
 
1174
- let alreadyDownloading = self.stateQueue.sync { () -> Bool in
1175
- if self.isDownloading { return true }
1439
+ let (alreadyDownloading, runDelegate, runSession) = self.stateQueue.sync { () -> (Bool, DownloadDelegate?, URLSession?) in
1440
+ if self.isDownloading || self.clearingDownloads { return (true, nil, nil) }
1176
1441
  self.isDownloading = true
1177
- return false
1442
+ self.downloadDelegate?.reset()
1443
+ self.activeRunDelegate = self.downloadDelegate
1444
+ return (false, self.downloadDelegate, self.urlSession)
1178
1445
  }
1179
1446
  guard !alreadyDownloading else {
1180
1447
  OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading")
1181
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Already downloading"])
1448
+ throw BundleUpdateError(.invalidArgument, .download)
1449
+ }
1450
+ guard let runDelegate, let runSession else {
1451
+ throw BundleUpdateError(.unknown, .download)
1182
1452
  }
1183
1453
  defer {
1184
1454
  // Clear isDownloading + the snapshot anchor under the same
@@ -1186,8 +1456,12 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1186
1456
  // a half-cleared state (isDownloading=true while
1187
1457
  // activeDownloadFilePath=nil, or vice versa).
1188
1458
  self.stateQueue.sync {
1189
- self.isDownloading = false
1190
- self.activeDownloadFilePath = nil
1459
+ runDelegate.finishRun()
1460
+ if self.activeRunDelegate === runDelegate {
1461
+ self.isDownloading = false
1462
+ self.activeDownloadFilePath = nil
1463
+ self.activeRunDelegate = nil
1464
+ }
1191
1465
  }
1192
1466
  }
1193
1467
 
@@ -1200,12 +1474,12 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1200
1474
 
1201
1475
  guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
1202
1476
  OneKeyLog.error("BundleUpdate", "downloadBundle: invalid version string format: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1203
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid version string format"])
1477
+ throw BundleUpdateError(.invalidArgument, .download)
1204
1478
  }
1205
1479
 
1206
1480
  guard downloadUrl.hasPrefix("https://") else {
1207
1481
  OneKeyLog.error("BundleUpdate", "downloadBundle: URL is not HTTPS: \(downloadUrl)")
1208
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle download URL must use HTTPS"])
1482
+ throw BundleUpdateError(.security, .download)
1209
1483
  }
1210
1484
 
1211
1485
  let fileName = "\(appVersion)-\(bundleVersion).zip"
@@ -1216,7 +1490,6 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1216
1490
  let resumeDataPath = "\(filePath).resume"
1217
1491
 
1218
1492
  let result = BundleDownloadResult(
1219
- downloadedFile: filePath,
1220
1493
  downloadUrl: downloadUrl,
1221
1494
  latestVersion: appVersion,
1222
1495
  bundleVersion: bundleVersion,
@@ -1233,8 +1506,12 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1233
1506
  // A valid completed bundle invalidates any stale resume blob.
1234
1507
  try? FileManager.default.removeItem(atPath: resumeDataPath)
1235
1508
  DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
1509
+ guard !runDelegate.wasCancelledByClear else { return }
1236
1510
  self?.sendEvent(type: "update/complete")
1237
1511
  }
1512
+ if runDelegate.wasCancelledByClear {
1513
+ throw BundleUpdateError(.cancelled, .download)
1514
+ }
1238
1515
  return result
1239
1516
  } else {
1240
1517
  OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading")
@@ -1261,6 +1538,9 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1261
1538
  self.activeDownloadFilePath = filePath
1262
1539
  self.lastReportedProgress = 0
1263
1540
  }
1541
+ if runDelegate.wasCancelledByClear {
1542
+ throw BundleUpdateError(.cancelled, .download)
1543
+ }
1264
1544
 
1265
1545
  // Stable, unique task id for this bundle. Same value across retry
1266
1546
  // attempts so the range downloader can resume its `.segN` files, and
@@ -1276,7 +1556,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1276
1556
  guard let self = self,
1277
1557
  event.channel.stringValue == DownloadChannel.bundle.stringValue,
1278
1558
  event.taskId == rangeTaskId,
1279
- event.type == "progress" else { return }
1559
+ event.type == "progress",
1560
+ !runDelegate.wasCancelledByClear else { return }
1280
1561
  // §5.7: record the concurrent floor so a later single-stream
1281
1562
  // transient fallback resumes the bar from here, not from 0.
1282
1563
  let floored: Int = self.stateQueue.sync {
@@ -1286,6 +1567,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1286
1567
  }
1287
1568
  self.sendEvent(type: "update/downloading", progress: floored)
1288
1569
  }
1570
+ defer { RangeDownloader.shared.removeListener(progressListenerId) }
1289
1571
 
1290
1572
  // A `.fallback` outcome covers two very different situations whose
1291
1573
  // recovery must NOT be the same:
@@ -1311,9 +1593,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1311
1593
  // `download(...)` directly, so we get the precise class here.
1312
1594
  var rangeClass: RangeDownloadClass = .fallbackPermanent
1313
1595
  var rangeReason: String?
1596
+ var rangeKind: RangeFallbackClass?
1597
+ var rangeError: Error?
1314
1598
  while true {
1315
1599
  concurrentAttempt += 1
1316
- (rangeClass, _, rangeReason, _) = await RangeDownloader.shared.download(
1600
+ (rangeClass, _, rangeReason, rangeKind, rangeError) = await RangeDownloader.shared.download(
1317
1601
  channel: .bundle,
1318
1602
  taskId: rangeTaskId,
1319
1603
  urlString: downloadUrl,
@@ -1324,9 +1608,24 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1324
1608
  segmentCount: nil,
1325
1609
  minConcurrentBytes: nil
1326
1610
  )
1611
+ if runDelegate.wasCancelledByClear {
1612
+ throw BundleUpdateError(.cancelled, .download)
1613
+ }
1327
1614
  if rangeClass == .completed {
1328
1615
  break // completed
1329
1616
  }
1617
+ if let rangeError {
1618
+ let updateError = bundleUpdateError(rangeError, step: .download)
1619
+ if updateError.code != .network && updateError.code != .timeout {
1620
+ self.sendEvent(type: "update/error", message: updateError.description)
1621
+ throw updateError
1622
+ }
1623
+ }
1624
+ if rangeKind == .redirectRejected || rangeKind == .checksumMismatch {
1625
+ let updateError = BundleUpdateError(rangeKind == .redirectRejected ? .security : .integrity, .download)
1626
+ self.sendEvent(type: "update/error", message: updateError.description)
1627
+ throw updateError
1628
+ }
1330
1629
  // §4: a Transient class is resumable — retry the concurrent path.
1331
1630
  // The core keeps `.segN`; the next call resumes only what's missing.
1332
1631
  if rangeClass == .fallbackTransient,
@@ -1335,11 +1634,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1335
1634
  // Brief backoff so a flapping network / suspend settles before
1336
1635
  // we re-probe and resume the missing segment(s).
1337
1636
  try? await Task.sleep(nanoseconds: 1_500_000_000)
1637
+ if runDelegate.wasCancelledByClear {
1638
+ throw BundleUpdateError(.cancelled, .download)
1639
+ }
1338
1640
  continue
1339
1641
  }
1340
1642
  break // permanent, or transient retries exhausted
1341
1643
  }
1342
- RangeDownloader.shared.removeListener(progressListenerId)
1644
+ if runDelegate.wasCancelledByClear {
1645
+ throw BundleUpdateError(.cancelled, .download)
1646
+ }
1343
1647
 
1344
1648
  if rangeClass != .completed {
1345
1649
  if rangeClass == .fallbackTransient {
@@ -1358,13 +1662,19 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1358
1662
  // fall through to the single-stream path below
1359
1663
  } else {
1360
1664
  OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent finished, verifying SHA256...")
1361
- if !self.verifyBundleSHA256(filePath, sha256: sha256) {
1665
+ guard let downloadedHash = BundleUpdateStore.calculateSHA256(filePath) else {
1666
+ throw BundleUpdateError(.storage, .download)
1667
+ }
1668
+ if !BundleCryptoCore.secureEqualHex(downloadedHash, sha256) {
1362
1669
  let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH"
1363
1670
  try? FileManager.default.removeItem(atPath: filePath)
1364
- self.sendEvent(type: "update/error", message: "SHA256_\(reason)")
1365
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
1671
+ self.sendEvent(type: "update/error", message: BundleUpdateError(.integrity, .download).description)
1672
+ throw BundleUpdateError(.integrity, .download)
1366
1673
  }
1367
1674
  try? FileManager.default.removeItem(atPath: resumeDataPath)
1675
+ if runDelegate.wasCancelledByClear {
1676
+ throw BundleUpdateError(.cancelled, .download)
1677
+ }
1368
1678
  self.sendEvent(type: "update/complete")
1369
1679
  OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1370
1680
  return result
@@ -1373,13 +1683,10 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1373
1683
  // Download the file
1374
1684
  guard let url = URL(string: downloadUrl) else {
1375
1685
  OneKeyLog.error("BundleUpdate", "downloadBundle: invalid URL: \(downloadUrl)")
1376
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
1686
+ throw BundleUpdateError(.invalidArgument, .download)
1377
1687
  }
1378
1688
 
1379
- guard let session = self.urlSession else {
1380
- OneKeyLog.error("BundleUpdate", "downloadBundle: URLSession not initialized")
1381
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "URLSession not initialized"])
1382
- }
1689
+ let session = runSession
1383
1690
 
1384
1691
  // Resume blob from a prior failed attempt; iOS rebuilds the byte
1385
1692
  // offset internally so we never need a Range header on this path.
@@ -1396,11 +1703,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1396
1703
  let request = URLRequest(url: url)
1397
1704
 
1398
1705
  // Use delegate-based download for real progress reporting
1399
- guard let delegate = self.downloadDelegate else {
1400
- OneKeyLog.error("BundleUpdate", "downloadBundle: download delegate not initialized")
1401
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download delegate not initialized"])
1402
- }
1403
- delegate.reset()
1706
+ let delegate = runDelegate
1404
1707
  // OCDS §5.7: reported progress is monotonic non-decreasing within a
1405
1708
  // run, and resets only on a GENUINE restart. The single-stream
1406
1709
  // delegate emits its own 0→100, which would jump the bar backward at
@@ -1412,7 +1715,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1412
1715
  : 0
1413
1716
  self.stateQueue.sync { self.lastReportedProgress = progressFloor }
1414
1717
  delegate.onProgress = { [weak self] progress in
1415
- guard let self = self else { return }
1718
+ guard let self = self, !runDelegate.wasCancelledByClear else { return }
1416
1719
  let floored: Int = self.stateQueue.sync {
1417
1720
  let clamped = max(self.lastReportedProgress, progress)
1418
1721
  self.lastReportedProgress = clamped
@@ -1428,11 +1731,14 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1428
1731
  // snapshot reader uses, so isDownloading=true and a non-nil
1429
1732
  // activeDownloadFilePath always go together.
1430
1733
  self.stateQueue.sync { self.activeDownloadFilePath = filePath }
1734
+ if runDelegate.wasCancelledByClear {
1735
+ throw BundleUpdateError(.cancelled, .download)
1736
+ }
1431
1737
 
1432
1738
  let downloadOutcome: Result<(URL, URLResponse), Error>
1433
1739
  do {
1434
1740
  let value = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in
1435
- delegate.setContinuation(continuation)
1741
+ guard delegate.setContinuation(continuation) else { return }
1436
1742
  let task: URLSessionDownloadTask
1437
1743
  if let resumeData = persistedResumeData {
1438
1744
  task = session.downloadTask(withResumeData: resumeData)
@@ -1467,20 +1773,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1467
1773
  }
1468
1774
  let nsError = error as NSError
1469
1775
  OneKeyLog.error("BundleUpdate", "downloadBundle: download failed: \(nsError.domain) \(nsError.code) \(nsError.localizedDescription)")
1470
- self.sendEvent(type: "update/error", message: "\(nsError.domain) \(nsError.code)")
1471
- // Rethrow a sanitized NSError. The original `error` is a
1472
- // URLSession/system error whose `localizedDescription` may
1473
- // include the temp file path (e.g. NSURLErrorFailingURLString
1474
- // userInfo, or the ~/Library/.../CFNetworkDownload_*.tmp
1475
- // path on cancel-with-resume) — Promise rejections surface
1476
- // that string to JS, which would re-leak the paths the
1477
- // event-payload sanitization just stripped. Keep the full
1478
- // detail in OneKeyLog (above) and emit only `domain code`.
1479
- throw NSError(
1480
- domain: nsError.domain,
1481
- code: nsError.code,
1482
- userInfo: [NSLocalizedDescriptionKey: "Download failed: \(nsError.domain) \(nsError.code)"]
1483
- )
1776
+ let intentionallyCancelled = delegate.wasCancelledByClear
1777
+ let updateError = intentionallyCancelled
1778
+ ? BundleUpdateError(.cancelled, .download)
1779
+ : (delegate.wasInsecureRedirectRejected
1780
+ ? BundleUpdateError(.security, .download)
1781
+ : bundleUpdateError(error, step: .download))
1782
+ if !intentionallyCancelled {
1783
+ self.sendEvent(type: "update/error", message: updateError.description)
1784
+ }
1785
+ throw updateError
1484
1786
 
1485
1787
  case .success(let (tempURL, response)):
1486
1788
  // Successful completion ⇒ no longer need the resume blob.
@@ -1491,7 +1793,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1491
1793
  let responseUrl = httpResponse.url,
1492
1794
  responseUrl.scheme?.lowercased() != "https" {
1493
1795
  OneKeyLog.error("BundleUpdate", "downloadBundle: redirected to non-HTTPS URL: \(responseUrl)")
1494
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download was redirected to non-HTTPS URL"])
1796
+ throw BundleUpdateError(.security, .download)
1495
1797
  }
1496
1798
 
1497
1799
  // 206 is acceptable when the OS finished a Range-resumed task on
@@ -1500,8 +1802,9 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1500
1802
  httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else {
1501
1803
  let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
1502
1804
  OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=\(statusCode)")
1503
- self.sendEvent(type: "update/error", message: "HTTP error \(statusCode)")
1504
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(statusCode)"])
1805
+ let updateError = BundleUpdateError(.http, .download, httpStatus: statusCode)
1806
+ self.sendEvent(type: "update/error", message: updateError.description)
1807
+ throw updateError
1505
1808
  }
1506
1809
 
1507
1810
  OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP \(httpResponse.statusCode), moving to destination...")
@@ -1518,12 +1821,15 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1518
1821
 
1519
1822
  // Verify SHA256
1520
1823
  OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1521
- if !self.verifyBundleSHA256(filePath, sha256: sha256) {
1824
+ guard let downloadedHash = BundleUpdateStore.calculateSHA256(filePath) else {
1825
+ throw BundleUpdateError(.storage, .download)
1826
+ }
1827
+ if !BundleCryptoCore.secureEqualHex(downloadedHash, sha256) {
1522
1828
  let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH"
1523
1829
  try? FileManager.default.removeItem(atPath: filePath)
1524
1830
  OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=\(reason)")
1525
- self.sendEvent(type: "update/error", message: "SHA256_\(reason)")
1526
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
1831
+ self.sendEvent(type: "update/error", message: BundleUpdateError(.integrity, .download).description)
1832
+ throw BundleUpdateError(.integrity, .download)
1527
1833
  }
1528
1834
 
1529
1835
  // A verified single-stream file is authoritative — drop any
@@ -1531,6 +1837,9 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1531
1837
  // transient fallback so they don't linger as orphans.
1532
1838
  RangeDownloader.shared.discardArtifacts(filePath: filePath)
1533
1839
 
1840
+ if runDelegate.wasCancelledByClear {
1841
+ throw BundleUpdateError(.cancelled, .download)
1842
+ }
1534
1843
  self.sendEvent(type: "update/complete")
1535
1844
  OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1536
1845
  return result
@@ -1549,30 +1858,71 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1549
1858
  }
1550
1859
 
1551
1860
  func downloadBundleASC(params: BundleDownloadASCParams) throws -> Promise<Void> {
1552
- return Promise.async {
1861
+ return updatePromise(step: .downloadASC) {
1553
1862
  let appVersion = params.latestVersion
1554
1863
  let bundleVersion = params.bundleVersion
1555
1864
  let signature = params.signature
1556
1865
 
1557
1866
  OneKeyLog.info("BundleUpdate", "downloadBundleASC: appVersion=\(appVersion), bundleVersion=\(bundleVersion), signatureLength=\(signature.count)")
1558
1867
 
1868
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
1869
+ OneKeyLog.error("BundleUpdate", "downloadBundleASC: invalid version string format: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1870
+ throw BundleUpdateError(.invalidArgument, .downloadASC)
1871
+ }
1872
+ guard signature.utf8.count <= 64 * 1024 else {
1873
+ OneKeyLog.error("BundleUpdate", "downloadBundleASC: signature exceeds maximum allowed size (\(signature.utf8.count))")
1874
+ throw BundleUpdateError(.invalidArgument, .downloadASC)
1875
+ }
1876
+
1559
1877
  let storageKey = "\(appVersion)-\(bundleVersion)"
1560
- BundleUpdateStore.writeSignatureFile(storageKey, signature: signature)
1878
+ let sigPath = (BundleUpdateStore.signatureFilePath(storageKey) as NSString).resolvingSymlinksInPath
1879
+ let baseAscDir = (BundleUpdateStore.ascDir() as NSString).resolvingSymlinksInPath
1880
+ guard sigPath.hasPrefix(baseAscDir + "/") else {
1881
+ OneKeyLog.error("BundleUpdate", "downloadBundleASC: signature path outside asc directory: \(sigPath)")
1882
+ throw BundleUpdateError(.security, .downloadASC)
1883
+ }
1561
1884
 
1562
- OneKeyLog.info("BundleUpdate", "downloadBundleASC: stored signature for key=\(storageKey)")
1563
1885
  }
1564
1886
  }
1565
1887
 
1566
1888
  func verifyBundleASC(params: BundleVerifyASCParams) throws -> Promise<Void> {
1567
- return Promise.async {
1568
- let filePath = params.downloadedFile
1889
+ return updatePromise(step: .verifyASC) {
1569
1890
  let sha256 = params.sha256
1570
1891
  let appVersion = params.latestVersion
1571
1892
  let bundleVersion = params.bundleVersion
1572
1893
  let signature = params.signature
1573
1894
 
1895
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
1896
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: invalid version string format: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1897
+ throw BundleUpdateError(.invalidArgument, .verifyASC)
1898
+ }
1899
+
1900
+ let filePath = (BundleUpdateStore.downloadBundleDir() as NSString)
1901
+ .appendingPathComponent("\(appVersion)-\(bundleVersion).zip")
1902
+ let resolvedFilePath = (filePath as NSString).resolvingSymlinksInPath
1903
+ let baseDownloadDir = (BundleUpdateStore.downloadBundleDir() as NSString).resolvingSymlinksInPath
1904
+ guard resolvedFilePath.hasPrefix(baseDownloadDir + "/") else {
1905
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: ZIP outside download directory: \(resolvedFilePath)")
1906
+ throw BundleUpdateError(.security, .verifyASC)
1907
+ }
1908
+ guard FileManager.default.fileExists(atPath: resolvedFilePath) else {
1909
+ throw BundleUpdateError(.missing, .verifyASC)
1910
+ }
1574
1911
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: appVersion=\(appVersion), bundleVersion=\(bundleVersion), file=\(filePath), signatureLength=\(signature.count)")
1575
1912
 
1913
+ let folderName = "\(appVersion)-\(bundleVersion)"
1914
+ let isInstalled = BundleUpdateStore.currentBundleVersion() == folderName ||
1915
+ BundleUpdateStore.readFallbackUpdateBundleDataFile().contains {
1916
+ $0["appVersion"] == appVersion && $0["bundleVersion"] == bundleVersion
1917
+ }
1918
+ let destination = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(folderName)
1919
+ let resolvedDestination = (destination as NSString).resolvingSymlinksInPath
1920
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
1921
+ guard resolvedDestination.hasPrefix(baseBundleDir + "/") else {
1922
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: destination outside bundle directory: \(resolvedDestination)")
1923
+ throw BundleUpdateError(.security, .verifyASC)
1924
+ }
1925
+
1576
1926
  // GPG verification skipped only when both DevSettings and skip-GPG toggle are enabled
1577
1927
  #if ALLOW_SKIP_GPG_VERIFICATION
1578
1928
  let skipGPG = BundleUpdateStore.isDevSettingsEnabled() && BundleUpdateStore.isSkipGPGEnabled()
@@ -1582,56 +1932,80 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1582
1932
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: GPG check: skipGPG=\(skipGPG)")
1583
1933
 
1584
1934
  if !skipGPG {
1935
+ OneKeyLog.info("BundleUpdate", "verifyBundleASC: pre-verifying GPG signature before extraction...")
1936
+ guard let preVerifiedSha256 = BundleUpdateStore.readMetadataFileSha256(
1937
+ signature,
1938
+ expectedAppVersion: appVersion,
1939
+ expectedBundleVersion: bundleVersion,
1940
+ expectedAppType: "ios"
1941
+ ), !preVerifiedSha256.isEmpty else {
1942
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: pre-extraction GPG signature verification failed")
1943
+ throw BundleUpdateError(.integrity, .verifyASC)
1944
+ }
1945
+
1585
1946
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...")
1586
- let calculated = BundleUpdateStore.calculateSHA256(filePath)
1587
- let isValid = calculated != nil && BundleCryptoCore.secureEqualHex(calculated!, sha256)
1588
- if !isValid {
1589
- // Promote the SHA256 subtype (FILE_TRUNCATED / OOM /
1590
- // IO_<class> / MISMATCH) into the thrown message so
1591
- // analytics' extractUpdateErrorCode can split this
1592
- // bucket the same way the download stage already does.
1947
+ guard let calculated = BundleUpdateStore.calculateSHA256(filePath) else {
1948
+ throw BundleUpdateError(.storage, .verifyASC)
1949
+ }
1950
+ if !BundleCryptoCore.secureEqualHex(calculated, sha256) {
1951
+ // Keep the detailed hash reason in the local log only.
1593
1952
  let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH"
1594
1953
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=\(filePath), reason=\(reason)")
1595
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
1954
+ throw BundleUpdateError(.integrity, .verifyASC)
1596
1955
  }
1597
1956
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK")
1598
1957
  } else {
1599
1958
  OneKeyLog.warn("BundleUpdate", "verifyBundleASC: SHA256 + GPG verification skipped (DevSettings enabled)")
1600
1959
  }
1601
1960
 
1602
- let folderName = "\(appVersion)-\(bundleVersion)"
1603
- let destination = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(folderName)
1961
+ if isInstalled {
1962
+ if !skipGPG && !BundleUpdateStore.validateMetadataFileSha256(folderName, signature: signature, expectedAppVersion: appVersion, expectedBundleVersion: bundleVersion) {
1963
+ throw BundleUpdateError(.integrity, .verifyASC)
1964
+ }
1965
+ guard let metadata = BundleUpdateStore.getMetadataFileContent(folderName),
1966
+ BundleCryptoCore.validateExtractedPathSafety(destination),
1967
+ BundleUpdateStore.validateAllFilesInDir(destination, metadata: metadata, appVersion: appVersion, bundleVersion: bundleVersion),
1968
+ BundleUpdateStore.validateBundlePairCompatibility(bundleDirPath: destination, metadata: metadata) else {
1969
+ throw BundleUpdateError(.integrity, .verifyASC)
1970
+ }
1971
+ return
1972
+ }
1604
1973
 
1605
- // Check zip file size before extraction (decompression bomb protection)
1974
+ // Check zip file size and uncompressed payload size before extraction (decompression bomb protection)
1606
1975
  let maxZipFileSize: UInt64 = 512 * 1024 * 1024 // 512 MB
1976
+ let maxUncompressedSize: UInt64 = 512 * 1024 * 1024
1607
1977
  if let attrs = try? FileManager.default.attributesOfItem(atPath: filePath),
1608
1978
  let fileSize = attrs[.size] as? UInt64 {
1609
1979
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: zip file size=\(fileSize) bytes")
1610
1980
  if fileSize > maxZipFileSize {
1611
1981
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: zip file too large (\(fileSize) > \(maxZipFileSize))")
1612
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Zip file exceeds maximum allowed size"])
1982
+ throw BundleUpdateError(.security, .verifyASC)
1613
1983
  }
1614
1984
  }
1615
1985
 
1986
+ var payloadError: NSError?
1987
+ let payloadSize = SSZipArchive.payloadSizeForArchive(atPath: filePath, error: &payloadError)
1988
+ if payloadError != nil || payloadSize.uint64Value == 0 || payloadSize.uint64Value > maxUncompressedSize {
1989
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: zip payload size invalid or exceeds limit (payloadSize=\(payloadSize), error=\(String(describing: payloadError)))")
1990
+ throw BundleUpdateError(.security, .verifyASC)
1991
+ }
1992
+
1993
+ // Clean up any existing destination directory before unzipping so leftover files cannot persist
1994
+ if FileManager.default.fileExists(atPath: destination) {
1995
+ try? FileManager.default.removeItem(atPath: destination)
1996
+ }
1997
+
1616
1998
  // Unzip using SSZipArchive
1617
1999
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: extracting zip to \(destination)...")
1618
2000
  do {
1619
2001
  try SSZipArchive.unzipFile(atPath: filePath, toDestination: destination, overwrite: true, password: nil)
1620
2002
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: extraction completed")
1621
2003
  } catch {
1622
- // SSZipArchive's NSError.localizedDescription often embeds the
1623
- // failing file path which on iOS includes the install UUID
1624
- // (/var/mobile/Containers/Data/Application/<UUID>/...). Keep
1625
- // the rich detail in OneKeyLog (local-only), but expose only
1626
- // a code-shaped tag in the thrown message so the JS-side
1627
- // analytics layer can never reflect that path back to the
1628
- // server. Subtypes intentionally low-cardinality so
1629
- // extractUpdateErrorCode picks them up cleanly:
1630
- // IO_<NSError code>
2004
+ // Keep the file path in the local log only.
1631
2005
  let nsErr = error as NSError
1632
2006
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: unzip failed: domain=\(nsErr.domain) code=\(nsErr.code) desc=\(nsErr.localizedDescription)")
1633
2007
  try? FileManager.default.removeItem(atPath: destination)
1634
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to unzip bundle: IO_\(nsErr.code)"])
2008
+ throw BundleUpdateError(.storage, .verifyASC)
1635
2009
  }
1636
2010
 
1637
2011
  // Validate extracted paths (symlinks, path traversal)
@@ -1639,23 +2013,48 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1639
2013
  if !BundleCryptoCore.validateExtractedPathSafety(destination) {
1640
2014
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: path traversal or symlink attack detected")
1641
2015
  try? FileManager.default.removeItem(atPath: destination)
1642
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Path traversal or symlink attack detected"])
2016
+ throw BundleUpdateError(.security, .verifyASC)
2017
+ }
2018
+
2019
+ // Enforce extracted entry count and total uncompressed size limits
2020
+ let maxZipEntries = 4096
2021
+ if let enumerator = FileManager.default.enumerator(atPath: destination) {
2022
+ var entryCount = 0
2023
+ var totalUncompressedBytes: UInt64 = 0
2024
+ while let relPath = enumerator.nextObject() as? String {
2025
+ entryCount += 1
2026
+ if entryCount > maxZipEntries {
2027
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: extracted entry count exceeds maximum allowed (\(maxZipEntries))")
2028
+ try? FileManager.default.removeItem(atPath: destination)
2029
+ throw BundleUpdateError(.security, .verifyASC)
2030
+ }
2031
+ let fullPath = (destination as NSString).appendingPathComponent(relPath)
2032
+ if let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath),
2033
+ let fileSize = attrs[.size] as? UInt64 {
2034
+ totalUncompressedBytes += fileSize
2035
+ if totalUncompressedBytes > maxUncompressedSize {
2036
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: extracted size exceeds maximum allowed (\(maxUncompressedSize))")
2037
+ try? FileManager.default.removeItem(atPath: destination)
2038
+ throw BundleUpdateError(.security, .verifyASC)
2039
+ }
2040
+ }
2041
+ }
1643
2042
  }
1644
2043
 
1645
2044
  let metadataJsonPath = (destination as NSString).appendingPathComponent("metadata.json")
1646
2045
  guard FileManager.default.fileExists(atPath: metadataJsonPath) else {
1647
2046
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: metadata.json not found after extraction")
1648
2047
  try? FileManager.default.removeItem(atPath: destination)
1649
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to read metadata.json"])
2048
+ throw BundleUpdateError(.missing, .verifyASC)
1650
2049
  }
1651
2050
 
1652
2051
  let currentBundleVersion = "\(appVersion)-\(bundleVersion)"
1653
2052
  if !skipGPG {
1654
2053
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: validating GPG signature for metadata...")
1655
- if !BundleUpdateStore.validateMetadataFileSha256(currentBundleVersion, signature: signature) {
2054
+ if !BundleUpdateStore.validateMetadataFileSha256(currentBundleVersion, signature: signature, expectedAppVersion: appVersion, expectedBundleVersion: bundleVersion) {
1656
2055
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: GPG signature verification failed")
1657
2056
  try? FileManager.default.removeItem(atPath: destination)
1658
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"])
2057
+ throw BundleUpdateError(.integrity, .verifyASC)
1659
2058
  }
1660
2059
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: GPG signature verified OK")
1661
2060
  } else {
@@ -1666,18 +2065,18 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1666
2065
  guard let metadata = BundleUpdateStore.getMetadataFileContent(currentBundleVersion) else {
1667
2066
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: failed to read metadata.json content")
1668
2067
  try? FileManager.default.removeItem(atPath: destination)
1669
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to read metadata.json after extraction"])
2068
+ throw BundleUpdateError(.missing, .verifyASC)
1670
2069
  }
1671
2070
 
1672
2071
  if !BundleUpdateStore.validateAllFilesInDir(destination, metadata: metadata, appVersion: appVersion, bundleVersion: bundleVersion) {
1673
2072
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: file integrity check failed")
1674
2073
  try? FileManager.default.removeItem(atPath: destination)
1675
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Extracted files verification against metadata failed"])
2074
+ throw BundleUpdateError(.integrity, .verifyASC)
1676
2075
  }
1677
2076
  if !BundleUpdateStore.validateBundlePairCompatibility(bundleDirPath: destination, metadata: metadata) {
1678
2077
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: bundle pair compatibility check failed")
1679
2078
  try? FileManager.default.removeItem(atPath: destination)
1680
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle pair compatibility check failed"])
2079
+ throw BundleUpdateError(.incompatible, .verifyASC)
1681
2080
  }
1682
2081
 
1683
2082
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: all verifications passed, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
@@ -1685,22 +2084,37 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1685
2084
  }
1686
2085
 
1687
2086
  func verifyBundle(params: BundleVerifyParams) throws -> Promise<Void> {
1688
- return Promise.async {
1689
- let filePath = params.downloadedFile
2087
+ return updatePromise(step: .verifyPackage) {
1690
2088
  let sha256 = params.sha256
1691
2089
  let appVersion = params.latestVersion
1692
2090
  let bundleVersion = params.bundleVersion
1693
2091
 
2092
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2093
+ OneKeyLog.error("BundleUpdate", "verifyBundle: invalid version string format: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2094
+ throw BundleUpdateError(.invalidArgument, .verifyPackage)
2095
+ }
2096
+
2097
+ let filePath = (BundleUpdateStore.downloadBundleDir() as NSString)
2098
+ .appendingPathComponent("\(appVersion)-\(bundleVersion).zip")
2099
+ let resolvedFilePath = (filePath as NSString).resolvingSymlinksInPath
2100
+ let baseDownloadDir = (BundleUpdateStore.downloadBundleDir() as NSString).resolvingSymlinksInPath
2101
+ guard resolvedFilePath.hasPrefix(baseDownloadDir + "/") else {
2102
+ OneKeyLog.error("BundleUpdate", "verifyBundle: ZIP outside download directory: \(resolvedFilePath)")
2103
+ throw BundleUpdateError(.security, .verifyPackage)
2104
+ }
2105
+ guard FileManager.default.fileExists(atPath: resolvedFilePath) else {
2106
+ throw BundleUpdateError(.missing, .verifyPackage)
2107
+ }
1694
2108
  OneKeyLog.info("BundleUpdate", "verifyBundle: appVersion=\(appVersion), bundleVersion=\(bundleVersion), file=\(filePath)")
1695
2109
 
1696
2110
  // Verify SHA256 of the downloaded file
1697
2111
  guard let calculated = BundleUpdateStore.calculateSHA256(filePath) else {
1698
2112
  OneKeyLog.error("BundleUpdate", "verifyBundle: failed to calculate SHA256 for file=\(filePath)")
1699
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to calculate SHA256"])
2113
+ throw BundleUpdateError(.storage, .verifyPackage)
1700
2114
  }
1701
2115
  guard BundleCryptoCore.secureEqualHex(calculated, sha256) else {
1702
2116
  OneKeyLog.error("BundleUpdate", "verifyBundle: SHA256 mismatch, expected=\(sha256.prefix(16))..., got=\(calculated.prefix(16))...")
1703
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "SHA256 verification failed"])
2117
+ throw BundleUpdateError(.integrity, .verifyPackage)
1704
2118
  }
1705
2119
 
1706
2120
  OneKeyLog.info("BundleUpdate", "verifyBundle: SHA256 verified OK for appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
@@ -1713,13 +2127,18 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1713
2127
  // commits so a same-version reinstall (or any reader that re-cached
1714
2128
  // pre-commit data inside the race window) can't leave stale entries.
1715
2129
  BundleUpdateStore.invalidateValidatedBundleInfoCache()
1716
- return Promise.async {
2130
+ return updatePromise(step: .install) {
1717
2131
  let appVersion = params.latestVersion
1718
2132
  let bundleVersion = params.bundleVersion
1719
2133
  let signature = params.signature
1720
2134
 
1721
2135
  OneKeyLog.info("BundleUpdate", "installBundle: appVersion=\(appVersion), bundleVersion=\(bundleVersion), signatureLength=\(signature.count)")
1722
2136
 
2137
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2138
+ OneKeyLog.error("BundleUpdate", "installBundle: invalid version string format: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2139
+ throw BundleUpdateError(.invalidArgument, .install)
2140
+ }
2141
+
1723
2142
  // GPG verification skipped only when both DevSettings and skip-GPG toggle are enabled
1724
2143
  #if ALLOW_SKIP_GPG_VERIFICATION
1725
2144
  let skipGPG = BundleUpdateStore.isDevSettingsEnabled() && BundleUpdateStore.isSkipGPGEnabled()
@@ -1730,20 +2149,63 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1730
2149
 
1731
2150
  let folderName = "\(appVersion)-\(bundleVersion)"
1732
2151
  let currentFolderName = BundleUpdateStore.currentBundleVersion()
2152
+ let previousActiveBundleVersion = currentFolderName
1733
2153
  OneKeyLog.info("BundleUpdate", "installBundle: target=\(folderName), current=\(currentFolderName ?? "nil")")
1734
2154
 
1735
- // Verify bundle directory exists
2155
+ // Verify bundle directory confinement and existence
1736
2156
  let bundleDirPath = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(folderName)
2157
+ let resolvedBundleDir = (bundleDirPath as NSString).resolvingSymlinksInPath
2158
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2159
+ guard resolvedBundleDir.hasPrefix(baseBundleDir + "/") else {
2160
+ OneKeyLog.error("BundleUpdate", "installBundle: bundle path outside bundle directory: \(resolvedBundleDir)")
2161
+ throw BundleUpdateError(.security, .install)
2162
+ }
2163
+ let sigPath = (BundleUpdateStore.signatureFilePath(folderName) as NSString).resolvingSymlinksInPath
2164
+ let baseAscDir = (BundleUpdateStore.ascDir() as NSString).resolvingSymlinksInPath
2165
+ guard sigPath.hasPrefix(baseAscDir + "/") else {
2166
+ OneKeyLog.error("BundleUpdate", "installBundle: signature path outside asc directory: \(sigPath)")
2167
+ throw BundleUpdateError(.security, .install)
2168
+ }
2169
+
1737
2170
  guard FileManager.default.fileExists(atPath: bundleDirPath) else {
1738
2171
  OneKeyLog.error("BundleUpdate", "installBundle: bundle directory not found: \(bundleDirPath)")
1739
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle directory not found: \(folderName)"])
2172
+ throw BundleUpdateError(.missing, .install)
2173
+ }
2174
+
2175
+ let mainBundlePath = (bundleDirPath as NSString).appendingPathComponent("main.jsbundle.hbc")
2176
+ let metadataJsonPath = (bundleDirPath as NSString).appendingPathComponent("metadata.json")
2177
+ guard FileManager.default.fileExists(atPath: mainBundlePath),
2178
+ FileManager.default.fileExists(atPath: metadataJsonPath) else {
2179
+ OneKeyLog.error("BundleUpdate", "installBundle: required bundle files missing in \(bundleDirPath)")
2180
+ throw BundleUpdateError(.missing, .install)
2181
+ }
2182
+
2183
+ if !skipGPG {
2184
+ guard !signature.isEmpty,
2185
+ BundleUpdateStore.validateMetadataFileSha256(folderName, signature: signature, expectedAppVersion: appVersion, expectedBundleVersion: bundleVersion) else {
2186
+ OneKeyLog.error("BundleUpdate", "installBundle: GPG signature verification failed for \(folderName)")
2187
+ throw BundleUpdateError(.integrity, .install)
2188
+ }
2189
+ }
2190
+
2191
+ guard let metadata = BundleUpdateStore.getMetadataFileContent(folderName) else {
2192
+ OneKeyLog.error("BundleUpdate", "installBundle: failed to read metadata.json content for \(folderName)")
2193
+ throw BundleUpdateError(.missing, .install)
2194
+ }
2195
+ if !BundleUpdateStore.validateEntryBundlesSha256(bundleDirPath: bundleDirPath, metadata: metadata) {
2196
+ OneKeyLog.error("BundleUpdate", "installBundle: entry bundle SHA256 verification failed for \(folderName)")
2197
+ throw BundleUpdateError(.integrity, .install)
2198
+ }
2199
+ if !BundleUpdateStore.validateBundlePairCompatibility(bundleDirPath: bundleDirPath, metadata: metadata) {
2200
+ OneKeyLog.error("BundleUpdate", "installBundle: bundle pair compatibility check failed for \(folderName)")
2201
+ throw BundleUpdateError(.incompatible, .install)
1740
2202
  }
1741
2203
 
1742
- let ud = UserDefaults.standard
1743
- ud.set(folderName, forKey: "currentBundleVersion")
1744
2204
  if !signature.isEmpty {
1745
2205
  BundleUpdateStore.writeSignatureFile(folderName, signature: signature)
1746
2206
  }
2207
+ let ud = UserDefaults.standard
2208
+ ud.set(folderName, forKey: "currentBundleVersion")
1747
2209
  let currentNativeVersion = BundleUpdateStore.getCurrentNativeVersion()
1748
2210
  BundleUpdateStore.setNativeVersion(currentNativeVersion)
1749
2211
  let currentBuildNumber = BundleUpdateStore.getCurrentNativeBuildNumber()
@@ -1754,13 +2216,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1754
2216
  var fallbackData = BundleUpdateStore.readFallbackUpdateBundleDataFile()
1755
2217
 
1756
2218
  if let current = currentFolderName,
1757
- let dashRange = current.range(of: "-", options: .backwards) {
1758
- let curAppVersion = String(current[current.startIndex..<dashRange.lowerBound])
1759
- let curBundleVersion = String(current[dashRange.upperBound...])
2219
+ let curBundleVersion = BundleUpdateStore.bundleVersion(inFolder: current, appVersion: currentNativeVersion) {
1760
2220
  let curSignature = BundleUpdateStore.readSignatureFile(current)
1761
2221
  if !curSignature.isEmpty {
1762
2222
  fallbackData.append([
1763
- "appVersion": curAppVersion,
2223
+ "appVersion": currentNativeVersion,
1764
2224
  "bundleVersion": curBundleVersion,
1765
2225
  "signature": curSignature
1766
2226
  ])
@@ -1772,10 +2232,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1772
2232
  let shifted = fallbackData.removeFirst()
1773
2233
  if let shiftApp = shifted["appVersion"], let shiftBundle = shifted["bundleVersion"] {
1774
2234
  let dirName = "\(shiftApp)-\(shiftBundle)"
1775
- BundleUpdateStore.deleteSignatureFile(dirName)
1776
- let oldPath = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(dirName)
1777
- if FileManager.default.fileExists(atPath: oldPath) {
1778
- try? FileManager.default.removeItem(atPath: oldPath)
2235
+ if dirName != previousActiveBundleVersion &&
2236
+ shiftBundle != previousActiveBundleVersion &&
2237
+ dirName != folderName {
2238
+ BundleUpdateStore.deleteSignatureFile(dirName)
2239
+ let oldPath = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(dirName)
2240
+ if FileManager.default.fileExists(atPath: oldPath) {
2241
+ try? FileManager.default.removeItem(atPath: oldPath)
2242
+ }
2243
+ } else {
2244
+ OneKeyLog.info("BundleUpdate", "installBundle: skipping disk deletion of evicted fallback \(dirName) because it is still in use")
1779
2245
  }
1780
2246
  }
1781
2247
  }
@@ -1793,53 +2259,57 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1793
2259
  }
1794
2260
  }
1795
2261
 
1796
- func clearDownload() throws -> Promise<Void> {
1797
- return Promise.async { [weak self] in
1798
- OneKeyLog.info("BundleUpdate", "clearDownload: clearing download directory and cancelling downloads...")
1799
- let downloadDir = BundleUpdateStore.downloadBundleDir()
1800
- if FileManager.default.fileExists(atPath: downloadDir) {
1801
- try FileManager.default.removeItem(atPath: downloadDir)
1802
- OneKeyLog.info("BundleUpdate", "clearDownload: download directory deleted")
1803
- } else {
1804
- OneKeyLog.info("BundleUpdate", "clearDownload: download directory does not exist, skipping")
1805
- }
1806
- // Cancel all in-flight downloads by invalidating the session
1807
- self?.urlSession?.invalidateAndCancel()
1808
- self?.urlSession = self?.createURLSession()
1809
- self?.stateQueue.sync { self?.isDownloading = false }
1810
- OneKeyLog.info("BundleUpdate", "clearDownload: completed")
2262
+ private func clearManagedFiles(includeInstalled: Bool) async throws {
2263
+ let (alreadyClearing, oldSession, activeDelegate, activePath) = stateQueue.sync { () -> (Bool, URLSession?, DownloadDelegate?, String?) in
2264
+ if clearingDownloads { return (true, nil, nil, nil) }
2265
+ clearingDownloads = true
2266
+ activeRunDelegate?.markCancelledByClear()
2267
+ return (false, urlSession, activeRunDelegate, activeDownloadFilePath)
1811
2268
  }
1812
- }
1813
-
1814
- func clearBundle() throws -> Promise<Void> {
1815
- BundleUpdateStore.invalidateValidatedBundleInfoCache()
1816
- return Promise.async { [weak self] in
1817
- OneKeyLog.info("BundleUpdate", "clearBundle: clearing download and bundle directories...")
1818
- // Clear download directory
1819
- let downloadDir = BundleUpdateStore.downloadBundleDir()
1820
- if FileManager.default.fileExists(atPath: downloadDir) {
1821
- try FileManager.default.removeItem(atPath: downloadDir)
1822
- OneKeyLog.info("BundleUpdate", "clearBundle: download directory deleted")
2269
+ guard !alreadyClearing else { throw BundleUpdateError(.invalidArgument, .cleanup) }
2270
+ defer {
2271
+ stateQueue.sync {
2272
+ urlSession = createURLSession()
2273
+ clearingDownloads = false
1823
2274
  }
1824
- // Clear installed bundle directory
2275
+ }
2276
+ oldSession?.invalidateAndCancel()
2277
+ if let activePath {
2278
+ let taskId = URL(fileURLWithPath: activePath).deletingPathExtension().lastPathComponent
2279
+ await RangeDownloader.shared.cancel(channel: .bundle, taskId: taskId, filePath: activePath)
2280
+ }
2281
+ if let activeDelegate, !(await activeDelegate.waitForRunCompletion(timeout: 65)) {
2282
+ throw BundleUpdateError(.unknown, .cleanup)
2283
+ }
2284
+ let downloadDir = BundleUpdateStore.downloadBundleDir()
2285
+ if FileManager.default.fileExists(atPath: downloadDir) {
2286
+ try FileManager.default.removeItem(atPath: downloadDir)
2287
+ }
2288
+ if includeInstalled {
1825
2289
  let bundleDir = BundleUpdateStore.bundleDir()
1826
2290
  if FileManager.default.fileExists(atPath: bundleDir) {
1827
2291
  try FileManager.default.removeItem(atPath: bundleDir)
1828
- OneKeyLog.info("BundleUpdate", "clearBundle: bundle directory deleted")
1829
2292
  }
1830
- // Cancel all in-flight downloads by invalidating the session
1831
- self?.urlSession?.invalidateAndCancel()
1832
- self?.urlSession = self?.createURLSession()
1833
- self?.stateQueue.sync { self?.isDownloading = false }
1834
- OneKeyLog.info("BundleUpdate", "clearBundle: completed")
1835
2293
  }
1836
2294
  }
1837
2295
 
1838
- /// Parses an "{appVersion}-{bundleVersion}" folder/file stem into its
1839
- /// appVersion component using the SAME last-dash split as listLocalBundles
1840
- /// and the installBundle fallback logic. Returns nil when the stem has no
1841
- /// dash or an empty appVersion, so callers leave unrecognized entries
1842
- /// untouched.
2296
+ func clearDownload() throws -> Promise<Void> {
2297
+ return updatePromise(step: .cleanup) { [weak self] in
2298
+ guard let self else { return }
2299
+ try await self.clearManagedFiles(includeInstalled: false)
2300
+ }
2301
+ }
2302
+
2303
+ func clearBundle() throws -> Promise<Void> {
2304
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
2305
+ return updatePromise(step: .cleanup) { [weak self] in
2306
+ guard let self else { return }
2307
+ try await self.clearManagedFiles(includeInstalled: true)
2308
+ }
2309
+ }
2310
+
2311
+ /// Classifies only non-current stems for best-effort stale cleanup.
2312
+ /// Current-version stems are matched by the exact native-version prefix.
1843
2313
  private static func appVersionFromStem(_ stem: String) -> String? {
1844
2314
  guard let lastDash = stem.range(of: "-", options: .backwards),
1845
2315
  lastDash.lowerBound > stem.startIndex else { return nil }
@@ -1874,6 +2344,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1874
2344
  /// is unparseable (leave foreign names alone).
1875
2345
  func shouldKeep(stem: String) -> Bool {
1876
2346
  if let active = currentBundleVersion, stem == active { return true }
2347
+ if BundleUpdateStore.bundleVersion(inFolder: stem, appVersion: currentAppV) != nil { return true }
1877
2348
  guard let appV = Self.appVersionFromStem(stem) else { return true }
1878
2349
  return appV == currentAppV
1879
2350
  }
@@ -1961,7 +2432,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1961
2432
 
1962
2433
  func resetToBuiltInBundle() throws -> Promise<Void> {
1963
2434
  BundleUpdateStore.invalidateValidatedBundleInfoCache()
1964
- return Promise.async {
2435
+ return updatePromise(step: .cleanup) {
1965
2436
  OneKeyLog.info("BundleUpdate", "resetToBuiltInBundle: clearing currentBundleVersion preference...")
1966
2437
  let ud = UserDefaults.standard
1967
2438
  if let cbv = ud.string(forKey: "currentBundleVersion") {
@@ -1978,7 +2449,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1978
2449
 
1979
2450
  func clearAllJSBundleData() throws -> Promise<TestResult> {
1980
2451
  BundleUpdateStore.invalidateValidatedBundleInfoCache()
1981
- return Promise.async {
2452
+ return updatePromise(step: .cleanup) {
1982
2453
  OneKeyLog.info("BundleUpdate", "clearAllJSBundleData: starting...")
1983
2454
  let bundleDir = BundleUpdateStore.bundleDir()
1984
2455
  if FileManager.default.fileExists(atPath: bundleDir) {
@@ -2015,15 +2486,34 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2015
2486
 
2016
2487
  func setCurrentUpdateBundleData(params: BundleSwitchParams) throws -> Promise<Void> {
2017
2488
  BundleUpdateStore.invalidateValidatedBundleInfoCache()
2018
- return Promise.async {
2489
+ return updatePromise(step: .install) {
2490
+ guard params.appVersion.isSafeVersionString, params.bundleVersion.isSafeVersionString else {
2491
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: invalid version string format: appVersion=\(params.appVersion), bundleVersion=\(params.bundleVersion)")
2492
+ throw BundleUpdateError(.invalidArgument, .install)
2493
+ }
2494
+
2019
2495
  let bundleVersion = "\(params.appVersion)-\(params.bundleVersion)"
2020
2496
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: switching to \(bundleVersion)")
2021
2497
 
2022
- // Verify the bundle directory actually exists
2498
+ // Verify bundle directory and signature confinement
2023
2499
  let bundleDirPath = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(bundleVersion)
2500
+ let resolvedBundleDir = (bundleDirPath as NSString).resolvingSymlinksInPath
2501
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2502
+ guard resolvedBundleDir.hasPrefix(baseBundleDir + "/") else {
2503
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: bundle path outside bundle directory: \(resolvedBundleDir)")
2504
+ throw BundleUpdateError(.security, .install)
2505
+ }
2506
+ let sigPath = (BundleUpdateStore.signatureFilePath(bundleVersion) as NSString).resolvingSymlinksInPath
2507
+ let baseAscDir = (BundleUpdateStore.ascDir() as NSString).resolvingSymlinksInPath
2508
+ guard sigPath.hasPrefix(baseAscDir + "/") else {
2509
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: signature path outside asc directory: \(sigPath)")
2510
+ throw BundleUpdateError(.security, .install)
2511
+ }
2512
+
2513
+ // Verify the bundle directory actually exists
2024
2514
  guard FileManager.default.fileExists(atPath: bundleDirPath) else {
2025
2515
  OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: bundle directory not found: \(bundleDirPath)")
2026
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle directory not found"])
2516
+ throw BundleUpdateError(.missing, .install)
2027
2517
  }
2028
2518
 
2029
2519
  // Verify GPG signature is valid (skipped when both DevSettings and skip-GPG toggle are enabled)
@@ -2035,21 +2525,39 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2035
2525
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: GPG check: skipGPG=\(skipGPGSwitch)")
2036
2526
  if !skipGPGSwitch {
2037
2527
  guard !params.signature.isEmpty,
2038
- BundleUpdateStore.validateMetadataFileSha256(bundleVersion, signature: params.signature) else {
2528
+ BundleUpdateStore.validateMetadataFileSha256(bundleVersion, signature: params.signature, expectedAppVersion: params.appVersion, expectedBundleVersion: params.bundleVersion) else {
2039
2529
  OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: GPG signature verification failed")
2040
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"])
2530
+ throw BundleUpdateError(.integrity, .install)
2041
2531
  }
2042
2532
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: GPG signature verified OK")
2043
2533
  } else {
2044
2534
  OneKeyLog.warn("BundleUpdate", "setCurrentUpdateBundleData: GPG signature verification skipped (DevSettings + skip-GPG enabled)")
2045
2535
  }
2046
2536
 
2047
- let ud = UserDefaults.standard
2048
- ud.set(bundleVersion, forKey: "currentBundleVersion")
2537
+ guard let metadata = BundleUpdateStore.getMetadataFileContent(bundleVersion) else {
2538
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: failed to read metadata.json content")
2539
+ throw BundleUpdateError(.missing, .install)
2540
+ }
2541
+ if !BundleUpdateStore.validateEntryBundlesSha256(bundleDirPath: bundleDirPath, metadata: metadata) {
2542
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: entry bundle SHA256 verification failed")
2543
+ throw BundleUpdateError(.integrity, .install)
2544
+ }
2545
+ if !BundleUpdateStore.validateBundlePairCompatibility(bundleDirPath: bundleDirPath, metadata: metadata) {
2546
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: bundle pair compatibility check failed")
2547
+ throw BundleUpdateError(.incompatible, .install)
2548
+ }
2549
+
2049
2550
  if !params.signature.isEmpty {
2050
2551
  BundleUpdateStore.writeSignatureFile(bundleVersion, signature: params.signature)
2051
2552
  }
2553
+ let ud = UserDefaults.standard
2554
+ ud.set(bundleVersion, forKey: "currentBundleVersion")
2555
+ let currentNativeVersion = BundleUpdateStore.getCurrentNativeVersion()
2556
+ BundleUpdateStore.setNativeVersion(currentNativeVersion)
2557
+ let currentBuildNumber = BundleUpdateStore.getCurrentNativeBuildNumber()
2558
+ BundleUpdateStore.setNativeBuildNumber(currentBuildNumber)
2052
2559
  ud.synchronize()
2560
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
2053
2561
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: switched to \(bundleVersion)")
2054
2562
  }
2055
2563
  }
@@ -2182,8 +2690,17 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2182
2690
 
2183
2691
  func isBundleExists(appVersion: String, bundleVersion: String) throws -> Promise<Bool> {
2184
2692
  return Promise.async {
2693
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2694
+ OneKeyLog.error("BundleUpdate", "isBundleExists: invalid version string format: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2695
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid version string format"])
2696
+ }
2185
2697
  let folderName = "\(appVersion)-\(bundleVersion)"
2186
2698
  let path = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(folderName)
2699
+ let resolvedPath = (path as NSString).resolvingSymlinksInPath
2700
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2701
+ guard resolvedPath.hasPrefix(baseBundleDir + "/") else {
2702
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2703
+ }
2187
2704
  let exists = FileManager.default.fileExists(atPath: path)
2188
2705
  OneKeyLog.info("BundleUpdate", "isBundleExists: appVersion=\(appVersion), bundleVersion=\(bundleVersion), exists=\(exists)")
2189
2706
  return exists
@@ -2191,32 +2708,41 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2191
2708
  }
2192
2709
 
2193
2710
  func verifyExtractedBundle(appVersion: String, bundleVersion: String) throws -> Promise<Void> {
2194
- return Promise.async {
2711
+ return updatePromise(step: .verifyPackage) {
2195
2712
  OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2713
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2714
+ OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: invalid version string format: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2715
+ throw BundleUpdateError(.invalidArgument, .verifyPackage)
2716
+ }
2196
2717
  let folderName = "\(appVersion)-\(bundleVersion)"
2197
2718
  let bundlePath = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(folderName)
2719
+ let resolvedPath = (bundlePath as NSString).resolvingSymlinksInPath
2720
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2721
+ guard resolvedPath.hasPrefix(baseBundleDir + "/") else {
2722
+ throw BundleUpdateError(.security, .verifyPackage)
2723
+ }
2198
2724
  guard FileManager.default.fileExists(atPath: bundlePath) else {
2199
2725
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: bundle directory not found: \(bundlePath)")
2200
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle directory not found"])
2726
+ throw BundleUpdateError(.missing, .verifyPackage)
2201
2727
  }
2202
2728
  let metadataJsonPath = (bundlePath as NSString).appendingPathComponent("metadata.json")
2203
2729
  guard FileManager.default.fileExists(atPath: metadataJsonPath) else {
2204
2730
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: metadata.json not found in \(bundlePath)")
2205
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "metadata.json not found"])
2731
+ throw BundleUpdateError(.missing, .verifyPackage)
2206
2732
  }
2207
2733
  guard let data = FileManager.default.contents(atPath: metadataJsonPath),
2208
2734
  let metadata = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
2209
2735
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: failed to parse metadata.json")
2210
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse metadata.json"])
2736
+ throw BundleUpdateError(.integrity, .verifyPackage)
2211
2737
  }
2212
2738
  OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: parsing metadata and validating files...")
2213
2739
  if !BundleUpdateStore.validateAllFilesInDir(bundlePath, metadata: metadata, appVersion: appVersion, bundleVersion: bundleVersion) {
2214
2740
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: file integrity check failed")
2215
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "File integrity check failed"])
2741
+ throw BundleUpdateError(.integrity, .verifyPackage)
2216
2742
  }
2217
2743
  if !BundleUpdateStore.validateBundlePairCompatibility(bundleDirPath: bundlePath, metadata: metadata) {
2218
2744
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: bundle pair compatibility check failed")
2219
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle pair compatibility check failed"])
2745
+ throw BundleUpdateError(.incompatible, .verifyPackage)
2220
2746
  }
2221
2747
  OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: all files verified OK, fileCount=\(metadata.count)")
2222
2748
  }
@@ -2225,6 +2751,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2225
2751
  func listLocalBundles() throws -> Promise<[LocalBundleInfo]> {
2226
2752
  return Promise.async {
2227
2753
  let bundleDir = BundleUpdateStore.bundleDir()
2754
+ let appVersion = BundleUpdateStore.getCurrentNativeVersion()
2755
+ guard !appVersion.isEmpty else { return [] }
2228
2756
  let fm = FileManager.default
2229
2757
  guard let contents = try? fm.contentsOfDirectory(atPath: bundleDir) else {
2230
2758
  OneKeyLog.info("BundleUpdate", "listLocalBundles: bundle directory empty or not found")
@@ -2235,13 +2763,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2235
2763
  let fullPath = (bundleDir as NSString).appendingPathComponent(name)
2236
2764
  var isDir: ObjCBool = false
2237
2765
  guard fm.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue else { continue }
2238
- guard let lastDash = name.range(of: "-", options: .backwards),
2239
- lastDash.lowerBound > name.startIndex else { continue }
2240
- let appVersion = String(name[name.startIndex..<lastDash.lowerBound])
2241
- let bundleVersion = String(name[lastDash.upperBound...])
2242
- if !appVersion.isEmpty && !bundleVersion.isEmpty {
2243
- results.append(LocalBundleInfo(appVersion: appVersion, bundleVersion: bundleVersion))
2244
- }
2766
+ guard let bundleVersion = BundleUpdateStore.bundleVersion(inFolder: name, appVersion: appVersion) else { continue }
2767
+ results.append(LocalBundleInfo(appVersion: appVersion, bundleVersion: bundleVersion))
2245
2768
  }
2246
2769
  OneKeyLog.info("BundleUpdate", "listLocalBundles: found \(results.count) bundles")
2247
2770
  return results
@@ -2300,11 +2823,24 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2300
2823
 
2301
2824
  func testDeleteJsBundle(appVersion: String, bundleVersion: String) throws -> Promise<TestResult> {
2302
2825
  return Promise.async {
2826
+ #if DEBUG
2827
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2828
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid version string"])
2829
+ }
2303
2830
  OneKeyLog.info("BundleUpdate", "testDeleteJsBundle: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2304
2831
  let folderName = "\(appVersion)-\(bundleVersion)"
2305
2832
  let jsBundlePath = (BundleUpdateStore.bundleDir() as NSString)
2306
2833
  .appendingPathComponent(folderName)
2834
+ let resolvedDir = (jsBundlePath as NSString).resolvingSymlinksInPath
2835
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2836
+ guard resolvedDir.hasPrefix(baseBundleDir + "/") else {
2837
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2838
+ }
2307
2839
  let path = (jsBundlePath as NSString).appendingPathComponent("main.jsbundle.hbc")
2840
+ let resolvedPath = (path as NSString).resolvingSymlinksInPath
2841
+ guard resolvedPath.hasPrefix(baseBundleDir + "/") else {
2842
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2843
+ }
2308
2844
 
2309
2845
  if FileManager.default.fileExists(atPath: path) {
2310
2846
  try FileManager.default.removeItem(atPath: path)
@@ -2313,14 +2849,26 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2313
2849
  }
2314
2850
  OneKeyLog.warn("BundleUpdate", "testDeleteJsBundle: file not found: \(path)")
2315
2851
  return TestResult(success: false, message: "jsBundle not found: \(path)")
2852
+ #else
2853
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Test fault-injection methods are disabled in release builds"])
2854
+ #endif
2316
2855
  }
2317
2856
  }
2318
2857
 
2319
2858
  func testDeleteJsRuntimeDir(appVersion: String, bundleVersion: String) throws -> Promise<TestResult> {
2320
2859
  return Promise.async {
2860
+ #if DEBUG
2861
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2862
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid version string"])
2863
+ }
2321
2864
  OneKeyLog.info("BundleUpdate", "testDeleteJsRuntimeDir: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2322
2865
  let folderName = "\(appVersion)-\(bundleVersion)"
2323
2866
  let dirPath = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(folderName)
2867
+ let resolvedDir = (dirPath as NSString).resolvingSymlinksInPath
2868
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2869
+ guard resolvedDir.hasPrefix(baseBundleDir + "/") else {
2870
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2871
+ }
2324
2872
 
2325
2873
  if FileManager.default.fileExists(atPath: dirPath) {
2326
2874
  try FileManager.default.removeItem(atPath: dirPath)
@@ -2329,16 +2877,32 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2329
2877
  }
2330
2878
  OneKeyLog.warn("BundleUpdate", "testDeleteJsRuntimeDir: directory not found: \(dirPath)")
2331
2879
  return TestResult(success: false, message: "js runtime directory not found: \(dirPath)")
2880
+ #else
2881
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Test fault-injection methods are disabled in release builds"])
2882
+ #endif
2332
2883
  }
2333
2884
  }
2334
2885
 
2335
2886
  func testDeleteMetadataJson(appVersion: String, bundleVersion: String) throws -> Promise<TestResult> {
2336
2887
  return Promise.async {
2888
+ #if DEBUG
2889
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2890
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid version string"])
2891
+ }
2337
2892
  OneKeyLog.info("BundleUpdate", "testDeleteMetadataJson: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2338
2893
  let folderName = "\(appVersion)-\(bundleVersion)"
2339
2894
  let metadataPath = (BundleUpdateStore.bundleDir() as NSString)
2340
2895
  .appendingPathComponent(folderName)
2896
+ let resolvedDir = (metadataPath as NSString).resolvingSymlinksInPath
2897
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2898
+ guard resolvedDir.hasPrefix(baseBundleDir + "/") else {
2899
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2900
+ }
2341
2901
  let path = (metadataPath as NSString).appendingPathComponent("metadata.json")
2902
+ let resolvedPath = (path as NSString).resolvingSymlinksInPath
2903
+ guard resolvedPath.hasPrefix(baseBundleDir + "/") else {
2904
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2905
+ }
2342
2906
 
2343
2907
  if FileManager.default.fileExists(atPath: path) {
2344
2908
  try FileManager.default.removeItem(atPath: path)
@@ -2347,15 +2911,31 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2347
2911
  }
2348
2912
  OneKeyLog.warn("BundleUpdate", "testDeleteMetadataJson: file not found: \(path)")
2349
2913
  return TestResult(success: false, message: "metadata.json not found: \(path)")
2914
+ #else
2915
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Test fault-injection methods are disabled in release builds"])
2916
+ #endif
2350
2917
  }
2351
2918
  }
2352
2919
 
2353
2920
  func testWriteEmptyMetadataJson(appVersion: String, bundleVersion: String) throws -> Promise<TestResult> {
2354
2921
  return Promise.async {
2922
+ #if DEBUG
2923
+ guard appVersion.isSafeVersionString, bundleVersion.isSafeVersionString else {
2924
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid version string"])
2925
+ }
2355
2926
  OneKeyLog.info("BundleUpdate", "testWriteEmptyMetadataJson: appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
2356
2927
  let folderName = "\(appVersion)-\(bundleVersion)"
2357
2928
  let jsRuntimeDir = (BundleUpdateStore.bundleDir() as NSString).appendingPathComponent(folderName)
2929
+ let resolvedDir = (jsRuntimeDir as NSString).resolvingSymlinksInPath
2930
+ let baseBundleDir = (BundleUpdateStore.bundleDir() as NSString).resolvingSymlinksInPath
2931
+ guard resolvedDir.hasPrefix(baseBundleDir + "/") else {
2932
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2933
+ }
2358
2934
  let metadataPath = (jsRuntimeDir as NSString).appendingPathComponent("metadata.json")
2935
+ let resolvedPath = (metadataPath as NSString).resolvingSymlinksInPath
2936
+ guard resolvedPath.hasPrefix(baseBundleDir + "/") else {
2937
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle path outside bundle directory"])
2938
+ }
2359
2939
 
2360
2940
  if !FileManager.default.fileExists(atPath: jsRuntimeDir) {
2361
2941
  try FileManager.default.createDirectory(atPath: jsRuntimeDir, withIntermediateDirectories: true)
@@ -2367,6 +2947,10 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
2367
2947
 
2368
2948
  OneKeyLog.info("BundleUpdate", "testWriteEmptyMetadataJson: created \(metadataPath)")
2369
2949
  return TestResult(success: true, message: "Created empty metadata.json: \(metadataPath)")
2950
+ #else
2951
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Test fault-injection methods are disabled in release builds"])
2952
+ #endif
2370
2953
  }
2371
2954
  }
2372
2955
  }
2956
+