@hot-updater/react-native 0.36.3 → 0.36.5

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.
@@ -547,34 +547,28 @@ class BundleFileStorageService(
547
547
  return regex.find(currentUrl)?.groupValues?.get(1)
548
548
  }
549
549
 
550
- private fun resolveBundleFile(bundleDir: File): File? {
551
- val manifest = readManifestFromBundleDir(bundleDir)
552
- val manifestBundlePath =
553
- (manifest?.get("assets") as? Map<*, *>)
554
- ?.keys
555
- ?.mapNotNull { key ->
556
- (key as? String)
557
- ?.trim()
558
- ?.takeIf { it.isNotEmpty() }
559
- ?.takeIf { File(it).name.endsWith(".android.bundle") }
560
- }?.singleOrNull()
561
-
562
- if (manifestBundlePath != null) {
563
- try {
564
- val canonicalBundleDir = bundleDir.canonicalFile
565
- val canonicalBundleFile = File(bundleDir, manifestBundlePath).canonicalFile
566
- val canonicalBundleDirPath = canonicalBundleDir.path
567
- val canonicalBundleFilePath = canonicalBundleFile.path
568
- val isWithinBundleDir =
569
- canonicalBundleFilePath == canonicalBundleDirPath ||
570
- canonicalBundleFilePath.startsWith("$canonicalBundleDirPath${File.separator}")
571
-
572
- if (isWithinBundleDir && canonicalBundleFile.isFile) {
573
- return canonicalBundleFile
574
- }
575
- } catch (e: Exception) {
576
- Log.w(TAG, "Failed to resolve manifest bundle file from ${bundleDir.absolutePath}: ${e.message}")
550
+ private fun resolveBundleFile(
551
+ bundleDir: File,
552
+ expectedBundleId: String? = null,
553
+ ): File? {
554
+ val manifestFile = File(bundleDir, "manifest.json")
555
+ if (manifestFile.exists()) {
556
+ val manifest = parseBundleManifestFromFile(manifestFile) ?: return null
557
+ if (expectedBundleId != null && manifest.bundleId != expectedBundleId) {
558
+ return null
577
559
  }
560
+
561
+ val resolvedAssets =
562
+ manifest.assets.keys.associateWith { assetPath ->
563
+ RelativePathResolver
564
+ .resolveInside(bundleDir, assetPath)
565
+ ?.takeIf { it.isFile }
566
+ ?: return null
567
+ }
568
+ return resolvedAssets
569
+ .filterKeys { assetPath -> File(assetPath).name.endsWith(".android.bundle") }
570
+ .values
571
+ .singleOrNull()
578
572
  }
579
573
 
580
574
  return File(bundleDir, "index.android.bundle").absoluteFile.takeIf { it.isFile }
@@ -582,7 +576,7 @@ class BundleFileStorageService(
582
576
 
583
577
  private fun findBundleFile(bundleId: String): File? {
584
578
  val bundleDir = File(getBundleStoreDir(), bundleId)
585
- return resolveBundleFile(bundleDir)
579
+ return resolveBundleFile(bundleDir, bundleId)
586
580
  }
587
581
 
588
582
  private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
@@ -1090,15 +1084,21 @@ class BundleFileStorageService(
1090
1084
  return null
1091
1085
  }
1092
1086
 
1093
- val file = File(urlString)
1094
- val exists = file.exists()
1095
- Log.d(TAG, "getCachedBundleURL: file exists = $exists at path: $urlString")
1096
- if (!exists) {
1087
+ val cachedBundleId = extractBundleIdFromCurrentURL()
1088
+ val resolvedBundle = cachedBundleId?.let(::findBundleFile)
1089
+ val isValid =
1090
+ if (cachedBundleId == null) {
1091
+ File(urlString).isFile
1092
+ } else {
1093
+ resolvedBundle != null
1094
+ }
1095
+ Log.d(TAG, "getCachedBundleURL: cached bundle valid = $isValid at path: $urlString")
1096
+ if (!isValid) {
1097
1097
  preferences.setItem("HotUpdaterBundleURL", null)
1098
- Log.d(TAG, "getCachedBundleURL: file doesn't exist, cleared preference")
1098
+ Log.d(TAG, "getCachedBundleURL: cached bundle is invalid, cleared preference")
1099
1099
  return null
1100
1100
  }
1101
- return urlString
1101
+ return resolvedBundle?.absolutePath ?: urlString
1102
1102
  }
1103
1103
 
1104
1104
  override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
@@ -1180,7 +1180,7 @@ class BundleFileStorageService(
1180
1180
  val finalBundleDir = File(bundleStoreDir, bundleId)
1181
1181
  if (finalBundleDir.exists()) {
1182
1182
  Log.d(TAG, "Bundle for bundleId $bundleId already exists. Using cached bundle.")
1183
- val existingBundleFile = resolveBundleFile(finalBundleDir)
1183
+ val existingBundleFile = resolveBundleFile(finalBundleDir, bundleId)
1184
1184
  if (existingBundleFile != null) {
1185
1185
  // Update last modified time
1186
1186
  finalBundleDir.setLastModified(System.currentTimeMillis())
@@ -1364,7 +1364,7 @@ class BundleFileStorageService(
1364
1364
  }
1365
1365
 
1366
1366
  // 4) Resolve the extracted Android bundle file.
1367
- val extractedBundleFile = resolveBundleFile(tmpDir)
1367
+ val extractedBundleFile = resolveBundleFile(tmpDir, bundleId)
1368
1368
  if (extractedBundleFile == null) {
1369
1369
  Log.d("BundleStorage", "Android bundle file could not be resolved in tmpDir.")
1370
1370
  tempDir.deleteRecursively()
@@ -1403,7 +1403,7 @@ class BundleFileStorageService(
1403
1403
  }
1404
1404
 
1405
1405
  // 8) Verify the Android bundle file exists inside finalBundleDir.
1406
- val finalBundleFile = resolveBundleFile(finalBundleDir)
1406
+ val finalBundleFile = resolveBundleFile(finalBundleDir, bundleId)
1407
1407
  if (finalBundleFile == null) {
1408
1408
  Log.d("BundleStorage", "Android bundle file could not be resolved in realDir.")
1409
1409
  tempDir.deleteRecursively()
@@ -12,7 +12,8 @@ internal object RelativePathResolver {
12
12
  return null
13
13
  }
14
14
 
15
- val components = path.split("/")
15
+ // Archive directory entries conventionally end with one slash.
16
+ val components = path.removeSuffix("/").split("/")
16
17
  if (components.any { it.isEmpty() || it == "." || it == ".." }) {
17
18
  return null
18
19
  }
@@ -13,7 +13,7 @@ class TarArchiveInputStream(
13
13
  ) : InputStream() {
14
14
  private var currentEntry: TarArchiveEntry? = null
15
15
  private var currentEntryBytesRead: Long = 0
16
- private var longName: String? = null
16
+ private var pendingName: String? = null
17
17
 
18
18
  companion object {
19
19
  private const val TAG = "TarInputStream"
@@ -73,14 +73,20 @@ class TarArchiveInputStream(
73
73
 
74
74
  // Handle GNU long filename extension
75
75
  if (entry.typeFlag == 'L') {
76
- longName = readLongName(entry.size)
76
+ pendingName = readLongName(entry.size)
77
77
  continue
78
78
  }
79
79
 
80
- // Apply long name if present
81
- if (longName != null) {
82
- entry.name = longName!!
83
- longName = null
80
+ // Handle POSIX PAX extended header
81
+ if (entry.typeFlag == 'x') {
82
+ readPaxPath(entry.size)?.let { pendingName = it }
83
+ continue
84
+ }
85
+
86
+ // Apply extended name if present
87
+ if (pendingName != null) {
88
+ entry.name = pendingName!!
89
+ pendingName = null
84
90
  }
85
91
 
86
92
  // Validate entry
@@ -306,6 +312,95 @@ class TarArchiveInputStream(
306
312
  return String(nameBytes, 0, nameLength, Charsets.UTF_8)
307
313
  }
308
314
 
315
+ /**
316
+ * Read the path attribute from a POSIX PAX extended header.
317
+ * Record lengths are measured in bytes and include the length itself.
318
+ */
319
+ private fun readPaxPath(size: Long): String? {
320
+ if (size < 0 || size > MAX_FILE_SIZE) {
321
+ throw SecurityException("Invalid PAX header size: $size")
322
+ }
323
+
324
+ val data = ByteArray(size.toInt())
325
+ var bytesRead = 0
326
+ while (bytesRead < data.size) {
327
+ val count = input.read(data, bytesRead, data.size - bytesRead)
328
+ if (count < 0) throw EOFException("Unexpected end reading PAX header")
329
+ bytesRead += count
330
+ }
331
+ skipPadding(size)
332
+
333
+ var offset = 0
334
+ var path: String? = null
335
+ while (offset < data.size) {
336
+ val space = findByte(data, ' '.code.toByte(), offset, data.size)
337
+ if (space <= offset) throw IOException("Invalid PAX record length")
338
+
339
+ val recordLength = parsePaxRecordLength(data, offset, space)
340
+ if (recordLength <= 0 || recordLength > data.size - offset) {
341
+ throw IOException("Invalid PAX record length: $recordLength")
342
+ }
343
+
344
+ val recordEnd = offset + recordLength
345
+ if (recordEnd <= space + 2 || data[recordEnd - 1] != '\n'.code.toByte()) {
346
+ throw IOException("Invalid PAX record")
347
+ }
348
+
349
+ val valueEnd = recordEnd - 1
350
+ val equals = findByte(data, '='.code.toByte(), space + 1, valueEnd)
351
+ if (equals <= space + 1) throw IOException("Invalid PAX record")
352
+
353
+ val key =
354
+ String(
355
+ data,
356
+ space + 1,
357
+ equals - space - 1,
358
+ Charsets.UTF_8,
359
+ )
360
+ if (key == "path") {
361
+ path =
362
+ String(
363
+ data,
364
+ equals + 1,
365
+ valueEnd - equals - 1,
366
+ Charsets.UTF_8,
367
+ )
368
+ }
369
+
370
+ offset = recordEnd
371
+ }
372
+
373
+ return path
374
+ }
375
+
376
+ private fun parsePaxRecordLength(
377
+ bytes: ByteArray,
378
+ start: Int,
379
+ end: Int,
380
+ ): Int {
381
+ var result = 0
382
+ for (index in start until end) {
383
+ val digit = bytes[index].toInt() - '0'.code
384
+ if (digit !in 0..9 || result > (Int.MAX_VALUE - digit) / 10) {
385
+ throw IOException("Invalid PAX record length")
386
+ }
387
+ result = result * 10 + digit
388
+ }
389
+ return result
390
+ }
391
+
392
+ private fun findByte(
393
+ bytes: ByteArray,
394
+ value: Byte,
395
+ start: Int,
396
+ end: Int,
397
+ ): Int {
398
+ for (index in start until end) {
399
+ if (bytes[index] == value) return index
400
+ }
401
+ return -1
402
+ }
403
+
309
404
  /**
310
405
  * Skip padding to 512-byte boundary
311
406
  */
@@ -48,29 +48,29 @@ class BundleFileStorageServiceTest {
48
48
  }
49
49
 
50
50
  @Test
51
- fun `resolveBundleFile falls back to root index when manifest has no android bundle candidate`() {
51
+ fun `resolveBundleFile rejects a manifest with missing assets even when root index exists`() {
52
52
  val rootDir = temporaryFolder.newFolder("no-android-candidate")
53
53
  val service = createService(rootDir)
54
54
  val bundleDir = createBundleDir(rootDir, "bundle-no-candidate")
55
- val fallbackBundleFile = writeFile(bundleDir, "index.android.bundle")
55
+ writeFile(bundleDir, "index.android.bundle")
56
56
 
57
57
  writeManifest(bundleDir, listOf("index.ios.bundle", "assets/image.png"))
58
58
 
59
- assertResolvedBundlePath(service, bundleDir, fallbackBundleFile)
59
+ assertNull(invokeResolveBundleFile(service, bundleDir))
60
60
  }
61
61
 
62
62
  @Test
63
- fun `resolveBundleFile falls back to root index when manifest has multiple android bundle candidates`() {
63
+ fun `resolveBundleFile rejects a manifest with multiple android bundle candidates`() {
64
64
  val rootDir = temporaryFolder.newFolder("multiple-android-candidates")
65
65
  val service = createService(rootDir)
66
66
  val bundleDir = createBundleDir(rootDir, "bundle-multiple-candidates")
67
- val fallbackBundleFile = writeFile(bundleDir, "index.android.bundle")
67
+ writeFile(bundleDir, "index.android.bundle")
68
68
 
69
69
  writeFile(bundleDir, "foo.android.bundle")
70
70
  writeFile(bundleDir, "dist/bar.android.bundle")
71
71
  writeManifest(bundleDir, listOf("foo.android.bundle", "dist/bar.android.bundle"))
72
72
 
73
- assertResolvedBundlePath(service, bundleDir, fallbackBundleFile)
73
+ assertNull(invokeResolveBundleFile(service, bundleDir))
74
74
  }
75
75
 
76
76
  @Test
@@ -122,7 +122,14 @@ class BundleFileStorageServiceTest {
122
122
  val service = createService(rootDir, preferences)
123
123
 
124
124
  val stagingDir = createBundleDir(rootDir, "staging-bundle")
125
- writeManifest(stagingDir, listOf("dist/missing.android.bundle"))
125
+ writeFile(stagingDir, "dist/staging.android.bundle")
126
+ writeManifest(
127
+ stagingDir,
128
+ listOf(
129
+ "dist/staging.android.bundle",
130
+ "assets/missing.png",
131
+ ),
132
+ )
126
133
 
127
134
  val stableDir = createBundleDir(rootDir, "stable-bundle")
128
135
  val stableBundleFile = writeFile(stableDir, "dist/stable.android.bundle")
@@ -141,7 +148,7 @@ class BundleFileStorageServiceTest {
141
148
  val selection = service.prepareLaunch(null)
142
149
  val report = service.notifyAppReady()
143
150
 
144
- assertEquals(stableBundleFile.canonicalFile.absolutePath, selection.bundleUrl)
151
+ assertEquals(stableBundleFile.absolutePath, selection.bundleUrl)
145
152
  assertEquals(stableDir.name, selection.launchedBundleId)
146
153
  assertFalse(selection.shouldRollbackOnCrash)
147
154
  assertFalse(stagingDir.exists())
@@ -153,7 +160,7 @@ class BundleFileStorageServiceTest {
153
160
  assertEquals(stableDir.name, metadata?.stagingBundleId)
154
161
  assertNull(metadata?.stableBundleId)
155
162
  assertFalse(metadata?.verificationPending ?: true)
156
- assertEquals(stableBundleFile.canonicalFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
163
+ assertEquals(stableBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
157
164
  }
158
165
 
159
166
  @Test
@@ -416,9 +423,10 @@ class BundleFileStorageServiceTest {
416
423
  BundleFileStorageService::class.java.getDeclaredMethod(
417
424
  "resolveBundleFile",
418
425
  File::class.java,
426
+ String::class.java,
419
427
  )
420
428
  method.isAccessible = true
421
- return method.invoke(service, bundleDir) as File?
429
+ return method.invoke(service, bundleDir, bundleDir.name) as File?
422
430
  }
423
431
 
424
432
  private fun invokeCanUseManifestDrivenInstall(service: BundleFileStorageService): Boolean {
@@ -0,0 +1,41 @@
1
+ package com.hotupdater
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Rule
6
+ import org.junit.Test
7
+ import org.junit.rules.TemporaryFolder
8
+ import java.io.File
9
+
10
+ class RelativePathResolverTest {
11
+ @get:Rule
12
+ val temporaryFolder = TemporaryFolder()
13
+
14
+ @Test
15
+ fun `resolves a standard archive directory entry ending in a slash`() {
16
+ val root = temporaryFolder.root
17
+
18
+ val resolved = RelativePathResolver.resolveInside(root, "drawable-mdpi/")
19
+
20
+ assertEquals(
21
+ File(root, "drawable-mdpi").canonicalFile,
22
+ resolved?.canonicalFile,
23
+ )
24
+ }
25
+
26
+ @Test
27
+ fun `rejects unsafe archive directory paths ending in a slash`() {
28
+ val root = temporaryFolder.root
29
+ val unsafePaths =
30
+ listOf(
31
+ "/",
32
+ "../",
33
+ "drawable-mdpi/../",
34
+ "drawable-mdpi//",
35
+ )
36
+
37
+ unsafePaths.forEach { path ->
38
+ assertNull(path, RelativePathResolver.resolveInside(root, path))
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,68 @@
1
+ package com.hotupdater
2
+
3
+ import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
4
+ import org.junit.Assert.assertArrayEquals
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertNull
7
+ import org.junit.Assert.assertThrows
8
+ import org.junit.Test
9
+ import java.io.ByteArrayInputStream
10
+ import java.io.ByteArrayOutputStream
11
+ import org.apache.commons.compress.archivers.tar.TarArchiveEntry as CommonsTarArchiveEntry
12
+
13
+ class TarArchiveInputStreamTest {
14
+ @Test
15
+ fun `reads a long basename from a POSIX PAX header`() {
16
+ val path = "raw/${"long-name-".repeat(14)}asset.bmp"
17
+ val contents = "pax asset".toByteArray()
18
+ val archive = createPaxArchive(path, contents)
19
+
20
+ TarArchiveInputStream(ByteArrayInputStream(archive)).use { tar ->
21
+ val entry = tar.getNextEntry()
22
+
23
+ assertEquals(path, entry?.name)
24
+ assertArrayEquals(contents, tar.readBytes())
25
+ assertNull(tar.getNextEntry())
26
+ }
27
+ }
28
+
29
+ @Test
30
+ fun `rejects path traversal from a POSIX PAX header`() {
31
+ val path = "../${"long-name-".repeat(14)}asset.bmp"
32
+ val archive =
33
+ createPaxArchive(
34
+ path = path,
35
+ contents = "malicious".toByteArray(),
36
+ headerPath = "safe.bmp",
37
+ )
38
+
39
+ val error =
40
+ assertThrows(SecurityException::class.java) {
41
+ TarArchiveInputStream(ByteArrayInputStream(archive)).use { tar ->
42
+ tar.getNextEntry()
43
+ }
44
+ }
45
+
46
+ assertEquals("Path traversal detected: $path", error.message)
47
+ }
48
+
49
+ private fun createPaxArchive(
50
+ path: String,
51
+ contents: ByteArray,
52
+ headerPath: String = path,
53
+ ): ByteArray {
54
+ val output = ByteArrayOutputStream()
55
+ TarArchiveOutputStream(output).use { tar ->
56
+ tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX)
57
+ val entry =
58
+ CommonsTarArchiveEntry(headerPath).apply {
59
+ size = contents.size.toLong()
60
+ if (path != headerPath) addPaxHeader("path", path)
61
+ }
62
+ tar.putArchiveEntry(entry)
63
+ tar.write(contents)
64
+ tar.closeArchiveEntry()
65
+ }
66
+ return output.toByteArray()
67
+ }
68
+ }
@@ -1141,7 +1141,10 @@ class BundleFileStorageService: BundleStorageService {
1141
1141
  let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
1142
1142
  if case .success(let storeDir) = bundleStoreDir() {
1143
1143
  let stableBundleDir = (storeDir as NSString).appendingPathComponent(candidate)
1144
- if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), bundlePath != nil {
1144
+ if case .success(let bundlePath) = findBundleFile(
1145
+ in: stableBundleDir,
1146
+ expectedBundleId: candidate
1147
+ ), bundlePath != nil {
1145
1148
  return candidate
1146
1149
  }
1147
1150
  }
@@ -1163,7 +1166,10 @@ class BundleFileStorageService: BundleStorageService {
1163
1166
  if let fallbackBundleId,
1164
1167
  case .success(let storeDir) = bundleStoreDir() {
1165
1168
  let fallbackBundleDir = (storeDir as NSString).appendingPathComponent(fallbackBundleId)
1166
- if case .success(let bundlePath) = findBundleFile(in: fallbackBundleDir), let bundlePath {
1169
+ if case .success(let bundlePath) = findBundleFile(
1170
+ in: fallbackBundleDir,
1171
+ expectedBundleId: fallbackBundleId
1172
+ ), let bundlePath {
1167
1173
  let _ = setBundleURL(localPath: bundlePath)
1168
1174
  }
1169
1175
  } else {
@@ -1278,9 +1284,45 @@ class BundleFileStorageService: BundleStorageService {
1278
1284
  * @param directoryPath Directory to search in
1279
1285
  * @return Result with path to bundle file or error
1280
1286
  */
1281
- func findBundleFile(in directoryPath: String) -> Result<String?, Error> {
1287
+ func findBundleFile(
1288
+ in directoryPath: String,
1289
+ expectedBundleId: String? = nil
1290
+ ) -> Result<String?, Error> {
1282
1291
  NSLog("[BundleStorage] Searching for bundle file in directory: \(directoryPath)")
1283
1292
 
1293
+ let manifestPath = (directoryPath as NSString).appendingPathComponent("manifest.json")
1294
+ if fileSystem.fileExists(atPath: manifestPath) {
1295
+ guard let manifest = readManifest(in: directoryPath),
1296
+ let parsedManifest = parseBundleManifest(from: manifest),
1297
+ expectedBundleId == nil || parsedManifest.bundleId == expectedBundleId else {
1298
+ return .success(nil)
1299
+ }
1300
+
1301
+ do {
1302
+ var bundlePaths: [String] = []
1303
+ for assetPath in parsedManifest.assets.keys {
1304
+ let assetURL = try ArchiveExtractionUtilities.extractionURL(
1305
+ for: assetPath,
1306
+ destinationRoot: directoryPath
1307
+ )
1308
+ guard fileSystem.fileExists(atPath: assetURL.path),
1309
+ let attributes = try? fileSystem.attributesOfItem(atPath: assetURL.path),
1310
+ attributes[.type] as? FileAttributeType == .typeRegular else {
1311
+ return .success(nil)
1312
+ }
1313
+
1314
+ let fileName = assetURL.lastPathComponent
1315
+ if fileName.hasSuffix(".bundle") || fileName == "main.jsbundle" {
1316
+ bundlePaths.append(assetURL.path)
1317
+ }
1318
+ }
1319
+
1320
+ return .success(bundlePaths.count == 1 ? bundlePaths[0] : nil)
1321
+ } catch {
1322
+ return .failure(error)
1323
+ }
1324
+ }
1325
+
1284
1326
  let iosBundlePath = (directoryPath as NSString).appendingPathComponent("index.ios.bundle")
1285
1327
  if self.fileSystem.fileExists(atPath: iosBundlePath) {
1286
1328
  NSLog("[BundleStorage] Found iOS bundle atPath: \(iosBundlePath)")
@@ -1421,12 +1463,39 @@ class BundleFileStorageService: BundleStorageService {
1421
1463
  */
1422
1464
  func getCachedBundleURL() -> URL? {
1423
1465
  do {
1424
- guard let savedURLString = try self.preferences.getItem(forKey: "HotUpdaterBundleURL"),
1425
- let bundleURL = URL(string: savedURLString),
1426
- self.fileSystem.fileExists(atPath: bundleURL.path) else {
1466
+ guard let savedURLString = try self.preferences.getItem(forKey: "HotUpdaterBundleURL") else {
1467
+ return nil
1468
+ }
1469
+
1470
+ let savedURL = URL(string: savedURLString)
1471
+ let bundleURL = (
1472
+ (savedURL?.isFileURL == true ? savedURL : nil)
1473
+ ?? URL(fileURLWithPath: savedURLString)
1474
+ ).standardizedFileURL
1475
+ guard self.fileSystem.fileExists(atPath: bundleURL.path),
1476
+ case .success(let storeDir) = bundleStoreDir() else {
1477
+ return nil
1478
+ }
1479
+
1480
+ let storeURL = URL(fileURLWithPath: storeDir, isDirectory: true).standardizedFileURL
1481
+ let storePrefix = storeURL.path + "/"
1482
+ guard bundleURL.path.hasPrefix(storePrefix),
1483
+ let bundleId = bundleURL.path
1484
+ .dropFirst(storePrefix.count)
1485
+ .split(separator: "/")
1486
+ .first
1487
+ .map(String.init) else {
1488
+ return nil
1489
+ }
1490
+
1491
+ let bundleDirectory = storeURL.appendingPathComponent(bundleId).path
1492
+ guard case .success(let resolvedBundlePath) = findBundleFile(
1493
+ in: bundleDirectory,
1494
+ expectedBundleId: bundleId
1495
+ ), let resolvedBundlePath else {
1427
1496
  return nil
1428
1497
  }
1429
- return bundleURL
1498
+ return URL(fileURLWithPath: resolvedBundlePath)
1430
1499
  } catch {
1431
1500
  NSLog("[BundleStorage] Error getting cached bundle URL: \(error.localizedDescription)")
1432
1501
  return nil
@@ -1454,7 +1523,10 @@ class BundleFileStorageService: BundleStorageService {
1454
1523
  if let stagingId = metadata.stagingBundleId,
1455
1524
  case .success(let storeDir) = bundleStoreDir() {
1456
1525
  let stagingBundleDir = (storeDir as NSString).appendingPathComponent(stagingId)
1457
- if case .success(let bundlePath) = findBundleFile(in: stagingBundleDir), let bundlePath {
1526
+ if case .success(let bundlePath) = findBundleFile(
1527
+ in: stagingBundleDir,
1528
+ expectedBundleId: stagingId
1529
+ ), let bundlePath {
1458
1530
  return LaunchSelection(
1459
1531
  bundleURL: URL(fileURLWithPath: bundlePath),
1460
1532
  launchedBundleId: stagingId,
@@ -1470,7 +1542,10 @@ class BundleFileStorageService: BundleStorageService {
1470
1542
  if let stableId = metadata.stableBundleId,
1471
1543
  case .success(let storeDir) = bundleStoreDir() {
1472
1544
  let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
1473
- if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let bundlePath {
1545
+ if case .success(let bundlePath) = findBundleFile(
1546
+ in: stableBundleDir,
1547
+ expectedBundleId: stableId
1548
+ ), let bundlePath {
1474
1549
  return LaunchSelection(
1475
1550
  bundleURL: URL(fileURLWithPath: bundlePath),
1476
1551
  launchedBundleId: stableId,
@@ -1552,7 +1627,10 @@ class BundleFileStorageService: BundleStorageService {
1552
1627
  let finalBundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
1553
1628
 
1554
1629
  if self.fileSystem.fileExists(atPath: finalBundleDir) {
1555
- let findResult = self.findBundleFile(in: finalBundleDir)
1630
+ let findResult = self.findBundleFile(
1631
+ in: finalBundleDir,
1632
+ expectedBundleId: bundleId
1633
+ )
1556
1634
  switch findResult {
1557
1635
  case .success(let existingBundlePath):
1558
1636
  if let bundlePath = existingBundlePath {
@@ -1969,7 +2047,7 @@ class BundleFileStorageService: BundleStorageService {
1969
2047
  let manifestDestination = (tmpDir as NSString).appendingPathComponent("manifest.json")
1970
2048
  try writeManifestFile(targetManifest, to: manifestDestination)
1971
2049
 
1972
- switch self.findBundleFile(in: tmpDir) {
2050
+ switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
1973
2051
  case .success(let maybeBundlePath):
1974
2052
  guard let bundlePathInTmp = maybeBundlePath else {
1975
2053
  throw BundleStorageError.invalidBundle
@@ -2289,7 +2367,7 @@ class BundleFileStorageService: BundleStorageService {
2289
2367
  progressHandler: progressHandler,
2290
2368
  progress: UpdateProgress.bundleValidation
2291
2369
  )
2292
- switch self.findBundleFile(in: tmpDir) {
2370
+ switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2293
2371
  case .success(let maybeBundlePath):
2294
2372
  if let bundlePathInTmp = maybeBundlePath {
2295
2373
  NSLog("[BundleStorage] Found valid bundle in tmpDir: \(bundlePathInTmp)")
@@ -4,5 +4,5 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.HOT_UPDATER_SDK_VERSION = void 0;
7
- const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "0.36.3";
7
+ const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "0.36.5";
8
8
  //# sourceMappingURL=sdkVersion.js.map
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
 
3
- export const HOT_UPDATER_SDK_VERSION = "0.36.3";
3
+ export const HOT_UPDATER_SDK_VERSION = "0.36.5";
4
4
  //# sourceMappingURL=sdkVersion.js.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "0.36.3";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "0.36.5";
2
2
  //# sourceMappingURL=sdkVersion.d.ts.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "0.36.3";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "0.36.5";
2
2
  //# sourceMappingURL=sdkVersion.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/react-native",
3
- "version": "0.36.3",
3
+ "version": "0.36.5",
4
4
  "description": "React Native OTA solution for self-hosted",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/commonjs/index.js",
@@ -131,14 +131,14 @@
131
131
  "react-native-builder-bob": "0.41.0",
132
132
  "@typescript/native": "npm:typescript@^7.0.2",
133
133
  "typescript": "npm:@typescript/typescript6@^6.0.2",
134
- "hot-updater": "0.36.3"
134
+ "hot-updater": "0.36.5"
135
135
  },
136
136
  "dependencies": {
137
137
  "use-sync-external-store": "1.5.0",
138
- "@hot-updater/cli-tools": "0.36.3",
139
- "@hot-updater/js": "0.36.3",
140
- "@hot-updater/core": "0.36.3",
141
- "@hot-updater/plugin-core": "0.36.3"
138
+ "@hot-updater/cli-tools": "0.36.5",
139
+ "@hot-updater/js": "0.36.5",
140
+ "@hot-updater/core": "0.36.5",
141
+ "@hot-updater/plugin-core": "0.36.5"
142
142
  },
143
143
  "scripts": {
144
144
  "build": "node ./scripts/sync-sdk-version.mjs run -- bob build && tsc -p plugin/tsconfig.build.json",
package/src/sdkVersion.ts CHANGED
@@ -1 +1 @@
1
- export const HOT_UPDATER_SDK_VERSION = "0.36.3";
1
+ export const HOT_UPDATER_SDK_VERSION = "0.36.5";