expo-updates 29.0.18 → 29.0.20
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 +17 -0
- package/android/build.gradle +2 -2
- package/android/src/main/java/expo/modules/updates/UpdatesUtils.kt +11 -0
- package/android/src/main/java/expo/modules/updates/db/Reaper.kt +16 -1
- package/android/src/main/java/expo/modules/updates/loader/FileDownloader.kt +6 -0
- package/android/src/main/java/expo/modules/updates/manifest/ExpoUpdatesUpdate.kt +35 -18
- package/ios/EXUpdates/AppLauncher/AppLauncherWithDatabase.swift +10 -2
- package/ios/EXUpdates/AppLoader/EmbeddedAppLoader.swift +69 -7
- package/ios/EXUpdates/AppLoader/RemoteAppLoader.swift +7 -0
- package/ios/EXUpdates/Database/UpdatesDatabase.swift +5 -1
- package/ios/EXUpdates/Database/UpdatesReaper.swift +4 -0
- package/ios/EXUpdates/Update/Update.swift +15 -1
- package/ios/EXUpdates/UpdatesError.swift +3 -0
- package/ios/EXUpdates/UpdatesUtils.swift +25 -0
- package/ios/EXUpdates.podspec +18 -1
- package/ios/Tests/EmbeddedAppLoaderTests.swift +94 -0
- package/package.json +2 -2
- package/utils/build/findUpProjectRoot.js +7 -6
- package/utils/src/findUpProjectRoot.ts +7 -5
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,23 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
+
## 29.0.20 — 2026-08-17
|
|
14
|
+
|
|
15
|
+
### 🐛 Bug fixes
|
|
16
|
+
|
|
17
|
+
- [iOS] Set `always_out_of_date` on the `Generate updates resources for expo-updates` script_phase to silence the Xcode "run script phase will run on every build" dependency-analysis warning. ([#47622](https://github.com/expo/expo/pull/47622) by [@ramonclaudio](https://github.com/ramonclaudio))
|
|
18
|
+
- 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))
|
|
19
|
+
|
|
20
|
+
### 💡 Others
|
|
21
|
+
|
|
22
|
+
- [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))
|
|
23
|
+
|
|
24
|
+
## 29.0.19 — 2026-07-15
|
|
25
|
+
|
|
26
|
+
### 💡 Others
|
|
27
|
+
|
|
28
|
+
- [Internal] Align find-up `package.json` search utilities ([#47127](https://github.com/expo/expo/pull/47127) by [@kitten](https://github.com/kitten))
|
|
29
|
+
|
|
13
30
|
## 29.0.18 — 2026-05-28
|
|
14
31
|
|
|
15
32
|
### 🐛 Bug fixes
|
package/android/build.gradle
CHANGED
|
@@ -42,7 +42,7 @@ expoModule {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
group = 'host.exp.exponent'
|
|
45
|
-
version = '29.0.
|
|
45
|
+
version = '29.0.20'
|
|
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
|
|
@@ -88,7 +88,7 @@ android {
|
|
|
88
88
|
namespace "expo.modules.updates"
|
|
89
89
|
defaultConfig {
|
|
90
90
|
versionCode 31
|
|
91
|
-
versionName '29.0.
|
|
91
|
+
versionName '29.0.20'
|
|
92
92
|
consumerProguardFiles("proguard-rules.pro")
|
|
93
93
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
94
94
|
|
|
@@ -157,6 +157,17 @@ object UpdatesUtils {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Asset filenames are built from manifest-controlled values, so a filename holding a path
|
|
162
|
+
* separator or a `..` component would resolve outside the updates directory.
|
|
163
|
+
*/
|
|
164
|
+
fun isSafeFilename(filename: String): Boolean {
|
|
165
|
+
return filename.isNotEmpty() &&
|
|
166
|
+
filename != "." &&
|
|
167
|
+
filename != ".." &&
|
|
168
|
+
filename.none { it == '/' || it == '\\' || it == '\u0000' }
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
fun shouldCheckForUpdateOnLaunch(
|
|
161
172
|
updatesConfiguration: UpdatesConfiguration,
|
|
162
173
|
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
|
|
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())
|
|
@@ -353,6 +353,12 @@ class FileDownloader(
|
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
val filename = UpdatesUtils.createFilenameForAsset(asset)
|
|
356
|
+
if (!UpdatesUtils.isSafeFilename(filename)) {
|
|
357
|
+
val message = "Failed to download asset ${asset.key}"
|
|
358
|
+
val error = IOException("Asset filename \"$filename\" would resolve outside the updates directory")
|
|
359
|
+
logger.error(message, error, UpdatesErrorCode.AssetsFailedToLoad)
|
|
360
|
+
throw IOException(message, error)
|
|
361
|
+
}
|
|
356
362
|
val path = File(destinationDirectory, filename)
|
|
357
363
|
|
|
358
364
|
if (path.exists()) {
|
|
@@ -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(
|
|
99
|
+
@Throws(Exception::class)
|
|
99
100
|
fun fromExpoUpdatesManifest(
|
|
100
101
|
manifest: ExpoUpdatesManifest,
|
|
101
102
|
extensions: JSONObject?,
|
|
102
103
|
configuration: UpdatesConfiguration
|
|
103
|
-
): ExpoUpdatesUpdate
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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 = Bundle.main.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
|
|
12
|
-
* expo-updates cache location.
|
|
11
|
+
* Subclass of AppLoader which loads the embedded update.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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)
|
|
@@ -99,6 +99,13 @@ public final class RemoteAppLoader: AppLoader {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
override public func downloadAsset(_ asset: UpdateAsset, extraHeaders: [String: Any]) {
|
|
102
|
+
guard UpdatesUtils.isSafeFilename(asset.filename) else {
|
|
103
|
+
self.handleAssetDownload(
|
|
104
|
+
withError: UpdatesError.remoteAppLoaderUnsafeAssetFilename(filename: asset.filename),
|
|
105
|
+
asset: asset
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
102
109
|
let urlOnDisk = self.directory.appendingPathComponent(asset.filename)
|
|
103
110
|
|
|
104
111
|
let progressBlock = { [weak self] fractionCompleted in
|
|
@@ -280,7 +280,11 @@ public final class UpdatesDatabase: NSObject {
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
public func markUpdateFinished(_ update: Update) throws {
|
|
283
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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:
|
|
@@ -59,6 +59,23 @@ public final class UpdatesUtils: NSObject {
|
|
|
59
59
|
return updatesDirectory
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Asset filenames are built from manifest-controlled values, so a filename holding a path
|
|
64
|
+
* separator or a `..` component would resolve outside the updates directory.
|
|
65
|
+
*
|
|
66
|
+
* Matching is done on unicode scalars because `String.contains` compares grapheme clusters, and
|
|
67
|
+
* a separator followed by a combining mark forms one cluster that does not equal the separator.
|
|
68
|
+
* The filesystem still sees the separator byte.
|
|
69
|
+
*/
|
|
70
|
+
public static func isSafeFilename(_ filename: String) -> Bool {
|
|
71
|
+
return !filename.isEmpty &&
|
|
72
|
+
filename != "." &&
|
|
73
|
+
filename != ".." &&
|
|
74
|
+
!filename.unicodeScalars.contains("/") &&
|
|
75
|
+
!filename.unicodeScalars.contains("\\") &&
|
|
76
|
+
!filename.unicodeScalars.contains("\0")
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
// MARK: - Internal methods
|
|
63
80
|
|
|
64
81
|
public static func defaultNativeStateMachineContextJson() -> [String: Any?] {
|
|
@@ -149,6 +166,14 @@ public final class UpdatesUtils: NSObject {
|
|
|
149
166
|
#endif
|
|
150
167
|
}
|
|
151
168
|
|
|
169
|
+
internal static func shouldCopyEmbeddedAssets() -> Bool {
|
|
170
|
+
#if EX_UPDATES_COPY_EMBEDDED_ASSETS
|
|
171
|
+
return true
|
|
172
|
+
#else
|
|
173
|
+
return false
|
|
174
|
+
#endif
|
|
175
|
+
}
|
|
176
|
+
|
|
152
177
|
internal static func runBlockOnMainThread(_ block: @escaping () -> Void) {
|
|
153
178
|
if Thread.isMainThread {
|
|
154
179
|
block()
|
package/ios/EXUpdates.podspec
CHANGED
|
@@ -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
|
|
@@ -61,6 +64,7 @@ Pod::Spec.new do |s|
|
|
|
61
64
|
|
|
62
65
|
ex_updates_native_debug = ENV['EX_UPDATES_NATIVE_DEBUG'] == '1'
|
|
63
66
|
ex_updates_custom_init = ENV['EX_UPDATES_CUSTOM_INIT'] == '1'
|
|
67
|
+
ex_updates_copy_embedded_assets = ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] == '1'
|
|
64
68
|
if ex_updates_native_debug
|
|
65
69
|
other_debug_c_flags << ' -DEX_UPDATES_NATIVE_DEBUG=1'
|
|
66
70
|
other_debug_swift_flags << ' -DEX_UPDATES_NATIVE_DEBUG'
|
|
@@ -71,6 +75,12 @@ Pod::Spec.new do |s|
|
|
|
71
75
|
other_release_c_flags << ' -DEX_UPDATES_CUSTOM_INIT=1'
|
|
72
76
|
other_release_swift_flags << ' -DEX_UPDATES_CUSTOM_INIT'
|
|
73
77
|
end
|
|
78
|
+
if ex_updates_copy_embedded_assets
|
|
79
|
+
other_debug_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
|
|
80
|
+
other_debug_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
|
|
81
|
+
other_release_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
|
|
82
|
+
other_release_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
|
|
83
|
+
end
|
|
74
84
|
if use_dev_client
|
|
75
85
|
other_debug_c_flags << ' -DUSE_DEV_CLIENT=1'
|
|
76
86
|
other_debug_swift_flags << ' -DUSE_DEV_CLIENT'
|
|
@@ -99,11 +109,17 @@ Pod::Spec.new do |s|
|
|
|
99
109
|
|
|
100
110
|
if $expo_updates_create_updates_resources != false
|
|
101
111
|
force_bundling_flag = ex_updates_native_debug ? "export FORCE_BUNDLING=1\n" : ""
|
|
102
|
-
|
|
112
|
+
script_phase = {
|
|
103
113
|
:name => 'Generate updates resources for expo-updates',
|
|
104
114
|
:script => force_bundling_flag + 'bash -l -c "$PODS_TARGET_SRCROOT/../scripts/create-updates-resources-ios.sh"',
|
|
105
115
|
:execution_position => :before_compile
|
|
106
116
|
}
|
|
117
|
+
# :always_out_of_date is only available in CocoaPods 1.13.0 and later
|
|
118
|
+
if Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.13.0')
|
|
119
|
+
# always run the script without warning
|
|
120
|
+
script_phase[:always_out_of_date] = "1"
|
|
121
|
+
end
|
|
122
|
+
s.script_phase = script_phase
|
|
107
123
|
|
|
108
124
|
# Generate EXUpdates.bundle without existing resources
|
|
109
125
|
# `create-updates-resources-ios.sh` will generate updates resources in EXUpdates.bundle
|
|
@@ -121,6 +137,7 @@ Pod::Spec.new do |s|
|
|
|
121
137
|
test_spec.dependency 'ExpoModulesTestCore'
|
|
122
138
|
|
|
123
139
|
test_spec.pod_target_xcconfig = {
|
|
140
|
+
'OTHER_LDFLAGS' => '$(inherited) -lc++',
|
|
124
141
|
'USER_HEADER_SEARCH_PATHS' => '"${CONFIGURATION_TEMP_DIR}/EXUpdates.build/DerivedSources"',
|
|
125
142
|
'GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS' => 'YES',
|
|
126
143
|
'GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS' => 'YES',
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Copyright (c) 2024 650 Industries, Inc. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import Testing
|
|
4
|
+
|
|
5
|
+
@testable import EXUpdates
|
|
6
|
+
|
|
7
|
+
import EXManifests
|
|
8
|
+
|
|
9
|
+
@Suite("EmbeddedAppLoader", .serialized)
|
|
10
|
+
@MainActor
|
|
11
|
+
class EmbeddedAppLoaderTests {
|
|
12
|
+
var testDatabaseDir: URL
|
|
13
|
+
var db: UpdatesDatabase
|
|
14
|
+
|
|
15
|
+
init() throws {
|
|
16
|
+
let applicationSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).last
|
|
17
|
+
testDatabaseDir = applicationSupportDir!.appendingPathComponent("EmbeddedAppLoaderTests")
|
|
18
|
+
|
|
19
|
+
try? FileManager.default.removeItem(atPath: testDatabaseDir.path)
|
|
20
|
+
|
|
21
|
+
if !FileManager.default.fileExists(atPath: testDatabaseDir.path) {
|
|
22
|
+
try FileManager.default.createDirectory(atPath: testDatabaseDir.path, withIntermediateDirectories: true)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
db = UpdatesDatabase()
|
|
26
|
+
db.databaseQueue.sync {
|
|
27
|
+
try! db.openDatabase(inDirectory: testDatabaseDir, logger: UpdatesLogger())
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
deinit {
|
|
32
|
+
db.databaseQueue.sync {
|
|
33
|
+
db.closeDatabase()
|
|
34
|
+
}
|
|
35
|
+
try? FileManager.default.removeItem(atPath: testDatabaseDir.path)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test
|
|
39
|
+
func `no-copy registers the embedded update without ingesting non-launch assets`() async throws {
|
|
40
|
+
let config = try UpdatesConfig.config(fromDictionary: [
|
|
41
|
+
UpdatesConfig.EXUpdatesConfigUpdateUrlKey: "https://example.com",
|
|
42
|
+
UpdatesConfig.EXUpdatesConfigScopeKeyKey: "dummyScope",
|
|
43
|
+
UpdatesConfig.EXUpdatesConfigRuntimeVersionKey: "1",
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
// Build an embedded update with a couple of non-launch assets without touching the app bundle,
|
|
47
|
+
// mirroring UpdateTests' `works for embedded bare manifest`.
|
|
48
|
+
let embeddedManifestJSON: [String: Any] = [
|
|
49
|
+
"id": "0eef8214-4833-4089-9dff-b4138a14f196",
|
|
50
|
+
"commitTime": 1609975977832,
|
|
51
|
+
"assets": [
|
|
52
|
+
["packagerHash": "embedded-asset-1", "type": "png", "nsBundleFilename": "image1"],
|
|
53
|
+
["packagerHash": "embedded-asset-2", "type": "png", "nsBundleFilename": "image2"],
|
|
54
|
+
],
|
|
55
|
+
]
|
|
56
|
+
let embeddedUpdate = Update.update(
|
|
57
|
+
withRawEmbeddedManifest: embeddedManifestJSON,
|
|
58
|
+
config: config,
|
|
59
|
+
database: db
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
let loader = EmbeddedAppLoader(
|
|
63
|
+
config: config,
|
|
64
|
+
logger: UpdatesLogger(),
|
|
65
|
+
database: db,
|
|
66
|
+
directory: testDatabaseDir,
|
|
67
|
+
launchedUpdate: nil,
|
|
68
|
+
completionQueue: DispatchQueue.global(qos: .default)
|
|
69
|
+
)
|
|
70
|
+
loader.shouldCopyEmbeddedAssets = false
|
|
71
|
+
|
|
72
|
+
let success: Bool = await withCheckedContinuation { continuation in
|
|
73
|
+
loader.updateResponseBlock = { _ in true }
|
|
74
|
+
loader.assetBlock = { _, _, _, _ in }
|
|
75
|
+
loader.successBlock = { _ in continuation.resume(returning: true) }
|
|
76
|
+
loader.errorBlock = { _ in continuation.resume(returning: false) }
|
|
77
|
+
loader.startEmbeddedLoad(fromEmbeddedManifest: embeddedUpdate)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#expect(success == true)
|
|
81
|
+
|
|
82
|
+
db.databaseQueue.sync {
|
|
83
|
+
// The update row is registered and stays StatusEmbedded (not promoted to StatusReady), so later
|
|
84
|
+
// launches keep resolving it from the app bundle rather than the empty database asset path.
|
|
85
|
+
let storedUpdate = try! db.update(withId: embeddedUpdate.updateId, config: config)
|
|
86
|
+
#expect(storedUpdate != nil)
|
|
87
|
+
#expect(storedUpdate?.status == .StatusEmbedded)
|
|
88
|
+
|
|
89
|
+
// Its non-launch assets were not read, hashed, or inserted.
|
|
90
|
+
let asset = try? db.asset(withKey: "embedded-asset-1")
|
|
91
|
+
#expect(asset == nil)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-updates",
|
|
3
|
-
"version": "29.0.
|
|
3
|
+
"version": "29.0.20",
|
|
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",
|
|
@@ -73,5 +73,5 @@
|
|
|
73
73
|
"react": "*",
|
|
74
74
|
"react-native": "*"
|
|
75
75
|
},
|
|
76
|
-
"gitHead": "
|
|
76
|
+
"gitHead": "5b42e3d21e0ac5e086752361ca8a5cb4de53bec1"
|
|
77
77
|
}
|
|
@@ -7,13 +7,14 @@ exports.findUpProjectRoot = findUpProjectRoot;
|
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
function findUpProjectRoot(cwd) {
|
|
10
|
-
if (
|
|
10
|
+
if (cwd === path_1.default.sep || cwd === '.') {
|
|
11
11
|
return null;
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
for (let dir = cwd; path_1.default.dirname(dir) !== dir; dir = path_1.default.dirname(dir)) {
|
|
14
|
+
const file = path_1.default.resolve(dir, 'package.json');
|
|
15
|
+
if (fs_1.default.existsSync(file)) {
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
18
|
}
|
|
19
|
+
return null;
|
|
19
20
|
}
|
|
@@ -2,13 +2,15 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
4
|
export function findUpProjectRoot(cwd: string): string | null {
|
|
5
|
-
if (
|
|
5
|
+
if (cwd === path.sep || cwd === '.') {
|
|
6
6
|
return null;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
for (let dir = cwd; path.dirname(dir) !== dir; dir = path.dirname(dir)) {
|
|
10
|
+
const file = path.resolve(dir, 'package.json');
|
|
11
|
+
if (fs.existsSync(file)) {
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
13
14
|
}
|
|
15
|
+
return null;
|
|
14
16
|
}
|