@onekeyfe/react-native-bundle-update 6.7.0-1 → 6.7.0-2

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
@@ -15,11 +15,13 @@ import okhttp3.Request
15
15
  import java.io.File
16
16
  import java.io.FileInputStream
17
17
  import java.io.FileOutputStream
18
+ import java.io.IOException
18
19
  import java.nio.file.Files
19
20
  import java.nio.file.Path
20
21
  import java.nio.file.Paths
21
22
  import java.util.concurrent.ConcurrentHashMap
22
23
  import java.util.concurrent.CopyOnWriteArrayList
24
+ import java.util.concurrent.CountDownLatch
23
25
  import java.util.concurrent.TimeUnit
24
26
  import java.util.concurrent.atomic.AtomicInteger
25
27
  import java.util.concurrent.atomic.AtomicLong
@@ -33,6 +35,49 @@ private data class BundleListener(
33
35
  val callback: (BundleDownloadEvent) -> Unit
34
36
  )
35
37
 
38
+ private class BundleUpdateError(
39
+ val code: String,
40
+ val step: String,
41
+ val httpStatus: Int? = null,
42
+ ) : Exception(buildString {
43
+ append("ONEKEY_UPDATE_ERROR:")
44
+ append(JSONObject().put("code", code).put("step", step).apply {
45
+ if (code == "HTTP_ERROR" && httpStatus != null && httpStatus in 100..599) {
46
+ put("httpStatus", httpStatus)
47
+ }
48
+ }.toString())
49
+ }) {
50
+ override fun printStackTrace(writer: java.io.PrintWriter) { writer.print(message.orEmpty()) }
51
+ override fun printStackTrace(stream: java.io.PrintStream) { stream.print(message.orEmpty()) }
52
+ }
53
+
54
+ private fun bundleUpdateError(error: Exception, step: String): BundleUpdateError {
55
+ if (error is BundleUpdateError) {
56
+ return if (error.step == step) error else BundleUpdateError(error.code, step, error.httpStatus)
57
+ }
58
+ val code = when (error) {
59
+ is ConcurrentRangeDownloader.PermanentHttpException,
60
+ is ConcurrentRangeDownloader.TransientHttpException -> "HTTP_ERROR"
61
+ is java.net.SocketTimeoutException -> "TIMEOUT"
62
+ is java.net.UnknownHostException,
63
+ is java.net.ConnectException,
64
+ is java.net.ProtocolException,
65
+ is java.net.SocketException -> "NETWORK_ERROR"
66
+ is javax.net.ssl.SSLException -> "SECURITY_REJECTED"
67
+ is java.io.FileNotFoundException,
68
+ is java.nio.file.AccessDeniedException -> "STORAGE_ERROR"
69
+ is IllegalArgumentException -> "INVALID_ARGUMENT"
70
+ is SecurityException -> "SECURITY_REJECTED"
71
+ else -> "UNKNOWN"
72
+ }
73
+ val status = when (error) {
74
+ is ConcurrentRangeDownloader.PermanentHttpException -> error.code
75
+ is ConcurrentRangeDownloader.TransientHttpException -> error.code
76
+ else -> null
77
+ }
78
+ return BundleUpdateError(code, step, status)
79
+ }
80
+
36
81
  // Public static store for CustomReactNativeHost access (called before JS starts)
37
82
  object BundleUpdateStoreAndroid {
38
83
  private const val PREFS_NAME = "BundleUpdatePrefs"
@@ -162,6 +207,7 @@ object BundleUpdateStoreAndroid {
162
207
  }
163
208
 
164
209
  fun clearUpdateBundleData(context: Context) {
210
+ didProcessPreLaunchTask = true
165
211
  invalidateValidatedBundleInfoCache()
166
212
  val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
167
213
  prefs.edit().clear().commit()
@@ -249,6 +295,16 @@ object BundleUpdateStoreAndroid {
249
295
  * Only handles the happy path (status=pending, bundle exists, env matches).
250
296
  * All complex logic (retry, download, error handling) remains in JS layer.
251
297
  */
298
+ private fun failPendingTask(mmkv: MMKV, taskObj: JSONObject, reason: String) {
299
+ try {
300
+ taskObj.put("status", "failed")
301
+ taskObj.put("lastError", reason)
302
+ mmkv.encode("onekey_pending_install_task", taskObj.toString())
303
+ } catch (e: Exception) {
304
+ OneKeyLog.warn("BundleUpdate", "processPreLaunchPendingTask: failed to update MMKV task failure: ${e.message}")
305
+ }
306
+ }
307
+
252
308
  fun processPreLaunchPendingTask(context: Context) {
253
309
  if (didProcessPreLaunchTask) return
254
310
  didProcessPreLaunchTask = true
@@ -282,11 +338,8 @@ object BundleUpdateStoreAndroid {
282
338
 
283
339
  val currentBV = getCurrentBundleVersion(context)
284
340
  val currentBundleVersionStr = if (currentBV != null) {
285
- val idx = currentBV.lastIndexOf("-")
286
- if (idx > 0) currentBV.substring(idx + 1) else getBuiltinBundleVersion(context)
287
- } else {
288
- getBuiltinBundleVersion(context)
289
- }
341
+ bundleVersionInFolder(currentBV, currentAppVersion) ?: return
342
+ } else getBuiltinBundleVersion(context)
290
343
  if (taskObj.optString("scheduledEnvBundleVersion") != currentBundleVersionStr) return
291
344
 
292
345
  // 4. Extract payload
@@ -295,20 +348,67 @@ object BundleUpdateStoreAndroid {
295
348
  val bundleVersion = payload.optString("bundleVersion")
296
349
  val signature = payload.optString("signature")
297
350
  if (appVersion.isEmpty() || bundleVersion.isEmpty() || signature.isEmpty()) return
298
- if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) return
351
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
352
+ failPendingTask(mmkv, taskObj, "ERR_VERIFY_FAILED:Invalid version string")
353
+ return
354
+ }
355
+ if (appVersion != currentAppVersion) {
356
+ OneKeyLog.warn("BundleUpdate", "processPreLaunchPendingTask: target appVersion ($appVersion) != currentAppVersion ($currentAppVersion)")
357
+ failPendingTask(mmkv, taskObj, "ERR_ENV_CHANGED:Target appVersion mismatch")
358
+ return
359
+ }
299
360
 
300
- // 5. Verify bundle directory and entry file exist
361
+ // 5. Verify bundle directory, canonical path confinement, and required files
301
362
  val folderName = "$appVersion-$bundleVersion"
363
+ val baseBundleDir = File(getBundleDir(context)).canonicalPath
302
364
  val bundleDirPath = File(getBundleDir(context), folderName)
365
+ if (Files.isSymbolicLink(bundleDirPath.toPath()) ||
366
+ !bundleDirPath.canonicalPath.startsWith(baseBundleDir + File.separator)
367
+ ) {
368
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: bundle path outside bundle directory: ${bundleDirPath.canonicalPath}")
369
+ failPendingTask(mmkv, taskObj, "ERR_VERIFY_FAILED:Bundle path outside bundle directory")
370
+ return
371
+ }
372
+ val sigFile = File(getSignatureFilePath(context, folderName))
373
+ val baseAscDir = File(getAscDir(context)).canonicalPath
374
+ if (Files.isSymbolicLink(sigFile.toPath()) ||
375
+ !sigFile.canonicalPath.startsWith(baseAscDir + File.separator)
376
+ ) {
377
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: signature path outside asc directory: ${sigFile.canonicalPath}")
378
+ failPendingTask(mmkv, taskObj, "ERR_VERIFY_FAILED:Signature path outside asc directory")
379
+ return
380
+ }
303
381
  if (!bundleDirPath.exists()) return
304
382
  val entryFile = File(bundleDirPath, "main.jsbundle.hbc")
305
- if (!entryFile.exists()) {
306
- OneKeyLog.warn("BundleUpdate", "processPreLaunchPendingTask: bundle dir exists but entry file missing: ${entryFile.absolutePath}")
383
+ val metadataFile = File(bundleDirPath, "metadata.json")
384
+ if (!entryFile.exists() || !metadataFile.exists()) {
385
+ OneKeyLog.warn("BundleUpdate", "processPreLaunchPendingTask: bundle dir exists but required files missing: ${bundleDirPath.absolutePath}")
386
+ failPendingTask(mmkv, taskObj, "ERR_VERIFY_FAILED:Required bundle files missing")
307
387
  return
308
388
  }
309
389
 
310
- // 6. Apply (same as installBundle) — use commit() for synchronous writes
311
- // to ensure all prefs are persisted atomically before proceeding.
390
+ val skipGPG = BuildConfig.ALLOW_SKIP_GPG_VERIFICATION && isDevSettingsEnabled(context) && isSkipGPGEnabled(context)
391
+ if (!skipGPG && !validateMetadataFileSha256(context, folderName, signature, appVersion, bundleVersion)) {
392
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: GPG signature verification failed for $folderName")
393
+ failPendingTask(mmkv, taskObj, "ERR_VERIFY_FAILED:Bundle signature verification failed")
394
+ return
395
+ }
396
+
397
+ val metadata = parseMetadataJson(readFileContent(metadataFile))
398
+ if (!validateAllFilesInDir(context, bundleDirPath.absolutePath, metadata, appVersion, bundleVersion)) {
399
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: extracted file verification failed for $folderName")
400
+ failPendingTask(mmkv, taskObj, "ERR_VERIFY_FAILED:Extracted file verification failed")
401
+ return
402
+ }
403
+ if (!validateBundlePairCompatibility(context, bundleDirPath.absolutePath, metadata)) {
404
+ OneKeyLog.error("BundleUpdate", "processPreLaunchPendingTask: bundle pair compatibility check failed for $folderName")
405
+ failPendingTask(mmkv, taskObj, "ERR_VERIFY_FAILED:Bundle pair compatibility check failed")
406
+ return
407
+ }
408
+
409
+ // 6. Apply (same as installBundle) — write signature BEFORE flipping
410
+ // currentBundleVersion, and use commit() for synchronous writes.
411
+ writeSignatureFile(context, folderName, signature)
312
412
  val bundlePrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
313
413
  val versionPrefs = context.getSharedPreferences(NATIVE_VERSION_PREFS_NAME, Context.MODE_PRIVATE)
314
414
  val currentVersion = bundlePrefs.getString(CURRENT_BUNDLE_VERSION_KEY, "")
@@ -320,9 +420,6 @@ object BundleUpdateStoreAndroid {
320
420
  }
321
421
  }
322
422
  .commit()
323
- if (!signature.isNullOrEmpty()) {
324
- writeSignatureFile(context, folderName, signature)
325
- }
326
423
  versionPrefs.edit()
327
424
  .putString("nativeVersion", currentAppVersion)
328
425
  .putString("nativeBuildNumber", getBuildNumber(context))
@@ -470,11 +567,30 @@ object BundleUpdateStoreAndroid {
470
567
  return compatible
471
568
  }
472
569
 
473
- fun readMetadataFileSha256(signature: String?): String? {
570
+ fun bundleVersionInFolder(folder: String, appVersion: String): String? {
571
+ if (!isSafeVersionString(appVersion)) return null
572
+ val prefix = "$appVersion-"
573
+ if (!folder.startsWith(prefix)) return null
574
+ val bundleVersion = folder.removePrefix(prefix)
575
+ return bundleVersion.takeIf { isSafeVersionString(it) }
576
+ }
577
+
578
+ @JvmOverloads
579
+ fun readMetadataFileSha256(
580
+ signature: String?,
581
+ expectedAppVersion: String? = null,
582
+ expectedBundleVersion: String? = null,
583
+ expectedAppType: String? = "android",
584
+ ): String? {
474
585
  if (signature.isNullOrEmpty()) return null
475
586
 
476
587
  // GPG cleartext signature verification is required
477
- val gpgResult = verifyGPGAndExtractSha256(signature)
588
+ val gpgResult = verifyGPGAndExtractSha256(
589
+ signature,
590
+ expectedAppVersion = expectedAppVersion,
591
+ expectedBundleVersion = expectedBundleVersion,
592
+ expectedAppType = expectedAppType,
593
+ )
478
594
  if (gpgResult != null) return gpgResult
479
595
 
480
596
  OneKeyLog.error("BundleUpdate", "readMetadataFileSha256: GPG verification failed, rejecting unsigned content")
@@ -505,8 +621,19 @@ object BundleUpdateStoreAndroid {
505
621
  * (NOT_PGP_SIGNED_MESSAGE / INVALID_FORMAT / NO_SIGNATURE / PUBKEY_NOT_FOUND
506
622
  * / SIGNATURE_INVALID / NO_SHA256_FIELD / ERROR_*) maps to null.
507
623
  */
508
- fun verifyGPGAndExtractSha256(signature: String): String? {
509
- val result = BundleCryptoCore.verifyGpgCleartext(signature)
624
+ @JvmOverloads
625
+ fun verifyGPGAndExtractSha256(
626
+ signature: String,
627
+ expectedAppVersion: String? = null,
628
+ expectedBundleVersion: String? = null,
629
+ expectedAppType: String? = "android",
630
+ ): String? {
631
+ val result = BundleCryptoCore.verifyGpgCleartext(
632
+ signature,
633
+ expectedAppVersion = expectedAppVersion,
634
+ expectedBundleVersion = expectedBundleVersion,
635
+ expectedAppType = expectedAppType,
636
+ )
510
637
  if (!result.valid) {
511
638
  OneKeyLog.error("BundleUpdate", "GPG verification failed: ${result.reason}")
512
639
  return null
@@ -514,12 +641,19 @@ object BundleUpdateStoreAndroid {
514
641
  return result.sha256
515
642
  }
516
643
 
517
- fun validateMetadataFileSha256(context: Context, currentBundleVersion: String, signature: String?): Boolean {
644
+ fun validateMetadataFileSha256(context: Context, currentBundleVersion: String, signature: String?, expectedAppVersion: String, expectedBundleVersion: String): Boolean {
518
645
  val metadataFilePath = getMetadataFilePath(context, currentBundleVersion) ?: run {
519
646
  OneKeyLog.debug("BundleUpdate", "metadataFilePath is null")
520
647
  return false
521
648
  }
522
- val extractedSha256 = readMetadataFileSha256(signature)
649
+ if (!isSafeVersionString(expectedAppVersion) || !isSafeVersionString(expectedBundleVersion) ||
650
+ currentBundleVersion != "$expectedAppVersion-$expectedBundleVersion") return false
651
+ val extractedSha256 = readMetadataFileSha256(
652
+ signature,
653
+ expectedAppVersion = expectedAppVersion,
654
+ expectedBundleVersion = expectedBundleVersion,
655
+ expectedAppType = "android",
656
+ )
523
657
  if (extractedSha256.isNullOrEmpty()) return false
524
658
  val calculated = calculateSHA256(metadataFilePath) ?: return false
525
659
  return secureCompare(calculated, extractedSha256)
@@ -717,7 +851,7 @@ object BundleUpdateStoreAndroid {
717
851
  private fun getValidatedCurrentBundleInfo(context: Context): ValidatedBundleInfo? {
718
852
  processPreLaunchPendingTask(context)
719
853
  return try {
720
- val currentAppVersion = getAppVersion(context)
854
+ val currentAppVersion = getAppVersion(context) ?: return null
721
855
  val currentBundleVersion = getCurrentBundleVersion(context) ?: run {
722
856
  OneKeyLog.warn("BundleUpdate", "getJsBundlePath: no currentBundleVersion stored")
723
857
  invalidateValidatedBundleInfoCache()
@@ -775,11 +909,12 @@ object BundleUpdateStoreAndroid {
775
909
  val signature = readSignatureFile(context, currentBundleVersion)
776
910
  OneKeyLog.debug("BundleUpdate", "getJsBundlePath: signatureLength=${signature.length}")
777
911
 
778
- val devSettingsEnabled = if (BuildConfig.ALLOW_SKIP_GPG_VERIFICATION) isDevSettingsEnabled(context) else false
779
- if (devSettingsEnabled) {
780
- OneKeyLog.warn("BundleUpdate", "Startup SHA256 validation skipped (DevSettings enabled)")
912
+ val skipGPG = BuildConfig.ALLOW_SKIP_GPG_VERIFICATION && isDevSettingsEnabled(context) && isSkipGPGEnabled(context)
913
+ if (skipGPG) {
914
+ OneKeyLog.warn("BundleUpdate", "Startup SHA256 validation skipped (DevSettings + skip-GPG enabled)")
781
915
  }
782
- if (!devSettingsEnabled && !validateMetadataFileSha256(context, currentBundleVersion, signature)) {
916
+ val currentBundleRelease = bundleVersionInFolder(currentBundleVersion, currentAppVersion) ?: return null
917
+ if (!skipGPG && !validateMetadataFileSha256(context, currentBundleVersion, signature, currentAppVersion, currentBundleRelease)) {
783
918
  OneKeyLog.warn("BundleUpdate", "getJsBundlePath: validateMetadataFileSha256 failed, signatureLength=${signature.length}")
784
919
  return null
785
920
  }
@@ -1066,6 +1201,7 @@ object BundleUpdateStoreAndroid {
1066
1201
  fun deleteDir(dir: File): Boolean = deleteDirectory(dir)
1067
1202
 
1068
1203
  private const val MAX_UNZIPPED_SIZE = 512L * 1024 * 1024 // 512 MB limit
1204
+ private const val MAX_ZIP_ENTRIES = 4096
1069
1205
 
1070
1206
  fun unzipFile(zipFilePath: String, destDirectory: String) {
1071
1207
  val destDir = File(destDirectory)
@@ -1073,9 +1209,14 @@ object BundleUpdateStoreAndroid {
1073
1209
 
1074
1210
  val destDirPath: Path = Paths.get(destDir.canonicalPath)
1075
1211
  var totalBytesWritten = 0L
1212
+ var entryCount = 0
1076
1213
  ZipInputStream(FileInputStream(zipFilePath)).use { zipIn ->
1077
1214
  var entry: ZipEntry? = zipIn.nextEntry
1078
1215
  while (entry != null) {
1216
+ entryCount++
1217
+ if (entryCount > MAX_ZIP_ENTRIES) {
1218
+ throw SecurityException("Zip entry count exceeds maximum allowed ($MAX_ZIP_ENTRIES)")
1219
+ }
1079
1220
  val outFile = File(destDir, entry.name)
1080
1221
  val outPath: Path = Paths.get(outFile.canonicalPath)
1081
1222
  if (!outPath.startsWith(destDirPath)) {
@@ -1135,7 +1276,9 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1135
1276
  // in-memory map, a crashed run leaves no stale lock — the entry is gone on
1136
1277
  // relaunch, giving inherent stale-lock recovery (OCDS §5.8).
1137
1278
  private val activeDownloads =
1138
- ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
1279
+ ConcurrentHashMap<String, Pair<ConcurrentRangeDownloader.CancelHandle, CountDownLatch>>()
1280
+ private val clearDownloadsLock = Any()
1281
+ private var clearingDownloads = false
1139
1282
  private val httpClient = OkHttpClient.Builder()
1140
1283
  // OCDS §5.4 timeouts. `readTimeout` is the primary inter-byte stall
1141
1284
  // window: OkHttp raises SocketTimeoutException when no bytes arrive within
@@ -1148,7 +1291,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1148
1291
  .addNetworkInterceptor { chain ->
1149
1292
  val req = chain.request()
1150
1293
  if (!req.url.isHttps) {
1151
- throw java.io.IOException("Redirect to non-HTTPS URL is not allowed: ${req.url}")
1294
+ throw SecurityException("Redirect to non-HTTPS URL is not allowed: ${req.url}")
1152
1295
  }
1153
1296
  chain.proceed(req)
1154
1297
  }
@@ -1165,33 +1308,16 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1165
1308
  }
1166
1309
  }
1167
1310
 
1168
- /**
1169
- * Returns a low-cardinality, path-free tag describing why a download
1170
- * failed. Used in the JS-facing `update/error` event so listeners
1171
- * never observe Android's `/data/data/<pkg>/...` or `/data/user/<u>/<pkg>/...`
1172
- * paths that FileNotFoundException / IOException embed in `e.message`.
1173
- *
1174
- * Recognized payloads (same shape extractUpdateErrorCode parses):
1175
- * - "Bundle SHA256 verification failed: <REASON>" → preserved verbatim;
1176
- * JS extractor maps to SHA256_<REASON>.
1177
- * - "HTTP <code>" / "HTTP 416 ..." → preserved verbatim; maps to HTTP_<code>.
1178
- * - "Already downloading" / "Invalid version string format" /
1179
- * "Bundle download URL must use HTTPS" → preserved verbatim; the
1180
- * hooks unrecoverable-list matches them by exact substring.
1181
- * - Anything else → "IO_<exceptionClassName>" so the JS extractor
1182
- * splits the bucket on exception class without leaking the message.
1183
- */
1184
- private fun sanitizeErrorMessageForEvent(e: Exception): String {
1185
- val msg = e.message ?: return "IO_${e.javaClass.simpleName}"
1186
- if (msg.startsWith("Bundle SHA256 verification failed:")) return msg
1187
- if (msg.startsWith("HTTP ")) return msg
1188
- if (msg == "Already downloading" ||
1189
- msg == "Invalid version string format" ||
1190
- msg == "Bundle download URL must use HTTPS" ||
1191
- msg == "Empty response body" ||
1192
- msg == "Failed to finalize download"
1193
- ) return msg
1194
- return "IO_${e.javaClass.simpleName}"
1311
+ /** Keep raw exceptions in local logs and send only the fixed update wire to JS. */
1312
+ private fun <T> updatePromise(step: String, work: suspend () -> T): Promise<T> {
1313
+ return Promise.async {
1314
+ try {
1315
+ work()
1316
+ } catch (e: Exception) {
1317
+ OneKeyLog.error("BundleUpdate", "$step failed: ${e.javaClass.simpleName}: ${e.message}")
1318
+ throw bundleUpdateError(e, step)
1319
+ }
1320
+ }
1195
1321
  }
1196
1322
 
1197
1323
  override fun addDownloadListener(callback: (BundleDownloadEvent) -> Unit): Double {
@@ -1239,7 +1365,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1239
1365
  }
1240
1366
 
1241
1367
  override fun downloadBundle(params: BundleDownloadParams): Promise<BundleDownloadResult> {
1242
- return Promise.async {
1368
+ return updatePromise("download") {
1243
1369
  // Per-dest single-flight is acquired below once `filePath` is known.
1244
1370
  // Input validation (version/HTTPS) runs FIRST and is intentionally
1245
1371
  // NOT gated by the lock — a malformed request must fail the same way
@@ -1247,6 +1373,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1247
1373
  // lock entry it then has to unwind.
1248
1374
  var acquiredKey: String? = null
1249
1375
  var cancelHandle: ConcurrentRangeDownloader.CancelHandle? = null
1376
+ var completionLatch: CountDownLatch? = null
1377
+ var duplicateDownload = false
1250
1378
 
1251
1379
  try {
1252
1380
  val context = getContext()
@@ -1260,12 +1388,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1260
1388
  if (!BundleUpdateStoreAndroid.isSafeVersionString(appVersion) ||
1261
1389
  !BundleUpdateStoreAndroid.isSafeVersionString(bundleVersion)) {
1262
1390
  OneKeyLog.error("BundleUpdate", "downloadBundle: invalid version string format: appVersion=$appVersion, bundleVersion=$bundleVersion")
1263
- throw Exception("Invalid version string format")
1391
+ throw BundleUpdateError("INVALID_ARGUMENT", "download")
1264
1392
  }
1265
1393
 
1266
1394
  if (!downloadUrl.startsWith("https://")) {
1267
1395
  OneKeyLog.error("BundleUpdate", "downloadBundle: URL is not HTTPS: $downloadUrl")
1268
- throw Exception("Bundle download URL must use HTTPS")
1396
+ throw BundleUpdateError("SECURITY_REJECTED", "download")
1269
1397
  }
1270
1398
 
1271
1399
  val fileName = "$appVersion-$bundleVersion.zip"
@@ -1282,15 +1410,24 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1282
1410
  // concurrently. The handle is passed to the concurrent downloader so
1283
1411
  // clearDownload/clearBundle can stop its workers before deleting files.
1284
1412
  val handle = ConcurrentRangeDownloader.CancelHandle()
1285
- if (activeDownloads.putIfAbsent(filePath, handle) != null) {
1413
+ val finished = CountDownLatch(1)
1414
+ val registration = handle to finished
1415
+ val wasActive = synchronized(activeDownloads) {
1416
+ clearingDownloads || activeDownloads.putIfAbsent(filePath, registration) != null
1417
+ }
1418
+ if (wasActive) {
1286
1419
  OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading $filePath")
1287
- throw Exception("Already downloading")
1420
+ duplicateDownload = true
1421
+ throw BundleUpdateError("INVALID_ARGUMENT", "download")
1288
1422
  }
1289
1423
  acquiredKey = filePath
1290
1424
  cancelHandle = handle
1425
+ completionLatch = finished
1426
+ fun ensureNotCancelled() {
1427
+ if (handle.aborted.get()) throw BundleUpdateError("CANCELLED", "download")
1428
+ }
1291
1429
 
1292
1430
  val result = BundleDownloadResult(
1293
- downloadedFile = filePath,
1294
1431
  downloadUrl = downloadUrl,
1295
1432
  latestVersion = appVersion,
1296
1433
  bundleVersion = bundleVersion,
@@ -1304,6 +1441,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1304
1441
  if (downloadedFile.exists()) {
1305
1442
  OneKeyLog.info("BundleUpdate", "downloadBundle: file already exists, verifying SHA256...")
1306
1443
  if (verifyBundleSHA256(filePath, sha256)) {
1444
+ ensureNotCancelled()
1307
1445
  OneKeyLog.info("BundleUpdate", "downloadBundle: existing file SHA256 valid, skipping download")
1308
1446
  // Final file is authoritative — drop any stale concurrent
1309
1447
  // partial + segment files left by an earlier interrupted
@@ -1318,8 +1456,9 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1318
1456
  // where a second downloadBundle for this dest could otherwise
1319
1457
  // pass the single-flight guard and run concurrently.
1320
1458
  Thread.sleep(1000)
1459
+ ensureNotCancelled()
1321
1460
  sendEvent("update/complete")
1322
- return@async result
1461
+ return@updatePromise result
1323
1462
  } else {
1324
1463
  OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading")
1325
1464
  downloadedFile.delete()
@@ -1337,6 +1476,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1337
1476
  // FALLBACK we fall through to the original single-stream path below.
1338
1477
  // Transient errors bubble to the outer catch (the concurrent
1339
1478
  // downloader keeps its partial + manifest for resume).
1479
+ ensureNotCancelled()
1340
1480
  sendEvent("update/start")
1341
1481
  run {
1342
1482
  // onProgress is invoked concurrently by all 8 worker threads, so
@@ -1350,6 +1490,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1350
1490
  httpClient = httpClient,
1351
1491
  log = { msg -> OneKeyLog.info("BundleUpdate", msg) },
1352
1492
  ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total ->
1493
+ if (handle.aborted.get()) return@download
1353
1494
  if (total > 0) {
1354
1495
  val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
1355
1496
  val prev = concurrentProgress.get()
@@ -1358,24 +1499,30 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1358
1499
  }
1359
1500
  }
1360
1501
  }
1502
+ ensureNotCancelled()
1361
1503
  if (concurrentOutcome == ConcurrentRangeDownloader.Outcome.COMPLETED) {
1362
1504
  // Promote .partial -> final, then SHA256 verify — identical
1363
1505
  // finalize to the single-stream path below.
1364
1506
  if (downloadedFile.exists()) downloadedFile.delete()
1365
1507
  if (!File(partialFilePath).renameTo(downloadedFile)) {
1366
1508
  OneKeyLog.error("BundleUpdate", "downloadBundle: concurrent rename .partial -> final failed")
1367
- throw Exception("Failed to finalize download")
1509
+ throw BundleUpdateError("STORAGE_ERROR", "download")
1368
1510
  }
1369
1511
  OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent download finished, verifying SHA256...")
1370
- if (!verifyBundleSHA256(filePath, sha256)) {
1512
+ ensureNotCancelled()
1513
+ val calculatedHash = BundleUpdateStoreAndroid.calculateSHA256(filePath)
1514
+ ?: throw BundleUpdateError("STORAGE_ERROR", "download")
1515
+ if (!BundleUpdateStoreAndroid.secureCompare(calculatedHash, sha256)) {
1516
+ ensureNotCancelled()
1371
1517
  val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1372
1518
  File(filePath).delete()
1373
1519
  OneKeyLog.error("BundleUpdate", "downloadBundle: concurrent SHA256 verification failed, reason=$reason")
1374
- throw Exception("Bundle SHA256 verification failed: $reason")
1520
+ throw BundleUpdateError("INTEGRITY_FAILED", "download")
1375
1521
  }
1522
+ ensureNotCancelled()
1376
1523
  sendEvent("update/complete")
1377
1524
  OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent completed successfully, appVersion=$appVersion, bundleVersion=$bundleVersion")
1378
- return@async result
1525
+ return@updatePromise result
1379
1526
  }
1380
1527
  OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent not used, falling back to single-stream")
1381
1528
  }
@@ -1402,13 +1549,15 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1402
1549
  OneKeyLog.info("BundleUpdate", "downloadBundle: partial matches expected size ($partialSize), trying promote+verify")
1403
1550
  if (downloadedFile.exists()) downloadedFile.delete()
1404
1551
  if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) {
1552
+ ensureNotCancelled()
1405
1553
  OneKeyLog.info("BundleUpdate", "downloadBundle: recovered crashed-before-rename bundle, skipping download")
1406
1554
  // The `finally` removes the per-dest lock after this
1407
1555
  // return; the lock therefore spans the skip delay,
1408
1556
  // same as the existing-file cache-hit path above.
1409
1557
  Thread.sleep(1000)
1558
+ ensureNotCancelled()
1410
1559
  sendEvent("update/complete")
1411
- return@async result
1560
+ return@updatePromise result
1412
1561
  } else {
1413
1562
  OneKeyLog.warn("BundleUpdate", "downloadBundle: promote+verify failed, discarding both files")
1414
1563
  if (downloadedFile.exists()) downloadedFile.delete()
@@ -1436,6 +1585,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1436
1585
  requestBuilder.addHeader("Range", "bytes=$partialBytes-")
1437
1586
  }
1438
1587
  val response = httpClient.newCall(requestBuilder.build()).execute()
1588
+ ensureNotCancelled()
1439
1589
 
1440
1590
  // 416 Range Not Satisfiable: server says our offset is past the
1441
1591
  // file length. Two sub-cases distinguishable from
@@ -1454,9 +1604,10 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1454
1604
  OneKeyLog.info("BundleUpdate", "downloadBundle: HTTP 416 with total==$totalFromHeader matches partial, attempting promote+verify")
1455
1605
  if (downloadedFile.exists()) downloadedFile.delete()
1456
1606
  if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) {
1607
+ ensureNotCancelled()
1457
1608
  OneKeyLog.info("BundleUpdate", "downloadBundle: 416 recovery succeeded, skipping download")
1458
1609
  sendEvent("update/complete")
1459
- return@async result
1610
+ return@updatePromise result
1460
1611
  }
1461
1612
  OneKeyLog.warn("BundleUpdate", "downloadBundle: 416 recovery failed verify, discarding")
1462
1613
  if (downloadedFile.exists()) downloadedFile.delete()
@@ -1464,10 +1615,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1464
1615
  OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt")
1465
1616
  if (partialFile.exists()) partialFile.delete()
1466
1617
  for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete()
1467
- // Don't pre-emit update/error here; the outer catch is the
1468
- // single source of error events. sanitizeErrorMessageForEvent
1469
- // recognizes "HTTP " prefix and forwards this string verbatim.
1470
- throw Exception("HTTP 416 (range not satisfiable)")
1618
+ // The outer catch emits one error event for this failure.
1619
+ throw BundleUpdateError("HTTP_ERROR", "download", 416)
1471
1620
  }
1472
1621
 
1473
1622
  val expectsResume = partialBytes > 0
@@ -1477,7 +1626,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1477
1626
  OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}")
1478
1627
  response.close()
1479
1628
  // outer catch is the single source of update/error events.
1480
- throw Exception("HTTP ${response.code}")
1629
+ throw BundleUpdateError("HTTP_ERROR", "download", response.code)
1481
1630
  }
1482
1631
 
1483
1632
  // Server can ignore `Range` and return the full body with 200; in
@@ -1521,7 +1670,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1521
1670
  // consumption would close it for us.
1522
1671
  val body = response.body ?: run {
1523
1672
  response.close()
1524
- throw Exception("Empty response body")
1673
+ throw BundleUpdateError("NETWORK_ERROR", "download")
1525
1674
  }
1526
1675
  val contentLength = body.contentLength()
1527
1676
  // Total size of the whole resource (not the slice). On 206 prefer
@@ -1547,6 +1696,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1547
1696
  }
1548
1697
 
1549
1698
  // Append iff server granted us a 206 partial; otherwise overwrite.
1699
+ val maxDownloadBytes = 512L * 1024 * 1024
1550
1700
  val appendMode = isPartialResponse
1551
1701
  var totalBytesRead = if (isPartialResponse) partialBytes else 0L
1552
1702
  body.byteStream().use { inputStream ->
@@ -1556,8 +1706,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1556
1706
 
1557
1707
  var prevProgress = -1
1558
1708
  while (inputStream.read(buffer).also { bytesRead = it } != -1) {
1559
- outputStream.write(buffer, 0, bytesRead)
1709
+ ensureNotCancelled()
1560
1710
  totalBytesRead += bytesRead
1711
+ if (totalBytesRead > maxDownloadBytes) {
1712
+ throw BundleUpdateError("SECURITY_REJECTED", "download")
1713
+ }
1714
+ outputStream.write(buffer, 0, bytesRead)
1561
1715
  if (totalSize > 0) {
1562
1716
  val progress = ((totalBytesRead * 100) / totalSize).toInt().coerceIn(0, 100)
1563
1717
  if (progress != prevProgress) {
@@ -1571,6 +1725,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1571
1725
  }
1572
1726
 
1573
1727
  val partialAfter = File(partialFilePath)
1728
+ ensureNotCancelled()
1574
1729
  OneKeyLog.info(
1575
1730
  "BundleUpdate",
1576
1731
  "downloadBundle: download finished, totalBytesRead=$totalBytesRead, partialExists=${partialAfter.exists()}, partialSize=${if (partialAfter.exists()) partialAfter.length() else -1}, finalizing..."
@@ -1583,63 +1738,39 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1583
1738
  if (!partialAfter.renameTo(downloadedFile)) {
1584
1739
  OneKeyLog.error("BundleUpdate", "downloadBundle: rename .partial -> final failed")
1585
1740
  // outer catch is the single source of update/error events;
1586
- // "Failed to finalize download" is in the verbatim allowlist.
1587
- throw Exception("Failed to finalize download")
1741
+ // The outer catch emits one error event for this failure.
1742
+ throw BundleUpdateError("STORAGE_ERROR", "download")
1588
1743
  }
1589
1744
 
1590
1745
  OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1591
- if (!verifyBundleSHA256(filePath, sha256)) {
1746
+ ensureNotCancelled()
1747
+ val calculatedHash = BundleUpdateStoreAndroid.calculateSHA256(filePath)
1748
+ ?: throw BundleUpdateError("STORAGE_ERROR", "download")
1749
+ if (!BundleUpdateStoreAndroid.secureCompare(calculatedHash, sha256)) {
1750
+ ensureNotCancelled()
1592
1751
  val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1593
1752
  File(filePath).delete()
1594
1753
  OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=$reason")
1595
- // outer catch emits the verbatim "Bundle SHA256 verification
1596
- // failed: <REASON>" payload (recognized by sanitize/JS).
1597
- throw Exception("Bundle SHA256 verification failed: $reason")
1754
+ // The outer catch emits the fixed integrity failure wire.
1755
+ throw BundleUpdateError("INTEGRITY_FAILED", "download")
1598
1756
  }
1599
1757
 
1758
+ ensureNotCancelled()
1600
1759
  sendEvent("update/complete")
1601
1760
  OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=$appVersion, bundleVersion=$bundleVersion")
1602
1761
  result
1603
1762
  } catch (e: Exception) {
1604
- // Keep the rich detail in OneKeyLog (local-only). The JS
1605
- // event channel must NOT carry e.message verbatim — Android
1606
- // FileNotFoundException etc. embed the full /data/user/.../
1607
- // path including the package identifier, and downstream
1608
- // listeners would forward that into analytics. Emit only the
1609
- // sanitized tag; mirrors the iOS sendEvent payload at
1610
- // ReactNativeBundleUpdate.swift's "update/error" sites and
1611
- // matches the verbatim guarantees documented on
1612
- // sanitizeErrorMessageForEvent.
1613
1763
  OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}")
1614
- val sanitized = sanitizeErrorMessageForEvent(e)
1615
- // An INTENTIONAL cancel (clearDownload/clearBundle flipped THIS
1616
- // run's CancelHandle, which aborts the workers → they throw
1617
- // IOException("aborted")) is NOT a download failure — the caller
1618
- // deliberately tore the run down. Before the cancel-then-delete
1619
- // path existed, clearing never aborted an in-flight run, so no
1620
- // `update/error` was emitted; preserve that. Detect it off the
1621
- // handle's `aborted` flag rather than the message, because
1622
- // sanitizeErrorMessageForEvent collapses "aborted" to the generic
1623
- // "IO_IOException" tag and would be indistinguishable from a real
1624
- // I/O failure.
1625
1764
  val intentionallyCancelled = cancelHandle?.aborted?.get() == true
1626
- // A duplicate-dest single-flight rejection ("Already downloading")
1627
- // is NOT a failure of any in-flight download — it means another
1628
- // run already owns this dest. The original global-boolean guard
1629
- // threw this BEFORE the try, so no `update/error` was emitted;
1630
- // preserve that so listeners don't observe a spurious error for the
1631
- // rejected caller while the real download is still progressing.
1632
- if (sanitized != "Already downloading" && !intentionallyCancelled) {
1633
- sendEvent("update/error", message = sanitized)
1765
+ val updateError = if (intentionallyCancelled) {
1766
+ BundleUpdateError("CANCELLED", "download")
1767
+ } else {
1768
+ bundleUpdateError(e, "download")
1769
+ }
1770
+ if (!duplicateDownload && !intentionallyCancelled) {
1771
+ sendEvent("update/error", message = updateError.message.orEmpty())
1634
1772
  }
1635
- // Rethrow with the same sanitized message so the Promise
1636
- // rejection surfacing to JS carries no /data/user/<u>/<pkg>/
1637
- // paths. Without this rewrap, FileNotFoundException etc.
1638
- // would re-leak via Promise.reject's message channel even
1639
- // though the event payload was already sanitized. Keep
1640
- // the original exception as `cause` so OneKeyLog (and any
1641
- // native crash reporter) still sees the full chain.
1642
- throw Exception(sanitized, e)
1773
+ throw updateError
1643
1774
  } finally {
1644
1775
  // Release THIS run's per-dest single-flight lock. Keyed remove
1645
1776
  // (only if we actually acquired it — input-validation failures
@@ -1648,8 +1779,10 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1648
1779
  // the same dest may have re-registered after a concurrent cancel.
1649
1780
  val key = acquiredKey
1650
1781
  val handle = cancelHandle
1651
- if (key != null && handle != null) {
1652
- activeDownloads.remove(key, handle)
1782
+ val finished = completionLatch
1783
+ finished?.countDown()
1784
+ if (key != null && handle != null && finished != null) {
1785
+ activeDownloads.remove(key, handle to finished)
1653
1786
  }
1654
1787
  }
1655
1788
  }
@@ -1674,31 +1807,73 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1674
1807
  return isValid
1675
1808
  }
1676
1809
 
1810
+ private fun isSafeVersionString(version: String): Boolean {
1811
+ return BundleUpdateStoreAndroid.isSafeVersionString(version)
1812
+ }
1813
+
1677
1814
  override fun downloadBundleASC(params: BundleDownloadASCParams): Promise<Unit> {
1678
- return Promise.async {
1679
- val context = getContext()
1815
+ return updatePromise("downloadASC") {
1680
1816
  val appVersion = params.latestVersion
1681
1817
  val bundleVersion = params.bundleVersion
1682
1818
  val signature = params.signature
1819
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
1820
+ throw BundleUpdateError("INVALID_ARGUMENT", "downloadASC")
1821
+ }
1822
+ if (signature.toByteArray(Charsets.UTF_8).size > 64 * 1024) {
1823
+ throw BundleUpdateError("INVALID_ARGUMENT", "downloadASC")
1824
+ }
1683
1825
 
1826
+ val context = getContext()
1684
1827
  OneKeyLog.info("BundleUpdate", "downloadBundleASC: appVersion=$appVersion, bundleVersion=$bundleVersion, signatureLength=${signature.length}")
1685
1828
 
1686
1829
  val storageKey = "$appVersion-$bundleVersion"
1687
- BundleUpdateStoreAndroid.writeSignatureFile(context, storageKey, signature)
1688
-
1689
- OneKeyLog.info("BundleUpdate", "downloadBundleASC: stored signature for key=$storageKey")
1830
+ val ascBaseCanonical = File(BundleUpdateStoreAndroid.getAscDir(context)).canonicalPath
1831
+ val signatureFile = File(BundleUpdateStoreAndroid.getSignatureFilePath(context, storageKey))
1832
+ if (Files.isSymbolicLink(signatureFile.toPath()) ||
1833
+ !signatureFile.canonicalPath.startsWith(ascBaseCanonical + File.separator)
1834
+ ) {
1835
+ throw BundleUpdateError("SECURITY_REJECTED", "downloadASC")
1836
+ }
1690
1837
  }
1691
1838
  }
1692
1839
 
1693
1840
  override fun verifyBundleASC(params: BundleVerifyASCParams): Promise<Unit> {
1694
- return Promise.async {
1695
- val context = getContext()
1696
- val filePath = params.downloadedFile
1841
+ return updatePromise("verifyASC") {
1697
1842
  val sha256 = params.sha256
1698
1843
  val appVersion = params.latestVersion
1699
1844
  val bundleVersion = params.bundleVersion
1700
1845
  val signature = params.signature
1701
1846
 
1847
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
1848
+ throw BundleUpdateError("INVALID_ARGUMENT", "verifyASC")
1849
+ }
1850
+
1851
+ val context = getContext()
1852
+ val filePath = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context),
1853
+ "$appVersion-$bundleVersion.zip").absolutePath
1854
+ val downloadBaseCanonical = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context)).canonicalPath
1855
+ val resolvedFilePath = File(filePath).canonicalPath
1856
+ if (!resolvedFilePath.startsWith(downloadBaseCanonical + File.separator)) {
1857
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: ZIP outside download directory: $resolvedFilePath")
1858
+ throw BundleUpdateError("SECURITY_REJECTED", "verifyASC")
1859
+ }
1860
+ if (!File(resolvedFilePath).isFile) {
1861
+ throw BundleUpdateError("PACKAGE_MISSING", "verifyASC")
1862
+ }
1863
+
1864
+ val folderName = "$appVersion-$bundleVersion"
1865
+ val isInstalled = BundleUpdateStoreAndroid.getCurrentBundleVersion(context) == folderName ||
1866
+ BundleUpdateStoreAndroid.readFallbackUpdateBundleDataFile(context).any {
1867
+ it["appVersion"] == appVersion && it["bundleVersion"] == bundleVersion
1868
+ }
1869
+ val bundleBaseCanonical = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
1870
+ val destinationDir = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
1871
+ val destination = destinationDir.canonicalPath
1872
+ if (!destination.startsWith(bundleBaseCanonical + File.separator)) {
1873
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: destination outside bundle directory: $destination")
1874
+ throw BundleUpdateError("SECURITY_REJECTED", "verifyASC")
1875
+ }
1876
+
1702
1877
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: appVersion=$appVersion, bundleVersion=$bundleVersion, file=$filePath, signatureLength=${signature.length}")
1703
1878
 
1704
1879
  // GPG verification skipped only when both DevSettings and skip-GPG toggle are enabled
@@ -1706,44 +1881,69 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1706
1881
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: GPG check: skipGPG=$skipGPG")
1707
1882
 
1708
1883
  if (!skipGPG) {
1884
+ OneKeyLog.info("BundleUpdate", "verifyBundleASC: pre-verifying GPG signature before extraction...")
1885
+ if (BundleUpdateStoreAndroid.readMetadataFileSha256(
1886
+ signature,
1887
+ expectedAppVersion = appVersion,
1888
+ expectedBundleVersion = bundleVersion,
1889
+ expectedAppType = "android",
1890
+ ).isNullOrEmpty()
1891
+ ) {
1892
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: pre-extraction GPG signature verification failed")
1893
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyASC")
1894
+ }
1895
+
1709
1896
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...")
1710
- if (!verifyBundleSHA256(filePath, sha256)) {
1711
- // Promote the SHA256 subtype (FILE_TRUNCATED / OOM /
1712
- // IO_<class> / MISMATCH) into the thrown message so
1713
- // extractUpdateErrorCode in the JS layer can split
1714
- // this bucket the same way the download stage does.
1897
+ val calculatedHash = BundleUpdateStoreAndroid.calculateSHA256(resolvedFilePath)
1898
+ ?: throw BundleUpdateError("STORAGE_ERROR", "verifyASC")
1899
+ if (!BundleUpdateStoreAndroid.secureCompare(calculatedHash, sha256)) {
1900
+ // Keep the detailed hash reason in the local log only.
1715
1901
  val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1716
1902
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=$filePath, reason=$reason")
1717
- throw Exception("Bundle SHA256 verification failed: $reason")
1903
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyASC")
1718
1904
  }
1719
1905
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK")
1720
1906
  } else {
1721
1907
  OneKeyLog.warn("BundleUpdate", "verifyBundleASC: SHA256 + GPG verification skipped (DevSettings enabled)")
1722
1908
  }
1723
1909
 
1724
- val folderName = "$appVersion-$bundleVersion"
1725
- val destination = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName).absolutePath
1726
- val destinationDir = File(destination)
1910
+ if (isInstalled) {
1911
+ if (!skipGPG && !BundleUpdateStoreAndroid.validateMetadataFileSha256(context, folderName, signature, appVersion, bundleVersion)) {
1912
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyASC")
1913
+ }
1914
+ val metadata = BundleUpdateStoreAndroid.parseMetadataJson(
1915
+ BundleUpdateStoreAndroid.readFileContent(File(destination, "metadata.json"))
1916
+ )
1917
+ if (!BundleUpdateStoreAndroid.validateAllFilesInDir(context, destination, metadata, appVersion, bundleVersion) ||
1918
+ !BundleUpdateStoreAndroid.validateBundlePairCompatibility(context, destination, metadata)
1919
+ ) {
1920
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyASC")
1921
+ }
1922
+ return@updatePromise
1923
+ }
1727
1924
 
1728
1925
  try {
1926
+ if (destinationDir.exists()) {
1927
+ BundleUpdateStoreAndroid.deleteDir(destinationDir)
1928
+ }
1729
1929
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: extracting zip to $destination...")
1730
- BundleUpdateStoreAndroid.unzipFile(filePath, destination)
1930
+ BundleUpdateStoreAndroid.unzipFile(resolvedFilePath, destination)
1731
1931
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: extraction completed")
1732
1932
 
1733
1933
  val metadataFile = File(destination, "metadata.json")
1734
1934
  if (!metadataFile.exists()) {
1735
1935
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: metadata.json not found after extraction")
1736
1936
  BundleUpdateStoreAndroid.deleteDir(destinationDir)
1737
- throw Exception("Failed to read metadata.json")
1937
+ throw BundleUpdateError("PACKAGE_MISSING", "verifyASC")
1738
1938
  }
1739
1939
 
1740
1940
  val currentBundleVersion = "$appVersion-$bundleVersion"
1741
1941
  if (!skipGPG) {
1742
1942
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: validating GPG signature for metadata...")
1743
- if (!BundleUpdateStoreAndroid.validateMetadataFileSha256(context, currentBundleVersion, signature)) {
1943
+ if (!BundleUpdateStoreAndroid.validateMetadataFileSha256(context, currentBundleVersion, signature, appVersion, bundleVersion)) {
1744
1944
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: GPG signature verification failed")
1745
1945
  BundleUpdateStoreAndroid.deleteDir(destinationDir)
1746
- throw Exception("Bundle signature verification failed")
1946
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyASC")
1747
1947
  }
1748
1948
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: GPG signature verified OK")
1749
1949
  }
@@ -1754,12 +1954,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1754
1954
  if (!BundleUpdateStoreAndroid.validateAllFilesInDir(context, destination, metadata, appVersion, bundleVersion)) {
1755
1955
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: file integrity check failed")
1756
1956
  BundleUpdateStoreAndroid.deleteDir(destinationDir)
1757
- throw Exception("Extracted files verification against metadata failed")
1957
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyASC")
1758
1958
  }
1759
1959
  if (!BundleUpdateStoreAndroid.validateBundlePairCompatibility(context, destination, metadata)) {
1760
1960
  OneKeyLog.error("BundleUpdate", "verifyBundleASC: bundle pair compatibility check failed")
1761
1961
  BundleUpdateStoreAndroid.deleteDir(destinationDir)
1762
- throw Exception("Bundle pair compatibility check failed")
1962
+ throw BundleUpdateError("INCOMPATIBLE", "verifyASC")
1763
1963
  }
1764
1964
 
1765
1965
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: all verifications passed, appVersion=$appVersion, bundleVersion=$bundleVersion")
@@ -1773,23 +1973,38 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1773
1973
  }
1774
1974
 
1775
1975
  override fun verifyBundle(params: BundleVerifyParams): Promise<Unit> {
1776
- return Promise.async {
1777
- val filePath = params.downloadedFile
1976
+ return updatePromise("verifyPackage") {
1778
1977
  val sha256 = params.sha256
1779
1978
  val appVersion = params.latestVersion
1780
1979
  val bundleVersion = params.bundleVersion
1781
1980
 
1981
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
1982
+ throw BundleUpdateError("INVALID_ARGUMENT", "verifyPackage")
1983
+ }
1984
+
1985
+ val context = getContext()
1986
+ val filePath = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context),
1987
+ "$appVersion-$bundleVersion.zip").absolutePath
1988
+ val downloadBaseCanonical = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context)).canonicalPath
1989
+ val resolvedFilePath = File(filePath).canonicalPath
1990
+ if (!resolvedFilePath.startsWith(downloadBaseCanonical + File.separator)) {
1991
+ throw BundleUpdateError("SECURITY_REJECTED", "verifyPackage")
1992
+ }
1993
+ if (!File(resolvedFilePath).isFile) {
1994
+ throw BundleUpdateError("PACKAGE_MISSING", "verifyPackage")
1995
+ }
1996
+
1782
1997
  OneKeyLog.info("BundleUpdate", "verifyBundle: appVersion=$appVersion, bundleVersion=$bundleVersion, file=$filePath")
1783
1998
 
1784
1999
  // Verify SHA256 of the downloaded file
1785
- val calculated = BundleUpdateStoreAndroid.calculateSHA256(filePath)
2000
+ val calculated = BundleUpdateStoreAndroid.calculateSHA256(resolvedFilePath)
1786
2001
  if (calculated == null) {
1787
2002
  OneKeyLog.error("BundleUpdate", "verifyBundle: failed to calculate SHA256 for file=$filePath")
1788
- throw Exception("Failed to calculate SHA256")
2003
+ throw BundleUpdateError("STORAGE_ERROR", "verifyPackage")
1789
2004
  }
1790
2005
  if (!BundleUpdateStoreAndroid.secureCompare(calculated, sha256)) {
1791
2006
  OneKeyLog.error("BundleUpdate", "verifyBundle: SHA256 mismatch, expected=${sha256.take(16)}..., got=${calculated.take(16)}...")
1792
- throw Exception("SHA256 verification failed")
2007
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyPackage")
1793
2008
  }
1794
2009
 
1795
2010
  OneKeyLog.info("BundleUpdate", "verifyBundle: SHA256 verified OK for appVersion=$appVersion, bundleVersion=$bundleVersion")
@@ -1802,12 +2017,16 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1802
2017
  // commits so a same-version reinstall (or any reader that re-cached
1803
2018
  // pre-commit data inside the race window) can't leave stale entries.
1804
2019
  BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1805
- return Promise.async {
1806
- val context = getContext()
2020
+ return updatePromise("install") {
1807
2021
  val appVersion = params.latestVersion
1808
2022
  val bundleVersion = params.bundleVersion
1809
2023
  val signature = params.signature
1810
2024
 
2025
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
2026
+ throw BundleUpdateError("INVALID_ARGUMENT", "install")
2027
+ }
2028
+
2029
+ val context = getContext()
1811
2030
  OneKeyLog.info("BundleUpdate", "installBundle: appVersion=$appVersion, bundleVersion=$bundleVersion, signatureLength=${signature.length}")
1812
2031
 
1813
2032
  // GPG verification skipped only when both DevSettings and skip-GPG toggle are enabled
@@ -1816,13 +2035,53 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1816
2035
 
1817
2036
  val folderName = "$appVersion-$bundleVersion"
1818
2037
  val currentFolderName = BundleUpdateStoreAndroid.getCurrentBundleVersion(context)
2038
+ val previousActiveBundleVersion = currentFolderName?.let {
2039
+ BundleUpdateStoreAndroid.bundleVersionInFolder(it, appVersion)
2040
+ }
1819
2041
  OneKeyLog.info("BundleUpdate", "installBundle: target=$folderName, current=$currentFolderName")
1820
2042
 
1821
- // Verify bundle directory exists
2043
+ // Verify bundle directory exists and is canonically confined inside getBundleDir(context)
2044
+ val bundleBaseCanonical = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
1822
2045
  val bundleDirPath = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
2046
+ if (Files.isSymbolicLink(bundleDirPath.toPath()) ||
2047
+ !bundleDirPath.canonicalPath.startsWith(bundleBaseCanonical + File.separator)
2048
+ ) {
2049
+ throw BundleUpdateError("SECURITY_REJECTED", "install")
2050
+ }
2051
+ val ascBaseCanonical = File(BundleUpdateStoreAndroid.getAscDir(context)).canonicalPath
2052
+ val sigFile = File(BundleUpdateStoreAndroid.getSignatureFilePath(context, folderName))
2053
+ if (Files.isSymbolicLink(sigFile.toPath()) ||
2054
+ !sigFile.canonicalPath.startsWith(ascBaseCanonical + File.separator)
2055
+ ) {
2056
+ throw BundleUpdateError("SECURITY_REJECTED", "install")
2057
+ }
1823
2058
  if (!bundleDirPath.exists()) {
1824
2059
  OneKeyLog.error("BundleUpdate", "installBundle: bundle directory not found: ${bundleDirPath.absolutePath}")
1825
- throw Exception("Bundle directory not found: $folderName")
2060
+ throw BundleUpdateError("PACKAGE_MISSING", "install")
2061
+ }
2062
+
2063
+ val mainBundleFile = File(bundleDirPath, "main.jsbundle.hbc")
2064
+ val metadataFile = File(bundleDirPath, "metadata.json")
2065
+ if (!mainBundleFile.exists() || !metadataFile.exists()) {
2066
+ OneKeyLog.error("BundleUpdate", "installBundle: required bundle files missing in: ${bundleDirPath.absolutePath}")
2067
+ throw BundleUpdateError("PACKAGE_MISSING", "install")
2068
+ }
2069
+
2070
+ if (!skipGPG) {
2071
+ if (signature.isEmpty() || !BundleUpdateStoreAndroid.validateMetadataFileSha256(context, folderName, signature, appVersion, bundleVersion)) {
2072
+ OneKeyLog.error("BundleUpdate", "installBundle: GPG signature verification failed for $folderName")
2073
+ throw BundleUpdateError("INTEGRITY_FAILED", "install")
2074
+ }
2075
+ }
2076
+
2077
+ val metadata = BundleUpdateStoreAndroid.parseMetadataJson(BundleUpdateStoreAndroid.readFileContent(metadataFile))
2078
+ if (!BundleUpdateStoreAndroid.validateAllFilesInDir(context, bundleDirPath.absolutePath, metadata, appVersion, bundleVersion)) {
2079
+ OneKeyLog.error("BundleUpdate", "installBundle: extracted file verification failed for $folderName")
2080
+ throw BundleUpdateError("INTEGRITY_FAILED", "install")
2081
+ }
2082
+ if (!BundleUpdateStoreAndroid.validateBundlePairCompatibility(context, bundleDirPath.absolutePath, metadata)) {
2083
+ OneKeyLog.error("BundleUpdate", "installBundle: bundle pair compatibility check failed for $folderName")
2084
+ throw BundleUpdateError("INCOMPATIBLE", "install")
1826
2085
  }
1827
2086
 
1828
2087
  val currentSignature = if (currentFolderName != null) BundleUpdateStoreAndroid.readSignatureFile(context, currentFolderName) else ""
@@ -1838,13 +2097,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1838
2097
  val fallbackData = BundleUpdateStoreAndroid.readFallbackUpdateBundleDataFile(context)
1839
2098
 
1840
2099
  if (!currentFolderName.isNullOrEmpty()) {
1841
- val lastDashIndex = currentFolderName.lastIndexOf("-")
1842
- if (lastDashIndex > 0) {
1843
- val curAppVersion = currentFolderName.substring(0, lastDashIndex)
1844
- val curBundleVersion = currentFolderName.substring(lastDashIndex + 1)
2100
+ val curBundleVersion = BundleUpdateStoreAndroid.bundleVersionInFolder(currentFolderName, appVersion)
2101
+ if (curBundleVersion != null) {
1845
2102
  if (currentSignature.isNotEmpty()) {
1846
2103
  fallbackData.add(mutableMapOf(
1847
- "appVersion" to curAppVersion,
2104
+ "appVersion" to appVersion,
1848
2105
  "bundleVersion" to curBundleVersion,
1849
2106
  "signature" to currentSignature
1850
2107
  ))
@@ -1853,16 +2110,20 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1853
2110
  }
1854
2111
 
1855
2112
  if (fallbackData.size > 3) {
1856
- val shifted = fallbackData.removeAt(0)
1857
- val shiftApp = shifted["appVersion"]
1858
- val shiftBundle = shifted["bundleVersion"]
2113
+ val removedBundle = fallbackData.removeAt(0)
2114
+ val shiftApp = removedBundle["appVersion"]
2115
+ val shiftBundle = removedBundle["bundleVersion"]
1859
2116
  if (shiftApp != null && shiftBundle != null) {
1860
2117
  val shiftFolderName = "$shiftApp-$shiftBundle"
1861
- val oldDir = File(BundleUpdateStoreAndroid.getBundleDir(context), shiftFolderName)
1862
- if (oldDir.exists()) {
1863
- BundleUpdateStoreAndroid.deleteDir(oldDir)
2118
+ if (shiftBundle != previousActiveBundleVersion && shiftFolderName != currentFolderName) {
2119
+ val oldDir = File(BundleUpdateStoreAndroid.getBundleDir(context), shiftFolderName)
2120
+ if (oldDir.exists()) {
2121
+ BundleUpdateStoreAndroid.deleteDir(oldDir)
2122
+ }
2123
+ BundleUpdateStoreAndroid.deleteSignatureFile(context, shiftFolderName)
2124
+ } else {
2125
+ OneKeyLog.info("BundleUpdate", "installBundle: skipping disk deletion of active bundle $shiftFolderName during eviction")
1864
2126
  }
1865
- BundleUpdateStoreAndroid.deleteSignatureFile(context, shiftFolderName)
1866
2127
  }
1867
2128
  }
1868
2129
 
@@ -1879,58 +2140,72 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1879
2140
  }
1880
2141
  }
1881
2142
 
1882
- // OCDS §5.8 cancel-then-delete. Stop every in-flight concurrent run's worker
1883
- // pool (flip its abort flag + shutdownNow) BEFORE the caller deletes the
1884
- // download directory. Without this, a live segment worker could re-create a
1885
- // `.segN` we just deleted (CRD streams into `.segN` via append). Removing each
1886
- // entry here also makes the dest immediately re-acquirable. The in-flight
1887
- // download's own `finally` does a value-checked remove, so racing it is safe.
1888
- private fun cancelAllActiveDownloads() {
1889
- val keys = activeDownloads.keys.toList()
1890
- for (key in keys) {
1891
- activeDownloads.remove(key)?.let {
1892
- OneKeyLog.info("BundleUpdate", "cancelling in-flight download: $key")
1893
- it.cancel()
2143
+ private fun clearDownloadFiles(context: Context, includeInstalled: Boolean) {
2144
+ synchronized(clearDownloadsLock) {
2145
+ try {
2146
+ val active = synchronized(activeDownloads) {
2147
+ clearingDownloads = true
2148
+ activeDownloads.entries.map { it.key to it.value }
2149
+ }
2150
+ active.forEach { (_, run) -> run.first.aborted.set(true) }
2151
+ active.forEach { (key, run) ->
2152
+ OneKeyLog.info("BundleUpdate", "cancelling in-flight download: $key")
2153
+ run.first.cancel()
2154
+ }
2155
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(65)
2156
+ for ((_, run) in active) {
2157
+ val remaining = deadline - System.nanoTime()
2158
+ val completed = try {
2159
+ remaining > 0 && run.second.await(remaining, TimeUnit.NANOSECONDS)
2160
+ } catch (e: InterruptedException) {
2161
+ Thread.currentThread().interrupt()
2162
+ false
2163
+ }
2164
+ if (!completed) throw BundleUpdateError("UNKNOWN", "cleanup")
2165
+ }
2166
+ synchronized(activeDownloads) {
2167
+ if (active.any { (key, run) ->
2168
+ val current = activeDownloads[key]
2169
+ current != null && current !== run
2170
+ }) {
2171
+ throw BundleUpdateError("UNKNOWN", "cleanup")
2172
+ }
2173
+ val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context))
2174
+ if (downloadDir.exists()) {
2175
+ if (!BundleUpdateStoreAndroid.deleteDir(downloadDir)) throw BundleUpdateError("STORAGE_ERROR", "cleanup")
2176
+ OneKeyLog.info("BundleUpdate", "download directory deleted")
2177
+ }
2178
+ if (includeInstalled) {
2179
+ val bundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context))
2180
+ if (bundleDir.exists()) {
2181
+ if (!BundleUpdateStoreAndroid.deleteDir(bundleDir)) throw BundleUpdateError("STORAGE_ERROR", "cleanup")
2182
+ OneKeyLog.info("BundleUpdate", "bundle directory deleted")
2183
+ }
2184
+ }
2185
+ }
2186
+ } finally {
2187
+ synchronized(activeDownloads) {
2188
+ clearingDownloads = false
2189
+ }
1894
2190
  }
1895
2191
  }
1896
2192
  }
1897
2193
 
1898
2194
  override fun clearDownload(): Promise<Unit> {
1899
- return Promise.async {
2195
+ return updatePromise("cleanup") {
1900
2196
  OneKeyLog.info("BundleUpdate", "clearDownload: clearing download directory...")
1901
2197
  val context = getContext()
1902
- // Cancel-then-delete: stop live workers before removing files.
1903
- cancelAllActiveDownloads()
1904
- val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context))
1905
- if (downloadDir.exists()) {
1906
- BundleUpdateStoreAndroid.deleteDir(downloadDir)
1907
- OneKeyLog.info("BundleUpdate", "clearDownload: download directory deleted")
1908
- } else {
1909
- OneKeyLog.info("BundleUpdate", "clearDownload: download directory does not exist, skipping")
1910
- }
2198
+ clearDownloadFiles(context, includeInstalled = false)
1911
2199
  OneKeyLog.info("BundleUpdate", "clearDownload: completed")
1912
2200
  }
1913
2201
  }
1914
2202
 
1915
2203
  override fun clearBundle(): Promise<Unit> {
1916
2204
  BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1917
- return Promise.async {
2205
+ return updatePromise("cleanup") {
1918
2206
  OneKeyLog.info("BundleUpdate", "clearBundle: clearing download and bundle directories...")
1919
2207
  val context = getContext()
1920
- // Cancel-then-delete: stop live workers before removing files.
1921
- cancelAllActiveDownloads()
1922
- // Clear download directory
1923
- val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context))
1924
- if (downloadDir.exists()) {
1925
- BundleUpdateStoreAndroid.deleteDir(downloadDir)
1926
- OneKeyLog.info("BundleUpdate", "clearBundle: download directory deleted")
1927
- }
1928
- // Clear installed bundle directory
1929
- val bundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context))
1930
- if (bundleDir.exists()) {
1931
- BundleUpdateStoreAndroid.deleteDir(bundleDir)
1932
- OneKeyLog.info("BundleUpdate", "clearBundle: bundle directory deleted")
1933
- }
2208
+ clearDownloadFiles(context, includeInstalled = true)
1934
2209
  OneKeyLog.info("BundleUpdate", "clearBundle: completed")
1935
2210
  }
1936
2211
  }
@@ -1943,9 +2218,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1943
2218
  * appVersion's artifacts and the active currentBundleVersion. Tolerates
1944
2219
  * already-missing files. Returns the count of deleted version directories.
1945
2220
  *
1946
- * appVersion is parsed from the "{appVersion}-{bundleVersion}" stem using
1947
- * the SAME last-dash split as listLocalBundles / the installBundle fallback
1948
- * logic, so behavior matches the rest of the module.
2221
+ * The running app version is matched as an exact prefix before best-effort
2222
+ * classification of non-current stems.
1949
2223
  */
1950
2224
  override fun pruneStaleAppVersionBundles(): Promise<Double> {
1951
2225
  BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
@@ -1978,6 +2252,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1978
2252
  // unparseable (leave foreign names alone).
1979
2253
  fun shouldKeep(stem: String): Boolean {
1980
2254
  if (currentBundleVersion != null && stem == currentBundleVersion) return true
2255
+ if (BundleUpdateStoreAndroid.bundleVersionInFolder(stem, currentAppV) != null) return true
1981
2256
  val appV = appVersionFromStem(stem) ?: return true
1982
2257
  return appV == currentAppV
1983
2258
  }
@@ -2083,7 +2358,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
2083
2358
 
2084
2359
  override fun resetToBuiltInBundle(): Promise<Unit> {
2085
2360
  BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
2086
- return Promise.async {
2361
+ return updatePromise("cleanup") {
2087
2362
  OneKeyLog.info("BundleUpdate", "resetToBuiltInBundle: clearing currentBundleVersion preference...")
2088
2363
  val context = getContext()
2089
2364
  val prefs = context.getSharedPreferences("BundleUpdatePrefs", Context.MODE_PRIVATE)
@@ -2100,7 +2375,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
2100
2375
 
2101
2376
  override fun clearAllJSBundleData(): Promise<TestResult> {
2102
2377
  BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
2103
- return Promise.async {
2378
+ return updatePromise("cleanup") {
2104
2379
  OneKeyLog.info("BundleUpdate", "clearAllJSBundleData: starting...")
2105
2380
  val context = getContext()
2106
2381
  val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context))
@@ -2138,16 +2413,37 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
2138
2413
 
2139
2414
  override fun setCurrentUpdateBundleData(params: BundleSwitchParams): Promise<Unit> {
2140
2415
  BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
2141
- return Promise.async {
2416
+ return updatePromise("install") {
2417
+ if (!isSafeVersionString(params.appVersion) || !isSafeVersionString(params.bundleVersion)) {
2418
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: invalid version string format: appVersion=${params.appVersion}, bundleVersion=${params.bundleVersion}")
2419
+ throw BundleUpdateError("INVALID_ARGUMENT", "install")
2420
+ }
2421
+
2142
2422
  val context = getContext()
2143
2423
  val bundleVersion = "${params.appVersion}-${params.bundleVersion}"
2144
2424
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: switching to $bundleVersion")
2145
2425
 
2146
- // Verify the bundle directory actually exists
2426
+ val baseBundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
2147
2427
  val bundleDirPath = File(BundleUpdateStoreAndroid.getBundleDir(context), bundleVersion)
2428
+ if (Files.isSymbolicLink(bundleDirPath.toPath()) ||
2429
+ !bundleDirPath.canonicalPath.startsWith(baseBundleDir + File.separator)
2430
+ ) {
2431
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: bundle path outside bundle directory: ${bundleDirPath.canonicalPath}")
2432
+ throw BundleUpdateError("SECURITY_REJECTED", "install")
2433
+ }
2434
+ val sigFile = File(BundleUpdateStoreAndroid.getSignatureFilePath(context, bundleVersion))
2435
+ val baseAscDir = File(BundleUpdateStoreAndroid.getAscDir(context)).canonicalPath
2436
+ if (Files.isSymbolicLink(sigFile.toPath()) ||
2437
+ !sigFile.canonicalPath.startsWith(baseAscDir + File.separator)
2438
+ ) {
2439
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: signature path outside asc directory: ${sigFile.canonicalPath}")
2440
+ throw BundleUpdateError("SECURITY_REJECTED", "install")
2441
+ }
2442
+
2443
+ // Verify the bundle directory actually exists
2148
2444
  if (!bundleDirPath.exists()) {
2149
2445
  OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: bundle directory not found: ${bundleDirPath.absolutePath}")
2150
- throw Exception("Bundle directory not found")
2446
+ throw BundleUpdateError("PACKAGE_MISSING", "install")
2151
2447
  }
2152
2448
 
2153
2449
  // Verify GPG signature is valid (skipped when both DevSettings and skip-GPG toggle are enabled)
@@ -2155,22 +2451,36 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
2155
2451
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: GPG check: skipGPG=$skipGPGSwitch")
2156
2452
  if (!skipGPGSwitch) {
2157
2453
  if (params.signature.isEmpty() ||
2158
- !BundleUpdateStoreAndroid.validateMetadataFileSha256(context, bundleVersion, params.signature)) {
2454
+ !BundleUpdateStoreAndroid.validateMetadataFileSha256(context, bundleVersion, params.signature, params.appVersion, params.bundleVersion)) {
2159
2455
  OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: GPG signature verification failed")
2160
- throw Exception("Bundle signature verification failed")
2456
+ throw BundleUpdateError("INTEGRITY_FAILED", "install")
2161
2457
  }
2162
2458
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: GPG signature verified OK")
2163
2459
  } else {
2164
2460
  OneKeyLog.warn("BundleUpdate", "setCurrentUpdateBundleData: GPG signature verification skipped (DevSettings + skip-GPG enabled)")
2165
2461
  }
2166
2462
 
2167
- val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
2168
- prefs.edit()
2169
- .putString("currentBundleVersion", bundleVersion)
2170
- .apply()
2171
- if (params.signature.isNotEmpty()) {
2172
- BundleUpdateStoreAndroid.writeSignatureFile(context, bundleVersion, params.signature)
2463
+ val metadataFile = File(bundleDirPath, "metadata.json")
2464
+ if (!metadataFile.exists()) {
2465
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: metadata.json not found in ${bundleDirPath.absolutePath}")
2466
+ throw BundleUpdateError("PACKAGE_MISSING", "install")
2467
+ }
2468
+ val metadata = BundleUpdateStoreAndroid.parseMetadataJson(BundleUpdateStoreAndroid.readFileContent(metadataFile))
2469
+ if (!BundleUpdateStoreAndroid.validateAllFilesInDir(context, bundleDirPath.absolutePath, metadata, params.appVersion, params.bundleVersion)) {
2470
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: extracted file verification failed")
2471
+ throw BundleUpdateError("INTEGRITY_FAILED", "install")
2472
+ }
2473
+ if (!BundleUpdateStoreAndroid.validateBundlePairCompatibility(context, bundleDirPath.absolutePath, metadata)) {
2474
+ OneKeyLog.error("BundleUpdate", "setCurrentUpdateBundleData: bundle pair compatibility check failed")
2475
+ throw BundleUpdateError("INCOMPATIBLE", "install")
2173
2476
  }
2477
+
2478
+ BundleUpdateStoreAndroid.setCurrentBundleVersionAndSignature(context, bundleVersion, params.signature)
2479
+ val currentNativeVersion = BundleUpdateStoreAndroid.getAppVersion(context) ?: ""
2480
+ BundleUpdateStoreAndroid.setNativeVersion(context, currentNativeVersion)
2481
+ val currentBuildNumber = BundleUpdateStoreAndroid.getBuildNumber(context)
2482
+ BundleUpdateStoreAndroid.setNativeBuildNumber(context, currentBuildNumber)
2483
+ BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
2174
2484
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: switched to $bundleVersion")
2175
2485
  }
2176
2486
  }
@@ -2278,7 +2588,7 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2278
2588
  91Js0rQ=
2279
2589
  =A/Ii
2280
2590
  -----END PGP SIGNATURE-----"""
2281
- val result = BundleUpdateStoreAndroid.verifyGPGAndExtractSha256(testSignature)
2591
+ val result = BundleUpdateStoreAndroid.verifyGPGAndExtractSha256(testSignature, expectedAppType = null)
2282
2592
  val isValid = result == "2ada9c871104fc40649fa3de67a7d8e33faadc18e9abd587e8bb85be0a003eba"
2283
2593
  OneKeyLog.info("BundleUpdate", "testVerification: GPG verification result: $isValid")
2284
2594
  isValid
@@ -2305,9 +2615,17 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2305
2615
 
2306
2616
  override fun isBundleExists(appVersion: String, bundleVersion: String): Promise<Boolean> {
2307
2617
  return Promise.async {
2618
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
2619
+ OneKeyLog.error("BundleUpdate", "isBundleExists: invalid version string format: appVersion=$appVersion, bundleVersion=$bundleVersion")
2620
+ throw SecurityException("Invalid version string")
2621
+ }
2308
2622
  val context = getContext()
2309
2623
  val folderName = "$appVersion-$bundleVersion"
2624
+ val baseBundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
2310
2625
  val bundlePath = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
2626
+ if (!bundlePath.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2627
+ throw SecurityException("Bundle path outside bundle directory")
2628
+ }
2311
2629
  val exists = bundlePath.exists()
2312
2630
  OneKeyLog.info("BundleUpdate", "isBundleExists: appVersion=$appVersion, bundleVersion=$bundleVersion, exists=$exists")
2313
2631
  exists
@@ -2315,19 +2633,27 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2315
2633
  }
2316
2634
 
2317
2635
  override fun verifyExtractedBundle(appVersion: String, bundleVersion: String): Promise<Unit> {
2318
- return Promise.async {
2636
+ return updatePromise("verifyPackage") {
2319
2637
  OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: appVersion=$appVersion, bundleVersion=$bundleVersion")
2638
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
2639
+ OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: invalid version string format: appVersion=$appVersion, bundleVersion=$bundleVersion")
2640
+ throw BundleUpdateError("INVALID_ARGUMENT", "verifyPackage")
2641
+ }
2320
2642
  val context = getContext()
2321
2643
  val folderName = "$appVersion-$bundleVersion"
2644
+ val baseBundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
2322
2645
  val bundlePath = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
2646
+ if (!bundlePath.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2647
+ throw BundleUpdateError("SECURITY_REJECTED", "verifyPackage")
2648
+ }
2323
2649
  if (!bundlePath.exists()) {
2324
2650
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: bundle directory not found: ${bundlePath.absolutePath}")
2325
- throw Exception("Bundle directory not found")
2651
+ throw BundleUpdateError("PACKAGE_MISSING", "verifyPackage")
2326
2652
  }
2327
2653
  val metadataFile = File(bundlePath, "metadata.json")
2328
2654
  if (!metadataFile.exists()) {
2329
2655
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: metadata.json not found in ${bundlePath.absolutePath}")
2330
- throw Exception("metadata.json not found")
2656
+ throw BundleUpdateError("PACKAGE_MISSING", "verifyPackage")
2331
2657
  }
2332
2658
 
2333
2659
  OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: parsing metadata and validating files...")
@@ -2335,11 +2661,11 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2335
2661
  val metadata = BundleUpdateStoreAndroid.parseMetadataJson(metadataContent)
2336
2662
  if (!BundleUpdateStoreAndroid.validateAllFilesInDir(context, bundlePath.absolutePath, metadata, appVersion, bundleVersion)) {
2337
2663
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: file integrity check failed")
2338
- throw Exception("File integrity check failed")
2664
+ throw BundleUpdateError("INTEGRITY_FAILED", "verifyPackage")
2339
2665
  }
2340
2666
  if (!BundleUpdateStoreAndroid.validateBundlePairCompatibility(context, bundlePath.absolutePath, metadata)) {
2341
2667
  OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: bundle pair compatibility check failed")
2342
- throw Exception("Bundle pair compatibility check failed")
2668
+ throw BundleUpdateError("INCOMPATIBLE", "verifyPackage")
2343
2669
  }
2344
2670
  OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: all files verified OK, fileCount=${metadata.size}")
2345
2671
  }
@@ -2348,19 +2674,15 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2348
2674
  override fun listLocalBundles(): Promise<Array<LocalBundleInfo>> {
2349
2675
  return Promise.async {
2350
2676
  val context = getContext()
2677
+ val appVersion = BundleUpdateStoreAndroid.getAppVersion(context) ?: return@async emptyArray<LocalBundleInfo>()
2351
2678
  val bundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context))
2352
2679
  val results = mutableListOf<LocalBundleInfo>()
2353
2680
  if (bundleDir.exists() && bundleDir.isDirectory) {
2354
2681
  bundleDir.listFiles()?.forEach { child ->
2355
2682
  if (!child.isDirectory) return@forEach
2356
2683
  val name = child.name
2357
- val lastDash = name.lastIndexOf('-')
2358
- if (lastDash <= 0) return@forEach
2359
- val appVer = name.substring(0, lastDash)
2360
- val bundleVer = name.substring(lastDash + 1)
2361
- if (appVer.isNotEmpty() && bundleVer.isNotEmpty()) {
2362
- results.add(LocalBundleInfo(appVersion = appVer, bundleVersion = bundleVer))
2363
- }
2684
+ val bundleVersion = BundleUpdateStoreAndroid.bundleVersionInFolder(name, appVersion) ?: return@forEach
2685
+ results.add(LocalBundleInfo(appVersion = appVersion, bundleVersion = bundleVersion))
2364
2686
  }
2365
2687
  }
2366
2688
  OneKeyLog.info("BundleUpdate", "listLocalBundles: found ${results.size} bundles")
@@ -2415,10 +2737,24 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2415
2737
 
2416
2738
  override fun testDeleteJsBundle(appVersion: String, bundleVersion: String): Promise<TestResult> {
2417
2739
  return Promise.async {
2740
+ if (!BuildConfig.DEBUG) {
2741
+ throw SecurityException("Test fault-injection methods are disabled in release builds")
2742
+ }
2743
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
2744
+ throw SecurityException("Invalid version string")
2745
+ }
2418
2746
  OneKeyLog.info("BundleUpdate", "testDeleteJsBundle: appVersion=$appVersion, bundleVersion=$bundleVersion")
2419
2747
  val context = getContext()
2420
2748
  val folderName = "$appVersion-$bundleVersion"
2421
- val jsBundlePath = File(File(BundleUpdateStoreAndroid.getBundleDir(context), folderName), "main.jsbundle.hbc")
2749
+ val baseBundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
2750
+ val bundleDirPath = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
2751
+ if (!bundleDirPath.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2752
+ throw SecurityException("Bundle path outside bundle directory")
2753
+ }
2754
+ val jsBundlePath = File(bundleDirPath, "main.jsbundle.hbc")
2755
+ if (!jsBundlePath.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2756
+ throw SecurityException("Bundle path outside bundle directory")
2757
+ }
2422
2758
 
2423
2759
  if (jsBundlePath.exists()) {
2424
2760
  val success = jsBundlePath.delete()
@@ -2438,10 +2774,20 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2438
2774
 
2439
2775
  override fun testDeleteJsRuntimeDir(appVersion: String, bundleVersion: String): Promise<TestResult> {
2440
2776
  return Promise.async {
2777
+ if (!BuildConfig.DEBUG) {
2778
+ throw SecurityException("Test fault-injection methods are disabled in release builds")
2779
+ }
2780
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
2781
+ throw SecurityException("Invalid version string")
2782
+ }
2441
2783
  OneKeyLog.info("BundleUpdate", "testDeleteJsRuntimeDir: appVersion=$appVersion, bundleVersion=$bundleVersion")
2442
2784
  val context = getContext()
2443
2785
  val folderName = "$appVersion-$bundleVersion"
2786
+ val baseBundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
2444
2787
  val dir = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
2788
+ if (!dir.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2789
+ throw SecurityException("Bundle path outside bundle directory")
2790
+ }
2445
2791
 
2446
2792
  if (dir.exists()) {
2447
2793
  BundleUpdateStoreAndroid.deleteDir(dir)
@@ -2461,10 +2807,24 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2461
2807
 
2462
2808
  override fun testDeleteMetadataJson(appVersion: String, bundleVersion: String): Promise<TestResult> {
2463
2809
  return Promise.async {
2810
+ if (!BuildConfig.DEBUG) {
2811
+ throw SecurityException("Test fault-injection methods are disabled in release builds")
2812
+ }
2813
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
2814
+ throw SecurityException("Invalid version string")
2815
+ }
2464
2816
  OneKeyLog.info("BundleUpdate", "testDeleteMetadataJson: appVersion=$appVersion, bundleVersion=$bundleVersion")
2465
2817
  val context = getContext()
2466
2818
  val folderName = "$appVersion-$bundleVersion"
2467
- val metadataFile = File(File(BundleUpdateStoreAndroid.getBundleDir(context), folderName), "metadata.json")
2819
+ val baseBundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
2820
+ val bundleDirPath = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
2821
+ if (!bundleDirPath.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2822
+ throw SecurityException("Bundle path outside bundle directory")
2823
+ }
2824
+ val metadataFile = File(bundleDirPath, "metadata.json")
2825
+ if (!metadataFile.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2826
+ throw SecurityException("Bundle path outside bundle directory")
2827
+ }
2468
2828
 
2469
2829
  if (metadataFile.exists()) {
2470
2830
  val success = metadataFile.delete()
@@ -2484,11 +2844,24 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2484
2844
 
2485
2845
  override fun testWriteEmptyMetadataJson(appVersion: String, bundleVersion: String): Promise<TestResult> {
2486
2846
  return Promise.async {
2847
+ if (!BuildConfig.DEBUG) {
2848
+ throw SecurityException("Test fault-injection methods are disabled in release builds")
2849
+ }
2850
+ if (!isSafeVersionString(appVersion) || !isSafeVersionString(bundleVersion)) {
2851
+ throw SecurityException("Invalid version string")
2852
+ }
2487
2853
  OneKeyLog.info("BundleUpdate", "testWriteEmptyMetadataJson: appVersion=$appVersion, bundleVersion=$bundleVersion")
2488
2854
  val context = getContext()
2489
2855
  val folderName = "$appVersion-$bundleVersion"
2856
+ val baseBundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)).canonicalPath
2490
2857
  val jsRuntimeDir = File(BundleUpdateStoreAndroid.getBundleDir(context), folderName)
2858
+ if (!jsRuntimeDir.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2859
+ throw SecurityException("Bundle path outside bundle directory")
2860
+ }
2491
2861
  val metadataPath = File(jsRuntimeDir, "metadata.json")
2862
+ if (!metadataPath.canonicalPath.startsWith(baseBundleDir + File.separator)) {
2863
+ throw SecurityException("Bundle path outside bundle directory")
2864
+ }
2492
2865
 
2493
2866
  if (!jsRuntimeDir.exists()) {
2494
2867
  if (!jsRuntimeDir.mkdirs()) {
@@ -2508,3 +2881,4 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
2508
2881
  }
2509
2882
  }
2510
2883
  }
2884
+