@rovq/react-native 0.0.0-stage → 0.1.0

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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
  19. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  22. package/ios/Engine/EngineState.swift +119 -0
  23. package/ios/Engine/ManifestVerifier.swift +197 -0
  24. package/ios/Engine/PackageDownloader.swift +231 -0
  25. package/ios/Engine/PackageRules.swift +38 -0
  26. package/ios/Engine/ProgressThrottle.swift +20 -0
  27. package/ios/Engine/RovqEngine.swift +633 -0
  28. package/ios/Engine/RovqError.swift +35 -0
  29. package/ios/Engine/SafePackageExtractor.swift +107 -0
  30. package/ios/Engine/UpdateInstaller.swift +126 -0
  31. package/ios/Engine/UpdateService.swift +158 -0
  32. package/ios/Engine/ZipReader.swift +378 -0
  33. package/ios/React/RovqHost.swift +253 -0
  34. package/ios/React/RovqModule.mm +89 -0
  35. package/package.json +74 -3
  36. package/react-native.config.js +11 -0
  37. package/src/NativeRovq.ts +72 -0
  38. package/src/errors.ts +44 -0
  39. package/src/index.ts +228 -0
@@ -0,0 +1,543 @@
1
+ package com.rovq.engine
2
+
3
+ import java.io.File
4
+ import java.io.IOException
5
+ import java.text.SimpleDateFormat
6
+ import java.util.Date
7
+ import java.util.Locale
8
+ import java.util.TimeZone
9
+ import java.util.UUID
10
+
11
+ private const val MAX_QUEUED_EVENTS = 200
12
+ private const val EVENT_BATCH_SIZE = 50
13
+
14
+ // A release closed before confirming gets this many more starts. A crash gets none.
15
+ private const val MAX_TRYING_RETRIES = 2
16
+
17
+ private const val FIRST_POSTPONE_MILLIS = 60 * 60 * 1000L
18
+ private const val MAX_POSTPONE_MILLIS = 24 * 60 * 60 * 1000L
19
+ private const val MAX_DOUBLINGS = 5
20
+
21
+ // The same file failed its checks, so downloading it again would likely waste data for nothing.
22
+ // A network failure is not here, because trying again can work.
23
+ private val POSTPONED_CODES = setOf(
24
+ RovqErrorCode.SIZE_MISMATCH,
25
+ RovqErrorCode.HASH_MISMATCH,
26
+ RovqErrorCode.INVALID_PACKAGE,
27
+ )
28
+
29
+ /**
30
+ * @param binaryId changes whenever the app itself is installed or updated from a store.
31
+ * @param trustedKeys key IDs and their Base64 public keys, built into the app.
32
+ */
33
+ data class EngineConfig(
34
+ val apiUrl: String,
35
+ val deploymentKey: String,
36
+ val runtimeVersion: String,
37
+ val binaryVersion: String,
38
+ val binaryId: String,
39
+ val trustedKeys: Map<String, String>,
40
+ val allowInsecureUrls: Boolean = false,
41
+ )
42
+
43
+ /** Facts about an update, all from a manifest whose signature has been verified. */
44
+ data class UpdateInfo(
45
+ val releaseId: String,
46
+ val sequence: Long,
47
+ val mandatory: Boolean,
48
+ val packageSize: Long,
49
+ )
50
+
51
+ sealed interface CheckResult {
52
+ data object UpToDate : CheckResult
53
+
54
+ /** @param alreadyDownloaded whether [RovqEngine.downloadUpdate] has nothing left to fetch. */
55
+ data class UpdateAvailable(val update: UpdateInfo, val alreadyDownloaded: Boolean) : CheckResult
56
+
57
+ /** The server asked to leave the current release. Restart to apply. */
58
+ data object RolledBack : CheckResult
59
+
60
+ data class Failed(val code: RovqErrorCode, val message: String) : CheckResult
61
+ }
62
+
63
+ sealed interface DownloadResult {
64
+ /** Verified and on disk. It loads only after [RovqEngine.installUpdate]. */
65
+ data class Downloaded(val update: UpdateInfo) : DownloadResult
66
+
67
+ data class Failed(val code: RovqErrorCode, val message: String) : DownloadResult
68
+ }
69
+
70
+ sealed interface InstallResult {
71
+ /** Loads on the next JavaScript start. */
72
+ data class Installed(val releaseId: String) : InstallResult
73
+
74
+ data class Failed(val code: RovqErrorCode, val message: String) : InstallResult
75
+ }
76
+
77
+ sealed interface SyncResult {
78
+ data object UpToDate : SyncResult
79
+
80
+ /** Installed, and loads on the next JavaScript start. */
81
+ data class UpdateReady(val releaseId: String, val mandatory: Boolean) : SyncResult
82
+
83
+ /** The server asked to leave the current release. Restart to apply. */
84
+ data object RolledBack : SyncResult
85
+
86
+ data class Failed(val code: RovqErrorCode, val message: String) : SyncResult
87
+ }
88
+
89
+ data class EngineStatus(
90
+ val deviceId: String,
91
+ val currentReleaseId: String?,
92
+ val downloadedReleaseId: String?,
93
+ val pendingReleaseId: String?,
94
+ val tryingReleaseId: String?,
95
+ val highestSequence: Long,
96
+ )
97
+
98
+ /**
99
+ * The state machine that decides which bundle runs. A wrong decision here can stop an
100
+ * app from starting, so every change is saved before it takes effect, and each method
101
+ * is written so that the process can die at any line.
102
+ */
103
+ class RovqEngine(
104
+ private val rootDir: File,
105
+ private val config: EngineConfig,
106
+ private val service: UpdateService = HttpUpdateService(config.apiUrl, config.allowInsecureUrls),
107
+ private val clock: () -> Long = System::currentTimeMillis,
108
+ ) {
109
+ private val store: EngineStateStore
110
+ private val updatesDir = File(rootDir, "updates")
111
+ private val verifier = ManifestVerifier(config.trustedKeys, config.deploymentKey, config.runtimeVersion)
112
+ private val installer: UpdateInstaller
113
+
114
+ // Held for a whole check, download or install, because each depends on the one before.
115
+ private val syncLock = Any()
116
+
117
+ // Kept in memory only, because its download URL expires. Guarded by syncLock.
118
+ private var offer: OfferedUpdate? = null
119
+ private var offerManifest: VerifiedManifest? = null
120
+ private var state: EngineState
121
+
122
+ init {
123
+ rootDir.mkdirs()
124
+ store = EngineStateStore(rootDir)
125
+ installer = UpdateInstaller(
126
+ rootDir,
127
+ verifier,
128
+ PackageDownloader(config.allowInsecureUrls),
129
+ )
130
+ state = loadState()
131
+ verifier.revokedPublicKeys = state.revokedPublicKeys
132
+ }
133
+
134
+ /**
135
+ * Called each time JavaScript starts, before it loads. Returns the bundle to run, or
136
+ * null for the one inside the app. It must never throw, or the app could not start.
137
+ */
138
+ @Synchronized
139
+ fun resolveBundle(): File? {
140
+ // Still marked as trying means the last start never confirmed that it works.
141
+ state.trying?.let { failed ->
142
+ // A crash does not pass through the background, but closing the app does, so this
143
+ // start was probably cut short by a person, and the release deserves another one.
144
+ if (state.backgroundedWhileTrying && state.tryingRetries < MAX_TRYING_RETRIES && bundleOf(failed).isFile) {
145
+ val retry = state.copy(backgroundedWhileTrying = false, tryingRetries = state.tryingRetries + 1)
146
+ if (save(retry)) return bundleOf(failed)
147
+ }
148
+ save(
149
+ queue(
150
+ state.copy(trying = null, backgroundedWhileTrying = false, tryingRetries = 0),
151
+ "ROLLED_BACK",
152
+ failed.id,
153
+ "NOT_CONFIRMED",
154
+ ),
155
+ )
156
+ }
157
+ state.pending?.let { next ->
158
+ if (!bundleOf(next).isFile) {
159
+ save(state.copy(pending = null))
160
+ } else if (save(state.copy(pending = null, trying = next))) {
161
+ // Saved first, so that if this bundle kills the process the next start knows.
162
+ removeUnusedReleases()
163
+ return bundleOf(next)
164
+ } else {
165
+ // Trying it unrecorded could crash on every start forever, so run what is
166
+ // known to work. The update stays pending for when saving works again.
167
+ state = state.copy(pending = next, trying = null)
168
+ return state.current?.let(::bundleOf)?.takeIf { it.isFile }
169
+ }
170
+ }
171
+
172
+ // A confirmed release can only disappear through outside interference.
173
+ state.current?.let { current ->
174
+ if (!bundleOf(current).isFile) save(state.copy(current = state.previous, previous = null))
175
+ }
176
+ val running = state.current?.takeIf { bundleOf(it).isFile }
177
+ if (running == null && state.current != null) save(state.copy(current = null, previous = null))
178
+ removeUnusedReleases()
179
+ return running?.let(::bundleOf)
180
+ }
181
+
182
+ /** Called when the app goes to the background. See [resolveBundle]. */
183
+ @Synchronized
184
+ fun noteBackgrounded() {
185
+ if (state.trying != null && !state.backgroundedWhileTrying) {
186
+ save(state.copy(backgroundedWhileTrying = true))
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Called when the app is crashing. Android can stop the activity while its crash dialog shows,
192
+ * which looks like going to the background, so this uses up the release's other starts first.
193
+ */
194
+ @Synchronized
195
+ fun noteCrashed() {
196
+ if (state.trying != null) {
197
+ save(state.copy(backgroundedWhileTrying = false, tryingRetries = MAX_TRYING_RETRIES))
198
+ }
199
+ }
200
+
201
+ /** Called by the app once it has started properly. It keeps the running release. */
202
+ @Synchronized
203
+ fun notifyReady() {
204
+ val confirmed = state.trying ?: return
205
+ save(
206
+ queue(
207
+ state.copy(
208
+ trying = null,
209
+ current = confirmed,
210
+ previous = state.current,
211
+ backgroundedWhileTrying = false,
212
+ tryingRetries = 0,
213
+ ),
214
+ "CONFIRMED",
215
+ confirmed.id,
216
+ null,
217
+ ),
218
+ )
219
+ }
220
+
221
+ /**
222
+ * Goes back to the previous confirmed release, or to the one inside the app. It takes
223
+ * effect on the next JavaScript start. The sequence stays, so the release that was
224
+ * left is not offered again.
225
+ *
226
+ * @return whether there was anything to leave.
227
+ */
228
+ @Synchronized
229
+ fun rollback(reason: String = "REQUESTED"): Boolean {
230
+ val leaving = state.trying ?: state.current ?: return false
231
+ val next = if (state.trying != null) state.current else state.previous
232
+ val previous = if (state.trying != null) state.previous else null
233
+ save(
234
+ queue(
235
+ state.copy(
236
+ trying = null,
237
+ current = next,
238
+ previous = previous,
239
+ backgroundedWhileTrying = false,
240
+ tryingRetries = 0,
241
+ ),
242
+ "ROLLED_BACK",
243
+ leaving.id,
244
+ reason,
245
+ ),
246
+ )
247
+ return true
248
+ }
249
+
250
+ /**
251
+ * Asks the server for updates, and verifies any offer before reporting it, so an app never
252
+ * shows the user an update that it would refuse to download.
253
+ *
254
+ * A rollback from the server applies here, because it is how a bad release gets stopped,
255
+ * and that must not depend on what the app does with the answer.
256
+ */
257
+ fun checkForUpdate(): CheckResult = synchronized(syncLock) {
258
+ val result = checkOnce()
259
+ flushEvents()
260
+ result
261
+ }
262
+
263
+ /** Fetches the update found by the last [checkForUpdate], without activating it. */
264
+ fun downloadUpdate(onProgress: (received: Long, total: Long) -> Unit = { _, _ -> }): DownloadResult =
265
+ synchronized(syncLock) {
266
+ val result = downloadOnce(onProgress)
267
+ flushEvents()
268
+ result
269
+ }
270
+
271
+ /**
272
+ * Makes the downloaded update load on the next JavaScript start. It uses no network, so
273
+ * an app can restart straight after it. Its event goes with the next check.
274
+ */
275
+ fun installUpdate(): InstallResult = synchronized(syncLock) { installOnce() }
276
+
277
+ /** The three steps together, for an app that does not need to ask the user in between. */
278
+ fun sync(onProgress: (received: Long, total: Long) -> Unit = { _, _ -> }): SyncResult =
279
+ synchronized(syncLock) {
280
+ val result = syncOnce(onProgress)
281
+ flushEvents()
282
+ result
283
+ }
284
+
285
+ @Synchronized
286
+ fun status(): EngineStatus = EngineStatus(
287
+ deviceId = state.deviceId,
288
+ currentReleaseId = (state.trying ?: state.current)?.id,
289
+ downloadedReleaseId = state.downloaded?.id,
290
+ pendingReleaseId = state.pending?.id,
291
+ tryingReleaseId = state.trying?.id,
292
+ highestSequence = state.highestSequence,
293
+ )
294
+
295
+ private fun syncOnce(onProgress: (Long, Long) -> Unit): SyncResult {
296
+ val update = when (val checked = checkOnce()) {
297
+ CheckResult.UpToDate -> return SyncResult.UpToDate
298
+ CheckResult.RolledBack -> return SyncResult.RolledBack
299
+ is CheckResult.Failed -> return SyncResult.Failed(checked.code, checked.message)
300
+ is CheckResult.UpdateAvailable -> checked.update
301
+ }
302
+ val downloaded = downloadOnce(onProgress)
303
+ if (downloaded is DownloadResult.Failed) return SyncResult.Failed(downloaded.code, downloaded.message)
304
+ return when (val installed = installOnce()) {
305
+ is InstallResult.Installed -> SyncResult.UpdateReady(update.releaseId, update.mandatory)
306
+ is InstallResult.Failed -> SyncResult.Failed(installed.code, installed.message)
307
+ }
308
+ }
309
+
310
+ private fun checkOnce(): CheckResult {
311
+ offer = null
312
+ offerManifest = null
313
+ val request = synchronized(this) {
314
+ CheckRequest(
315
+ deploymentKey = config.deploymentKey,
316
+ deviceId = state.deviceId,
317
+ runtimeVersion = config.runtimeVersion,
318
+ binaryVersion = config.binaryVersion,
319
+ currentReleaseId = (state.trying ?: state.current)?.id,
320
+ highestSequence = state.highestSequence,
321
+ )
322
+ }
323
+ val answer = try {
324
+ service.check(request)
325
+ } catch (error: IOException) {
326
+ return CheckResult.Failed(RovqErrorCode.DOWNLOAD_FAILED, error.message ?: "Network failure")
327
+ }
328
+ // First, so that an offer signed by a key revoked in this same answer is refused.
329
+ applyRevocations(answer.revocations)
330
+ return when (val response = answer.response) {
331
+ CheckResponse.None -> {
332
+ keepDownloadedOnlyIf(null)
333
+ CheckResult.UpToDate
334
+ }
335
+ CheckResponse.Rollback -> {
336
+ keepDownloadedOnlyIf(null)
337
+ if (rollback("DISABLED_ON_SERVER")) CheckResult.RolledBack else CheckResult.UpToDate
338
+ }
339
+ is CheckResponse.Update -> verifyOffer(response.offer, request.highestSequence)
340
+ }
341
+ }
342
+
343
+ /** Permanent, so a server compromised later cannot make this device trust the key again. */
344
+ private fun applyRevocations(revocations: List<KeyRevocation>) {
345
+ val valid = revocations.mapNotNull { verifier.verifyRevocation(it.keyId, it.statement, it.signature) }
346
+ if (valid.isEmpty()) return
347
+ synchronized(this) {
348
+ val all = state.revokedPublicKeys + valid
349
+ if (all == state.revokedPublicKeys) return
350
+ // A downloaded release may have been signed with that key, and nothing checks it again.
351
+ save(state.copy(revokedPublicKeys = all, downloaded = null))
352
+ verifier.revokedPublicKeys = all
353
+ }
354
+ }
355
+
356
+ private fun verifyOffer(offered: OfferedUpdate, highestSequence: Long): CheckResult = try {
357
+ val manifest = verifier.verify(offered.manifest, offered.signature, offered.keyId, highestSequence)
358
+ offer = offered
359
+ offerManifest = manifest
360
+ keepDownloadedOnlyIf(manifest.releaseId)
361
+ val alreadyDownloaded = synchronized(this) { state.downloaded != null }
362
+ CheckResult.UpdateAvailable(manifest.toUpdateInfo(), alreadyDownloaded)
363
+ } catch (error: RovqException) {
364
+ recordFailure(error)
365
+ CheckResult.Failed(error.code, error.message ?: error.code.name)
366
+ }
367
+
368
+ /**
369
+ * The server offers a release until the device installs it, so an answer that does not
370
+ * include the downloaded release means it was disabled or replaced, and must not be
371
+ * installed. A failed check says nothing about it, so that does not get here.
372
+ */
373
+ private fun keepDownloadedOnlyIf(offeredReleaseId: String?) = synchronized(this) {
374
+ val downloaded = state.downloaded
375
+ if (downloaded != null && downloaded.id != offeredReleaseId) save(state.copy(downloaded = null))
376
+ }
377
+
378
+ private fun downloadOnce(onProgress: (Long, Long) -> Unit): DownloadResult {
379
+ val offered = offer ?: return DownloadResult.Failed(
380
+ RovqErrorCode.NO_UPDATE_AVAILABLE,
381
+ "No update to download. Check for updates first",
382
+ )
383
+ val releaseId = offerManifest?.releaseId
384
+ val highestSequence = synchronized(this) {
385
+ val postponed = state.postponed
386
+ // A clock moved back must not make the wait longer than the longest one allowed.
387
+ if (postponed?.releaseId == releaseId && postponed != null) {
388
+ val remaining = postponed.untilMillis - clock()
389
+ if (remaining in 1..MAX_POSTPONE_MILLIS) {
390
+ return DownloadResult.Failed(
391
+ RovqErrorCode.DOWNLOAD_POSTPONED,
392
+ "This update failed its checks, so it will not be downloaded again yet",
393
+ )
394
+ }
395
+ }
396
+ state.highestSequence
397
+ }
398
+ return try {
399
+ val manifest = installer.install(offered, highestSequence, onProgress).manifest
400
+ synchronized(this) {
401
+ save(
402
+ state.copy(
403
+ downloaded = ReleaseRef(manifest.releaseId, manifest.sequence),
404
+ postponed = null,
405
+ ),
406
+ )
407
+ }
408
+ DownloadResult.Downloaded(manifest.toUpdateInfo())
409
+ } catch (error: RovqException) {
410
+ recordFailure(error)
411
+ if (error.code in POSTPONED_CODES && releaseId != null) postpone(releaseId)
412
+ DownloadResult.Failed(error.code, error.message ?: error.code.name)
413
+ }
414
+ }
415
+
416
+ private fun postpone(releaseId: String) = synchronized(this) {
417
+ val attempts = if (state.postponed?.releaseId == releaseId) state.postponed!!.attempts + 1 else 1
418
+ val wait = (FIRST_POSTPONE_MILLIS shl (attempts - 1).coerceAtMost(MAX_DOUBLINGS))
419
+ .coerceAtMost(MAX_POSTPONE_MILLIS)
420
+ save(state.copy(postponed = PostponedDownload(releaseId, attempts, clock() + wait)))
421
+ }
422
+
423
+ private fun installOnce(): InstallResult = synchronized(this) {
424
+ val downloaded = state.downloaded?.takeIf { bundleOf(it).isFile }
425
+ if (downloaded == null) {
426
+ if (state.downloaded != null) save(state.copy(downloaded = null))
427
+ return InstallResult.Failed(RovqErrorCode.NO_UPDATE_DOWNLOADED, "No update has been downloaded")
428
+ }
429
+ offer = null
430
+ offerManifest = null
431
+ save(
432
+ queue(
433
+ state.copy(
434
+ downloaded = null,
435
+ pending = downloaded,
436
+ // Recorded now, so a release that later fails is never accepted again.
437
+ highestSequence = maxOf(state.highestSequence, downloaded.sequence),
438
+ ),
439
+ "INSTALLED",
440
+ downloaded.id,
441
+ null,
442
+ ),
443
+ )
444
+ InstallResult.Installed(downloaded.id)
445
+ }
446
+
447
+ private fun recordFailure(error: RovqException) {
448
+ val type = if (error.code == RovqErrorCode.DOWNLOAD_FAILED) "DOWNLOAD_FAILED" else "VERIFICATION_FAILED"
449
+ synchronized(this) { save(queue(state, type, null, error.code.name)) }
450
+ }
451
+
452
+ private fun VerifiedManifest.toUpdateInfo() = UpdateInfo(releaseId, sequence, mandatory, packageSize)
453
+
454
+ /** Best effort. Events stay queued and go with a later sync if this fails. */
455
+ private fun flushEvents() {
456
+ val (batch, deviceId) = synchronized(this) { state.events.take(EVENT_BATCH_SIZE) to state.deviceId }
457
+ if (batch.isEmpty()) return
458
+ try {
459
+ service.report(config.deploymentKey, deviceId, batch)
460
+ } catch (error: IOException) {
461
+ return
462
+ }
463
+ val sent = batch.map { it.clientEventId }.toSet()
464
+ synchronized(this) {
465
+ save(state.copy(events = state.events.filterNot { it.clientEventId in sent }))
466
+ }
467
+ }
468
+
469
+ private fun loadState(): EngineState {
470
+ val saved = store.read()
471
+ val fresh = EngineState(
472
+ deviceId = saved?.deviceId ?: UUID.randomUUID().toString(),
473
+ binaryId = config.binaryId,
474
+ runtimeVersion = config.runtimeVersion,
475
+ )
476
+ if (saved == null) {
477
+ // Without a readable state nothing on disk can be trusted, so start clean.
478
+ updatesDir.deleteRecursively()
479
+ trySave(fresh)
480
+ return fresh
481
+ }
482
+ if (saved.binaryId == config.binaryId && saved.runtimeVersion == config.runtimeVersion) {
483
+ return saved
484
+ }
485
+ // A new app from the store is newer than any update for the old one, and the old
486
+ // bundles may not work with its native code. Sequences belong to a runtime version,
487
+ // so the count only carries over if that did not change.
488
+ updatesDir.deleteRecursively()
489
+ val reset = fresh.copy(
490
+ highestSequence = if (saved.runtimeVersion == config.runtimeVersion) saved.highestSequence else 0,
491
+ events = saved.events,
492
+ // Revocation is permanent, whatever the version of the app.
493
+ revokedPublicKeys = saved.revokedPublicKeys,
494
+ )
495
+ trySave(reset)
496
+ return reset
497
+ }
498
+
499
+ private fun bundleOf(release: ReleaseRef) =
500
+ File(File(updatesDir, release.id), PackageRules.BUNDLE_FILE_NAME)
501
+
502
+ private fun removeUnusedReleases() {
503
+ val inUse = listOfNotNull(state.current, state.previous, state.downloaded, state.pending, state.trying)
504
+ .map { it.id }
505
+ .toSet()
506
+ updatesDir.listFiles()?.forEach { folder ->
507
+ if (folder.name !in inUse) folder.deleteRecursively()
508
+ }
509
+ }
510
+
511
+ private fun queue(
512
+ base: EngineState,
513
+ type: String,
514
+ releaseId: String?,
515
+ errorCode: String?,
516
+ ): EngineState {
517
+ val event = QueuedEvent(UUID.randomUUID().toString(), type, releaseId, errorCode, now())
518
+ return base.copy(events = (base.events + event).takeLast(MAX_QUEUED_EVENTS))
519
+ }
520
+
521
+ /** @return whether the change will survive a process death. */
522
+ private fun save(next: EngineState): Boolean {
523
+ state = next
524
+ return trySave(next)
525
+ }
526
+
527
+ /**
528
+ * A failed save must not stop the app, for example when the disk is full. In general
529
+ * the state from before the change applies on the next start. Callers that depend on
530
+ * the change surviving must check the result.
531
+ */
532
+ private fun trySave(next: EngineState): Boolean = try {
533
+ store.write(next)
534
+ true
535
+ } catch (error: IOException) {
536
+ false
537
+ }
538
+
539
+ private fun now(): String =
540
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT)
541
+ .apply { timeZone = TimeZone.getTimeZone("UTC") }
542
+ .format(Date())
543
+ }
@@ -0,0 +1,31 @@
1
+ package com.rovq.engine
2
+
3
+ /**
4
+ * Why an update failed. The names are also sent to the server as device event error codes,
5
+ * except for the last four, which describe the app's use of the engine. The React Native
6
+ * SDK has the same list, and a test keeps them equal.
7
+ */
8
+ enum class RovqErrorCode {
9
+ UNKNOWN_SIGNING_KEY,
10
+ REVOKED_SIGNING_KEY,
11
+ INVALID_SIGNATURE,
12
+ INVALID_MANIFEST,
13
+ MANIFEST_MISMATCH,
14
+ STALE_SEQUENCE,
15
+ INSECURE_URL,
16
+ DOWNLOAD_FAILED,
17
+ SIZE_MISMATCH,
18
+ HASH_MISMATCH,
19
+ INVALID_PACKAGE,
20
+ STORAGE_FAILED,
21
+ NO_UPDATE_AVAILABLE,
22
+ NO_UPDATE_DOWNLOADED,
23
+ ENGINE_UNAVAILABLE,
24
+ DOWNLOAD_POSTPONED,
25
+ }
26
+
27
+ class RovqException(
28
+ val code: RovqErrorCode,
29
+ message: String,
30
+ cause: Throwable? = null,
31
+ ) : Exception(message, cause)
@@ -0,0 +1,121 @@
1
+ package com.rovq.engine
2
+
3
+ import java.io.File
4
+ import java.io.FileOutputStream
5
+ import java.io.IOException
6
+ import java.util.zip.ZipEntry
7
+ import java.util.zip.ZipException
8
+ import java.util.zip.ZipFile
9
+
10
+ private const val BUFFER_SIZE = 64 * 1024
11
+
12
+ /**
13
+ * Extracts a package without trusting it, even though its signature was already
14
+ * verified. If a signing key ever leaks, this keeps an attacker inside the package
15
+ * folder, so the files of the app itself stay out of reach.
16
+ */
17
+ internal class SafePackageExtractor {
18
+
19
+ /** @param destination an empty folder that only this extraction uses. */
20
+ fun extract(packageFile: File, destination: File) {
21
+ val root = destination.canonicalPath + File.separator
22
+ ZipStructureCheck.check(packageFile)
23
+ try {
24
+ // ZipFile reads names from the central directory, the same source the server
25
+ // checked. Local file headers could hold different names.
26
+ ZipFile(packageFile).use { zip ->
27
+ extractEntries(zip, destination, root)
28
+ }
29
+ } catch (error: ZipException) {
30
+ throw invalid("Not a readable ZIP file", error)
31
+ } catch (error: IllegalArgumentException) {
32
+ // Android rejects some malformed names with this exception.
33
+ throw invalid("The ZIP file has a malformed entry", error)
34
+ } catch (error: IllegalStateException) {
35
+ throw invalid("The ZIP file could not be read", error)
36
+ }
37
+ }
38
+
39
+ private fun extractEntries(zip: ZipFile, destination: File, root: String) {
40
+ val seen = HashSet<String>()
41
+ var hasBundle = false
42
+ var count = 0
43
+ var totalSize = 0L
44
+
45
+ val entries = zip.entries()
46
+ while (entries.hasMoreElements()) {
47
+ val entry = entries.nextElement()
48
+ count += 1
49
+ if (count > PackageRules.MAX_ENTRIES) throw invalid("Too many files")
50
+ if (entry.isDirectory) throw invalid("Folder entries are not allowed")
51
+
52
+ val name = entry.name
53
+ PackageRules.findPathProblem(name)?.let { throw invalid("The file $name $it") }
54
+ if (!seen.add(PackageRules.pathIdentity(name))) {
55
+ throw invalid("The file $name appears more than once")
56
+ }
57
+
58
+ val target = File(destination, name)
59
+ // A second, independent check: whatever the rules allow, the real path must
60
+ // stay inside the folder.
61
+ if (!target.canonicalPath.startsWith(root)) throw invalid("The file $name leaves the folder")
62
+
63
+ val written = writeEntry(zip, entry, target, PackageRules.MAX_TOTAL_SIZE - totalSize)
64
+ totalSize += written
65
+ if (name == PackageRules.BUNDLE_FILE_NAME) {
66
+ if (written == 0L) throw invalid("The bundle file is empty")
67
+ hasBundle = true
68
+ }
69
+ }
70
+ if (!hasBundle) throw invalid("The bundle file is missing")
71
+ }
72
+
73
+ /** Counts real bytes, because a ZIP file can lie about how large its contents are. */
74
+ private fun writeEntry(zip: ZipFile, entry: ZipEntry, target: File, allowance: Long): Long {
75
+ target.parentFile?.let { parent ->
76
+ if (!parent.isDirectory && !parent.mkdirs()) {
77
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create ${parent.name}")
78
+ }
79
+ }
80
+ var written = 0L
81
+ val input = try {
82
+ zip.getInputStream(entry)
83
+ } catch (error: ZipException) {
84
+ throw invalid("Could not read ${entry.name}", error)
85
+ }
86
+ input.use { source ->
87
+ val output = try {
88
+ FileOutputStream(target)
89
+ } catch (error: IOException) {
90
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create ${entry.name}", error)
91
+ }
92
+ output.use { file ->
93
+ val buffer = ByteArray(BUFFER_SIZE)
94
+ while (true) {
95
+ val count = try {
96
+ source.read(buffer)
97
+ } catch (error: IOException) {
98
+ throw invalid("Could not read ${entry.name}", error)
99
+ }
100
+ if (count < 0) break
101
+ written += count
102
+ if (written > allowance) throw invalid("The package is too large when decompressed")
103
+ try {
104
+ file.write(buffer, 0, count)
105
+ } catch (error: IOException) {
106
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not write ${entry.name}", error)
107
+ }
108
+ }
109
+ // Forces the bytes to disk, because the release becomes active by renaming
110
+ // its folder, and that rename must not outrun the data.
111
+ file.fd.sync()
112
+ }
113
+ }
114
+ // Not every ZIP reader enforces this, so a mismatch has to be checked here.
115
+ if (written != entry.size) throw invalid("The file ${entry.name} lies about its size")
116
+ return written
117
+ }
118
+
119
+ private fun invalid(message: String, cause: Throwable? = null) =
120
+ RovqException(RovqErrorCode.INVALID_PACKAGE, message, cause)
121
+ }