@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.
- package/README.md +49 -0
- package/android/build.gradle +7 -0
- package/android/src/main/java/dev/shipvo/runtime/BootRules.kt +53 -0
- package/android/src/main/java/dev/shipvo/runtime/Clock.kt +9 -0
- package/android/src/main/java/dev/shipvo/runtime/HermesGuard.kt +42 -0
- package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +36 -0
- package/android/src/main/java/dev/shipvo/runtime/ResumableDownload.kt +39 -0
- package/android/src/main/java/dev/shipvo/runtime/Rotation.kt +134 -0
- package/android/src/main/java/dev/shipvo/runtime/ShipvoHostHandler.kt +4 -5
- package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +66 -10
- package/android/src/main/java/dev/shipvo/runtime/Store.kt +36 -3
- package/android/src/test/java/dev/shipvo/runtime/HermesGuardTest.kt +73 -0
- package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +45 -0
- package/android/src/test/java/dev/shipvo/runtime/ResumableDownloadTest.kt +90 -0
- package/android/src/test/java/dev/shipvo/runtime/RotationTest.kt +141 -0
- package/android/src/test/java/dev/shipvo/runtime/StabilityTest.kt +76 -0
- package/app.plugin.js +75 -2
- package/ios/BundleSelector.swift +21 -1
- package/ios/HermesGuard.swift +43 -0
- package/ios/Rotation.swift +147 -0
- package/ios/ShipvoRuntimeModule.swift +27 -0
- package/ios/Store.swift +47 -0
- package/ios/Updater.swift +88 -14
- package/package.json +9 -1
- package/skills/shipvo-runtime/SKILL.md +39 -0
- package/src/index.ts +102 -1
- package/src/index.web.ts +3 -0
- package/src/install.ts +74 -0
- 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
|
-
|
|
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
|
};
|
package/ios/BundleSelector.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|