@shipvo/runtime 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +49 -0
  2. package/android/build.gradle +7 -0
  3. package/android/src/main/java/dev/shipvo/runtime/BootRules.kt +53 -0
  4. package/android/src/main/java/dev/shipvo/runtime/Clock.kt +9 -0
  5. package/android/src/main/java/dev/shipvo/runtime/HermesGuard.kt +42 -0
  6. package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +36 -0
  7. package/android/src/main/java/dev/shipvo/runtime/ResumableDownload.kt +39 -0
  8. package/android/src/main/java/dev/shipvo/runtime/Rotation.kt +134 -0
  9. package/android/src/main/java/dev/shipvo/runtime/ShipvoHostHandler.kt +4 -5
  10. package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +66 -10
  11. package/android/src/main/java/dev/shipvo/runtime/Store.kt +36 -3
  12. package/android/src/test/java/dev/shipvo/runtime/HermesGuardTest.kt +73 -0
  13. package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +45 -0
  14. package/android/src/test/java/dev/shipvo/runtime/ResumableDownloadTest.kt +90 -0
  15. package/android/src/test/java/dev/shipvo/runtime/RotationTest.kt +141 -0
  16. package/android/src/test/java/dev/shipvo/runtime/StabilityTest.kt +76 -0
  17. package/app.plugin.js +75 -2
  18. package/ios/BundleSelector.swift +21 -1
  19. package/ios/HermesGuard.swift +43 -0
  20. package/ios/Rotation.swift +147 -0
  21. package/ios/ShipvoRuntimeModule.swift +27 -0
  22. package/ios/Store.swift +47 -0
  23. package/ios/Updater.swift +88 -14
  24. package/package.json +9 -1
  25. package/skills/shipvo-runtime/SKILL.md +39 -0
  26. package/src/index.ts +102 -1
  27. package/src/index.web.ts +3 -0
  28. package/src/install.ts +74 -0
  29. package/src/runtimeEvents.ts +38 -0
@@ -0,0 +1,73 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+ import java.io.ByteArrayInputStream
7
+ import java.io.IOException
8
+ import java.io.InputStream
9
+
10
+ class HermesGuardTest {
11
+ private fun hbc(v: Int) = byteArrayOf(0xc6.toByte(), 0x1f, 0xbc.toByte(), 0x03, 0xc1.toByte(), 0x03, 0x19, 0x1f,
12
+ v.toByte(), (v shr 8).toByte(), (v shr 16).toByte(), (v shr 24).toByte())
13
+
14
+ @Test fun readsVersionFromHeader() {
15
+ assertEquals(96, HermesGuard.versionOf(hbc(96)))
16
+ assertEquals(300, HermesGuard.versionOf(hbc(300)))
17
+ assertNull(HermesGuard.versionOf("var a = 1; // js".toByteArray()))
18
+ assertNull(HermesGuard.versionOf(ByteArray(4)))
19
+ }
20
+
21
+ @Test fun mismatchIsRefusedWithTypedReason() {
22
+ assertEquals("hermes_bytecode_mismatch:7:release=98:shell=96", HermesGuard.mismatchEvent(7, 98, 96))
23
+ }
24
+
25
+ @Test fun matchIsAccepted() { assertNull(HermesGuard.mismatchEvent(7, 96, 96)) }
26
+
27
+ @Test fun missingFieldIsAccepted() {
28
+ assertNull(HermesGuard.mismatchEvent(7, null, 96))
29
+ assertNull(HermesGuard.mismatchEvent(7, 96, null))
30
+ }
31
+
32
+ /** Hands out at most `chunk` bytes per read, like a slow stream. */
33
+ private fun trickle(b: ByteArray, chunk: Int) = object : InputStream() {
34
+ var i = 0
35
+ override fun read(): Int = if (i < b.size) b[i++].toInt() and 0xff else -1
36
+ override fun read(buf: ByteArray, off: Int, len: Int): Int {
37
+ if (i >= b.size) return -1
38
+ val n = minOf(len, chunk, b.size - i); System.arraycopy(b, i, buf, off, n); i += n; return n
39
+ }
40
+ }
41
+
42
+ @Test fun headerIsReadAcrossShortReads() {
43
+ assertEquals(96, HermesGuard.readHeader { trickle(hbc(96) + ByteArray(50), 1) }?.let { HermesGuard.versionOf(it) })
44
+ }
45
+
46
+ @Test fun shortStreamAndMissingAssetAreUnknown() {
47
+ assertNull(HermesGuard.readHeader { trickle(hbc(96).copyOf(5), 3) }?.let { HermesGuard.versionOf(it) })
48
+ assertNull(HermesGuard.readHeader { ByteArrayInputStream(hbc(96).copyOf(9)) }?.let { HermesGuard.versionOf(it) })
49
+ assertNull(HermesGuard.readHeader { null })
50
+ assertNull(HermesGuard.readHeader { throw IOException("gone") })
51
+ assertNull(HermesGuard.decide(7, 98) { null })
52
+ assertNull(HermesGuard.decide(7, 98) { ByteArrayInputStream(hbc(96).copyOf(9)) })
53
+ }
54
+
55
+ @Test fun versionFieldMustBeAWholePositiveNumber() {
56
+ assertEquals(96, HermesGuard.releaseVersionOf(96))
57
+ assertEquals(96, HermesGuard.releaseVersionOf(96L))
58
+ assertNull(HermesGuard.releaseVersionOf(96.5))
59
+ assertNull(HermesGuard.releaseVersionOf("96"))
60
+ assertNull(HermesGuard.releaseVersionOf(0))
61
+ assertNull(HermesGuard.releaseVersionOf(-3))
62
+ assertNull(HermesGuard.releaseVersionOf(null))
63
+ }
64
+
65
+ @Test fun decideCoversMismatchMatchAndMissingField() {
66
+ val shell = { ByteArrayInputStream(hbc(96) + ByteArray(20)) }
67
+ assertEquals("hermes_bytecode_mismatch:7:release=98:shell=96", HermesGuard.decide(7, 98, shell))
68
+ assertNull(HermesGuard.decide(7, 96, shell))
69
+ assertNull(HermesGuard.decide(7, null, shell))
70
+ assertNull(HermesGuard.decide(7, 96.5, shell))
71
+ assertNull(HermesGuard.decide(7, 98) { ByteArrayInputStream("var a;".toByteArray()) })
72
+ }
73
+ }
@@ -0,0 +1,45 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class PointerQueryTest {
8
+ private fun url(hbc: String?, nm: String?) = PointerQuery.url("https://x.dev", "app", 1, "b2", hbc, nm)
9
+
10
+ @Test fun noQueryWhenNothingDeclared() {
11
+ assertEquals("https://x.dev/p/app/1/b2.json", url(null, null))
12
+ assertEquals("https://x.dev/p/app/1/b2.json", url("", " "))
13
+ }
14
+
15
+ @Test fun queryWhenDeclared() {
16
+ assertEquals("https://x.dev/p/app/1/b2.json?hbc=96,98", url("96,98", null))
17
+ assertEquals("https://x.dev/p/app/1/b2.json?hbc=96&nm=a%401.0.0", url("96", "a@1.0.0"))
18
+ assertEquals("https://x.dev/p/app/1/b2.json?hbc=96&hbe=250829098.0.10&nm=a%401.0.0", PointerQuery.url("https://x.dev", "app", 1, "b2", "96", "a@1.0.0", "250829098.0.10"))
19
+ assertEquals("https://x.dev/p/app/1/b2.json?hbe=1.2.3", PointerQuery.url("https://x.dev", "app", 1, "b2", null, null, " 1.2.3 "))
20
+ assertEquals("https://x.dev/p/app/1/b2.json", PointerQuery.url("https://x.dev", "app", 1, "b2", null, null, " "))
21
+ }
22
+
23
+ @Test fun scopedNamesAreEncoded() {
24
+ assertEquals("https://x.dev/p/app/1/b2.json?nm=%40s%2Fp%401.2.3,b%402.0.0", url(null, "@s/p@1.2.3,b@2.0.0"))
25
+ }
26
+
27
+ @Test fun readsTypedReason() {
28
+ assertEquals("native_module_missing", PointerQuery.mismatchReason("{\"error\":\"native_module_missing\"}"))
29
+ assertEquals("hermes_bytecode_mismatch", PointerQuery.mismatchReason("{ \"error\": \"hermes_bytecode_mismatch\" }"))
30
+ assertNull(PointerQuery.mismatchReason("{\"error\":\"not_found\"}"))
31
+ assertNull(PointerQuery.mismatchReason(null))
32
+ }
33
+
34
+ @Test fun scopedNameWithoutVersionIsEncodedAsIs() {
35
+ // The control plane splits on the last "@", so "@s/p" has no version there.
36
+ assertEquals("https://x.dev/p/app/1/b2.json?nm=%40s%2Fp", url(null, "@s/p"))
37
+ }
38
+
39
+ @Test fun queuesRejectionOncePerReason() {
40
+ assertEquals("pointer_rejected:native_module_missing", PointerQuery.rejectionEvent(emptyList(), "native_module_missing"))
41
+ val queued = listOf("crash_loop_revert", "pointer_rejected:native_module_missing")
42
+ assertNull(PointerQuery.rejectionEvent(queued, "native_module_missing"))
43
+ assertEquals("pointer_rejected:hermes_bytecode_mismatch", PointerQuery.rejectionEvent(queued, "hermes_bytecode_mismatch"))
44
+ }
45
+ }
@@ -0,0 +1,90 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import java.io.File
4
+ import java.net.InetAddress
5
+ import java.net.ServerSocket
6
+ import kotlin.concurrent.thread
7
+ import org.junit.After
8
+ import org.junit.Assert.assertArrayEquals
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Before
12
+ import org.junit.Test
13
+
14
+ class ResumableDownloadTest {
15
+ private val data = ByteArray(300_000) { (it * 31 % 251).toByte() }
16
+ private val ranges = mutableListOf<String?>()
17
+ private var honourRange = true
18
+ // A minimal HTTP/1.1 server on a plain socket: com.sun.net.httpserver is not on the Android unit-test classpath.
19
+ private lateinit var server: ServerSocket
20
+ private lateinit var dir: File
21
+ private val url get() = "http://127.0.0.1:${server.localPort}/b"
22
+
23
+ @Before fun start() {
24
+ dir = File.createTempFile("shipvo", "dl").apply { delete(); mkdirs() }
25
+ server = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
26
+ thread(isDaemon = true) {
27
+ while (!server.isClosed) {
28
+ val s = try { server.accept() } catch (e: Exception) { break }
29
+ s.use { sock ->
30
+ val reader = sock.getInputStream().bufferedReader(Charsets.ISO_8859_1)
31
+ reader.readLine() ?: return@use
32
+ var range: String? = null
33
+ while (true) {
34
+ val line = reader.readLine() ?: break
35
+ if (line.isEmpty()) break
36
+ val i = line.indexOf(':')
37
+ if (i > 0 && line.substring(0, i).trim().equals("Range", ignoreCase = true)) range = line.substring(i + 1).trim()
38
+ }
39
+ synchronized(ranges) { ranges.add(range) }
40
+ val start = if (honourRange && range != null) range.removePrefix("bytes=").removeSuffix("-").toInt() else 0
41
+ val body = data.copyOfRange(start, data.size)
42
+ val head = buildString {
43
+ append(if (start > 0) "HTTP/1.1 206 Partial Content\r\n" else "HTTP/1.1 200 OK\r\n")
44
+ if (start > 0) append("Content-Range: bytes $start-${data.size - 1}/${data.size}\r\n")
45
+ append("Content-Length: ${body.size}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n")
46
+ }
47
+ val out = sock.getOutputStream()
48
+ out.write(head.toByteArray(Charsets.ISO_8859_1)); out.write(body); out.flush()
49
+ }
50
+ }
51
+ }
52
+ }
53
+ @After fun stop() { server.close(); dir.deleteRecursively() }
54
+
55
+ @Test fun secondRequestStartsAtThePartialSize() {
56
+ val part = ResumableDownload.partialFile(dir, "abc")
57
+ part.writeBytes(data.copyOfRange(0, 100_000))
58
+ val from = ResumableDownload.fetch(url, part, data.size.toLong())
59
+ assertEquals(100_000L, from)
60
+ assertEquals(listOf<String?>("bytes=100000-"), ranges)
61
+ assertArrayEquals(data, part.readBytes())
62
+ }
63
+
64
+ @Test fun a200AnswerToARangeRequestRestartsCleanly() {
65
+ honourRange = false
66
+ val part = ResumableDownload.partialFile(dir, "abc")
67
+ part.writeBytes(data.copyOfRange(0, 100_000))
68
+ ResumableDownload.fetch(url, part, data.size.toLong())
69
+ assertEquals(listOf<String?>("bytes=100000-"), ranges)
70
+ assertArrayEquals(data, part.readBytes())
71
+ }
72
+
73
+ @Test fun freshDownloadSendsNoRange() {
74
+ val part = ResumableDownload.partialFile(dir, "abc")
75
+ assertEquals(0L, ResumableDownload.fetch(url, part, data.size.toLong()))
76
+ assertEquals(listOf<String?>(null), ranges)
77
+ assertArrayEquals(data, part.readBytes())
78
+ }
79
+
80
+ @Test fun anOversizedPartialIsDiscarded() {
81
+ val part = ResumableDownload.partialFile(dir, "abc")
82
+ part.writeBytes(ByteArray(data.size + 5))
83
+ assertEquals(0L, ResumableDownload.fetch(url, part, data.size.toLong()))
84
+ assertArrayEquals(data, part.readBytes())
85
+ }
86
+
87
+ @Test fun partialFilesAreNamedByReleaseHash() {
88
+ assertFalse(ResumableDownload.partialFile(dir, "a") == ResumableDownload.partialFile(dir, "b"))
89
+ }
90
+ }
@@ -0,0 +1,141 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import net.i2p.crypto.eddsa.EdDSAEngine
4
+ import net.i2p.crypto.eddsa.EdDSAPrivateKey
5
+ import net.i2p.crypto.eddsa.EdDSAPublicKey
6
+ import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
7
+ import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
8
+ import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
9
+ import org.json.JSONArray
10
+ import org.json.JSONObject
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Test
15
+ import java.security.MessageDigest
16
+ import java.text.SimpleDateFormat
17
+ import java.util.Base64
18
+ import java.util.Locale
19
+ import java.util.TimeZone
20
+
21
+ /** Runs the shared vectors (packages/runtime/test-vectors/rotation.json) against Rotation with an in-memory store. */
22
+ class RotationTest {
23
+ private class Mem : RotationState {
24
+ override var storedReleaseKey: String? = null
25
+ override var storedSequence = 0L
26
+ override var storedPinnedKey: String? = null
27
+ override var storedPending: String? = null
28
+ var commits = 0
29
+ override fun commit(releaseKey: String?, sequence: Long, pinnedKey: String?, pending: String?) {
30
+ storedReleaseKey = releaseKey; storedSequence = sequence; storedPinnedKey = pinnedKey; storedPending = pending; commits++
31
+ }
32
+ }
33
+
34
+ private val spec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519)
35
+ private fun dec(s: String) = Base64.getDecoder().decode(s)
36
+ private fun verify(m: ByteArray, sig: String, key: String): Boolean = try {
37
+ val e = EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm))
38
+ e.initVerify(EdDSAPublicKey(EdDSAPublicKeySpec(dec(key), spec)))
39
+ e.update(m); e.verify(dec(sig))
40
+ } catch (_: Exception) { false }
41
+ private fun sign(m: ByteArray, seed: String): String {
42
+ val e = EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm))
43
+ e.initSign(EdDSAPrivateKey(EdDSAPrivateKeySpec(dec(seed), spec)))
44
+ e.update(m); return Base64.getEncoder().encodeToString(e.sign())
45
+ }
46
+ private fun time(s: String) = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.parse(s)!!.time
47
+
48
+ private val vectors = JSONObject(javaClass.classLoader!!.getResourceAsStream("rotation.json")!!.bufferedReader().readText())
49
+ private val keys = vectors.getJSONObject("keys")
50
+ private fun pub(name: String) = keys.getJSONObject(name).getString("publicKey")
51
+
52
+
53
+ private fun make(state: Mem, clock: Array<Long>, count: IntArray) = Rotation(
54
+ state, vectors.getString("appId"), pub("K1"), vectors.getJSONObject("root").getString("publicKey"),
55
+ vectors.getLong("minSequence"), { clock[0] }, { m, s, k -> count[0]++; verify(m, s, k) }, { dec(it) })
56
+
57
+ @Test fun sharedVectors() {
58
+ val cases = vectors.getJSONArray("cases")
59
+ assertEquals(14, cases.length())
60
+ for (c in 0 until cases.length()) {
61
+ val case = cases.getJSONObject(c)
62
+ val name = "case ${case.getInt("id")}: ${case.getString("name")}"
63
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
64
+ var rot = make(state, clock, count)
65
+ val steps = case.getJSONArray("steps")
66
+ for (i in 0 until steps.length()) {
67
+ val step = steps.getJSONObject(i)
68
+ clock[0] = time(step.getString("now"))
69
+ if (step.optBoolean("relaunch")) rot = make(state, clock, count) // new process, same stored state
70
+ count[0] = 0
71
+ val results = rot.handle(step.getJSONArray("deliver"))
72
+ if (step.optBoolean("rejectedWhole")) {
73
+ assertTrue("$name step $i whole", results.isEmpty())
74
+ assertEquals("$name step $i signature checks", step.getInt("signatureChecks"), count[0])
75
+ } else {
76
+ val want = step.getJSONArray("verify")
77
+ assertEquals("$name step $i result count", want.length(), results.size)
78
+ for (k in 0 until want.length()) {
79
+ val w = want.getJSONObject(k)
80
+ assertEquals("$name step $i env $k", w.getBoolean("ok"), results[k].ok)
81
+ if (!w.getBoolean("ok")) assertEquals("$name step $i env $k reason", w.getString("reason"), results[k].reason)
82
+ }
83
+ }
84
+ val expect = step.getJSONObject("expect")
85
+ assertEquals("$name step $i key", pub(expect.getString("currentKey")), rot.currentReleaseKey())
86
+ assertEquals("$name step $i sequence", expect.getLong("sequence"), rot.effectiveSequence())
87
+ val pending = state.storedPending?.let { JSONArray(it) } ?: JSONArray()
88
+ val seqs = (0 until pending.length()).map { JSONObject(pending.getJSONObject(it).getString("body")).getLong("sequence") }
89
+ val wantSeqs = expect.getJSONArray("pending").let { a -> (0 until a.length()).map { a.getLong(it) } }
90
+ assertEquals("$name step $i pending", wantSeqs, seqs)
91
+ }
92
+ }
93
+ }
94
+
95
+ private fun firstDelivery(caseId: Int): JSONArray {
96
+ val cases = vectors.getJSONArray("cases")
97
+ for (i in 0 until cases.length()) if (cases.getJSONObject(i).getInt("id") == caseId)
98
+ return cases.getJSONObject(i).getJSONArray("steps").getJSONObject(0).getJSONArray("deliver")
99
+ error("no case $caseId")
100
+ }
101
+
102
+ @Test fun pointersFollowTheRotatedKey() {
103
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
104
+ val rot = make(state, clock, count)
105
+ val pointer = "{\"release\":2}".toByteArray()
106
+ val bySeed = { n: String -> sign(pointer, keys.getJSONObject(n).getString("seed")) }
107
+ assertTrue(verify(pointer, bySeed("K1"), rot.currentReleaseKey()))
108
+ assertTrue(rot.handle(firstDelivery(1))[0].ok)
109
+ assertTrue(verify(pointer, bySeed("K2"), rot.currentReleaseKey()))
110
+ assertFalse(verify(pointer, bySeed("K1"), rot.currentReleaseKey()))
111
+ }
112
+
113
+ @Test fun corruptPendingIsClearedAndKeyAndSequenceKept() {
114
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
115
+ val rot = make(state, clock, count)
116
+ assertTrue(rot.handle(firstDelivery(1))[0].ok)
117
+ state.storedPending = "not json {"
118
+ rot.applyDue()
119
+ assertEquals(null, state.storedPending)
120
+ assertEquals(1L, state.storedSequence)
121
+ assertEquals(pub("K2"), rot.currentReleaseKey())
122
+ }
123
+
124
+ @Test fun newPinDiscardsStoredKeyButKeepsSequence() {
125
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
126
+ assertTrue(make(state, clock, count).handle(firstDelivery(1))[0].ok)
127
+ // A new shell build pins K3 and MIN_SEQUENCE 1: the stored K2 is dropped, and statement 1 cannot come back.
128
+ val rot = Rotation(state, "tide", pub("K3"), vectors.getJSONObject("root").getString("publicKey"), 1, { clock[0] }, ::verify, { dec(it) })
129
+ assertEquals(pub("K3"), rot.currentReleaseKey())
130
+ assertFalse(rot.handle(firstDelivery(1))[0].ok)
131
+ assertEquals(pub("K3"), rot.currentReleaseKey())
132
+ assertEquals(1L, state.storedSequence)
133
+ }
134
+
135
+ @Test fun noRootKeyMeansNothingApplies() {
136
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime")))
137
+ val rot = Rotation(state, "tide", pub("K1"), "", 0, { clock[0] }, ::verify, { dec(it) })
138
+ assertFalse(rot.handle(firstDelivery(1))[0].ok)
139
+ assertEquals(pub("K1"), rot.currentReleaseKey())
140
+ }
141
+ }
@@ -0,0 +1,76 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Before
7
+ import org.junit.Test
8
+
9
+ class StabilityTest {
10
+ private class Counts : BootCounts { override var unhealthyBoots = 0; override var firstUnhealthyAt = 0L }
11
+ private val max = ShipvoHostHandler.MAX_BOOTS
12
+ private val win = ShipvoHostHandler.WINDOW_MS
13
+
14
+ @Before fun reset() { BootRules.healthyLoad = 0; BootRules.crashedLoad = 0 }
15
+
16
+ /** One launch of a promoted release: count it, then report healthy. Returns true if the launch reverts. */
17
+ private fun launch(c: Counts, now: Long, load: Int): Boolean {
18
+ if (BootRules.countBoot(c, now, max, win)) return true
19
+ BootRules.onHealthy(c, load, now, promoted = false); return false
20
+ }
21
+
22
+ @Test fun releaseThatDiesAfterMarkHealthyIsReverted() {
23
+ val c = Counts()
24
+ BootRules.onHealthy(c, 1, 0, promoted = true) // boot 1: promoted, counted as 1
25
+ assertFalse(launch(c, 5_000, 2)) // boot 2
26
+ assertFalse(launch(c, 10_000, 3)) // boot 3
27
+ assertTrue("fourth launch reverts", launch(c, 15_000, 4))
28
+ }
29
+
30
+ @Test fun releaseThatReachesStabilityIsKept() {
31
+ val c = Counts()
32
+ BootRules.onHealthy(c, 1, 0, promoted = true)
33
+ for (i in 2..20) {
34
+ assertFalse(launch(c, i * 5_000L, i))
35
+ assertTrue(BootRules.markStableIfHealthy(c, i))
36
+ }
37
+ }
38
+
39
+ @Test fun backgroundBeforeMarkHealthyDoesNotClearTheCount() {
40
+ val c = Counts()
41
+ BootRules.onHealthy(c, 1, 0, promoted = true) // load 1 reported healthy, then the app reloaded
42
+ BootRules.countBoot(c, 1_000, max, win) // load 2 starts
43
+ assertFalse(BootRules.markStableIfHealthy(c, 2))
44
+ assertEquals(2, c.unhealthyBoots)
45
+ }
46
+
47
+ @Test fun crashAfterMarkHealthyIsNotStableWhenHostIsDestroyed() {
48
+ val c = Counts()
49
+ BootRules.onHealthy(c, 1, 0, promoted = true)
50
+ BootRules.noteCrash(1) // JS error, then the host pause calls markStableIfHealthy
51
+ assertFalse(BootRules.markStableIfHealthy(c, 1))
52
+ assertEquals(1, c.unhealthyBoots)
53
+ }
54
+
55
+ @Test fun crashInOneLoadDoesNotBlockTheNextLoad() {
56
+ val c = Counts()
57
+ BootRules.onHealthy(c, 1, 0, promoted = true)
58
+ BootRules.noteCrash(1)
59
+ BootRules.onHealthy(c, 2, 1_000, promoted = false)
60
+ assertTrue(BootRules.markStableIfHealthy(c, 2))
61
+ }
62
+
63
+ @Test fun countsExpireOutsideTheWindow() {
64
+ val c = Counts()
65
+ BootRules.onHealthy(c, 1, 0, promoted = true)
66
+ assertFalse(BootRules.countBoot(c, win + 1, max, win))
67
+ assertEquals(1, c.unhealthyBoots)
68
+ }
69
+
70
+ @Test fun aReleaseAlreadyPendingCurrentOrBlockedIsNotStagedAgain() {
71
+ assertTrue(BootRules.mayStage(5, 3, 4, 2))
72
+ assertFalse(BootRules.mayStage(5, 3, 5, 0)) // pending: staging again would reset trialBoots
73
+ assertFalse(BootRules.mayStage(5, 5, 0, 0))
74
+ assertFalse(BootRules.mayStage(5, 3, 0, 5))
75
+ }
76
+ }
package/app.plugin.js CHANGED
@@ -3,15 +3,83 @@
3
3
  // change which key it is verified against.
4
4
  const { withAndroidManifest, withInfoPlist, AndroidConfig } = require("expo/config-plugins");
5
5
 
6
+ // Returns the comma list for the Hermes bytecode versions, or undefined when the prop is omitted.
7
+ function formatBytecodeVersions(value) {
8
+ if (value === undefined) return undefined;
9
+ if (!Array.isArray(value)) throw new Error("@shipvo/runtime: hermesBytecodeVersions must be an array of integers");
10
+ for (const v of value) {
11
+ if (!Number.isInteger(v) || v < 0) {
12
+ throw new Error(`@shipvo/runtime: hermesBytecodeVersions must be non-negative integers, got ${JSON.stringify(v)}`);
13
+ }
14
+ }
15
+ return value.join(",");
16
+ }
17
+
18
+ // Returns the engine build id (like "250829098.0.10"), or undefined when the prop is omitted.
19
+ function formatHermesEngine(value) {
20
+ if (value === undefined) return undefined;
21
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+$/.test(value.trim())) {
22
+ throw new Error(`@shipvo/runtime: hermesEngine must look like 250829098.0.10, got ${JSON.stringify(value)}`);
23
+ }
24
+ return value.trim();
25
+ }
26
+
27
+ // Returns "name@version,..." for the native modules, or undefined when the prop is omitted.
28
+ function formatNativeModules(value) {
29
+ if (value === undefined) return undefined;
30
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
31
+ throw new Error("@shipvo/runtime: nativeModules must be an object of package name to version");
32
+ }
33
+ return Object.entries(value)
34
+ .map(([name, version]) => {
35
+ if (!name.trim()) throw new Error("@shipvo/runtime: nativeModules has an empty package name");
36
+ if (typeof version !== "string" || !version.trim()) {
37
+ throw new Error(`@shipvo/runtime: nativeModules version for "${name}" must be a non-empty string`);
38
+ }
39
+ if (/[,\s]/.test(version) || /[,\s]/.test(name)) {
40
+ throw new Error(`@shipvo/runtime: nativeModules entry "${name}": "${version}" has a comma or space where it cannot`);
41
+ }
42
+ return `${name}@${version}`;
43
+ })
44
+ .join(",");
45
+ }
46
+
47
+ // Returns the root key, or undefined when the prop is omitted. Must be base64 of 32 bytes (an Ed25519 public key).
48
+ function checkRootKey(value) {
49
+ if (value === undefined) return undefined;
50
+ const ok = typeof value === "string" && /^[A-Za-z0-9+/]+={0,2}$/.test(value) && value.length % 4 === 0 && Buffer.from(value, "base64").length === 32;
51
+ if (!ok) throw new Error("@shipvo/runtime: rootKey must be a base64 string that decodes to 32 bytes");
52
+ return value;
53
+ }
54
+
55
+ // Returns the minimum rotation sequence, or undefined when the prop is omitted.
56
+ function checkMinSequence(value) {
57
+ if (value === undefined) return undefined;
58
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
59
+ throw new Error(`@shipvo/runtime: minSequence must be a non-negative integer, got ${JSON.stringify(value)}`);
60
+ }
61
+ return value;
62
+ }
63
+
6
64
  module.exports = function withShipvo(config, props) {
7
- const { baseUrl, publicKey, appId, shellMajor = 1 } = props || {};
65
+ const { baseUrl, publicKey, appId, shellMajor = 1, rootKey, minSequence } = props || {};
8
66
  if (!baseUrl || !publicKey || !appId) throw new Error("@shipvo/runtime: baseUrl, publicKey and appId are required");
9
- config.extra = { ...(config.extra ?? {}), shipvo: { baseUrl, appId, shellMajor } };
67
+ checkRootKey(rootKey);
68
+ checkMinSequence(minSequence);
69
+ // minSequence is the replay floor for rotation statements; without a root key no statement is accepted, so it would do nothing.
70
+ if (minSequence !== undefined && rootKey === undefined) throw new Error("@shipvo/runtime: minSequence needs rootKey");
71
+ const hbc = formatBytecodeVersions(props.hermesBytecodeVersions);
72
+ const hbe = formatHermesEngine(props.hermesEngine);
73
+ const nm = formatNativeModules(props.nativeModules);
74
+ config.extra = { ...(config.extra ?? {}), shipvo: { baseUrl, appId, shellMajor, ...(props.reportEvents === false ? { reportEvents: false } : {}) } };
10
75
  config = withInfoPlist(config, (cfg) => {
11
76
  cfg.modResults.ShipvoBaseURL = baseUrl;
12
77
  cfg.modResults.ShipvoPublicKey = publicKey;
13
78
  cfg.modResults.ShipvoAppId = appId;
14
79
  cfg.modResults.ShipvoShellMajor = String(shellMajor);
80
+ if (hbc !== undefined) cfg.modResults.ShipvoHermesBytecodeVersions = hbc;
81
+ if (hbe !== undefined) cfg.modResults.ShipvoHermesEngine = hbe;
82
+ if (nm !== undefined) cfg.modResults.ShipvoNativeModules = nm;
15
83
  return cfg;
16
84
  });
17
85
  return withAndroidManifest(config, (cfg) => {
@@ -21,6 +89,11 @@ module.exports = function withShipvo(config, props) {
21
89
  set("dev.shipvo.PUBLIC_KEY", publicKey);
22
90
  set("dev.shipvo.APP_ID", appId);
23
91
  set("dev.shipvo.SHELL_MAJOR", shellMajor);
92
+ if (rootKey !== undefined) set("dev.shipvo.ROOT_KEY", rootKey);
93
+ if (minSequence !== undefined) set("dev.shipvo.MIN_SEQUENCE", minSequence);
94
+ if (hbc !== undefined) set("dev.shipvo.HERMES_BYTECODE_VERSIONS", hbc);
95
+ if (hbe !== undefined) set("dev.shipvo.HERMES_ENGINE", hbe);
96
+ if (nm !== undefined) set("dev.shipvo.NATIVE_MODULES", nm);
24
97
  return cfg;
25
98
  });
26
99
  };
@@ -4,6 +4,8 @@ import Foundation
4
4
  enum ShipvoBundleSelector {
5
5
  static let maxBoots = 3
6
6
  static let windowSeconds: TimeInterval = 60
7
+ /// A release is stable once its process has stayed in the foreground this long after `markHealthy`. Android uses the same value.
8
+ static let stabilitySeconds: TimeInterval = 30
7
9
 
8
10
  /// Idempotent per load: React Native asks for the URL more than once, and only the first ask counts as a boot.
9
11
  static func select(_ s: Store) -> URL? {
@@ -25,12 +27,30 @@ enum ShipvoBundleSelector {
25
27
  s.currentRelease = s.pendingRelease; s.currentPath = s.pendingPath; s.currentSha = s.pendingSha
26
28
  s.pendingRelease = 0; s.pendingPath = nil; s.trialBoots = 0
27
29
  s.addEvent("applied:\(s.currentRelease)")
30
+ // The promoting boot was a trial boot and was not counted. Count it now, until the stability window ends.
31
+ s.unhealthyBoots = 1; s.firstUnhealthyAt = Store.now()
28
32
  promoted = true
29
33
  }
30
- s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
34
+ // The boot counter is not cleared here: `markStableIfHealthy` does that after the stability window.
31
35
  return promoted
32
36
  }
33
37
 
38
+ /// The load that last reported healthy. `markStableIfHealthy` only acts for that load.
39
+ private static var healthyLoad = 0
40
+
41
+ /// Called from `markHealthy`: the current load reached its first screen.
42
+ static func noteHealthy() { Store.lock.lock(); healthyLoad = Store.loadsInProcess; Store.lock.unlock() }
43
+
44
+ /// Clears the count only if the current load reported healthy. After a reload, the new load has not, so a crash
45
+ /// before its `markHealthy` still counts. Returns whether the count was cleared.
46
+ @discardableResult
47
+ static func markStableIfHealthy(_ s: Store) -> Bool {
48
+ Store.lock.lock(); defer { Store.lock.unlock() }
49
+ guard healthyLoad != 0, healthyLoad == Store.loadsInProcess else { return false }
50
+ s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
51
+ return true
52
+ }
53
+
34
54
  /// Called before a reload so the next ask is a fresh load.
35
55
  static func invalidate() { Store.lock.lock(); Store.selection = (nil, false); Store.lock.unlock() }
36
56
 
@@ -0,0 +1,43 @@
1
+ import Foundation
2
+
3
+ /// Refuses a release built for a different Hermes bytecode version than the shell runs. Runs in addition to the
4
+ /// signature and hash checks, never instead of them.
5
+ enum HermesGuard {
6
+ static let reason = "hermes_bytecode_mismatch"
7
+ // Same magic and layout as the CLI code that writes `hermes_bytecode_version` on publish.
8
+ private static let magic: [UInt8] = [0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f]
9
+
10
+ /// The bytecode version in a bundle header (8-byte magic, then a 32-bit little-endian version), or nil for plain JS.
11
+ static func version(of head: Data) -> Int? {
12
+ let b = [UInt8](head.prefix(12))
13
+ guard b.count >= 12, Array(b[0..<8]) == magic else { return nil }
14
+ let v = (0..<4).reduce(0) { $0 | Int(b[8 + $1]) << (8 * $1) }
15
+ return v > 0 ? v : nil
16
+ }
17
+
18
+ /// The release's version field, or nil ("unknown") unless it is a whole number from 1 to Int32.max.
19
+ /// Same rule as `releaseVersionOf` on Android: rejects Bool, fractions, strings, zero and negatives.
20
+ static func releaseVersionOf(_ value: Any?) -> Int? {
21
+ guard let n = value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID() else { return nil }
22
+ let d = n.doubleValue
23
+ guard d == d.rounded(), d > 0, d <= Double(Int32.max) else { return nil }
24
+ return Int(d)
25
+ }
26
+
27
+ /// The event to log when the release must be refused, or nil when it may be staged.
28
+ /// A missing field on either side means "unknown", so the release is applied as before.
29
+ static func mismatchEvent(release: Int, releaseVersion: Int?, shellVersion: Int?) -> String? {
30
+ guard let r = releaseVersion, let s = shellVersion, r != s else { return nil }
31
+ return "\(reason):\(release):release=\(r):shell=\(s)"
32
+ }
33
+
34
+ /// The version of the bundle embedded in the app, read from its header.
35
+ /// The embedded bundle header is the source of truth here, not `ShipvoHermesBytecodeVersions` in Info.plist
36
+ /// (that list is only sent to the control plane to filter releases).
37
+ /// Read by file path with `FileHandle`, not through `Bundle(path:)` on a plain folder.
38
+ static func shellVersion(_ bundle: Bundle) -> Int? {
39
+ guard let url = bundle.url(forResource: "main", withExtension: "jsbundle"), let h = try? FileHandle(forReadingFrom: url) else { return nil }
40
+ defer { try? h.close() }
41
+ return version(of: (try? h.read(upToCount: 12)) ?? Data())
42
+ }
43
+ }