@rovq/react-native 0.1.0 → 0.2.1

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.
@@ -1,13 +1,22 @@
1
1
  package com.rovq.engine
2
2
 
3
3
  import java.io.File
4
+ import java.io.IOException
5
+ import java.io.InputStream
6
+ import java.security.MessageDigest
7
+
8
+ // Download links last an hour, so they are fetched a few files at a time, just before use.
9
+ private const val LINK_BATCH_SIZE = 20
10
+ private const val BUFFER_SIZE = 64 * 1024
11
+ private const val EMPTY_FILE_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
4
12
 
5
13
  /** What the server offered. None of this is trusted until the manifest has been verified. */
6
14
  data class OfferedUpdate(
7
15
  val manifest: String,
8
16
  val signature: String,
9
17
  val keyId: String,
10
- val downloadUrl: String,
18
+ /** Only for a ZIP package. A release made of files is fetched file by file. */
19
+ val downloadUrl: String?,
11
20
  )
12
21
 
13
22
  /** A verified release, fully extracted in its own folder. */
@@ -17,6 +26,18 @@ data class InstalledRelease(
17
26
  val bundleFile: File,
18
27
  )
19
28
 
29
+ /** Asks the server for download links, by hash, for files of one release. */
30
+ fun interface FileLinks {
31
+ /** @throws IOException if the server cannot be reached or answers with an error. */
32
+ fun linksFor(releaseId: String, hashes: List<String>): Map<String, String>
33
+ }
34
+
35
+ /** The app's own copies of package files, which a release made of files can reuse. */
36
+ fun interface BuiltInFiles {
37
+ /** Opens the app's copy of a package path, or returns null when it has none. */
38
+ fun open(path: String): InputStream?
39
+ }
40
+
20
41
  /**
21
42
  * Takes an offered update to a verified folder on disk, without activating it.
22
43
  *
@@ -24,11 +45,13 @@ data class InstalledRelease(
24
45
  * path would show old images after a reload. See docs/android-bundle-loading.md.
25
46
  *
26
47
  * @param rootDir a folder only the engine uses, normally inside the app's private storage.
48
+ * @param builtInFiles the app's own assets, which can be reused instead of downloaded.
27
49
  */
28
50
  class UpdateInstaller(
29
51
  rootDir: File,
30
52
  private val verifier: ManifestVerifier,
31
53
  private val downloader: PackageDownloader,
54
+ private val builtInFiles: BuiltInFiles? = null,
32
55
  ) {
33
56
  private val updatesDir = File(rootDir, "updates")
34
57
  private val workDir = File(rootDir, "work")
@@ -39,12 +62,14 @@ class UpdateInstaller(
39
62
 
40
63
  /**
41
64
  * @param highestSequence the highest sequence this device has accepted in this stream.
65
+ * @param fileLinks download links for a release made of files.
42
66
  * @throws RovqException if any step fails. Nothing is left behind in that case.
43
67
  */
44
68
  @Synchronized
45
69
  fun install(
46
70
  offer: OfferedUpdate,
47
71
  highestSequence: Long,
72
+ fileLinks: FileLinks = FileLinks { _, _ -> throw IOException("No way to fetch files") },
48
73
  onProgress: (received: Long, total: Long) -> Unit = { _, _ -> },
49
74
  ): InstalledRelease {
50
75
  // Verified first, so an unsigned update causes no network traffic or disk use.
@@ -53,35 +78,61 @@ class UpdateInstaller(
53
78
  val folder = File(updatesDir, manifest.releaseId)
54
79
  // Folders appear only through the atomic rename below, so one that exists is complete.
55
80
  if (!folder.isDirectory) {
56
- install(offer, manifest, folder, onProgress)
81
+ val files = manifest.files
82
+ if (files != null) {
83
+ installFileList(files, manifest.releaseId, folder, fileLinks, onProgress)
84
+ } else {
85
+ installZip(offer, manifest, folder, onProgress)
86
+ }
57
87
  }
58
88
  return InstalledRelease(manifest, folder, File(folder, PackageRules.BUNDLE_FILE_NAME))
59
89
  }
60
90
 
61
- private fun install(
62
- offer: OfferedUpdate,
63
- manifest: VerifiedManifest,
64
- folder: File,
65
- onProgress: (Long, Long) -> Unit,
66
- ) {
91
+ /** Creates an empty work folder for assembling a release, and the folders around it. */
92
+ private fun startWork(): File {
67
93
  // Removes anything an earlier run left when the app was killed or the phone lost power.
68
94
  workDir.deleteRecursively()
69
- val extracted = File(workDir, "extracted")
70
- if (!extracted.mkdirs() ||
95
+ val assembled = File(workDir, "extracted")
96
+ if (!assembled.mkdirs() ||
71
97
  !(updatesDir.isDirectory || updatesDir.mkdirs()) ||
72
98
  !(partialDir.isDirectory || partialDir.mkdirs())
73
99
  ) {
74
100
  throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the engine folders")
75
101
  }
102
+ return assembled
103
+ }
104
+
105
+ private fun moveIntoPlace(assembled: File, folder: File) {
106
+ if (!assembled.renameTo(folder)) {
107
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not move the release into place")
108
+ }
109
+ }
110
+
111
+ private fun installZip(
112
+ offer: OfferedUpdate,
113
+ manifest: VerifiedManifest,
114
+ folder: File,
115
+ onProgress: (Long, Long) -> Unit,
116
+ ) {
117
+ val downloadUrl = offer.downloadUrl
118
+ ?: throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The server sent no download URL")
119
+ val extracted = startWork()
76
120
  val packageFile = File(partialDir, "${manifest.packageHash}.zip")
77
121
  // Only the current offer is worth finishing.
78
122
  partialDir.listFiles()?.filter { it != packageFile }?.forEach { it.delete() }
79
123
  try {
80
- downloader.download(offer.downloadUrl, manifest, packageFile, onProgress)
124
+ // Extracting needs room too, and its size is unknown until then, so this asks for
125
+ // the package twice. A package can still expand further, and extraction reports that.
126
+ downloader.download(
127
+ downloadUrl,
128
+ manifest.packageHash,
129
+ manifest.packageSize,
130
+ packageFile,
131
+ extraSpace = manifest.packageSize,
132
+ onProgress = onProgress,
133
+ )
81
134
  extractor.extract(packageFile, extracted)
82
- if (!extracted.renameTo(folder)) {
83
- throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not move the release into place")
84
- }
135
+ moveIntoPlace(extracted, folder)
85
136
  packageFile.delete()
86
137
  } catch (error: RovqException) {
87
138
  // A file that failed anything but the network is not worth continuing from.
@@ -91,4 +142,206 @@ class UpdateInstaller(
91
142
  workDir.deleteRecursively()
92
143
  }
93
144
  }
145
+
146
+ /**
147
+ * Builds a release from its files: each comes from a kept release, from the app itself, or
148
+ * from the network, in that order, and only after its content matches the signed hash. See
149
+ * docs/file-packages.md.
150
+ */
151
+ private fun installFileList(
152
+ list: List<ManifestFile>,
153
+ releaseId: String,
154
+ folder: File,
155
+ fileLinks: FileLinks,
156
+ onProgress: (Long, Long) -> Unit,
157
+ ) {
158
+ val assembled = startWork()
159
+ try {
160
+ val wanted = list.map { it.hash }.toSet()
161
+ // Only the current offer is worth finishing.
162
+ partialDir.listFiles()?.filter { it.name !in wanted }?.forEach { it.delete() }
163
+ val root = assembled.canonicalPath + File.separator
164
+
165
+ // A hash already placed, so the same content at another path is linked, not fetched.
166
+ val placed = HashMap<String, File>()
167
+ // Files with no local copy at their own path. Another path may still provide them.
168
+ val unresolved = ArrayList<Pair<ManifestFile, File>>()
169
+
170
+ for (file in list) {
171
+ val target = File(assembled, file.path)
172
+ val parent = target.parentFile ?: assembled
173
+ if (!(parent.isDirectory || parent.mkdirs())) {
174
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create a folder")
175
+ }
176
+ // A second, independent check: whatever the rules allow, the real path must stay
177
+ // inside the folder.
178
+ if (!(parent.canonicalPath + File.separator).startsWith(root)) {
179
+ throw RovqException(RovqErrorCode.INVALID_PACKAGE, "The file ${file.path} leaves the folder")
180
+ }
181
+ val source = placed[file.hash]
182
+ when {
183
+ source != null -> link(source, target)
184
+ file.size == 0L -> {
185
+ if (file.hash != EMPTY_FILE_HASH) {
186
+ throw RovqException(RovqErrorCode.HASH_MISMATCH, "An empty file does not match its signed hash")
187
+ }
188
+ if (!target.createNewFile()) {
189
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the file")
190
+ }
191
+ placed[file.hash] = target
192
+ }
193
+ placeLocalCopy(file, target, folder) -> placed[file.hash] = target
194
+ else -> unresolved += file to target
195
+ }
196
+ }
197
+
198
+ // Files to download, by hash, with every path that needs each one, in manifest order.
199
+ val targets = LinkedHashMap<String, MutableList<File>>()
200
+ val missing = ArrayList<ManifestFile>()
201
+ for ((file, target) in unresolved) {
202
+ val source = placed[file.hash]
203
+ when {
204
+ source != null -> link(source, target)
205
+ targets.containsKey(file.hash) -> targets.getValue(file.hash) += target
206
+ else -> {
207
+ targets[file.hash] = mutableListOf(target)
208
+ missing += file
209
+ }
210
+ }
211
+ }
212
+
213
+ download(missing, targets, releaseId, fileLinks, onProgress)
214
+ moveIntoPlace(assembled, folder)
215
+ // Linked into the release, so the content stays where it is needed.
216
+ missing.forEach { File(partialDir, it.hash).delete() }
217
+ } finally {
218
+ workDir.deleteRecursively()
219
+ }
220
+ }
221
+
222
+ private fun download(
223
+ missing: List<ManifestFile>,
224
+ targets: Map<String, List<File>>,
225
+ releaseId: String,
226
+ fileLinks: FileLinks,
227
+ onProgress: (Long, Long) -> Unit,
228
+ ) {
229
+ if (missing.isEmpty()) return
230
+ val total = missing.sumOf { it.size }
231
+ // Checked for the whole release first, so a full disk fails before any download.
232
+ val remaining = missing.sumOf { file ->
233
+ val partial = File(partialDir, file.hash)
234
+ file.size - (if (partial.isFile) minOf(partial.length(), file.size) else 0L)
235
+ }
236
+ if (downloader.freeSpaceIn(partialDir) < remaining) {
237
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Not enough free space for this update")
238
+ }
239
+
240
+ var finished = 0L
241
+ onProgress(0, total)
242
+ for (batch in missing.chunked(LINK_BATCH_SIZE)) {
243
+ val links = try {
244
+ fileLinks.linksFor(releaseId, batch.map { it.hash })
245
+ } catch (error: IOException) {
246
+ throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, error.message ?: "Could not get download links", error)
247
+ }
248
+ for (file in batch) {
249
+ val url = links[file.hash]
250
+ ?: throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The server sent no link for a file")
251
+ val partial = File(partialDir, file.hash)
252
+ val before = finished
253
+ downloader.download(url, file.hash, file.size, partial) { received, _ ->
254
+ onProgress(before + received, total)
255
+ }
256
+ finished += file.size
257
+ targets[file.hash]?.forEach { link(partial, it) }
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Places the same path from a kept release, or from the app itself, if its content is the
264
+ * signed one. Hashed every time, because reusing a damaged file would break the new release.
265
+ */
266
+ private fun placeLocalCopy(file: ManifestFile, target: File, folder: File): Boolean {
267
+ val kept = updatesDir.listFiles()?.filter { it.isDirectory && it.name != folder.name }.orEmpty()
268
+ for (release in kept) {
269
+ val candidate = File(release, file.path)
270
+ if (candidate.isFile && candidate.length() == file.size && hashOf(candidate) == file.hash) {
271
+ link(candidate, target)
272
+ return true
273
+ }
274
+ }
275
+ return copyBuiltIn(file, target)
276
+ }
277
+
278
+ /** Copies the app's own file while hashing it, and keeps the copy only if it matches. */
279
+ private fun copyBuiltIn(file: ManifestFile, target: File): Boolean {
280
+ val input = try {
281
+ builtInFiles?.open(file.path)
282
+ } catch (error: Exception) {
283
+ null
284
+ } ?: return false
285
+ val digest = MessageDigest.getInstance("SHA-256")
286
+ var copied = 0L
287
+ try {
288
+ input.use { source ->
289
+ target.outputStream().use { output ->
290
+ val buffer = ByteArray(BUFFER_SIZE)
291
+ while (true) {
292
+ val count = source.read(buffer)
293
+ if (count < 0) break
294
+ copied += count
295
+ if (copied > file.size) break
296
+ digest.update(buffer, 0, count)
297
+ output.write(buffer, 0, count)
298
+ }
299
+ }
300
+ }
301
+ } catch (error: IOException) {
302
+ target.delete()
303
+ return false
304
+ }
305
+ val matches = copied == file.size && digest.digest().toHex() == file.hash
306
+ if (!matches) target.delete()
307
+ return matches
308
+ }
309
+
310
+ private fun hashOf(file: File): String? = try {
311
+ val digest = MessageDigest.getInstance("SHA-256")
312
+ file.inputStream().use { input ->
313
+ val buffer = ByteArray(BUFFER_SIZE)
314
+ while (true) {
315
+ val count = input.read(buffer)
316
+ if (count < 0) break
317
+ digest.update(buffer, 0, count)
318
+ }
319
+ }
320
+ digest.digest().toHex()
321
+ } catch (error: IOException) {
322
+ null
323
+ }
324
+
325
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
326
+
327
+ /**
328
+ * A hard link costs no space. Files in a release never change, so sharing them is safe.
329
+ * Android before 8.0 has no java.nio.file, so it copies instead.
330
+ */
331
+ private fun link(source: File, target: File) {
332
+ val linked = try {
333
+ java.nio.file.Files.createLink(target.toPath(), source.toPath())
334
+ true
335
+ } catch (error: Exception) {
336
+ false
337
+ } catch (error: LinkageError) {
338
+ false
339
+ }
340
+ if (linked) return
341
+ try {
342
+ source.copyTo(target, overwrite = true)
343
+ } catch (error: IOException) {
344
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not place a file", error)
345
+ }
346
+ }
94
347
  }
@@ -42,6 +42,14 @@ interface UpdateService {
42
42
 
43
43
  /** @throws IOException if the events could not be delivered. */
44
44
  fun report(deploymentKey: String, deviceId: String, events: List<QueuedEvent>)
45
+
46
+ /**
47
+ * Download links for files of a release made of files, by hash. Each file is checked against
48
+ * the signed manifest, so the links are trusted for nothing.
49
+ *
50
+ * @throws IOException if the server cannot be reached or answers with an error.
51
+ */
52
+ fun fileLinks(deploymentKey: String, deviceId: String, releaseId: String, hashes: List<String>): Map<String, String>
45
53
  }
46
54
 
47
55
  class HttpUpdateService(
@@ -57,6 +65,8 @@ class HttpUpdateService(
57
65
  .put("runtimeVersion", request.runtimeVersion)
58
66
  .put("binaryVersion", request.binaryVersion)
59
67
  .put("highestSequence", request.highestSequence)
68
+ // Both a ZIP package and a list of files. See docs/file-packages.md.
69
+ .put("packageFormats", JSONArray().put(1).put(2))
60
70
  request.currentReleaseId?.let { body.put("currentReleaseId", it) }
61
71
 
62
72
  return try {
@@ -71,7 +81,8 @@ class HttpUpdateService(
71
81
  manifest = update.getString("manifest"),
72
82
  signature = update.getString("signature"),
73
83
  keyId = update.getString("keyId"),
74
- downloadUrl = update.getString("downloadUrl"),
84
+ // A release made of files has no single download URL.
85
+ downloadUrl = update.optString("downloadUrl").ifEmpty { null },
75
86
  ),
76
87
  )
77
88
  }
@@ -111,6 +122,31 @@ class HttpUpdateService(
111
122
  )
112
123
  }
113
124
 
125
+ override fun fileLinks(
126
+ deploymentKey: String,
127
+ deviceId: String,
128
+ releaseId: String,
129
+ hashes: List<String>,
130
+ ): Map<String, String> {
131
+ val response = post(
132
+ "/device/updates/files",
133
+ JSONObject()
134
+ .put("deploymentKey", deploymentKey)
135
+ .put("deviceId", deviceId)
136
+ .put("releaseId", releaseId)
137
+ .put("hashes", JSONArray(hashes)),
138
+ )
139
+ return try {
140
+ val files = response.getJSONArray("files")
141
+ (0 until files.length()).associate { index ->
142
+ val file = files.getJSONObject(index)
143
+ file.getString("hash") to file.getString("url")
144
+ }
145
+ } catch (error: JSONException) {
146
+ throw IOException("The server sent an unreadable response", error)
147
+ }
148
+ }
149
+
114
150
  private fun post(path: String, body: JSONObject): JSONObject {
115
151
  val url = URL(apiUrl.trimEnd('/') + path)
116
152
  if (url.protocol != "https" && !allowInsecureUrls) {
@@ -0,0 +1,52 @@
1
+ package com.rovq.engine.react
2
+
3
+ import android.content.Context
4
+ import android.util.TypedValue
5
+ import com.rovq.engine.BuiltInFiles
6
+ import java.io.InputStream
7
+
8
+ // The densities react-native bundle writes drawables for, in dots per inch.
9
+ private val DENSITIES = mapOf(
10
+ "ldpi" to 120,
11
+ "mdpi" to 160,
12
+ "hdpi" to 240,
13
+ "xhdpi" to 320,
14
+ "xxhdpi" to 480,
15
+ "xxxhdpi" to 640,
16
+ )
17
+
18
+ /**
19
+ * Reads the app's own copy of a package file, so a release made of files can reuse it instead
20
+ * of downloading it again. In an APK, react-native bundle's files are resources: a path such as
21
+ * raw/src_assets_audio_1.mp3 is the raw resource src_assets_audio_1, and drawable-xhdpi/x.png is
22
+ * the drawable x at 320 dpi.
23
+ *
24
+ * Whatever this returns is hashed and compared with the signed hash, so a resource that the
25
+ * build changed, such as a PNG that was compressed again, is simply downloaded instead.
26
+ */
27
+ internal class ResourceFiles(private val context: Context) : BuiltInFiles {
28
+ override fun open(path: String): InputStream? {
29
+ val segments = path.split('/')
30
+ if (segments.size != 2) return null
31
+ val (folder, fileName) = segments
32
+ val type = folder.substringBefore('-')
33
+ val name = fileName.substringBeforeLast('.')
34
+ val resources = context.resources
35
+ // The only way to find a resource by the name a package uses.
36
+ @Suppress("DiscouragedApi")
37
+ val id = resources.getIdentifier(name, type, context.packageName)
38
+ if (id == 0) return null
39
+ return when (type) {
40
+ "raw" -> resources.openRawResource(id)
41
+ "drawable" -> {
42
+ val density = DENSITIES[folder.substringAfter('-', "")] ?: return null
43
+ val value = TypedValue()
44
+ resources.getValueForDensity(id, density, value, true)
45
+ val file = value.string?.toString() ?: return null
46
+ // Works for files stored uncompressed in the APK, which images and audio are.
47
+ resources.assets.openNonAssetFd(value.assetCookie, file).createInputStream()
48
+ }
49
+ else -> null
50
+ }
51
+ }
52
+ }
@@ -48,6 +48,7 @@ object Rovq {
48
48
  trustedKeys = config.trustedKeys,
49
49
  allowInsecureUrls = config.allowInsecureUrls,
50
50
  ),
51
+ builtInFiles = ResourceFiles(application),
51
52
  )
52
53
  instance = engine
53
54
  application.registerActivityLifecycleCallbacks(BackgroundWatcher(engine))
@@ -3,23 +3,53 @@ import Foundation
3
3
 
4
4
  private let publicKeySize = 32
5
5
  private let signatureSize = 64
6
- private let manifestVersion: Int64 = 1
7
6
  private let platform = "IOS"
8
- private let manifestFields: Set<String> = [
7
+ private let streamFields: Set<String> = [
9
8
  "version", "releaseId", "deploymentKey", "platform", "runtimeVersion", "binaryVersion",
10
- "sequence", "mandatory", "package",
9
+ "sequence", "mandatory",
11
10
  ]
11
+ /// Version 1 names one ZIP package. Version 2 lists files. See docs/file-packages.md.
12
+ private let zipManifestFields = streamFields.union(["package"])
13
+ private let filesManifestFields = streamFields.union(["files"])
12
14
  private let packageFields: Set<String> = ["hash", "size"]
15
+ private let fileFields: Set<String> = ["path", "hash", "size"]
13
16
  private let uuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
14
17
  private let hashPattern = "^[0-9a-f]{64}$"
15
18
 
19
+ /// One file of a release made of files, from a verified manifest.
20
+ public struct ManifestFile: Equatable {
21
+ public let path: String
22
+ public let hash: String
23
+ public let size: Int64
24
+ }
25
+
16
26
  /// Facts from a manifest whose signature has been verified. Nothing else should be trusted.
17
27
  public struct VerifiedManifest: Equatable {
18
28
  public let releaseId: String
19
29
  public let sequence: Int64
20
30
  public let mandatory: Bool
31
+ /// The ZIP package's hash. Empty for a release made of files.
21
32
  public let packageHash: String
33
+ /// The ZIP package's size, or all files together.
22
34
  public let packageSize: Int64
35
+ /// Set for a release made of files, sorted by path. Nil for a ZIP package.
36
+ public let files: [ManifestFile]?
37
+
38
+ init(
39
+ releaseId: String,
40
+ sequence: Int64,
41
+ mandatory: Bool,
42
+ packageHash: String,
43
+ packageSize: Int64,
44
+ files: [ManifestFile]? = nil
45
+ ) {
46
+ self.releaseId = releaseId
47
+ self.sequence = sequence
48
+ self.mandatory = mandatory
49
+ self.packageHash = packageHash
50
+ self.packageSize = packageSize
51
+ self.files = files
52
+ }
23
53
  }
24
54
 
25
55
  /// Decodes standard Base64, accepting only one spelling of each value, as the server does.
@@ -118,10 +148,9 @@ public final class ManifestVerifier {
118
148
  guard let root = parsed as? [String: Any] else {
119
149
  throw invalid("Manifest is not a JSON object")
120
150
  }
121
- try requireOnlyFields(root, allowed: manifestFields)
122
- guard try integerField(root, "version") == manifestVersion else {
123
- throw invalid("Unknown manifest version")
124
- }
151
+ let version = try integerField(root, "version")
152
+ guard version == 1 || version == 2 else { throw invalid("Unknown manifest version") }
153
+ try requireOnlyFields(root, allowed: version == 1 ? zipManifestFields : filesManifestFields)
125
154
 
126
155
  let releaseId = try stringField(root, "releaseId")
127
156
  // Later used as a folder name, so it must be exactly a UUID.
@@ -135,6 +164,23 @@ public final class ManifestVerifier {
135
164
  }
136
165
  _ = try stringField(root, "binaryVersion")
137
166
 
167
+ // A valid signature for another deployment, platform or runtime must not install here.
168
+ try requireMatch("deploymentKey", try stringField(root, "deploymentKey"), deploymentKey)
169
+ try requireMatch("platform", try stringField(root, "platform"), platform)
170
+ try requireMatch("runtimeVersion", try stringField(root, "runtimeVersion"), runtimeVersion)
171
+
172
+ if version == 2 {
173
+ let files = try fileList(root["files"])
174
+ return VerifiedManifest(
175
+ releaseId: releaseId,
176
+ sequence: sequence,
177
+ mandatory: mandatoryNumber.boolValue,
178
+ packageHash: "",
179
+ packageSize: files.reduce(0) { $0 + $1.size },
180
+ files: files
181
+ )
182
+ }
183
+
138
184
  guard let package = root["package"] as? [String: Any] else {
139
185
  throw invalid("package must be an object")
140
186
  }
@@ -144,11 +190,6 @@ public final class ManifestVerifier {
144
190
  let size = try integerField(package, "size")
145
191
  guard size > 0 else { throw invalid("package size must be positive") }
146
192
 
147
- // A valid signature for another deployment, platform or runtime must not install here.
148
- try requireMatch("deploymentKey", try stringField(root, "deploymentKey"), deploymentKey)
149
- try requireMatch("platform", try stringField(root, "platform"), platform)
150
- try requireMatch("runtimeVersion", try stringField(root, "runtimeVersion"), runtimeVersion)
151
-
152
193
  return VerifiedManifest(
153
194
  releaseId: releaseId,
154
195
  sequence: sequence,
@@ -158,6 +199,47 @@ public final class ManifestVerifier {
158
199
  )
159
200
  }
160
201
 
202
+ /// The same rules as a ZIP package, because each path is written to disk the same way.
203
+ /// Sorted by UTF-16 code units, as JavaScript sorts, so one set of files has one signed form.
204
+ private func fileList(_ value: Any?) throws -> [ManifestFile] {
205
+ guard let list = value as? [Any] else { throw invalid("files must be an array") }
206
+ if list.isEmpty || list.count > PackageRules.maxEntries {
207
+ throw invalid("A package must have from 1 to \(PackageRules.maxEntries) files")
208
+ }
209
+ var files: [ManifestFile] = []
210
+ var seen = Set<String>()
211
+ var total: Int64 = 0
212
+ for item in list {
213
+ guard let entry = item as? [String: Any] else { throw invalid("Each file must be an object") }
214
+ try requireOnlyFields(entry, allowed: fileFields)
215
+ let path = try stringField(entry, "path")
216
+ if let problem = PackageRules.findPathProblem(path) {
217
+ throw invalid("The file \(path) \(problem)")
218
+ }
219
+ if let previous = files.last, !Array(previous.path.utf16).lexicographicallyPrecedes(path.utf16) {
220
+ throw invalid("The files must be sorted by path, each path once")
221
+ }
222
+ if !seen.insert(PackageRules.pathIdentity(path)).inserted {
223
+ throw invalid("The file \(path) differs from another only by letter case")
224
+ }
225
+ let hash = try stringField(entry, "hash")
226
+ guard matches(hash, hashPattern) else { throw invalid("The file \(path) has a malformed hash") }
227
+ let size = try integerField(entry, "size")
228
+ // Bounded before adding, because an overflow would stop the app.
229
+ guard size >= 0, size <= PackageRules.maxTotalSize else {
230
+ throw invalid("The file \(path) has an invalid size")
231
+ }
232
+ total += size
233
+ if total > PackageRules.maxTotalSize { throw invalid("The files are larger than the allowed total") }
234
+ files.append(ManifestFile(path: path, hash: hash, size: size))
235
+ }
236
+ guard let bundle = files.first(where: { $0.path == PackageRules.bundleFileName }) else {
237
+ throw invalid("The bundle file is missing")
238
+ }
239
+ if bundle.size == 0 { throw invalid("The bundle file is empty") }
240
+ return files
241
+ }
242
+
161
243
  private func matches(_ value: String, _ pattern: String) -> Bool {
162
244
  value.range(of: pattern, options: .regularExpression) != nil
163
245
  }