expo-updates 56.0.24 → 56.0.26

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,33 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.26 — 2026-08-28
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [Android] Register the embedded update in a single transaction. An interrupted registration previously left an update row with no launch asset, which is treated as launchable and then fails every cold start with "Launch asset not found for update"; it now leaves no row, so the next launch registers it cleanly. ([#49130](https://github.com/expo/expo/pull/49130) by [@gwdp](https://github.com/gwdp))
18
+ - [Android] Widen `UpdatesLogEntry.create`'s catch from `JSONException` to `Exception` so log-line parse failures consistently degrade to "skip the entry" instead of propagating. ([#46182](https://github.com/expo/expo/pull/46182) by [@jakequade-pc](https://github.com/jakequade-pc))
19
+ - [Android] Correct `UpdatesLogReader.ONE_DAY_MILLISECONDS` from `86400` (seconds) to `86_400_000` (milliseconds), so the "older than one day" purge filter actually retains a day's worth of entries instead of ~86 seconds' worth. ([#46182](https://github.com/expo/expo/pull/46182) by [@jakequade-pc](https://github.com/jakequade-pc))
20
+ - [iOS] Propagate database failures from `addNewAssets` instead of swallowing them, which let an update be marked ready without its launch asset and then fail every launch. ([#49456](https://github.com/expo/expo/pull/49456) by [@alanjhughes](https://github.com/alanjhughes))
21
+ - [iOS] Repair ready updates that are missing their launch asset by demoting them to pending during launcher selection, so a corrupted row is skipped and retried instead of failing every launch. ([#49457](https://github.com/expo/expo/pull/49457) by [@alanjhughes](https://github.com/alanjhughes))
22
+ - [Android] Skip and repair updates that are missing their launch asset instead of selecting them for launch, which previously failed every cold start with "Launch asset not found for update". ([#49470](https://github.com/expo/expo/pull/49470) by [@alanjhughes](https://github.com/alanjhughes), based on [#48733](https://github.com/expo/expo/pull/48733) by [@martintreurnicht](https://github.com/martintreurnicht))
23
+ - [iOS] Fix the embedded manifest recording the wrong `packagerHash` for assets that ship scale variants iOS does not allow (such as `@1.5x` and `@4x`): the hashes were read by the filtered scale index instead of the asset's own, so those images resolved to an empty URI and rendered blank in release builds. ([#48811](https://github.com/expo/expo/pull/48811) by [@expo-bot](https://github.com/expo-bot))
24
+
25
+ ### 💡 Others
26
+
27
+ - [Android] Replace the "this should never happen" wording in the missing launch asset error with the likely cause and how the state resolves. ([#49130](https://github.com/expo/expo/pull/49130) by [@gwdp](https://github.com/gwdp))
28
+ - [Android] Log purge completion errors via `android.util.Log.e` directly instead of `logger.error`, so the failure path doesn't re-enter the `PersistentFileLog` dispatch queue from inside one of its own tasks. ([#46182](https://github.com/expo/expo/pull/46182) by [@jakequade-pc](https://github.com/jakequade-pc))
29
+
30
+ ## 56.0.25 — 2026-08-17
31
+
32
+ ### 🐛 Bug fixes
33
+
34
+ - Reject updates whose asset key or file extension contains a path separator, which previously let a manifest write and delete files outside the updates directory. ([#48762](https://github.com/expo/expo/pull/48762), [#48763](https://github.com/expo/expo/pull/48763) by [@alanjhughes](https://github.com/alanjhughes))
35
+
36
+ ### 💡 Others
37
+
38
+ - [iOS] Link `libc++` in the test spec so the unit test bundle resolves the C++ symbols it pulls from `ExpoModulesCore`. ([#48762](https://github.com/expo/expo/pull/48762) by [@alanjhughes](https://github.com/alanjhughes))
39
+
13
40
  ## 56.0.24 — 2026-08-06
14
41
 
15
42
  ### 🐛 Bug fixes
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '56.0.24'
45
+ version = '56.0.26'
46
46
 
47
47
  // Utility method to derive boolean values from the environment or from Java properties,
48
48
  // and return them as strings to be used in BuildConfig fields
@@ -89,7 +89,7 @@ android {
89
89
  namespace "expo.modules.updates"
90
90
  defaultConfig {
91
91
  versionCode 31
92
- versionName '56.0.24'
92
+ versionName '56.0.26'
93
93
  consumerProguardFiles("proguard-rules.pro")
94
94
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
95
95
 
@@ -4,6 +4,7 @@ import android.app.Activity
4
4
  import android.content.Context
5
5
  import android.net.Uri
6
6
  import android.os.Bundle
7
+ import android.util.Log
7
8
  import com.facebook.react.ReactHost
8
9
  import com.facebook.react.bridge.ReactContext
9
10
  import com.facebook.react.devsupport.interfaces.DevSupportManager
@@ -18,7 +19,6 @@ import expo.modules.updates.events.IUpdatesEventManager
18
19
  import expo.modules.updates.events.UpdatesEventManager
19
20
  import expo.modules.updates.launcher.Launcher.LauncherCallback
20
21
  import expo.modules.updates.loader.FileDownloader
21
- import expo.modules.updates.logging.UpdatesErrorCode
22
22
  import expo.modules.updates.logging.UpdatesLogReader
23
23
  import expo.modules.updates.logging.UpdatesLogger
24
24
  import expo.modules.updates.manifest.EmbeddedManifestUtils
@@ -90,7 +90,12 @@ class EnabledUpdatesController(
90
90
  private fun purgeUpdatesLogsOlderThanOneDay() {
91
91
  UpdatesLogReader(context.filesDir).purgeLogEntries {
92
92
  if (it != null) {
93
- logger.error("UpdatesLogReader: error in purgeLogEntries", it, UpdatesErrorCode.Unknown)
93
+ // Log directly via android.util.Log rather than through `logger.error`,
94
+ // which writes via the PersistentFileLog dispatch queue. This callback
95
+ // is invoked from inside one of that queue's own tasks, so feeding
96
+ // another entry back into the queue from here re-enters it. Bypassing
97
+ // the queue keeps the failure path off its own back.
98
+ Log.e("expo-updates", "UpdatesLogReader: error in purgeLogEntries", it)
94
99
  }
95
100
  }
96
101
  }
@@ -166,6 +166,17 @@ object UpdatesUtils {
166
166
  }
167
167
  }
168
168
 
169
+ /**
170
+ * Asset filenames are built from manifest-controlled values, so a filename holding a path
171
+ * separator or a `..` component would resolve outside the updates directory.
172
+ */
173
+ fun isSafeFilename(filename: String): Boolean {
174
+ return filename.isNotEmpty() &&
175
+ filename != "." &&
176
+ filename != ".." &&
177
+ filename.none { it == '/' || it == '\\' || it == '\u0000' }
178
+ }
179
+
169
180
  fun shouldCheckForUpdateOnLaunch(
170
181
  updatesConfiguration: UpdatesConfiguration,
171
182
  logger: UpdatesLogger,
@@ -2,10 +2,12 @@ package expo.modules.updates.db
2
2
 
3
3
  import android.util.Log
4
4
  import expo.modules.updates.UpdatesConfiguration
5
+ import expo.modules.updates.UpdatesUtils
5
6
  import expo.modules.updates.db.entity.AssetEntity
6
7
  import expo.modules.updates.db.entity.UpdateEntity
7
8
  import expo.modules.updates.manifest.ManifestMetadata
8
9
  import expo.modules.updates.selectionpolicy.SelectionPolicy
10
+ import expo.modules.updates.utils.AndroidResourceAssetUtils
9
11
  import java.io.File
10
12
 
11
13
  /**
@@ -47,7 +49,20 @@ object Reaper {
47
49
  )
48
50
  continue
49
51
  }
50
- val path = File(updatesDirectory, asset.relativePath)
52
+ val relativePath = asset.relativePath
53
+ // Embedded assets are served from the APK, so there is no file here to delete.
54
+ if (relativePath == null || AndroidResourceAssetUtils.isAndroidResourceAsset(relativePath)) {
55
+ continue
56
+ }
57
+ // A row written before asset filenames were validated may point outside the updates directory.
58
+ if (!UpdatesUtils.isSafeFilename(relativePath)) {
59
+ Log.e(
60
+ TAG,
61
+ "Refusing to delete asset with URL " + asset.url + " at unsafe path " + relativePath
62
+ )
63
+ continue
64
+ }
65
+ val path = File(updatesDirectory, relativePath)
51
66
  try {
52
67
  if (path.exists() && !path.delete()) {
53
68
  Log.e(TAG, "Failed to delete asset with URL " + asset.url + " at path " + path.toString())
@@ -87,7 +87,7 @@ class DatabaseLauncher(
87
87
  // verify that we have all assets on disk
88
88
  // according to the database, we should, but something could have gone wrong on disk
89
89
  val launchAsset = database.updateDao().loadLaunchAssetForUpdate(launchedUpdate!!.id)
90
- ?: throw Exception("Launch asset not found for update; this should never happen. Debug info: ${launchedUpdate!!.debugInfo()}")
90
+ ?: throw Exception("Launch asset not found for update. The update row has no launch asset; an interrupted registration in an older version of expo-updates can leave an update in this state, and it is replaced once a newer update is downloaded. Debug info: ${launchedUpdate!!.debugInfo()}")
91
91
 
92
92
  if (launchAsset.relativePath == null) {
93
93
  throw Exception("Launch asset relative path should not be null. Debug info: ${launchedUpdate!!.debugInfo()}")
@@ -161,6 +161,15 @@ class DatabaseLauncher(
161
161
  if (!configuration.hasEmbeddedUpdate && embeddedUpdate?.updateEntity?.id == update.id) {
162
162
  continue
163
163
  }
164
+
165
+ // An update with no launch asset can never launch. Excluding it here lets the loader
166
+ // re-run and repair the row instead of failing every cold start.
167
+ if (update.status != UpdateStatus.DEVELOPMENT &&
168
+ database.updateDao().loadLaunchAssetForUpdate(update.id) == null
169
+ ) {
170
+ logger.warn("Skipping launchable update with no launch asset. Debug info: ${update.debugInfo()}")
171
+ continue
172
+ }
164
173
  filteredLaunchableUpdates.add(update)
165
174
  }
166
175
  val manifestFilters = ManifestMetadata.getManifestFilters(database, configuration)
@@ -687,6 +687,12 @@ class FileDownloader(
687
687
  }
688
688
 
689
689
  val filename = UpdatesUtils.createFilenameForAsset(asset)
690
+ if (!UpdatesUtils.isSafeFilename(filename)) {
691
+ val message = "Failed to download asset ${asset.key}"
692
+ val error = IOException("Asset filename \"$filename\" would resolve outside the updates directory")
693
+ logger.error(message, error, UpdatesErrorCode.AssetsFailedToLoad)
694
+ throw IOException(message, error)
695
+ }
690
696
  val path = File(destinationDirectory, filename)
691
697
 
692
698
  if (path.exists()) {
@@ -1,6 +1,7 @@
1
1
  package expo.modules.updates.loader
2
2
 
3
3
  import android.content.Context
4
+ import androidx.room.withTransaction
4
5
  import expo.modules.updates.UpdatesConfiguration
5
6
  import expo.modules.updates.UpdatesUtils
6
7
  import expo.modules.updates.db.UpdatesDatabase
@@ -17,6 +18,7 @@ import expo.modules.updates.logging.UpdatesErrorCode
17
18
  import expo.modules.updates.logging.UpdatesLogger
18
19
  import expo.modules.updates.manifest.ManifestMetadata
19
20
  import expo.modules.updates.manifest.Update
21
+ import kotlinx.coroutines.CancellationException
20
22
  import kotlinx.coroutines.CoroutineScope
21
23
  import kotlinx.coroutines.Dispatchers
22
24
  import kotlinx.coroutines.SupervisorJob
@@ -164,21 +166,32 @@ abstract class Loader protected constructor(
164
166
  database.updateDao().setUpdateScopeKey(existingUpdateEntity, newUpdateEntity.scopeKey)
165
167
  }
166
168
 
167
- if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY) {
169
+ // A READY update with no launch asset is not actually ready. Fall through to
170
+ // downloadAllAssets so its assets are re-registered instead of staying broken forever.
171
+ if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY &&
172
+ database.updateDao().loadLaunchAssetForUpdate(existingUpdateEntity.id) != null
173
+ ) {
168
174
  // hooray, we already have this update downloaded and ready to go!
169
175
  updateEntity = existingUpdateEntity
170
176
  return finish()
171
177
  } else {
178
+ val insertUpdateEntityOnFinish: Boolean
172
179
  if (existingUpdateEntity == null) {
173
- // no update already exists with this ID, so we need to insert it and download everything.
180
+ // no update already exists with this ID, so we need to download everything.
174
181
  updateEntity = newUpdateEntity
175
- database.updateDao().insertUpdate(updateEntity!!)
182
+ // EMBEDDED is already in the launchable set, so a row inserted before its launch asset exists
183
+ // gets picked as launchable and then fails every launch.
184
+ insertUpdateEntityOnFinish = newUpdateEntity.status == UpdateStatus.EMBEDDED
185
+ if (!insertUpdateEntityOnFinish) {
186
+ database.updateDao().insertUpdate(updateEntity!!)
187
+ }
176
188
  } else {
177
189
  // we've already partially downloaded the update, so we should use the existing entity.
178
190
  // however, it's not ready, so we should try to download all the assets again.
179
191
  updateEntity = existingUpdateEntity
192
+ insertUpdateEntityOnFinish = false
180
193
  }
181
- return downloadAllAssets(update)
194
+ return downloadAllAssets(update, insertUpdateEntityOnFinish)
182
195
  }
183
196
  }
184
197
 
@@ -188,7 +201,7 @@ abstract class Loader protected constructor(
188
201
  ERRORED
189
202
  }
190
203
 
191
- private suspend fun downloadAllAssets(update: Update): LoaderResult {
204
+ private suspend fun downloadAllAssets(update: Update, insertUpdateEntityOnFinish: Boolean): LoaderResult {
192
205
  val assetList = update.assetEntityList.distinctBy { it.key }
193
206
  assetTotal = assetList.size
194
207
 
@@ -235,26 +248,34 @@ abstract class Loader protected constructor(
235
248
  assetDownloadJobs.awaitAll()
236
249
 
237
250
  try {
238
- for (asset in existingAssetList) {
239
- val existingAssetFound = database.assetDao()
240
- .addExistingAssetToUpdate(updateEntity!!, asset, asset.isLaunchAsset)
241
- if (!existingAssetFound) {
242
- // the database and filesystem have gotten out of sync
243
- // do our best to create a new entry for this file even though it already existed on disk
244
- // TODO: we should probably get rid of this assumption that if an asset exists on disk with the same filename, it's the same asset
245
- var hash: ByteArray? = null
246
- try {
247
- hash = UpdatesUtils.sha256(File(updatesDirectory, asset.relativePath))
248
- } catch (_: Exception) {
251
+ database.withTransaction {
252
+ if (insertUpdateEntityOnFinish) {
253
+ database.updateDao().insertUpdate(updateEntity!!)
254
+ }
255
+
256
+ for (asset in existingAssetList) {
257
+ val existingAssetFound = database.assetDao()
258
+ .addExistingAssetToUpdate(updateEntity!!, asset, asset.isLaunchAsset)
259
+ if (!existingAssetFound) {
260
+ // the database and filesystem have gotten out of sync
261
+ // do our best to create a new entry for this file even though it already existed on disk
262
+ // TODO: we should probably get rid of this assumption that if an asset exists on disk with the same filename, it's the same asset
263
+ var hash: ByteArray? = null
264
+ try {
265
+ hash = UpdatesUtils.sha256(File(updatesDirectory, asset.relativePath))
266
+ } catch (_: Exception) {
267
+ }
268
+ asset.downloadTime = Date()
269
+ asset.hash = hash
270
+ finishedAssetList.add(asset)
249
271
  }
250
- asset.downloadTime = Date()
251
- asset.hash = hash
252
- finishedAssetList.add(asset)
253
272
  }
254
- }
255
273
 
256
- database.assetDao().insertAssets(finishedAssetList, updateEntity!!)
257
- database.updateDao().markUpdateFinished(updateEntity!!)
274
+ database.assetDao().insertAssets(finishedAssetList, updateEntity!!)
275
+ database.updateDao().markUpdateFinished(updateEntity!!)
276
+ }
277
+ } catch (e: CancellationException) {
278
+ throw e
258
279
  } catch (e: Exception) {
259
280
  throw IOException("Error while adding new update to database", e)
260
281
  }
@@ -3,7 +3,6 @@ package expo.modules.updates.logging
3
3
  import expo.modules.jsonutils.getNullable
4
4
  import expo.modules.jsonutils.require
5
5
  import org.json.JSONArray
6
- import org.json.JSONException
7
6
  import org.json.JSONObject
8
7
 
9
8
  /**
@@ -59,7 +58,11 @@ data class UpdatesLogEntry(
59
58
  List(jsonArray.length()) { i -> jsonArray.getString(i) }
60
59
  }
61
60
  )
62
- } catch (e: JSONException) {
61
+ } catch (e: Exception) {
62
+ // `JSONObject.require<T>` can surface a ClassCastException via a blind
63
+ // cast in the catch-all branch (e.g. an int-sized numeric `duration`
64
+ // token surfaces as `Integer`), which would otherwise escape and
65
+ // propagate to the caller.
63
66
  null
64
67
  }
65
68
  }
@@ -54,6 +54,6 @@ class UpdatesLogReader(
54
54
  }
55
55
 
56
56
  companion object {
57
- private const val ONE_DAY_MILLISECONDS = 86400
57
+ private const val ONE_DAY_MILLISECONDS = 86_400_000L
58
58
  }
59
59
  }
@@ -14,6 +14,7 @@ import expo.modules.updates.db.enums.UpdateStatus
14
14
  import org.json.JSONArray
15
15
  import org.json.JSONException
16
16
  import org.json.JSONObject
17
+ import java.io.IOException
17
18
  import java.text.ParseException
18
19
  import java.util.*
19
20
 
@@ -95,27 +96,43 @@ class ExpoUpdatesUpdate private constructor(
95
96
  companion object {
96
97
  private val TAG = Update::class.java.simpleName
97
98
 
98
- @Throws(JSONException::class)
99
+ @Throws(Exception::class)
99
100
  fun fromExpoUpdatesManifest(
100
101
  manifest: ExpoUpdatesManifest,
101
102
  extensions: JSONObject?,
102
103
  configuration: UpdatesConfiguration
103
- ): ExpoUpdatesUpdate = ExpoUpdatesUpdate(
104
- manifest,
105
- id = UUID.fromString(manifest.getID()),
106
- configuration.scopeKey,
107
- commitTime = try {
108
- UpdatesUtils.parseDateString(manifest.getCreatedAt())
109
- } catch (e: ParseException) {
110
- Log.e(TAG, "Could not parse manifest createdAt string; falling back to current time", e)
111
- Date()
112
- },
113
- runtimeVersion = manifest.getRuntimeVersion(),
114
- launchAsset = manifest.getLaunchAsset(),
115
- assets = manifest.getAssets(),
116
- extensions = extensions,
117
- url = configuration.updateUrl,
118
- requestHeaders = configuration.requestHeaders
119
- )
104
+ ): ExpoUpdatesUpdate {
105
+ val update = ExpoUpdatesUpdate(
106
+ manifest,
107
+ id = UUID.fromString(manifest.getID()),
108
+ configuration.scopeKey,
109
+ commitTime = try {
110
+ UpdatesUtils.parseDateString(manifest.getCreatedAt())
111
+ } catch (e: ParseException) {
112
+ Log.e(TAG, "Could not parse manifest createdAt string; falling back to current time", e)
113
+ Date()
114
+ },
115
+ runtimeVersion = manifest.getRuntimeVersion(),
116
+ launchAsset = manifest.getLaunchAsset(),
117
+ assets = manifest.getAssets(),
118
+ extensions = extensions,
119
+ url = configuration.updateUrl,
120
+ requestHeaders = configuration.requestHeaders
121
+ )
122
+
123
+ update.assetEntityList.forEach { asset ->
124
+ val filename = UpdatesUtils.createFilenameForAsset(asset)
125
+ if (!UpdatesUtils.isSafeFilename(filename)) {
126
+ throw IOException(
127
+ "Update ${manifest.getID()} could not be loaded because the asset filename " +
128
+ "\"$filename\" is not a valid filename. Assets are stored under their key and file " +
129
+ "extension, so neither may contain a path separator. Check the server that produced " +
130
+ "this manifest."
131
+ )
132
+ }
133
+ }
134
+
135
+ return update
136
+ }
120
137
  }
121
138
  }
@@ -32,6 +32,11 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher {
32
32
  public var launchAssetUrl: URL?
33
33
  public var assetFilesMap: [String: String]?
34
34
 
35
+ /// Set when the launched update is served directly from the embedded app binary. Tracked
36
+ /// explicitly because `assetFilesMap` is now populated with the bundle-resolved embedded assets,
37
+ /// so it can no longer double as the "using embedded assets" signal.
38
+ private var launchedFromEmbeddedBundle = false
39
+
35
40
  private let launcherQueue: DispatchQueue
36
41
  private var completedAssets: Int
37
42
  private let config: UpdatesConfig
@@ -54,7 +59,7 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher {
54
59
  }
55
60
 
56
61
  public func isUsingEmbeddedAssets() -> Bool {
57
- return assetFilesMap == nil
62
+ return launchedFromEmbeddedBundle
58
63
  }
59
64
 
60
65
  public static func launchableUpdate(
@@ -178,7 +183,10 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher {
178
183
  }
179
184
 
180
185
  if launchedUpdate.status == UpdateStatus.StatusEmbedded {
181
- precondition(assetFilesMap == nil, "assetFilesMap should be null for embedded updates")
186
+ launchedFromEmbeddedBundle = true
187
+ // The embedded update's assets aren't copied into the cache, so resolve them from the app
188
+ // binary. Populating the map here keeps `Updates.localAssets` available instead of empty.
189
+ assetFilesMap = UpdatesUtils.embeddedAssetsMap(withConfig: config, database: database, logger: logger)
182
190
  launchAssetUrl = updatesBundle.url(
183
191
  forResource: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFilename,
184
192
  withExtension: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFileType
@@ -8,14 +8,12 @@
8
8
  import Foundation
9
9
 
10
10
  /**
11
- * Subclass of AppLoader which handles copying the embedded update's assets into the
12
- * expo-updates cache location.
11
+ * Subclass of AppLoader which loads the embedded update.
13
12
  *
14
- * Rather than launching the embedded update directly from its location in the app bundle/apk, we
15
- * first try to read it into the expo-updates cache and database and launch it like any other
16
- * update. The benefits of this include (a) a single code path for launching most updates and (b)
17
- * assets included in embedded updates and copied into the cache in this way do not need to be
18
- * redownloaded if included in future updates.
13
+ * By default (`EX_UPDATES_COPY_EMBEDDED_ASSETS` off) the update is registered but its assets aren't
14
+ * read into the database; it keeps StatusEmbedded and launches from the app binary, so first launch
15
+ * stays fast. When the flag is on, assets are read into the database and it launches like any other
16
+ * update, so a later update reusing them avoids a redownload.
19
17
  */
20
18
  @objc(EXUpdatesEmbeddedAppLoader)
21
19
  @objcMembers
@@ -29,6 +27,24 @@ public final class EmbeddedAppLoader: AppLoader {
29
27
 
30
28
  private static var embeddedManifestInternal: EmbeddedUpdate?
31
29
 
30
+ /// Whether to read and hash embedded assets into the database on first launch. Defaults to the
31
+ /// build-time `EX_UPDATES_COPY_EMBEDDED_ASSETS` flag (off). Overridable for testing.
32
+ internal var shouldCopyEmbeddedAssets: Bool = UpdatesUtils.shouldCopyEmbeddedAssets()
33
+
34
+ internal let completionQueue: DispatchQueue
35
+
36
+ public required override init(
37
+ config: UpdatesConfig,
38
+ logger: UpdatesLogger,
39
+ database: UpdatesDatabase,
40
+ directory: URL,
41
+ launchedUpdate: Update?,
42
+ completionQueue: DispatchQueue
43
+ ) {
44
+ self.completionQueue = completionQueue
45
+ super.init(config: config, logger: logger, database: database, directory: directory, launchedUpdate: launchedUpdate, completionQueue: completionQueue)
46
+ }
47
+
32
48
  /**
33
49
  Gets the embedded update.
34
50
  If the `UpdatesConfig.hasEmbeddedUpdate` is false, it returns nil
@@ -136,6 +152,18 @@ public final class EmbeddedAppLoader: AppLoader {
136
152
  self.assetBlock = assetBlock
137
153
  self.successBlock = successBlock
138
154
  self.errorBlock = errorBlock
155
+ startEmbeddedLoad(fromEmbeddedManifest: embeddedManifest)
156
+ }
157
+
158
+ /// Loads the embedded update: with copying off (default) registers it without ingesting assets,
159
+ /// otherwise loads them like any other update. Separate from `loadUpdateResponseFromEmbeddedManifest`
160
+ /// so tests can pass a manifest without reading the bundle.
161
+ internal func startEmbeddedLoad(fromEmbeddedManifest embeddedManifest: Update) {
162
+ if !shouldCopyEmbeddedAssets {
163
+ registerEmbeddedUpdateWithoutCopying(embeddedManifest)
164
+ return
165
+ }
166
+
139
167
  startLoading(fromUpdateResponse: UpdateResponse(
140
168
  responseHeaderData: nil,
141
169
  manifestUpdateResponsePart: ManifestUpdateResponsePart(updateManifest: embeddedManifest),
@@ -143,6 +171,40 @@ public final class EmbeddedAppLoader: AppLoader {
143
171
  ))
144
172
  }
145
173
 
174
+ /// Registers the embedded update without reading, hashing, or copying its assets. A StatusEmbedded
175
+ /// update resolves its bundle and assets from the app binary at launch. Tradeoff: a future update
176
+ /// reusing an embedded asset re-downloads it.
177
+ private func registerEmbeddedUpdateWithoutCopying(_ embeddedManifest: Update) {
178
+ database.databaseQueue.async {
179
+ do {
180
+ let existingUpdate = try? self.database.update(withId: embeddedManifest.updateId, config: self.config)
181
+ if existingUpdate == nil {
182
+ try self.database.addUpdate(embeddedManifest, config: self.config)
183
+ }
184
+ // Mark finished (keep = 1) so the reaper retains it; it stays StatusEmbedded since copying is off.
185
+ try self.database.markUpdateFinished(embeddedManifest)
186
+ } catch {
187
+ let errorBlock = self.errorBlock
188
+ self.completionQueue.async {
189
+ errorBlock?(UpdatesError.appLoaderUnknownError(cause: error))
190
+ self.reset()
191
+ }
192
+ return
193
+ }
194
+
195
+ let successBlock = self.successBlock
196
+ let updateResponse = UpdateResponse(
197
+ responseHeaderData: nil,
198
+ manifestUpdateResponsePart: ManifestUpdateResponsePart(updateManifest: embeddedManifest),
199
+ directiveUpdateResponsePart: nil
200
+ )
201
+ self.completionQueue.async {
202
+ successBlock?(updateResponse)
203
+ self.reset()
204
+ }
205
+ }
206
+ }
207
+
146
208
  override public func downloadAsset(_ asset: UpdateAsset, extraHeaders: [String: Any]) {
147
209
  FileDownloader.assetFilesQueue.async {
148
210
  self.handleAssetDownloadAlreadyExists(asset)
@@ -105,6 +105,13 @@ public final class RemoteAppLoader: AppLoader {
105
105
  }
106
106
 
107
107
  override public func downloadAsset(_ asset: UpdateAsset, extraHeaders: [String: Any]) {
108
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
109
+ self.handleAssetDownload(
110
+ withError: UpdatesError.remoteAppLoaderUnsafeAssetFilename(filename: asset.filename),
111
+ asset: asset
112
+ )
113
+ return
114
+ }
108
115
  let urlOnDisk = self.directory.appendingPathComponent(asset.filename)
109
116
 
110
117
  let progressBlock = { [weak self] fractionCompleted in
@@ -160,7 +160,7 @@ public final class UpdatesDatabase: NSObject {
160
160
  )
161
161
  } catch {
162
162
  sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
163
- return
163
+ throw error
164
164
  }
165
165
 
166
166
  // statements must stay in precisely this order for last_insert_rowid() to work correctly
@@ -170,7 +170,7 @@ public final class UpdatesDatabase: NSObject {
170
170
  _ = try execute(sql: updateSql, withArgs: [updateId])
171
171
  } catch {
172
172
  sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
173
- return
173
+ throw error
174
174
  }
175
175
  }
176
176
 
@@ -181,7 +181,7 @@ public final class UpdatesDatabase: NSObject {
181
181
  _ = try execute(sql: updateInsertSql, withArgs: [updateId])
182
182
  } catch {
183
183
  sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
184
- return
184
+ throw error
185
185
  }
186
186
  }
187
187
 
@@ -280,7 +280,11 @@ public final class UpdatesDatabase: NSObject {
280
280
  }
281
281
 
282
282
  public func markUpdateFinished(_ update: Update) throws {
283
- if update.status != UpdateStatus.StatusDevelopment {
283
+ // With copying off, embedded updates have no asset rows, so they must stay StatusEmbedded to keep
284
+ // launching from the bundle. Promoting to StatusReady would route launch through the database
285
+ // asset path, which has no rows for them.
286
+ let keepEmbedded = update.status == UpdateStatus.StatusEmbedded && !UpdatesUtils.shouldCopyEmbeddedAssets()
287
+ if update.status != UpdateStatus.StatusDevelopment && !keepEmbedded {
284
288
  update.status = UpdateStatus.StatusReady
285
289
  }
286
290
 
@@ -440,7 +444,23 @@ public final class UpdatesDatabase: NSObject {
440
444
  )
441
445
 
442
446
  let rows = try execute(sql: sql, withArgs: [config.scopeKey])
443
- return rows.map { row in
447
+
448
+ // A ready row with no launch asset is corrupt (e.g. from an interrupted registration) and
449
+ // would fail every launch. Demote it for the loader to retry instead of offering it.
450
+ var launchableRows: [[String: Any?]] = []
451
+ for row in rows {
452
+ let status: NSNumber = row.requiredValue(forKey: "status")
453
+ let launchAssetId: NSNumber? = row.optionalValue(forKey: "launch_asset_id")
454
+ if status.intValue == UpdateStatus.StatusReady.rawValue && launchAssetId == nil {
455
+ let updateId: UUID = row.requiredValue(forKey: "id")
456
+ let demoteSql = "UPDATE updates SET status = ?1 WHERE id = ?2;"
457
+ _ = try execute(sql: demoteSql, withArgs: [UpdateStatus.StatusPending.rawValue, updateId])
458
+ } else {
459
+ launchableRows.append(row)
460
+ }
461
+ }
462
+
463
+ return launchableRows.map { row in
444
464
  update(withRow: row, config: config)
445
465
  }
446
466
  }
@@ -71,6 +71,10 @@ public final class UpdatesReaper: NSObject {
71
71
 
72
72
  let beginDeleteAssets = Date()
73
73
  for asset in assetsForDeletion {
74
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
75
+ logger.warn(message: "Refusing to delete asset at unsafe path \(asset.filename)")
76
+ continue
77
+ }
74
78
  let localUrl = directory.appendingPathComponent(asset.filename)
75
79
  if FileManager.default.fileExists(atPath: localUrl.path) {
76
80
  do {
@@ -77,6 +77,7 @@ public enum UpdateStatus: Int {
77
77
  public enum UpdateError: Error, Sendable, LocalizedError {
78
78
  case invalidExpoProtocolVersion(protocolVersion: Int)
79
79
  case legacyManifestInstantiationInvalid
80
+ case unsafeAssetFilename(updateId: String, filename: String)
80
81
 
81
82
  public var errorDescription: String? {
82
83
  switch self {
@@ -84,6 +85,10 @@ public enum UpdateError: Error, Sendable, LocalizedError {
84
85
  return "Invalid Expo Updates protocol version: \(protocolVersion)"
85
86
  case .legacyManifestInstantiationInvalid:
86
87
  return "This version of expo-updates can no longer load legacy manifests"
88
+ case let .unsafeAssetFilename(updateId, filename):
89
+ return "Update \(updateId) could not be loaded because the asset filename \"\(filename)\" " +
90
+ "is not a valid filename. Assets are stored under their key and file extension, so neither " +
91
+ "may contain a path separator. Check the server that produced this manifest."
87
92
  }
88
93
  }
89
94
  }
@@ -157,12 +162,21 @@ public class Update: NSObject {
157
162
  }
158
163
  switch protocolVersion {
159
164
  case 0, 1:
160
- return ExpoUpdatesUpdate.update(
165
+ let update = ExpoUpdatesUpdate.update(
161
166
  withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: withManifest),
162
167
  extensions: extensions,
163
168
  config: config,
164
169
  database: database
165
170
  )
171
+ try update.assets()?.forEach { asset in
172
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
173
+ throw UpdateError.unsafeAssetFilename(
174
+ updateId: update.updateId.uuidString,
175
+ filename: asset.filename
176
+ )
177
+ }
178
+ }
179
+ return update
166
180
  default:
167
181
  throw UpdateError.invalidExpoProtocolVersion(protocolVersion: protocolVersion)
168
182
  }
@@ -26,6 +26,7 @@ public enum UpdatesError: Error, Sendable, LocalizedError {
26
26
  case fileDownloaderFailedUpdateIDsFailure(cause: Error)
27
27
  case fileDownloaderUnknownError(cause: Error)
28
28
  case remoteAppLoaderAssetMissingUrl
29
+ case remoteAppLoaderUnsafeAssetFilename(filename: String)
29
30
  case remoteAppLoaderHeaderDataError(cause: Error)
30
31
  case remoteAppLoaderUnknownError(cause: Error)
31
32
  case appLoaderFailedToLoadAllAssets
@@ -92,6 +93,8 @@ public enum UpdatesError: Error, Sendable, LocalizedError {
92
93
  return "Unknown error: \(cause.localizedDescription)"
93
94
  case .remoteAppLoaderAssetMissingUrl:
94
95
  return "Failed to download asset with no URL provided"
96
+ case let .remoteAppLoaderUnsafeAssetFilename(filename):
97
+ return "Failed to download asset: filename \"\(filename)\" would resolve outside the updates directory"
95
98
  case let .remoteAppLoaderHeaderDataError(cause):
96
99
  return "Error persisting header data to disk: \(cause.localizedDescription)"
97
100
  case .appLoaderFailedToLoadAllAssets:
@@ -75,6 +75,23 @@ public final class UpdatesUtils: NSObject {
75
75
  return updatesDirectory
76
76
  }
77
77
 
78
+ /**
79
+ * Asset filenames are built from manifest-controlled values, so a filename holding a path
80
+ * separator or a `..` component would resolve outside the updates directory.
81
+ *
82
+ * Matching is done on unicode scalars because `String.contains` compares grapheme clusters, and
83
+ * a separator followed by a combining mark forms one cluster that does not equal the separator.
84
+ * The filesystem still sees the separator byte.
85
+ */
86
+ public static func isSafeFilename(_ filename: String) -> Bool {
87
+ return !filename.isEmpty &&
88
+ filename != "." &&
89
+ filename != ".." &&
90
+ !filename.unicodeScalars.contains("/") &&
91
+ !filename.unicodeScalars.contains("\\") &&
92
+ !filename.unicodeScalars.contains("\0")
93
+ }
94
+
78
95
  // MARK: - Internal methods
79
96
 
80
97
  public static func defaultNativeStateMachineContextJson() -> [String: Any?] {
@@ -165,6 +182,14 @@ public final class UpdatesUtils: NSObject {
165
182
  #endif
166
183
  }
167
184
 
185
+ internal static func shouldCopyEmbeddedAssets() -> Bool {
186
+ #if EX_UPDATES_COPY_EMBEDDED_ASSETS
187
+ return true
188
+ #else
189
+ return false
190
+ #endif
191
+ }
192
+
168
193
  internal static func runBlockOnMainThread(_ block: @escaping () -> Void) {
169
194
  if Thread.isMainThread {
170
195
  block()
@@ -9,6 +9,9 @@ end
9
9
  if ENV['EX_UPDATES_CUSTOM_INIT'] != '1'
10
10
  ENV['EX_UPDATES_CUSTOM_INIT'] = podfile_properties['updatesCustomInit'] == 'true' ? '1' : '0'
11
11
  end
12
+ if ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] != '1'
13
+ ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] = podfile_properties['updatesCopyEmbeddedAssets'] == 'true' ? '1' : '0'
14
+ end
12
15
 
13
16
  use_dev_client = false
14
17
  begin
@@ -72,6 +75,7 @@ Pod::Spec.new do |s|
72
75
 
73
76
  ex_updates_native_debug = ENV['EX_UPDATES_NATIVE_DEBUG'] == '1'
74
77
  ex_updates_custom_init = ENV['EX_UPDATES_CUSTOM_INIT'] == '1'
78
+ ex_updates_copy_embedded_assets = ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] == '1'
75
79
  if ex_updates_native_debug
76
80
  other_debug_c_flags << ' -DEX_UPDATES_NATIVE_DEBUG=1'
77
81
  other_debug_swift_flags << ' -DEX_UPDATES_NATIVE_DEBUG'
@@ -82,6 +86,12 @@ Pod::Spec.new do |s|
82
86
  other_release_c_flags << ' -DEX_UPDATES_CUSTOM_INIT=1'
83
87
  other_release_swift_flags << ' -DEX_UPDATES_CUSTOM_INIT'
84
88
  end
89
+ if ex_updates_copy_embedded_assets
90
+ other_debug_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
91
+ other_debug_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
92
+ other_release_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
93
+ other_release_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
94
+ end
85
95
  if use_dev_client
86
96
  other_debug_c_flags << ' -DUSE_DEV_CLIENT=1'
87
97
  other_debug_swift_flags << ' -DUSE_DEV_CLIENT'
@@ -141,6 +151,7 @@ Pod::Spec.new do |s|
141
151
  test_spec.dependency 'ExpoModulesTestCore'
142
152
 
143
153
  test_spec.pod_target_xcconfig = {
154
+ 'OTHER_LDFLAGS' => '$(inherited) -lc++',
144
155
  'USER_HEADER_SEARCH_PATHS' => '"${CONFIGURATION_TEMP_DIR}/EXUpdates.build/DerivedSources"',
145
156
  'GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS' => 'YES',
146
157
  'GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS' => 'YES',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "56.0.24",
3
+ "version": "56.0.26",
4
4
  "description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -60,10 +60,10 @@
60
60
  "picomatch": "^4.0.4",
61
61
  "ts-node": "^10.9.2",
62
62
  "xstate": "^4.37.2",
63
- "expo": "56.0.19",
64
- "expo-dev-client": "56.0.24",
65
- "@expo/metro-config": "56.0.18",
66
- "expo-module-scripts": "56.0.3"
63
+ "expo": "56.0.21",
64
+ "@expo/metro-config": "56.0.19",
65
+ "expo-module-scripts": "56.0.3",
66
+ "expo-dev-client": "56.0.26"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "expo": "*",
@@ -82,7 +82,7 @@
82
82
  "./scripts/with-node.sh"
83
83
  ]
84
84
  },
85
- "gitHead": "eebecc21d8d868d97e72bd61bb8e668fb2feb704",
85
+ "gitHead": "1b8eb0e13635d3ef52c1c11d305ff927d01390ae",
86
86
  "scripts": {
87
87
  "build": "expo-module build",
88
88
  "clean": "expo-module clean",
@@ -64,12 +64,14 @@ export async function createManifestForBuildAsync(
64
64
  'The hashAssetFiles Metro plugin is not configured. You need to add a metro.config.js to your project that configures Metro to use this plugin. See https://github.com/expo/expo/blob/main/packages/expo-updates/README.md#metroconfigjs for an example.'
65
65
  );
66
66
  }
67
- filterPlatformAssetScales(platform, asset.scales).forEach(function (scale, index) {
67
+ filterPlatformAssetScales(platform, asset.scales).forEach(function (scale) {
68
68
  const baseAssetInfoForManifest = {
69
69
  name: asset.name,
70
70
  type: asset.type,
71
71
  scale,
72
- packagerHash: asset.fileHashes[index],
72
+ // `fileHashes` is parallel to the unfiltered `asset.scales`, so it must be indexed by the
73
+ // scale's position there rather than by its position in the filtered list.
74
+ packagerHash: asset.fileHashes[asset.scales.indexOf(scale)],
73
75
  subdirectory: asset.httpServerLocation,
74
76
  };
75
77
  if (platform === 'ios') {