@onekeyfe/react-native-bundle-update 3.0.20 → 3.0.22

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.
@@ -114,8 +114,43 @@ object BundleUpdateStoreAndroid {
114
114
  private const val COMMON_BUNDLE_FILE_NAME = "common.bundle"
115
115
  private const val METADATA_REQUIRES_BACKGROUND_BUNDLE_KEY = "requiresBackgroundBundle"
116
116
  private const val METADATA_BACKGROUND_PROTOCOL_VERSION_KEY = "backgroundProtocolVersion"
117
+ private const val METADATA_REQUIRES_COMMON_BUNDLE_KEY = "requiresCommonBundle"
118
+ private const val METADATA_BUNDLE_FORMAT_KEY = "bundleFormat"
119
+ private const val BUNDLE_FORMAT_THREE_BUNDLE = "three-bundle"
117
120
  private const val SUPPORTED_BACKGROUND_PROTOCOL_VERSION = "1"
118
121
 
122
+ // In-memory cache for getValidatedCurrentBundleInfo. Without this, every
123
+ // bundleURL / common / main / background path getter on startup re-runs
124
+ // the whole validation pipeline. Cache key is currentBundleVersion;
125
+ // invalidated on any mutation of the current bundle.
126
+ @Volatile private var cachedValidatedBundleInfo: ValidatedBundleInfo? = null
127
+
128
+ // Lazy per-version cache for the web-embed subtree. getWebEmbedPath is
129
+ // called every time a WebView is created, but the subtree contents are
130
+ // immutable for a given currentBundleVersion. Stores the version that
131
+ // most recently passed full sha256 sweep over web-embed/**; invalidated
132
+ // alongside cachedValidatedBundleInfo on any bundle mutation.
133
+ @Volatile private var cachedWebEmbedVerifiedVersion: String? = null
134
+
135
+ @Synchronized
136
+ fun invalidateValidatedBundleInfoCache() {
137
+ cachedValidatedBundleInfo = null
138
+ cachedWebEmbedVerifiedVersion = null
139
+ }
140
+
141
+ /**
142
+ * True when the current bundle's metadata declares the three-bundle /
143
+ * split-thread layout (or explicitly opts in via requiresCommonBundle).
144
+ * SplitBundleLoader queries this reflectively to decide whether an empty
145
+ * per-segment sha256 is a back-compat skip (older formats) or a hard
146
+ * fail (three-bundle, which always ships per-segment hashes).
147
+ */
148
+ @JvmStatic
149
+ fun currentBundleRequiresPerSegmentHash(context: Context): Boolean {
150
+ val info = getValidatedCurrentBundleInfo(context) ?: return false
151
+ return metadataRequiresCommonBundle(info.metadata)
152
+ }
153
+
119
154
  fun getDownloadBundleDir(context: Context): String {
120
155
  val dir = File(context.filesDir, "onekey-bundle-download")
121
156
  if (!dir.exists()) dir.mkdirs()
@@ -190,6 +225,7 @@ object BundleUpdateStoreAndroid {
190
225
  }
191
226
 
192
227
  fun clearUpdateBundleData(context: Context) {
228
+ invalidateValidatedBundleInfoCache()
193
229
  val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
194
230
  prefs.edit().clear().commit()
195
231
  // Clear all signature files
@@ -407,7 +443,16 @@ object BundleUpdateStoreAndroid {
407
443
  val keys = obj.keys()
408
444
  while (keys.hasNext()) {
409
445
  val key = keys.next()
410
- metadata[key] = obj.getString(key)
446
+ // Only accept string-valued entries. Any nested object / array
447
+ // / numeric value is silently skipped so the metadata can carry
448
+ // non-string scalars (e.g. runtimeGraphVersion) or future V2
449
+ // descriptors without breaking file-level SHA verification.
450
+ val value = obj.opt(key)
451
+ if (value is String) {
452
+ metadata[key] = value
453
+ } else if (value is Boolean || value is Number) {
454
+ metadata[key] = value.toString()
455
+ }
411
456
  }
412
457
  } catch (e: Exception) {
413
458
  OneKeyLog.error("BundleUpdate", "Error parsing metadata JSON: ${e.message}")
@@ -421,7 +466,9 @@ object BundleUpdateStoreAndroid {
421
466
 
422
467
  private fun isReservedMetadataKey(key: String): Boolean {
423
468
  return key == METADATA_REQUIRES_BACKGROUND_BUNDLE_KEY ||
424
- key == METADATA_BACKGROUND_PROTOCOL_VERSION_KEY
469
+ key == METADATA_BACKGROUND_PROTOCOL_VERSION_KEY ||
470
+ key == METADATA_REQUIRES_COMMON_BUNDLE_KEY ||
471
+ key == METADATA_BUNDLE_FORMAT_KEY
425
472
  }
426
473
 
427
474
  private fun getFileMetadataEntries(metadata: Map<String, String>): Map<String, String> {
@@ -435,6 +482,18 @@ object BundleUpdateStoreAndroid {
435
482
  ?: false
436
483
  }
437
484
 
485
+ private fun metadataRequiresCommonBundle(metadata: Map<String, String>): Boolean {
486
+ // OR semantics matching iOS: an explicit `requiresCommonBundle=false`
487
+ // does NOT suppress the `bundleFormat=three-bundle` signal. Both
488
+ // signals are treated as opt-ins; either one being true is enough.
489
+ val explicit = metadata[METADATA_REQUIRES_COMMON_BUNDLE_KEY]
490
+ ?.lowercase()
491
+ ?.let { value -> value == "1" || value == "true" || value == "yes" }
492
+ ?: false
493
+ if (explicit) return true
494
+ return metadata[METADATA_BUNDLE_FORMAT_KEY]?.lowercase() == BUNDLE_FORMAT_THREE_BUNDLE
495
+ }
496
+
438
497
  private fun metadataBackgroundProtocolVersion(metadata: Map<String, String>): String {
439
498
  return metadata[METADATA_BACKGROUND_PROTOCOL_VERSION_KEY] ?: ""
440
499
  }
@@ -686,6 +745,21 @@ object BundleUpdateStoreAndroid {
686
745
  return false
687
746
  }
688
747
 
748
+ // Three-bundle (union / split thread) mode requires a common.bundle
749
+ // shipped alongside main.jsbundle.hbc. Without it, entry-only main
750
+ // bundle references moduleIds that live in the common bundle and the
751
+ // runtime crashes on first require().
752
+ if (metadataRequiresCommonBundle(metadata)) {
753
+ val commonBundleFile = File(bundleDir, COMMON_BUNDLE_FILE_NAME)
754
+ if (!commonBundleFile.exists()) {
755
+ OneKeyLog.error(
756
+ "BundleUpdate",
757
+ "requiresCommonBundle is true but common.bundle is missing at ${commonBundleFile.absolutePath}",
758
+ )
759
+ return false
760
+ }
761
+ }
762
+
689
763
  if (!metadataRequiresBackgroundBundle(metadata)) {
690
764
  return true
691
765
  }
@@ -723,9 +797,19 @@ object BundleUpdateStoreAndroid {
723
797
  val currentAppVersion = getAppVersion(context)
724
798
  val currentBundleVersion = getCurrentBundleVersion(context) ?: run {
725
799
  OneKeyLog.warn("BundleUpdate", "getJsBundlePath: no currentBundleVersion stored")
800
+ invalidateValidatedBundleInfoCache()
726
801
  return null
727
802
  }
728
803
 
804
+ // Memo cache: avoid re-running signature + entry-bundle sha256 for
805
+ // every bundleURL / common / main / background path getter on
806
+ // startup.
807
+ cachedValidatedBundleInfo?.let { cached ->
808
+ if (cached.currentBundleVersion == currentBundleVersion) {
809
+ return cached
810
+ }
811
+ }
812
+
729
813
  OneKeyLog.info("BundleUpdate", "currentAppVersion: $currentAppVersion, currentBundleVersion: $currentBundleVersion")
730
814
 
731
815
  val prevNativeVersion = getNativeVersion(context)
@@ -777,31 +861,78 @@ object BundleUpdateStoreAndroid {
777
861
  }
778
862
  val metadata = parseMetadataJson(metadataContent)
779
863
 
780
- val lastDashIndex = currentBundleVersion.lastIndexOf("-")
781
- if (lastDashIndex > 0) {
782
- val appVer = currentBundleVersion.substring(0, lastDashIndex)
783
- val bundleVer = currentBundleVersion.substring(lastDashIndex + 1)
784
- if (!validateAllFilesInDir(context, bundleDir, metadata, appVer, bundleVer)) {
785
- OneKeyLog.info("BundleUpdate", "validateAllFilesInDir failed on startup")
786
- return null
787
- }
864
+ // Startup hot path: only verify entry-bundle SHA-256
865
+ // (main + common + background as required by metadata flags). The
866
+ // full-tree sha256 sweep already runs at install time
867
+ // (validateAllFilesInDir in installBundle), so re-doing it on every
868
+ // launch costs hundreds of ms per startup with no security gain
869
+ // (sandboxed app data + signed metadata.json bind every file's
870
+ // expected hash). Per-segment integrity is checked at loadSegment
871
+ // time by SplitBundleLoader.
872
+ if (!validateEntryBundlesSha256(bundleDir, metadata)) {
873
+ OneKeyLog.info("BundleUpdate", "validateEntryBundlesSha256 failed on startup")
874
+ return null
788
875
  }
789
876
 
790
877
  if (!validateBundlePairCompatibility(bundleDir, metadata)) {
791
878
  return null
792
879
  }
793
880
 
794
- ValidatedBundleInfo(
881
+ val info = ValidatedBundleInfo(
795
882
  bundleDir = bundleDir,
796
883
  currentBundleVersion = currentBundleVersion,
797
884
  metadata = metadata,
798
885
  )
886
+ cachedValidatedBundleInfo = info
887
+ info
799
888
  } catch (e: Exception) {
800
889
  OneKeyLog.error("BundleUpdate", "Error getting bundle: ${e.message}")
801
890
  null
802
891
  }
803
892
  }
804
893
 
894
+ /**
895
+ * Verifies SHA-256 of just the entry bundles required to boot the JS
896
+ * runtime (main + common + background, gated by metadata flags). Runs in
897
+ * place of the legacy validateAllFilesInDir on the startup hot path.
898
+ */
899
+ fun validateEntryBundlesSha256(bundleDir: String, metadata: Map<String, String>): Boolean {
900
+ val fileEntries = getFileMetadataEntries(metadata)
901
+ val entriesToCheck = mutableListOf(MAIN_JS_BUNDLE_FILE_NAME)
902
+ if (metadataRequiresCommonBundle(metadata)) {
903
+ entriesToCheck += COMMON_BUNDLE_FILE_NAME
904
+ }
905
+ if (metadataRequiresBackgroundBundle(metadata)) {
906
+ entriesToCheck += BACKGROUND_BUNDLE_FILE_NAME
907
+ }
908
+ for (entry in entriesToCheck) {
909
+ val file = File(bundleDir, entry)
910
+ if (!file.exists()) {
911
+ OneKeyLog.error("BundleUpdate", "[entry-verify] missing entry file: $entry")
912
+ return false
913
+ }
914
+ val expected = fileEntries[entry]
915
+ if (expected.isNullOrEmpty()) {
916
+ OneKeyLog.error("BundleUpdate", "[entry-verify] no metadata sha256 for entry: $entry")
917
+ return false
918
+ }
919
+ val actual = calculateSHA256(file.absolutePath)
920
+ // secureCompare is byte-wise (case-sensitive); lowercase both
921
+ // sides so an uppercase-hex manifest doesn't false-mismatch
922
+ // against `calculateSHA256`'s lowercase output. Matches the
923
+ // semantic of the previous `equals(actual, ignoreCase = true)`
924
+ // while preserving the constant-time guarantee against timing
925
+ // attacks on hash comparisons (consistent with every other
926
+ // sha256 comparison in this file: validateFilesRecursive,
927
+ // validateWebEmbedRecursive, iOS validateEntryBundlesSha256).
928
+ if (actual == null || !secureCompare(expected.lowercase(), actual.lowercase())) {
929
+ OneKeyLog.error("BundleUpdate", "[entry-verify] sha256 mismatch for entry: $entry")
930
+ return false
931
+ }
932
+ }
933
+ return true
934
+ }
935
+
805
936
  fun getCurrentBundleEntryPath(context: Context, entryFileName: String): String? {
806
937
  val bundleInfo = getValidatedCurrentBundleInfo(context) ?: return null
807
938
  val entryFile = File(bundleInfo.bundleDir, entryFileName)
@@ -826,9 +957,122 @@ object BundleUpdateStoreAndroid {
826
957
 
827
958
  fun getWebEmbedPath(context: Context): String {
828
959
  val bundleInfo = getValidatedCurrentBundleInfo(context) ?: return ""
960
+ // Lazy full-tree sha256 sweep for web-embed/**. The startup hot path
961
+ // only validates the JS entry bundles (validateEntryBundlesSha256), so
962
+ // without this, a tampered web-embed asset would slip through. Result
963
+ // is cached per currentBundleVersion and invalidated on any bundle
964
+ // mutation, so cost is paid once per (re)install.
965
+ if (!ensureWebEmbedVerified(
966
+ bundleDir = bundleInfo.bundleDir,
967
+ currentBundleVersion = bundleInfo.currentBundleVersion,
968
+ metadata = bundleInfo.metadata,
969
+ )) {
970
+ return ""
971
+ }
829
972
  return File(bundleInfo.bundleDir, "web-embed").absolutePath
830
973
  }
831
974
 
975
+ @Synchronized
976
+ private fun ensureWebEmbedVerified(
977
+ bundleDir: String,
978
+ currentBundleVersion: String,
979
+ metadata: Map<String, String>,
980
+ ): Boolean {
981
+ if (cachedWebEmbedVerifiedVersion == currentBundleVersion) {
982
+ return true
983
+ }
984
+ if (!validateWebEmbedSha256(bundleDir, metadata)) {
985
+ OneKeyLog.error("BundleUpdate", "validateWebEmbedSha256 failed for $currentBundleVersion")
986
+ return false
987
+ }
988
+ cachedWebEmbedVerifiedVersion = currentBundleVersion
989
+ return true
990
+ }
991
+
992
+ /**
993
+ * Walks bundleDir/web-embed/** and verifies every file's sha256 against
994
+ * the metadata entry (key is the bundle-relative path). Also rejects
995
+ * files on disk that aren't listed in metadata, and metadata entries
996
+ * whose backing file is missing. Returns true when web-embed is absent
997
+ * from both disk and metadata (bundles without web-embed).
998
+ */
999
+ fun validateWebEmbedSha256(bundleDir: String, metadata: Map<String, String>): Boolean {
1000
+ val webEmbedRoot = File(bundleDir, "web-embed")
1001
+ val fileEntries = getFileMetadataEntries(metadata)
1002
+ val webEmbedEntries = fileEntries.filterKeys { it.startsWith("web-embed/") }
1003
+
1004
+ if (!webEmbedRoot.exists() || !webEmbedRoot.isDirectory) {
1005
+ if (webEmbedEntries.isNotEmpty()) {
1006
+ OneKeyLog.error(
1007
+ "BundleUpdate",
1008
+ "[web-embed-verify] metadata lists web-embed entries but directory is missing",
1009
+ )
1010
+ return false
1011
+ }
1012
+ return true
1013
+ }
1014
+
1015
+ val bundleDirWithSlash = if (bundleDir.endsWith("/")) bundleDir else "$bundleDir/"
1016
+ if (!validateWebEmbedRecursive(webEmbedRoot, webEmbedEntries, bundleDirWithSlash)) {
1017
+ return false
1018
+ }
1019
+ for ((key, _) in webEmbedEntries) {
1020
+ val expectedFile = File(bundleDirWithSlash + key)
1021
+ if (!expectedFile.exists()) {
1022
+ OneKeyLog.error(
1023
+ "BundleUpdate",
1024
+ "[web-embed-verify] file listed in metadata but missing on disk: $key",
1025
+ )
1026
+ return false
1027
+ }
1028
+ }
1029
+ return true
1030
+ }
1031
+
1032
+ private fun validateWebEmbedRecursive(
1033
+ dir: File,
1034
+ webEmbedEntries: Map<String, String>,
1035
+ bundleDirWithSlash: String,
1036
+ ): Boolean {
1037
+ val files = dir.listFiles()
1038
+ if (files == null) {
1039
+ // listFiles() returns null on I/O error or unreadable directory.
1040
+ // Fail closed instead of treating an unlistable subtree as
1041
+ // "nothing to verify" — that would silently allow a tampered
1042
+ // web-embed asset whose containing dir was made unreadable.
1043
+ OneKeyLog.error(
1044
+ "BundleUpdate",
1045
+ "[web-embed-verify] failed to list directory: ${dir.absolutePath}",
1046
+ )
1047
+ return false
1048
+ }
1049
+ for (file in files) {
1050
+ if (file.isDirectory) {
1051
+ if (!validateWebEmbedRecursive(file, webEmbedEntries, bundleDirWithSlash)) return false
1052
+ continue
1053
+ }
1054
+ if (file.name == ".DS_Store") continue
1055
+ val relativePath = file.absolutePath.removePrefix(bundleDirWithSlash)
1056
+ val expected = webEmbedEntries[relativePath]
1057
+ if (expected.isNullOrEmpty()) {
1058
+ OneKeyLog.error(
1059
+ "BundleUpdate",
1060
+ "[web-embed-verify] file on disk not in metadata: $relativePath",
1061
+ )
1062
+ return false
1063
+ }
1064
+ val actual = calculateSHA256(file.absolutePath)
1065
+ if (actual == null || !secureCompare(expected, actual)) {
1066
+ OneKeyLog.error(
1067
+ "BundleUpdate",
1068
+ "[web-embed-verify] sha256 mismatch for $relativePath",
1069
+ )
1070
+ return false
1071
+ }
1072
+ }
1073
+ return true
1074
+ }
1075
+
832
1076
  /**
833
1077
  * Returns true if the OneKey developer mode (DevSettings) is enabled.
834
1078
  * Reads the persisted value from MMKV storage (key: onekey_developer_mode_enabled,
@@ -1242,6 +1486,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1242
1486
  }
1243
1487
 
1244
1488
  override fun installBundle(params: BundleInstallParams): Promise<Unit> {
1489
+ // Invalidate up front so any concurrent reader misses the cache while
1490
+ // the install is running. We invalidate again after the async body
1491
+ // commits so a same-version reinstall (or any reader that re-cached
1492
+ // pre-commit data inside the race window) can't leave stale entries.
1493
+ BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1245
1494
  return Promise.async {
1246
1495
  val context = getContext()
1247
1496
  val appVersion = params.latestVersion
@@ -1311,6 +1560,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1311
1560
  } catch (e: Exception) {
1312
1561
  OneKeyLog.error("BundleUpdate", "installBundle: fallbackUpdateBundleData error: ${e.message}")
1313
1562
  }
1563
+ // Second invalidate: closes the race window between the up-front
1564
+ // invalidate and the actual currentBundleVersion write. Without
1565
+ // this, a reader entering getValidatedCurrentBundleInfo() between
1566
+ // the two could re-cache pre-install state and leave it stale.
1567
+ BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1314
1568
  }
1315
1569
  }
1316
1570
 
@@ -1331,6 +1585,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1331
1585
  }
1332
1586
 
1333
1587
  override fun clearBundle(): Promise<Unit> {
1588
+ BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1334
1589
  return Promise.async {
1335
1590
  OneKeyLog.info("BundleUpdate", "clearBundle: clearing download and bundle directories...")
1336
1591
  val context = getContext()
@@ -1352,6 +1607,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1352
1607
  }
1353
1608
 
1354
1609
  override fun resetToBuiltInBundle(): Promise<Unit> {
1610
+ BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1355
1611
  return Promise.async {
1356
1612
  OneKeyLog.info("BundleUpdate", "resetToBuiltInBundle: clearing currentBundleVersion preference...")
1357
1613
  val context = getContext()
@@ -1368,6 +1624,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1368
1624
  }
1369
1625
 
1370
1626
  override fun clearAllJSBundleData(): Promise<TestResult> {
1627
+ BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1371
1628
  return Promise.async {
1372
1629
  OneKeyLog.info("BundleUpdate", "clearAllJSBundleData: starting...")
1373
1630
  val context = getContext()
@@ -1405,6 +1662,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1405
1662
  }
1406
1663
 
1407
1664
  override fun setCurrentUpdateBundleData(params: BundleSwitchParams): Promise<Unit> {
1665
+ BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache()
1408
1666
  return Promise.async {
1409
1667
  val context = getContext()
1410
1668
  val bundleVersion = "${params.appVersion}-${params.bundleVersion}"
@@ -82,10 +82,58 @@ public class BundleUpdateStore: NSObject {
82
82
  private static let nativeBuildNumberKey = "nativeBuildNumber"
83
83
  private static let mainBundleEntryFileName = "main.jsbundle.hbc"
84
84
  private static let backgroundBundleEntryFileName = "background.bundle"
85
+ private static let commonBundleEntryFileName = "common.bundle"
85
86
  private static let metadataRequiresBackgroundBundleKey = "requiresBackgroundBundle"
86
87
  private static let metadataBackgroundProtocolVersionKey = "backgroundProtocolVersion"
88
+ private static let metadataRequiresCommonBundleKey = "requiresCommonBundle"
89
+ private static let metadataBundleFormatKey = "bundleFormat"
90
+ private static let bundleFormatThreeBundle = "three-bundle"
87
91
  private static let supportedBackgroundProtocolVersion = "1"
88
92
 
93
+ // In-memory cache for validatedCurrentBundleInfo. Without this, every
94
+ // bundleURL / common / main / background path getter on startup re-runs
95
+ // the whole validation pipeline (signature + sha256 of every entry file).
96
+ // Cache key is currentBundleVersion; invalidated on any mutation of the
97
+ // current bundle (setCurrentUpdateBundleData / clearBundle /
98
+ // resetToBuiltInBundle / clearAllJSBundleData / native version change).
99
+ private static var cachedValidatedBundleInfo: (
100
+ bundleDirPath: String,
101
+ currentBundleVersion: String,
102
+ metadata: [String: Any]
103
+ )?
104
+ private static let cachedValidatedBundleInfoLock = NSLock()
105
+
106
+ // Lazy per-version cache for the web-embed subtree. getWebEmbedPath is
107
+ // called every time a WebView is created, but the subtree contents are
108
+ // immutable for a given currentBundleVersion. Stores the version that
109
+ // most recently passed full sha256 sweep over web-embed/**; invalidated
110
+ // alongside cachedValidatedBundleInfo on any bundle mutation.
111
+ private static var cachedWebEmbedVerifiedVersion: String?
112
+ private static let cachedWebEmbedVerifiedVersionLock = NSLock()
113
+
114
+ public static func invalidateValidatedBundleInfoCache() {
115
+ cachedValidatedBundleInfoLock.lock()
116
+ cachedValidatedBundleInfo = nil
117
+ cachedValidatedBundleInfoLock.unlock()
118
+ cachedWebEmbedVerifiedVersionLock.lock()
119
+ cachedWebEmbedVerifiedVersion = nil
120
+ cachedWebEmbedVerifiedVersionLock.unlock()
121
+ }
122
+
123
+ /// True when the current bundle's metadata declares the three-bundle /
124
+ /// split-thread layout (or explicitly opts in via requiresCommonBundle).
125
+ /// SplitBundleLoader queries this reflectively to decide whether an empty
126
+ /// per-segment sha256 is a back-compat skip (older formats) or a hard
127
+ /// fail (three-bundle, which always ships per-segment hashes).
128
+ public static func currentBundleRequiresPerSegmentHash() -> Bool {
129
+ guard let info = validatedCurrentBundleInfo() else { return false }
130
+ if metadataBoolValue(info.metadata, key: metadataRequiresCommonBundleKey) {
131
+ return true
132
+ }
133
+ let format = metadataStringValue(info.metadata, key: metadataBundleFormatKey) ?? ""
134
+ return format.lowercased() == bundleFormatThreeBundle
135
+ }
136
+
89
137
  public static func documentDirectory() -> String {
90
138
  NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
91
139
  }
@@ -165,9 +213,105 @@ public class BundleUpdateStore: NSObject {
165
213
 
166
214
  public static func getWebEmbedPath() -> String {
167
215
  guard let bundleInfo = validatedCurrentBundleInfo() else { return "" }
216
+ // Lazy full-tree sha256 sweep for web-embed/**. The startup hot path
217
+ // only validates the JS entry bundles (validateEntryBundlesSha256), so
218
+ // without this, a tampered web-embed asset would slip through. Result
219
+ // is cached per currentBundleVersion and invalidated on any bundle
220
+ // mutation, so cost is paid once per (re)install.
221
+ if !ensureWebEmbedVerified(
222
+ bundleDirPath: bundleInfo.bundleDirPath,
223
+ currentBundleVersion: bundleInfo.currentBundleVersion,
224
+ metadata: bundleInfo.metadata,
225
+ ) {
226
+ return ""
227
+ }
168
228
  return (bundleInfo.bundleDirPath as NSString).appendingPathComponent("web-embed")
169
229
  }
170
230
 
231
+ private static func ensureWebEmbedVerified(
232
+ bundleDirPath: String,
233
+ currentBundleVersion: String,
234
+ metadata: [String: Any],
235
+ ) -> Bool {
236
+ cachedWebEmbedVerifiedVersionLock.lock()
237
+ if cachedWebEmbedVerifiedVersion == currentBundleVersion {
238
+ cachedWebEmbedVerifiedVersionLock.unlock()
239
+ return true
240
+ }
241
+ cachedWebEmbedVerifiedVersionLock.unlock()
242
+
243
+ if !validateWebEmbedSha256(bundleDirPath: bundleDirPath, metadata: metadata) {
244
+ OneKeyLog.error("BundleUpdate", "validateWebEmbedSha256 failed for \(currentBundleVersion)")
245
+ return false
246
+ }
247
+
248
+ cachedWebEmbedVerifiedVersionLock.lock()
249
+ cachedWebEmbedVerifiedVersion = currentBundleVersion
250
+ cachedWebEmbedVerifiedVersionLock.unlock()
251
+ return true
252
+ }
253
+
254
+ /// Walks bundleDirPath/web-embed/** and verifies every file's sha256
255
+ /// against the metadata entry (key is the bundle-relative path). Also
256
+ /// rejects files on disk that aren't listed in metadata, and metadata
257
+ /// entries whose backing file is missing. Returns true when web-embed
258
+ /// is absent from both disk and metadata (bundles without web-embed).
259
+ static func validateWebEmbedSha256(
260
+ bundleDirPath: String,
261
+ metadata: [String: Any],
262
+ ) -> Bool {
263
+ let fm = FileManager.default
264
+ let webEmbedDir = (bundleDirPath as NSString).appendingPathComponent("web-embed")
265
+ let allEntries = fileMetadataEntries(from: metadata)
266
+ let webEmbedEntries = allEntries.filter { $0.key.hasPrefix("web-embed/") }
267
+
268
+ var dirExists: ObjCBool = false
269
+ let dirPresent = fm.fileExists(atPath: webEmbedDir, isDirectory: &dirExists) && dirExists.boolValue
270
+ if !dirPresent {
271
+ // No web-embed in this bundle is fine, as long as metadata agrees.
272
+ if !webEmbedEntries.isEmpty {
273
+ OneKeyLog.error("BundleUpdate", "[web-embed-verify] metadata lists web-embed entries but directory is missing")
274
+ return false
275
+ }
276
+ return true
277
+ }
278
+
279
+ let bundleDirWithSlash = bundleDirPath.hasSuffix("/") ? bundleDirPath : bundleDirPath + "/"
280
+ guard let enumerator = fm.enumerator(atPath: webEmbedDir) else {
281
+ OneKeyLog.error("BundleUpdate", "[web-embed-verify] failed to enumerate web-embed directory")
282
+ return false
283
+ }
284
+ while let entry = enumerator.nextObject() as? String {
285
+ let fullPath = (webEmbedDir as NSString).appendingPathComponent(entry)
286
+ var isDir: ObjCBool = false
287
+ if fm.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue { continue }
288
+ if entry.hasSuffix(".DS_Store") { continue }
289
+
290
+ let relativePath = fullPath.replacingOccurrences(of: bundleDirWithSlash, with: "")
291
+ guard let expected = webEmbedEntries[relativePath], !expected.isEmpty else {
292
+ OneKeyLog.error("BundleUpdate", "[web-embed-verify] file on disk not in metadata: \(relativePath)")
293
+ return false
294
+ }
295
+ guard let actual = calculateSHA256(fullPath) else {
296
+ OneKeyLog.error("BundleUpdate", "[web-embed-verify] failed to hash file: \(relativePath)")
297
+ return false
298
+ }
299
+ if !expected.secureCompare(actual) {
300
+ OneKeyLog.error("BundleUpdate", "[web-embed-verify] sha256 mismatch for \(relativePath)")
301
+ return false
302
+ }
303
+ }
304
+
305
+ for key in webEmbedEntries.keys {
306
+ let expectedFilePath = bundleDirWithSlash + key
307
+ if !fm.fileExists(atPath: expectedFilePath) {
308
+ OneKeyLog.error("BundleUpdate", "[web-embed-verify] file listed in metadata but missing on disk: \(key)")
309
+ return false
310
+ }
311
+ }
312
+ return true
313
+ }
314
+
171
315
  public static func calculateSHA256(_ filePath: String) -> String? {
172
316
  guard let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
173
317
  defer { fileHandle.closeFile() }
@@ -335,7 +479,10 @@ public class BundleUpdateStore: NSObject {
335
479
  }
336
480
 
337
481
  private static func isReservedMetadataKey(_ key: String) -> Bool {
338
- key == metadataRequiresBackgroundBundleKey || key == metadataBackgroundProtocolVersionKey
482
+ key == metadataRequiresBackgroundBundleKey ||
483
+ key == metadataBackgroundProtocolVersionKey ||
484
+ key == metadataRequiresCommonBundleKey ||
485
+ key == metadataBundleFormatKey
339
486
  }
340
487
 
341
488
  private static func metadataStringValue(
@@ -589,6 +736,26 @@ public class BundleUpdateStore: NSObject {
589
736
  return false
590
737
  }
591
738
 
739
+ // Three-bundle (union build / split thread) mode requires a
740
+ // common.bundle shipped alongside main.jsbundle.hbc. Without it
741
+ // the entry-only main bundle references moduleIds that only exist in
742
+ // the common bundle and the runtime crashes on first require().
743
+ let bundleFormat = metadataStringValue(metadata, key: metadataBundleFormatKey) ?? ""
744
+ let requiresCommonBundle =
745
+ metadataBoolValue(metadata, key: metadataRequiresCommonBundleKey) ||
746
+ bundleFormat.lowercased() == bundleFormatThreeBundle
747
+ if requiresCommonBundle {
748
+ let commonBundlePath = (bundleDirPath as NSString)
749
+ .appendingPathComponent(commonBundleEntryFileName)
750
+ guard FileManager.default.fileExists(atPath: commonBundlePath) else {
751
+ OneKeyLog.error(
752
+ "BundleUpdate",
753
+ "requiresCommonBundle is true but common.bundle is missing at \(commonBundlePath)",
754
+ )
755
+ return false
756
+ }
757
+ }
758
+
592
759
  let requiresBackgroundBundle = metadataBoolValue(
593
760
  metadata,
594
761
  key: metadataRequiresBackgroundBundleKey,
@@ -630,9 +797,19 @@ public class BundleUpdateStore: NSObject {
630
797
  processPreLaunchPendingTask()
631
798
  guard let currentBundleVer = currentBundleVersion() else {
632
799
  OneKeyLog.warn("BundleUpdate", "getJsBundlePath: no currentBundleVersion stored")
800
+ invalidateValidatedBundleInfoCache()
633
801
  return nil
634
802
  }
635
803
 
804
+ // Memo cache: avoid re-running signature + entry-bundle sha256 for every
805
+ // bundleURL / common / main / background path getter on startup.
806
+ cachedValidatedBundleInfoLock.lock()
807
+ if let cached = cachedValidatedBundleInfo, cached.currentBundleVersion == currentBundleVer {
808
+ cachedValidatedBundleInfoLock.unlock()
809
+ return cached
810
+ }
811
+ cachedValidatedBundleInfoLock.unlock()
812
+
636
813
  let currentAppVersion = getCurrentNativeVersion()
637
814
  guard let prevNativeVersion = getNativeVersion() else {
638
815
  OneKeyLog.warn("BundleUpdate", "getJsBundlePath: prevNativeVersion is nil")
@@ -694,20 +871,69 @@ public class BundleUpdateStore: NSObject {
694
871
  return nil
695
872
  }
696
873
 
697
- if let dashRange = currentBundleVer.range(of: "-", options: .backwards) {
698
- let appVer = String(currentBundleVer[currentBundleVer.startIndex..<dashRange.lowerBound])
699
- let bundleVer = String(currentBundleVer[dashRange.upperBound...])
700
- if !validateAllFilesInDir(folderName, metadata: metadata, appVersion: appVer, bundleVersion: bundleVer) {
701
- OneKeyLog.info("BundleUpdate", "validateAllFilesInDir failed on startup")
702
- return nil
703
- }
874
+ // Startup hot path: only verify entry-bundle SHA-256
875
+ // (main + common + background as required by metadata flags). The
876
+ // full-tree sha256 sweep already runs at install time
877
+ // (validateAllFilesInDir in installBundle), so re-doing it on every
878
+ // launch costs hundreds of ms per startup with no security gain
879
+ // (sandboxed app data + signed metadata.json bind every file's
880
+ // expected hash). Per-segment integrity is checked at loadSegment
881
+ // time by SplitBundleLoader.
882
+ if !validateEntryBundlesSha256(bundleDirPath: folderName, metadata: metadata) {
883
+ OneKeyLog.info("BundleUpdate", "validateEntryBundlesSha256 failed on startup")
884
+ return nil
704
885
  }
705
886
 
706
887
  if !validateBundlePairCompatibility(bundleDirPath: folderName, metadata: metadata) {
707
888
  return nil
708
889
  }
709
890
 
710
- return (folderName, currentBundleVer, metadata)
891
+ let result = (folderName, currentBundleVer, metadata)
892
+ cachedValidatedBundleInfoLock.lock()
893
+ cachedValidatedBundleInfo = result
894
+ cachedValidatedBundleInfoLock.unlock()
895
+ return result
896
+ }
897
+
898
+ /// Verifies SHA-256 of just the entry bundles required to boot the JS
899
+ /// runtime (main + common + background, gated by metadata flags). Runs in
900
+ /// place of the legacy validateAllFilesInDir on the startup hot path.
901
+ static func validateEntryBundlesSha256(
902
+ bundleDirPath: String,
903
+ metadata: [String: Any],
904
+ ) -> Bool {
905
+ let fileEntries = fileMetadataEntries(from: metadata)
906
+
907
+ var entriesToCheck: [String] = [mainBundleEntryFileName]
908
+ let bundleFormat = metadataStringValue(metadata, key: metadataBundleFormatKey) ?? ""
909
+ if metadataBoolValue(metadata, key: metadataRequiresCommonBundleKey) ||
910
+ bundleFormat.lowercased() == bundleFormatThreeBundle {
911
+ entriesToCheck.append(commonBundleEntryFileName)
912
+ }
913
+ if metadataBoolValue(metadata, key: metadataRequiresBackgroundBundleKey) {
914
+ entriesToCheck.append(backgroundBundleEntryFileName)
915
+ }
916
+
917
+ for entry in entriesToCheck {
918
+ let path = (bundleDirPath as NSString).appendingPathComponent(entry)
919
+ guard FileManager.default.fileExists(atPath: path) else {
920
+ OneKeyLog.error("BundleUpdate", "[entry-verify] missing entry file: \(entry)")
921
+ return false
922
+ }
923
+ guard let expected = fileEntries[entry], !expected.isEmpty else {
924
+ OneKeyLog.error("BundleUpdate", "[entry-verify] no metadata sha256 for entry: \(entry)")
925
+ return false
926
+ }
927
+ guard let actual = calculateSHA256(path) else {
928
+ OneKeyLog.error("BundleUpdate", "[entry-verify] failed to hash entry: \(entry)")
929
+ return false
930
+ }
931
+ if !expected.secureCompare(actual) {
932
+ OneKeyLog.error("BundleUpdate", "[entry-verify] sha256 mismatch for entry: \(entry)")
933
+ return false
934
+ }
935
+ }
936
+ return true
711
937
  }
712
938
 
713
939
  private static func currentBundleEntryPath(_ entryFileName: String) -> String? {
@@ -731,6 +957,10 @@ public class BundleUpdateStore: NSObject {
731
957
  currentBundleEntryPath(backgroundBundleEntryFileName)
732
958
  }
733
959
 
960
+ public static func currentBundleCommonJSBundle() -> String? {
961
+ currentBundleEntryPath(commonBundleEntryFileName)
962
+ }
963
+
734
964
  // Fallback data management
735
965
  static func getFallbackUpdateBundleDataPath() -> String {
736
966
  let path = (bundleDir() as NSString).appendingPathComponent("fallbackUpdateBundleData.json")
@@ -759,6 +989,7 @@ public class BundleUpdateStore: NSObject {
759
989
  }
760
990
 
761
991
  public static func clearUpdateBundleData() {
992
+ invalidateValidatedBundleInfoCache()
762
993
  let bDir = bundleDir()
763
994
  let fm = FileManager.default
764
995
  if fm.fileExists(atPath: bDir) {
@@ -1225,6 +1456,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1225
1456
  }
1226
1457
 
1227
1458
  func installBundle(params: BundleInstallParams) throws -> Promise<Void> {
1459
+ // Invalidate up front so any concurrent reader misses the cache while
1460
+ // the install is running. We invalidate again after the async body
1461
+ // commits so a same-version reinstall (or any reader that re-cached
1462
+ // pre-commit data inside the race window) can't leave stale entries.
1463
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
1228
1464
  return Promise.async {
1229
1465
  let appVersion = params.latestVersion
1230
1466
  let bundleVersion = params.bundleVersion
@@ -1295,6 +1531,12 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1295
1531
  BundleUpdateStore.writeFallbackUpdateBundleDataFile(fallbackData)
1296
1532
  ud.synchronize()
1297
1533
 
1534
+ // Second invalidate: closes the race window between the up-front
1535
+ // invalidate and the actual currentBundleVersion write. Without
1536
+ // this, a reader entering validatedCurrentBundleInfo() between
1537
+ // the two could re-cache pre-install state and leave it stale.
1538
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
1539
+
1298
1540
  OneKeyLog.info("BundleUpdate", "installBundle: completed successfully, installed version=\(folderName), fallbackCount=\(fallbackData.count)")
1299
1541
  }
1300
1542
  }
@@ -1318,6 +1560,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1318
1560
  }
1319
1561
 
1320
1562
  func clearBundle() throws -> Promise<Void> {
1563
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
1321
1564
  return Promise.async { [weak self] in
1322
1565
  OneKeyLog.info("BundleUpdate", "clearBundle: clearing download and bundle directories...")
1323
1566
  // Clear download directory
@@ -1341,6 +1584,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1341
1584
  }
1342
1585
 
1343
1586
  func resetToBuiltInBundle() throws -> Promise<Void> {
1587
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
1344
1588
  return Promise.async {
1345
1589
  OneKeyLog.info("BundleUpdate", "resetToBuiltInBundle: clearing currentBundleVersion preference...")
1346
1590
  let ud = UserDefaults.standard
@@ -1357,6 +1601,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1357
1601
  }
1358
1602
 
1359
1603
  func clearAllJSBundleData() throws -> Promise<TestResult> {
1604
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
1360
1605
  return Promise.async {
1361
1606
  OneKeyLog.info("BundleUpdate", "clearAllJSBundleData: starting...")
1362
1607
  let bundleDir = BundleUpdateStore.bundleDir()
@@ -1393,6 +1638,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1393
1638
  }
1394
1639
 
1395
1640
  func setCurrentUpdateBundleData(params: BundleSwitchParams) throws -> Promise<Void> {
1641
+ BundleUpdateStore.invalidateValidatedBundleInfoCache()
1396
1642
  return Promise.async {
1397
1643
  let bundleVersion = "\(params.appVersion)-\(params.bundleVersion)"
1398
1644
  OneKeyLog.info("BundleUpdate", "setCurrentUpdateBundleData: switching to \(bundleVersion)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-bundle-update",
3
- "version": "3.0.20",
3
+ "version": "3.0.22",
4
4
  "description": "react-native-bundle-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",