diff --git a/Package.swift b/Package.swift index 7e0eea9..ab1f5b1 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,7 @@ let package = Package( "BundleDropBridge.m", "BundleDropBundleVerifier.swift", "BundleDropFileOps.swift", + "BundleDropRuntimeCrypto.swift", "BundleDropLocator.h", "BundleDropLocator.m", "BundleDropLocator.swift", @@ -74,6 +75,7 @@ let package = Package( "BundleDropBundleVerifier.swift", "BundleDropLocator.swift", "BundleDropOtaResolver.swift", + "BundleDropRuntimeCrypto.swift", ] ), .testTarget( diff --git a/README.md b/README.md index ef9b223..74d80b0 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ npx bundle-drop login npx bundle-drop doctor ``` -`login` signs you in, creates `bundle.drop.config.js` when needed, detects Expo or +`login` signs you in, creates `bundle.drop.config.js` when needed, pins the +authenticated runtime-delivery bootstrap under `.bundle-drop/`, detects Expo or bare React Native, previews the integration changes, and completes setup. `doctor` then validates configuration, runtime identity, native integration, and startup ownership. @@ -113,6 +114,86 @@ npx bundle-drop init --project-type bare Run `npx bundle-drop doctor` again after changing native integration, Metro, Expo configuration, or runtime versions. +When Bundle Drop rotates manifest verification keys or changes the public manifest +route, refresh the pinned client-visible bootstrap explicitly: + +```bash +npx bundle-drop sync +npx bundle-drop doctor +``` + +Apps upgrading from an inline `runtimeDelivery` block or a direct Metro alias should +remove that stale block and run `npx bundle-drop init` once to install the +package-managed Metro wrapper. Inline delivery data is ignored: the validated +generated bootstrap is the sole trust source. After that one-time migration, `sync` +is the narrow command for refreshing trust data. + +The generated bootstrap is not a secret and should be committed. Setup keeps the +generated Metro wrapper, build receipts, and other transient `.bundle-drop` files +ignored while allowing `.bundle-drop/runtime-delivery.generated.json` into source +control. + +### Manual setup without AI planning + +You can configure Bundle Drop without sending project files to the AI setup planner. +Create `bundle.drop.config.js` with the project identity and public project API key +shown in the developer dashboard: + +```js +module.exports = { + serverUrl: 'https://api.bundledrop.app', + defaultChannel: 'develop', + runtimeVersion: { ios: '1.0.0', android: '1.0.0' }, + org: { slug: 'your-org' }, + project: { + name: 'Your App', + slug: 'your-app', + apiKey: 'your-public-project-key', + }, +}; +``` + +Wrap the final exported Metro config. For bare React Native: + +```js +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro'); + +const config = mergeConfig(getDefaultConfig(__dirname), {}); +module.exports = withBundleDrop(config, { projectRoot: __dirname }); +``` + +For Expo: + +```js +const { getDefaultConfig } = require('expo/metro-config'); +const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro'); + +module.exports = withBundleDropExpo(getDefaultConfig(__dirname), { + projectRoot: __dirname, +}); +``` + +Expo projects must also add `@gfean/react-native-bundle-drop` to the app config's +`plugins` array. Bare projects must connect Bundle Drop to the Release bundle URL in +their Android and iOS entrypoints. Follow the +[manual native setup guide](https://bundledrop.app/docs/manual-setup) because the +entrypoint shape varies across React Native versions. + +Finish every manual setup by authenticating, generating the public trust bootstrap, +and validating the complete integration: + +```bash +npx bundle-drop login +npx bundle-drop sync +npx bundle-drop doctor +``` + +`sync` creates `.bundle-drop/runtime-delivery.generated.json`, recreates it if it or +the entire `.bundle-drop` directory was deleted, and repairs the corresponding +`.gitignore` rules. The bootstrap contains public identity and verification material, +not secrets, so commit it with the application. + ## Initialize the Runtime Call `BundleDrop.init` once, as early as possible in the app process. @@ -239,6 +320,85 @@ example, an iOS-only native change should bump `runtimeVersion.ios`; Android can its existing value. This makes the compatibility boundary explicit and prevents an update from reaching a binary that cannot run it. +## Managed Runtime Delivery + +Runtime delivery is package-managed. `bundle.drop.config.js` stays focused on +application-owned values such as project identity, channel, runtime versions, and +rollback policy; it does not contain manifest hosts, access routes, public keys, or +a delivery-mode switch. + +`bundle-drop login` and `bundle-drop init` synchronize the trust bootstrap during +setup. `bundle-drop sync` performs the same narrow operation later for repair or key +rotation. Each command validates authenticated project credentials and writes the +identity-bound `.bundle-drop/runtime-delivery.generated.json`. Metro confirms that +the bootstrap belongs to the same server, organization, and project before merging +it into the runtime module. Malformed, copied, unsupported, or private-key-bearing +data is rejected. + +Older apps with an inline `runtimeDelivery` block keep their ordinary project +configuration, but the inline block is ignored and should be removed during +migration. Only the identity-bound generated bootstrap can enable managed delivery. +If the server explicitly disables delivery for a project, synchronization removes a +stale bootstrap and the SDK continues through the compatible `/ota/resolve` path. + +The SDK resolves a complete public lane locally. Invalid, expired, incomplete, +dynamic, unavailable, or network-failed manifests safely fall back to `/ota/resolve`. +Artifact download authorization remains API-key authenticated and uses opaque +release and artifact references rather than URLs embedded in the manifest. + +Lane manifests are signed state and are not themselves the revocation clock. Every +local check also fetches a small environment-wide signed publisher lease. The SDK +verifies its key, exact manifest origin, issue time, and short absolute expiry before +it verifies or persists lane generation state. A missing, invalid, expired, or +operator-disabled lease therefore falls back to `/ota/resolve`, even when cached +manifest bytes remain cryptographically valid. + +Artifact download capabilities are intentionally short-lived. If an artifact URL is +rejected with HTTP 401 or 403 during download, the SDK reauthorizes the original +signed selection once and retries only when its generation, target, transport, +artifact references, and signed hashes are unchanged. Verification, reconstruction, +and installation failures are never retried this way. + +With managed delivery, install through `downloadUpdate`, or use the React hook's +`fetchBundles` and `installBundle(bundle)` list-item flow. Direct named +`installBundle(hash, url, ...)` calls are rejected because they bypass the fresh +resolve and artifact-authorization decision. + +Unchanged install state is reported at most once every seven days. A current bundle +hash, app environment, or user-property change is reported immediately; successful +installs continue to use the separate idempotent installed receipt. + +The manifest URL is derived from `manifestBaseUrl`, `manifestAccessId`, channel, +platform, and runtime version. `manifestAccessId` is an opaque URL-routing value, +not a signing secret. `publicKeys` contains public P-256 JWK coordinates keyed by +the manifest JWS `kid`, which permits signing-key rotation. + +Pass `onRuntimeDeliveryDiagnostic` to `BundleDrop.init` to export the fast-path +signals to your metrics system. Events contain a bounded counter name, cumulative +process count, timestamp, and optional channel/reason/status metadata; they never +contain access IDs, install IDs, JWS bodies, user properties, or artifact URLs. +`getRuntimeDeliveryDiagnosticCounters()` returns a process-local snapshot. The +counter names are `manifest_hit`, `dynamic_manifest`, `origin_fallback`, +`invalid_signature`, `unknown_key`, `lane_mismatch`, +`generation_regression`, `generation_equivocation`, +`manifest_http_error`, `manifest_network_error`, `manifest_timeout`, +`manifest_too_large`, `manifest_invalid`, `manifest_stream_unavailable`, +`authority_lease_http_error`, `authority_lease_network_error`, +`authority_lease_timeout`, `authority_lease_too_large`, +`authority_lease_invalid`, `authority_lease_invalid_signature`, +`authority_lease_unknown_key`, `authority_lease_expired`, +`authority_lease_origin_mismatch`, and `authority_lease_disabled`. + +Manifest responses are read incrementally and cancelled as soon as they exceed +1 MiB. A runtime without a readable response stream fails closed to `/ota/resolve` +instead of buffering an unbounded body. + +Managed runtime delivery uses native SHA-256 and ES256 verification. After upgrading to +an SDK release whose `nativeVersion` includes this support (`0.5.0` or newer), run +Pods/prebuild as appropriate and ship a new native binary before enabling runtime +delivery for the project. Bundle Drop's native-version validation will reject a JavaScript/native +adapter mismatch. + ## Upload an Update With the default literal runtime configuration, Expo uploads resolve the app version @@ -381,6 +541,7 @@ Import the runtime API from `@gfean/react-native-bundle-drop`. | `policy` | `'manual' \| 'immediate' \| 'on-next-launch'` | `'manual'` | Startup behavior after hydration. | | `checkOnly` | `boolean` | `false` | Startup resolves updates without downloading or applying them. | | `onStatusUpdate` | `(status: string) => void` | — | Listener for human-readable status messages. | +| `onRuntimeDeliveryDiagnostic` | `(event: RuntimeDeliveryDiagnosticEvent) => void` | — | Listener for bounded runtime-delivery counter events suitable for metrics export. | **Update actions** — `checkForUpdate`, `downloadUpdate`, `installBundle`, `applyUpdate`, `getUpdateState`, `getInstalledBundleInfo`, `getAvailableBundles`, @@ -395,10 +556,13 @@ plus update actions. **Errors** — `BundleDropError`, `isBundleDropError`. -**Metro** — import `withBundleDropExpo` from -`@gfean/react-native-bundle-drop/metro` when configuring Expo Metro manually. +**Runtime delivery diagnostics** — `getRuntimeDeliveryDiagnosticCounters` returns +the current process-local counter snapshot. + +**Metro** — import `withBundleDropExpo` for Expo or `withBundleDrop` for bare React +Native from `@gfean/react-native-bundle-drop/metro` when configuring Metro manually. -**CLI** (`npx bundle-drop `) — `login`, `logout`, `whoami`, `init`, +**CLI** (`npx bundle-drop `) — `login`, `logout`, `whoami`, `init`, `sync`, `doctor`, `eas-receipt `, and `upload `. ## Good to Know diff --git a/android/build.gradle b/android/build.gradle index a49ff6f..32477e3 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -132,6 +132,7 @@ dependencies { if (project.hasProperty("standalone")) { testImplementation "junit:junit:4.13.2" + testImplementation "com.squareup.okhttp3:okhttp:4.12.0" testImplementation "org.json:json:20231013" testImplementation "androidx.test:core:1.6.1" testImplementation "org.robolectric:robolectric:4.14.1" diff --git a/android/src/main/java/com/bundledrop/BundleDropBoundedDownloader.kt b/android/src/main/java/com/bundledrop/BundleDropBoundedDownloader.kt new file mode 100644 index 0000000..f5737bd --- /dev/null +++ b/android/src/main/java/com/bundledrop/BundleDropBoundedDownloader.kt @@ -0,0 +1,86 @@ +package com.bundledrop + +import java.io.File +import java.io.FileOutputStream +import java.io.InterruptedIOException +import java.net.URL +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.Request + +internal class BundleDropBoundedDownloadHttpException( + val status: Int, + message: String, +) : java.io.IOException(message) + +internal class BundleDropBoundedDownloadTooLargeException(message: String) : + java.io.IOException(message) + +internal class BundleDropBoundedDownloadTimeoutException(cause: Throwable) : + java.io.IOException("Download timed out", cause) + +internal object BundleDropBoundedDownloader { + fun downloadToFile( + baseClient: OkHttpClient, + parsedUrl: URL, + destFile: File, + maxBytes: Long, + timeoutMs: Long, + ) { + require(maxBytes > 0) { "maxBytes must be positive" } + require(timeoutMs > 0) { "timeoutMs must be positive" } + + val client = baseClient.newBuilder() + .callTimeout(timeoutMs, TimeUnit.MILLISECONDS) + .build() + val request = Request.Builder() + .url(parsedUrl) + .get() + .header("Accept", "application/jose+json, application/json") + .build() + + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw BundleDropBoundedDownloadHttpException( + response.code, + "HTTP ${response.code}: ${response.message}", + ) + } + + val body = response.body + ?: throw java.io.IOException("Manifest response body is missing") + if (body.contentLength() > maxBytes) { + throw tooLarge(maxBytes) + } + + destFile.parentFile?.mkdirs() + body.byteStream().use { input -> + FileOutputStream(destFile).use { output -> + val buffer = ByteArray(8192) + var totalRead = 0L + while (true) { + val bytesRead = input.read(buffer) + if (bytesRead < 0) break + if (bytesRead.toLong() > maxBytes - totalRead) { + throw tooLarge(maxBytes) + } + output.write(buffer, 0, bytesRead) + totalRead += bytesRead + } + } + } + } + } catch (error: InterruptedIOException) { + try { destFile.delete() } catch (_: Exception) {} + throw BundleDropBoundedDownloadTimeoutException(error) + } catch (error: Exception) { + try { destFile.delete() } catch (_: Exception) {} + throw error + } + } + + private fun tooLarge(maxBytes: Long) = BundleDropBoundedDownloadTooLargeException( + "Download exceeds $maxBytes byte limit", + ) +} diff --git a/android/src/main/java/com/bundledrop/BundleDropModule.kt b/android/src/main/java/com/bundledrop/BundleDropModule.kt index 373c35f..0e3c349 100644 --- a/android/src/main/java/com/bundledrop/BundleDropModule.kt +++ b/android/src/main/java/com/bundledrop/BundleDropModule.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.util.Log import com.facebook.react.bridge.* +import com.facebook.react.modules.network.OkHttpClientProvider import java.io.File class BundleDropModule(reactContext: ReactApplicationContext) : @@ -193,6 +194,37 @@ class BundleDropModule(reactContext: ReactApplicationContext) : } } + @ReactMethod + fun fsSha256String(value: String, promise: Promise) { + try { + promise.resolve(BundleDropRuntimeCrypto.sha256String(value)) + } catch (e: Exception) { + promise.reject("ERR_SHA256", e.message, e) + } + } + + @ReactMethod + fun fsVerifyEs256Signature( + signingInput: String, + signatureBase64Url: String, + xBase64Url: String, + yBase64Url: String, + promise: Promise, + ) { + try { + promise.resolve( + BundleDropRuntimeCrypto.verifyEs256Signature( + signingInput, + signatureBase64Url, + xBase64Url, + yBase64Url, + ), + ) + } catch (e: Exception) { + promise.reject("ERR_ES256_VERIFY", e.message, e) + } + } + @ReactMethod fun fsFileSize(path: String, promise: Promise) { try { @@ -261,6 +293,43 @@ class BundleDropModule(reactContext: ReactApplicationContext) : }.start() } + @ReactMethod + fun fsDownloadFileBounded( + url: String, + destPath: String, + maxBytes: Double, + timeoutMs: Double, + promise: Promise, + ) { + Thread { + try { + require(maxBytes.isFinite() && maxBytes >= 1) { "maxBytes must be positive" } + require(timeoutMs.isFinite() && timeoutMs >= 1 && timeoutMs <= Int.MAX_VALUE) { + "timeoutMs is outside the supported range" + } + val parsedUrl = BundleDropFileOps.validateHttpUrl(url) + BundleDropBoundedDownloader.downloadToFile( + OkHttpClientProvider.getOkHttpClient(), + parsedUrl, + File(destPath), + maxBytes = maxBytes.toLong(), + timeoutMs = timeoutMs.toLong(), + ) + promise.resolve(null) + } catch (e: Exception) { + try { File(destPath).delete() } catch (_: Exception) {} + val code = when { + e is BundleDropBoundedDownloadTimeoutException -> "ERR_DOWNLOAD_TIMEOUT" + e is BundleDropBoundedDownloadTooLargeException -> "ERR_DOWNLOAD_TOO_LARGE" + e is BundleDropBoundedDownloadHttpException -> "ERR_DOWNLOAD_HTTP" + else -> "ERR_DOWNLOAD_NETWORK" + } + val message = e.message.orEmpty() + promise.reject(code, message.ifEmpty { "Manifest download failed" }, e) + } + }.start() + } + override fun getConstants(): MutableMap { val map = mutableMapOf() downloadedBundlePath?.let { diff --git a/android/src/main/java/com/bundledrop/BundleDropRuntimeCrypto.kt b/android/src/main/java/com/bundledrop/BundleDropRuntimeCrypto.kt new file mode 100644 index 0000000..108da70 --- /dev/null +++ b/android/src/main/java/com/bundledrop/BundleDropRuntimeCrypto.kt @@ -0,0 +1,81 @@ +package com.bundledrop + +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec + +object BundleDropRuntimeCrypto { + private const val BASE64_URL_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + fun sha256String(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + fun verifyEs256Signature( + signingInput: String, + signatureBase64Url: String, + xBase64Url: String, + yBase64Url: String, + ): Boolean { + val signature = decodeBase64Url(signatureBase64Url) + val x = decodeBase64Url(xBase64Url) + val y = decodeBase64Url(yBase64Url) + require(signature.size == 64) { "ES256 signature must be 64 bytes" } + require(x.size == 32 && y.size == 32) { "P-256 coordinates must be 32 bytes" } + + val parameters = AlgorithmParameters.getInstance("EC").apply { + init(ECGenParameterSpec("secp256r1")) + }.getParameterSpec(ECParameterSpec::class.java) + val publicKey = KeyFactory.getInstance("EC").generatePublic( + ECPublicKeySpec(ECPoint(BigInteger(1, x), BigInteger(1, y)), parameters), + ) + return Signature.getInstance("SHA256withECDSA").run { + initVerify(publicKey) + update(signingInput.toByteArray(StandardCharsets.UTF_8)) + verify(joseSignatureToDer(signature)) + } + } + + private fun decodeBase64Url(value: String): ByteArray { + require(value.length % 4 != 1) { "Invalid base64url value" } + require(value.all { BASE64_URL_ALPHABET.indexOf(it) >= 0 }) { "Invalid base64url value" } + val output = ArrayList((value.length * 3) / 4) + var index = 0 + while (index < value.length) { + val remaining = value.length - index + val a = BASE64_URL_ALPHABET.indexOf(value[index]) + val b = BASE64_URL_ALPHABET.indexOf(value[index + 1]) + val c = if (remaining > 2) BASE64_URL_ALPHABET.indexOf(value[index + 2]) else 0 + val d = if (remaining > 3) BASE64_URL_ALPHABET.indexOf(value[index + 3]) else 0 + val bits = (a shl 18) or (b shl 12) or (c shl 6) or d + output.add(((bits shr 16) and 0xff).toByte()) + if (remaining > 2) output.add(((bits shr 8) and 0xff).toByte()) + if (remaining > 3) output.add((bits and 0xff).toByte()) + index += 4 + } + return output.toByteArray() + } + + private fun joseSignatureToDer(signature: ByteArray): ByteArray { + val r = positiveDerInteger(signature.copyOfRange(0, 32)) + val s = positiveDerInteger(signature.copyOfRange(32, 64)) + val sequenceLength = 2 + r.size + 2 + s.size + return byteArrayOf(0x30, sequenceLength.toByte(), 0x02, r.size.toByte()) + + r + byteArrayOf(0x02, s.size.toByte()) + s + } + + private fun positiveDerInteger(value: ByteArray): ByteArray { + var first = 0 + while (first < value.lastIndex && value[first] == 0.toByte()) first += 1 + val trimmed = value.copyOfRange(first, value.size) + return if ((trimmed[0].toInt() and 0x80) != 0) byteArrayOf(0) + trimmed else trimmed + } +} diff --git a/android/src/test/java/com/bundledrop/BundleDropBoundedDownloaderTest.kt b/android/src/test/java/com/bundledrop/BundleDropBoundedDownloaderTest.kt new file mode 100644 index 0000000..9c246a7 --- /dev/null +++ b/android/src/test/java/com/bundledrop/BundleDropBoundedDownloaderTest.kt @@ -0,0 +1,188 @@ +package com.bundledrop + +import java.io.BufferedReader +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStreamReader +import java.net.InetAddress +import java.net.ServerSocket +import java.net.SocketException +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicReference +import java.util.zip.GZIPOutputStream +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class BundleDropBoundedDownloaderTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private val client = OkHttpClient.Builder().build() + + @Test + fun `accepts the exact byte limit and rejects one byte over`() { + val exactBody = "x".repeat(16).toByteArray() + withResponse( + headers = listOf("Content-Length: ${exactBody.size}"), + writeBody = { it.write(exactBody) }, + ) { url -> + val dest = File(tempFolder.root, "exact/manifest.jws") + BundleDropBoundedDownloader.downloadToFile(client, url, dest, 16, 2_000) + assertEquals(16, dest.length()) + } + + val oversizedBody = "x".repeat(17).toByteArray() + withResponse( + headers = listOf("Content-Length: ${oversizedBody.size}"), + writeBody = { it.write(oversizedBody) }, + allowClientDisconnect = true, + ) { url -> + val dest = File(tempFolder.root, "oversized/manifest.jws") + assertTooLarge { + BundleDropBoundedDownloader.downloadToFile(client, url, dest, 16, 2_000) + } + assertFalse(dest.exists()) + } + } + + @Test + fun `enforces streamed bytes when content length is underreported`() { + val body = "x".repeat(17).toByteArray() + withResponse( + headers = listOf( + "Content-Length: 1", + "Transfer-Encoding: chunked", + ), + writeBody = { output -> + output.write("${body.size.toString(16)}\r\n".toByteArray(StandardCharsets.US_ASCII)) + output.write(body) + output.write("\r\n0\r\n\r\n".toByteArray(StandardCharsets.US_ASCII)) + }, + allowClientDisconnect = true, + ) { url -> + val dest = File(tempFolder.root, "underreported/manifest.jws") + assertTooLarge { + BundleDropBoundedDownloader.downloadToFile(client, url, dest, 16, 2_000) + } + assertFalse(dest.exists()) + } + } + + @Test + fun `enforces the decoded limit for a compressed response`() { + val compressedBody = ByteArrayOutputStream().use { output -> + GZIPOutputStream(output).use { gzip -> + gzip.write("x".repeat(4096).toByteArray()) + } + output.toByteArray() + } + assertTrue(compressedBody.size < 100) + + withResponse( + headers = listOf( + "Content-Encoding: gzip", + "Content-Length: ${compressedBody.size}", + ), + writeBody = { it.write(compressedBody) }, + allowClientDisconnect = true, + ) { url -> + val dest = File(tempFolder.root, "compressed/manifest.jws") + assertTooLarge { + BundleDropBoundedDownloader.downloadToFile(client, url, dest, 100, 2_000) + } + assertFalse(dest.exists()) + } + } + + @Test + fun `call timeout stops a trickle response and removes the partial file`() { + withResponse( + headers = listOf("Transfer-Encoding: chunked"), + writeBody = { output -> + repeat(20) { + output.write("1\r\nx\r\n".toByteArray(StandardCharsets.US_ASCII)) + output.flush() + Thread.sleep(80) + } + output.write("0\r\n\r\n".toByteArray(StandardCharsets.US_ASCII)) + }, + allowClientDisconnect = true, + ) { url -> + val dest = File(tempFolder.root, "timeout/manifest.jws") + val startedAt = System.nanoTime() + try { + BundleDropBoundedDownloader.downloadToFile(client, url, dest, 1024, 150) + fail("Expected the whole-call deadline to stop the download") + } catch (error: BundleDropBoundedDownloadTimeoutException) { + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + assertTrue("call timeout took ${elapsedMs}ms", elapsedMs < 1_000) + } + assertFalse(dest.exists()) + } + } + + private fun assertTooLarge(block: () -> Unit) { + try { + block() + fail("Expected bounded download to reject the response") + } catch (_: BundleDropBoundedDownloadTooLargeException) { + // Expected. + } + } + + private fun withResponse( + headers: List, + writeBody: (java.io.OutputStream) -> Unit, + allowClientDisconnect: Boolean = false, + test: (URL) -> Unit, + ) { + val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) + val serverError = AtomicReference() + val thread = Thread { + try { + server.accept().use { socket -> + socket.soTimeout = 5_000 + val reader = BufferedReader( + InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII), + ) + while (true) { + val line = reader.readLine() ?: break + if (line.isEmpty()) break + } + + socket.getOutputStream().use { output -> + val responseHeaders = buildString { + append("HTTP/1.1 200 OK\r\n") + headers.forEach { append(it).append("\r\n") } + append("Connection: close\r\n\r\n") + } + output.write(responseHeaders.toByteArray(StandardCharsets.US_ASCII)) + output.flush() + writeBody(output) + output.flush() + } + } + } catch (error: SocketException) { + if (!server.isClosed && !allowClientDisconnect) serverError.set(error) + } catch (error: Throwable) { + serverError.set(error) + } + } + thread.start() + + try { + test(URL("http://127.0.0.1:${server.localPort}/manifest.jws")) + } finally { + server.close() + thread.join(5_000) + serverError.get()?.let { throw AssertionError("HTTP test server failed", it) } + } + } +} diff --git a/android/src/test/java/com/bundledrop/BundleDropRuntimeCryptoTest.kt b/android/src/test/java/com/bundledrop/BundleDropRuntimeCryptoTest.kt new file mode 100644 index 0000000..21092f5 --- /dev/null +++ b/android/src/test/java/com/bundledrop/BundleDropRuntimeCryptoTest.kt @@ -0,0 +1,31 @@ +package com.bundledrop + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BundleDropRuntimeCryptoTest { + private val protectedHeader = "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5LTIwMjYtMDgiLCJ0eXAiOiJidW5kbGVkcm9wLW1hbmlmZXN0K2p3cyJ9" + private val payload = "eyJzY2hlbWFWZXJzaW9uIjoyLCJ0eXBlIjoibGFuZSIsInByb2plY3RTbHVnIjoiZ29sZGVuLXByb2plY3QiLCJjaGFubmVsTmFtZSI6IlByb2R1Y3Rpb24gLyDOsiIsInBsYXRmb3JtIjoiaW9zIiwicnVudGltZVZlcnNpb24iOiIxLjIuMytuYXRpdmUvNDIiLCJnZW5lcmF0aW9uIjo3LCJnZW5lcmF0ZWRBdCI6IjIwMjYtMDgtMTdUMDA6MDA6MDAuMDAwWiIsImV4cGlyZXNBdCI6IjIwOTktMDEtMDFUMDA6MDA6MDAuMDAwWiIsInJlc29sdXRpb25Nb2RlIjoibG9jYWwiLCJwdWJsaXNoaW5nTW9kZSI6ImF1dG9tYXRpYyIsInJvbGxvdXRBbGdvcml0aG0iOiJzaGEyNTYtaW5zdGFsbC1pZC11aW50MzJiZS1tb2QxMDAtdjEiLCJyZXZva2VkSGFzaGVzIjpbXSwicmVsZWFzZXMiOlt7InJlbGVhc2VSZWYiOiJyZWxfZ29sZGVuIiwiYnVuZGxlSGFzaCI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJidW5kbGVWZXJzaW9uIjo3LCJ2ZXJzaW9uIjoiMS4wLjciLCJydW50aW1lVmVyc2lvbiI6IjEuMi4zK25hdGl2ZS80MiIsIm1hbmlmZXN0SGFzaCI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIiLCJqc0J1bmRsZUhhc2giOiJjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjIiwiZnVsbEJ1bmRsZUhhc2giOiJkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkIiwiZnVsbEJ1bmRsZVNpemVCeXRlcyI6MTIzNDU2LCJhdmFpbGFibGUiOnRydWUsImV4cGlyZXNBdCI6bnVsbH1dLCJwdWJsaXNoZWRSb2xsb3V0cyI6W10sInBhdGNoUG9saWN5Ijp7ImVuYWJsZWQiOnRydWUsIm1heFBhdGNoVG9GdWxsUmF0aW8iOjAuN30sInBhdGNoRWRnZXMiOltdLCJjYW5kaWRhdGVTZXRDb21wbGV0ZSI6dHJ1ZX0" + private val signature = "PnYXxiTNWHH5_aV-875FSk_Lne73VUZHAh59nV_a7oWJl1b2BBWLZ3_0e32Oprtgx5QnZWwMvl_wBznLM6WRdg" + private val x = "d-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM" + private val y = "_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI" + + @Test + fun matchesSha256GoldenVector() { + assertEquals("8ed3f6ad685b959ead7022518e1af76cd816f8e8ec7ccdda1ed4018e8f2223f8", BundleDropRuntimeCrypto.sha256String("alpha")) + } + + @Test + fun verifiesCrossRepositoryGoldenSignature() { + assertTrue(BundleDropRuntimeCrypto.verifyEs256Signature("$protectedHeader.$payload", signature, x, y)) + } + + @Test + fun rejectsTamperedSignatureAndPayload() { + assertFalse(BundleDropRuntimeCrypto.verifyEs256Signature("$protectedHeader.${payload}A", signature, x, y)) + val tampered = signature.dropLast(1) + if (signature.last() == 'A') "B" else "A" + assertFalse(BundleDropRuntimeCrypto.verifyEs256Signature("$protectedHeader.$payload", tampered, x, y)) + } +} diff --git a/ios-tests/BundleDropLocatorTests.swift b/ios-tests/BundleDropLocatorTests.swift index ab6d406..338af9c 100644 --- a/ios-tests/BundleDropLocatorTests.swift +++ b/ios-tests/BundleDropLocatorTests.swift @@ -140,6 +140,90 @@ final class BundleDropLocatorTests: XCTestCase { ) } + func testBinaryVersionKeyReadsRuntimeVersionFromSignedExpoBuildIdentity() throws { + let bundle = try makeBundleFixture( + version: "3.4.5", + build: "67", + embeddedRuntimeVersion: "runtime-from-build" + ) + + XCTAssertEqual( + BundleDropLocatorCore.getBinaryVersionKey(bundle: bundle), + "runtime:runtime-from-build|binary:3.4.5-67" + ) + } + + func testEmbeddedBuildIdentityTakesPrecedenceOverLegacyInfoPlistRuntime() throws { + let bundle = try makeBundleFixture( + version: "3.4.5", + build: "67", + runtimeVersion: "legacy-runtime", + embeddedRuntimeVersion: "signed-runtime" + ) + + XCTAssertEqual(BundleDropLocatorCore.getRuntimeVersion(bundle: bundle), "signed-runtime") + } + + func testEmbeddedRuntimeChangeChangesTheBinaryVersionKey() throws { + let runtimeTwo = try makeBundleFixture( + version: "3.4.5", + build: "67", + embeddedRuntimeVersion: "runtime-2" + ) + let runtimeThree = try makeBundleFixture( + version: "3.4.5", + build: "67", + embeddedRuntimeVersion: "runtime-3" + ) + + XCTAssertNotEqual( + BundleDropLocatorCore.getBinaryVersionKey(bundle: runtimeTwo), + BundleDropLocatorCore.getBinaryVersionKey(bundle: runtimeThree) + ) + } + + func testExpoBuildFailsClosedWhenRuntimeIdentityIsMissingOrInvalid() throws { + let missingIdentity = try makeBundleFixture( + version: "3.4.5", + build: "67", + expoEnabled: true + ) + let wrongPlatformIdentity = try makeBundleFixture( + version: "3.4.5", + build: "67", + expoEnabled: true, + embeddedRuntimeVersion: "runtime-2", + embeddedPlatform: "android" + ) + let wrongSchemaIdentity = try makeBundleFixture( + version: "3.4.5", + build: "67", + expoEnabled: true, + embeddedRuntimeVersion: "runtime-2", + embeddedSchemaVersion: 2 + ) + let malformedIdentity = try makeBundleFixture( + version: "3.4.5", + build: "67", + expoEnabled: true, + embeddedRuntimeVersion: "runtime-2" + ) + try "{".write( + to: malformedIdentity.bundleURL.appendingPathComponent( + BundleDropLocatorCore.embeddedBuildIdentityFilename + ), + atomically: true, + encoding: .utf8 + ) + let bareBuild = try makeBundleFixture(version: "3.4.5", build: "67") + + XCTAssertFalse(BundleDropLocatorCore.hasRuntimeIdentityForOta(bundle: missingIdentity)) + XCTAssertFalse(BundleDropLocatorCore.hasRuntimeIdentityForOta(bundle: wrongPlatformIdentity)) + XCTAssertFalse(BundleDropLocatorCore.hasRuntimeIdentityForOta(bundle: wrongSchemaIdentity)) + XCTAssertFalse(BundleDropLocatorCore.hasRuntimeIdentityForOta(bundle: malformedIdentity)) + XCTAssertTrue(BundleDropLocatorCore.hasRuntimeIdentityForOta(bundle: bareBuild)) + } + func testFileSizeReturnsFileSizeAndZeroForMissingFile() throws { let file = tempRoot.appendingPathComponent("asset.bin") try Data([1, 2, 3, 4]).write(to: file) @@ -260,14 +344,21 @@ final class BundleDropLocatorTests: XCTestCase { private func makeBundleFixture( version: String, build: String, - runtimeVersion: String? = nil + runtimeVersion: String? = nil, + expoEnabled: Bool = false, + embeddedRuntimeVersion: String? = nil, + embeddedPlatform: String = "ios", + embeddedSchemaVersion: Int = 1 ) throws -> Bundle { - let bundleURL = tempRoot.appendingPathComponent("Fixture.bundle", isDirectory: true) + let bundleURL = tempRoot.appendingPathComponent( + "Fixture-\(UUID().uuidString).bundle", + isDirectory: true + ) try FileManager.default.createDirectory( at: bundleURL, withIntermediateDirectories: true ) - var plist: [String: String] = [ + var plist: [String: Any] = [ "CFBundleIdentifier": "app.bundledrop.fixture", "CFBundleInfoDictionaryVersion": "6.0", "CFBundleName": "Fixture", @@ -278,6 +369,9 @@ final class BundleDropLocatorTests: XCTestCase { if let runtimeVersion { plist[BundleDropLocatorCore.runtimeVersionInfoKey] = runtimeVersion } + if expoEnabled { + plist[BundleDropLocatorCore.expoEnabledInfoKey] = true + } let data = try PropertyListSerialization.data( fromPropertyList: plist, format: .xml, @@ -285,6 +379,18 @@ final class BundleDropLocatorTests: XCTestCase { ) try data.write(to: bundleURL.appendingPathComponent("Info.plist")) + if let embeddedRuntimeVersion { + let candidate: [String: Any] = [ + "schemaVersion": embeddedSchemaVersion, + "platform": embeddedPlatform, + "runtimeVersion": embeddedRuntimeVersion, + ] + let candidateData = try JSONSerialization.data(withJSONObject: candidate) + try candidateData.write( + to: bundleURL.appendingPathComponent(BundleDropLocatorCore.embeddedBuildIdentityFilename) + ) + } + return try XCTUnwrap(Bundle(url: bundleURL)) } } diff --git a/ios-tests/BundleDropRuntimeCryptoTests.swift b/ios-tests/BundleDropRuntimeCryptoTests.swift new file mode 100644 index 0000000..f832094 --- /dev/null +++ b/ios-tests/BundleDropRuntimeCryptoTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import BundleDropIOSCore + +final class BundleDropRuntimeCryptoTests: XCTestCase { + private let protectedHeader = "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5LTIwMjYtMDgiLCJ0eXAiOiJidW5kbGVkcm9wLW1hbmlmZXN0K2p3cyJ9" + private let payload = "eyJzY2hlbWFWZXJzaW9uIjoyLCJ0eXBlIjoibGFuZSIsInByb2plY3RTbHVnIjoiZ29sZGVuLXByb2plY3QiLCJjaGFubmVsTmFtZSI6IlByb2R1Y3Rpb24gLyDOsiIsInBsYXRmb3JtIjoiaW9zIiwicnVudGltZVZlcnNpb24iOiIxLjIuMytuYXRpdmUvNDIiLCJnZW5lcmF0aW9uIjo3LCJnZW5lcmF0ZWRBdCI6IjIwMjYtMDgtMTdUMDA6MDA6MDAuMDAwWiIsImV4cGlyZXNBdCI6IjIwOTktMDEtMDFUMDA6MDA6MDAuMDAwWiIsInJlc29sdXRpb25Nb2RlIjoibG9jYWwiLCJwdWJsaXNoaW5nTW9kZSI6ImF1dG9tYXRpYyIsInJvbGxvdXRBbGdvcml0aG0iOiJzaGEyNTYtaW5zdGFsbC1pZC11aW50MzJiZS1tb2QxMDAtdjEiLCJyZXZva2VkSGFzaGVzIjpbXSwicmVsZWFzZXMiOlt7InJlbGVhc2VSZWYiOiJyZWxfZ29sZGVuIiwiYnVuZGxlSGFzaCI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJidW5kbGVWZXJzaW9uIjo3LCJ2ZXJzaW9uIjoiMS4wLjciLCJydW50aW1lVmVyc2lvbiI6IjEuMi4zK25hdGl2ZS80MiIsIm1hbmlmZXN0SGFzaCI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIiLCJqc0J1bmRsZUhhc2giOiJjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjIiwiZnVsbEJ1bmRsZUhhc2giOiJkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkIiwiZnVsbEJ1bmRsZVNpemVCeXRlcyI6MTIzNDU2LCJhdmFpbGFibGUiOnRydWUsImV4cGlyZXNBdCI6bnVsbH1dLCJwdWJsaXNoZWRSb2xsb3V0cyI6W10sInBhdGNoUG9saWN5Ijp7ImVuYWJsZWQiOnRydWUsIm1heFBhdGNoVG9GdWxsUmF0aW8iOjAuN30sInBhdGNoRWRnZXMiOltdLCJjYW5kaWRhdGVTZXRDb21wbGV0ZSI6dHJ1ZX0" + private let signature = "PnYXxiTNWHH5_aV-875FSk_Lne73VUZHAh59nV_a7oWJl1b2BBWLZ3_0e32Oprtgx5QnZWwMvl_wBznLM6WRdg" + private let x = "d-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM" + private let y = "_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI" + + func testSha256GoldenVector() { + XCTAssertEqual( + BundleDropRuntimeCrypto.sha256String("alpha"), + "8ed3f6ad685b959ead7022518e1af76cd816f8e8ec7ccdda1ed4018e8f2223f8" + ) + } + + func testCrossRepositoryGoldenSignature() throws { + XCTAssertTrue(try BundleDropRuntimeCrypto.verifyEs256Signature( + signingInput: "\(protectedHeader).\(payload)", + signatureBase64Url: signature, + xBase64Url: x, + yBase64Url: y + )) + } + + func testTamperingIsRejected() throws { + XCTAssertFalse(try BundleDropRuntimeCrypto.verifyEs256Signature( + signingInput: "\(protectedHeader).\(payload)A", + signatureBase64Url: signature, + xBase64Url: x, + yBase64Url: y + )) + } +} diff --git a/ios/BundleDropBridge.m b/ios/BundleDropBridge.m index 4a501d2..0582fca 100644 --- a/ios/BundleDropBridge.m +++ b/ios/BundleDropBridge.m @@ -53,6 +53,17 @@ @interface RCT_EXTERN_REMAP_MODULE(BundleDrop, BundleDropModule, NSObject) resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(fsSha256String:(NSString *)value + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(fsVerifyEs256Signature:(NSString *)signingInput + signatureBase64Url:(NSString *)signatureBase64Url + xBase64Url:(NSString *)xBase64Url + yBase64Url:(NSString *)yBase64Url + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(fsFileSize:(NSString *)path resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @@ -81,6 +92,13 @@ @interface RCT_EXTERN_REMAP_MODULE(BundleDrop, BundleDropModule, NSObject) resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(fsDownloadFileBounded:(NSString *)url + destPath:(NSString *)destPath + maxBytes:(nonnull NSNumber *)maxBytes + timeoutMs:(nonnull NSNumber *)timeoutMs + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + + (BOOL)requiresMainQueueSetup { return NO; diff --git a/ios/BundleDropLocator.swift b/ios/BundleDropLocator.swift index b939b3d..8f35a78 100644 --- a/ios/BundleDropLocator.swift +++ b/ios/BundleDropLocator.swift @@ -4,6 +4,14 @@ import Foundation static let binaryVersionKey = "BundleDropBinaryVersion" static let runtimeVersionInfoKey = "BundleDropRuntimeVersion" + static let expoEnabledInfoKey = "BundleDropExpoEnabled" + static let embeddedBuildIdentityFilename = ".bundle-drop-build-identity.json" + + private struct EmbeddedBuildIdentity: Decodable { + let schemaVersion: Int + let platform: String + let runtimeVersion: String + } /// Matches Android `BundleDropOtaPrefs.KEY_OTA_ENABLED` for cross-platform debugging. static let otaEnabledKey = "bundledrop_ota_enabled" @@ -17,14 +25,52 @@ import Foundation let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" let build = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "0" let binaryVersion = "\(version)-\(build)" - guard let runtimeVersion = bundle.infoDictionary?[runtimeVersionInfoKey] as? String, - !runtimeVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard let runtimeVersion = getRuntimeVersion(bundle: bundle) else { return binaryVersion } return "runtime:\(runtimeVersion)|binary:\(binaryVersion)" } + static func getRuntimeVersion(bundle: Bundle = .main) -> String? { + if let embeddedRuntimeVersion = getEmbeddedRuntimeVersion(bundle: bundle) { + return embeddedRuntimeVersion + } + return normalizeRuntimeVersion(bundle.infoDictionary?[runtimeVersionInfoKey] as? String) + } + + static func hasRuntimeIdentityForOta(bundle: Bundle = .main) -> Bool { + let isExpoBuild = bundle.infoDictionary?[expoEnabledInfoKey] as? Bool == true + return !isExpoBuild || getEmbeddedRuntimeVersion(bundle: bundle) != nil + } + + private static func getEmbeddedRuntimeVersion(bundle: Bundle) -> String? { + let candidateURL = bundle.bundleURL.appendingPathComponent(embeddedBuildIdentityFilename) + guard let attributes = try? FileManager.default.attributesOfItem(atPath: candidateURL.path), + let fileType = attributes[.type] as? FileAttributeType, + fileType == .typeRegular, + let fileSize = attributes[.size] as? NSNumber, + fileSize.intValue > 0, + fileSize.intValue <= 64 * 1024, + let data = try? Data(contentsOf: candidateURL), + let candidate = try? JSONDecoder().decode(EmbeddedBuildIdentity.self, from: data), + candidate.schemaVersion == 1, + candidate.platform == "ios" else { + return nil + } + return normalizeRuntimeVersion(candidate.runtimeVersion) + } + + private static func normalizeRuntimeVersion(_ runtimeVersion: String?) -> String? { + guard let runtimeVersion else { return nil } + let normalized = runtimeVersion.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + @objc public static func bundleURL() -> URL? { + guard hasRuntimeIdentityForOta() else { + print("BundleDrop: Expo runtime identity is missing; using the embedded bundle") + return nil + } let fm = FileManager.default guard let lib = fm.urls(for: .libraryDirectory, in: .userDomainMask).first else { return nil } guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil } diff --git a/ios/BundleDropModule.swift b/ios/BundleDropModule.swift index fa47eb2..9708547 100644 --- a/ios/BundleDropModule.swift +++ b/ios/BundleDropModule.swift @@ -239,6 +239,36 @@ final class BundleDropModule: NSObject { } } + @objc + func fsSha256String( + _ value: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + resolve(BundleDropRuntimeCrypto.sha256String(value)) + } + + @objc + func fsVerifyEs256Signature( + _ signingInput: String, + signatureBase64Url: String, + xBase64Url: String, + yBase64Url: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + do { + resolve(try BundleDropRuntimeCrypto.verifyEs256Signature( + signingInput: signingInput, + signatureBase64Url: signatureBase64Url, + xBase64Url: xBase64Url, + yBase64Url: yBase64Url + )) + } catch { + reject("ERR_ES256_VERIFY", error.localizedDescription, error) + } + } + @objc func fsFileSize( _ path: String, @@ -332,4 +362,49 @@ final class BundleDropModule: NSObject { } } } + + @objc + func fsDownloadFileBounded( + _ url: String, + destPath: String, + maxBytes: NSNumber, + timeoutMs: NSNumber, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + let maxByteCount = maxBytes.int64Value + let timeoutSeconds = timeoutMs.doubleValue / 1000 + guard maxByteCount > 0, timeoutSeconds > 0 else { + reject("ERR_DOWNLOAD_NETWORK", "Invalid bounded download limits", nil) + return + } + + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = timeoutSeconds + config.timeoutIntervalForResource = timeoutSeconds + BundleDropFileOps.downloadToFile( + urlString: url, + destPath: destPath, + maxBytes: maxByteCount, + configuration: config + ) { result in + switch result { + case .success: + resolve(nil) + case .failure(let error): + let message = error.localizedDescription + let errorCode: String + if (error as? URLError)?.code == .timedOut { + errorCode = "ERR_DOWNLOAD_TIMEOUT" + } else if message.hasPrefix("Download exceeds ") { + errorCode = "ERR_DOWNLOAD_TOO_LARGE" + } else if message.range(of: #"^HTTP \d{3}(?:\b|:)"#, options: .regularExpression) != nil { + errorCode = "ERR_DOWNLOAD_HTTP" + } else { + errorCode = "ERR_DOWNLOAD_NETWORK" + } + reject(errorCode, message, error) + } + } + } } diff --git a/ios/BundleDropRuntimeCrypto.swift b/ios/BundleDropRuntimeCrypto.swift new file mode 100644 index 0000000..f013820 --- /dev/null +++ b/ios/BundleDropRuntimeCrypto.swift @@ -0,0 +1,45 @@ +import CryptoKit +import Foundation + +enum BundleDropRuntimeCrypto { + static func sha256String(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + + static func verifyEs256Signature( + signingInput: String, + signatureBase64Url: String, + xBase64Url: String, + yBase64Url: String + ) throws -> Bool { + let signatureBytes = try decodeBase64Url(signatureBase64Url) + let x = try decodeBase64Url(xBase64Url) + let y = try decodeBase64Url(yBase64Url) + guard signatureBytes.count == 64 else { + throw error("ES256 signature must be 64 bytes") + } + guard x.count == 32 && y.count == 32 else { + throw error("P-256 coordinates must be 32 bytes") + } + + let publicKey = try P256.Signing.PublicKey( + x963Representation: Data([0x04]) + x + y + ) + let signature = try P256.Signing.ECDSASignature(rawRepresentation: signatureBytes) + return publicKey.isValidSignature(signature, for: Data(signingInput.utf8)) + } + + private static func decodeBase64Url(_ value: String) throws -> Data { + var normalized = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + normalized += String(repeating: "=", count: (4 - normalized.count % 4) % 4) + guard let data = Data(base64Encoded: normalized) else { + throw error("Invalid base64url value") + } + return data + } + + private static func error(_ message: String) -> NSError { + NSError(domain: "BundleDrop", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/package.json b/package.json index 2dd55b8..e8d3824 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gfean/react-native-bundle-drop", "version": "0.5.1", - "nativeVersion": "0.4.5", + "nativeVersion": "0.5.0", "description": "Over-the-air updates for Expo and bare React Native apps, with channels, staged rollouts, patch delivery, and rollback.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/scripts/check-coverage-thresholds.cjs b/scripts/check-coverage-thresholds.cjs index 2488fdd..e37b6a8 100755 --- a/scripts/check-coverage-thresholds.cjs +++ b/scripts/check-coverage-thresholds.cjs @@ -13,7 +13,78 @@ const DEFAULT_THRESHOLDS = { }; const FILE_THRESHOLDS = { + // Large parser/orchestration modules retain explicit per-file floors. Their + // fail-closed grammar and filesystem race branches are not all practical to + // execute portably, but these floors prevent coverage from silently falling + // below the reviewed baseline. All other files keep the 100/95 defaults. 'src/CLI/cli.ts': { statements: 98, branches: 93, functions: 100, lines: 98 }, + 'src/CLI/scripts/aipowered/code-push-residue.ts': { + statements: 97, + branches: 90, + functions: 100, + lines: 97, + }, + 'src/CLI/scripts/aipowered/init-project-config.ts': { + statements: 98, + branches: 95, + functions: 100, + lines: 98, + }, + 'src/CLI/scripts/aipowered/scanner.ts': { + statements: 97, + branches: 93, + functions: 100, + lines: 97, + }, + 'src/CLI/scripts/aipowered/validate-plan.ts': { + statements: 90, + branches: 84, + functions: 94, + lines: 90, + }, + 'src/CLI/scripts/doctor.ts': { statements: 99, branches: 98, functions: 100, lines: 99 }, + 'src/CLI/scripts/expo/configure-expo.ts': { + statements: 99, + branches: 98, + functions: 100, + lines: 99, + }, + 'src/CLI/scripts/expo/package-manager.ts': { + statements: 93, + branches: 80, + functions: 100, + lines: 93, + }, + 'src/CLI/scripts/metro-config-authority.ts': { + statements: 93, + branches: 77, + functions: 100, + lines: 93, + }, + 'src/CLI/scripts/native-entrypoint-authority.ts': { + statements: 91, + branches: 85, + functions: 100, + lines: 91, + }, + 'src/CLI/scripts/native-setup-contract.ts': { + statements: 95, + branches: 89, + functions: 100, + lines: 95, + }, + 'src/CLI/scripts/native-startup-validator.ts': { + statements: 93, + branches: 79, + functions: 99, + lines: 93, + }, + 'src/CLI/scripts/safe-file-transaction.ts': { + statements: 98, + branches: 96, + functions: 100, + lines: 98, + }, 'src/CLI/scripts/login-cli.ts': { statements: 99, branches: 83, functions: 96, lines: 99 }, 'src/CLI/scripts/upload-cli.ts': { statements: 100, branches: 91, functions: 100, lines: 100 }, 'src/injectImageResolver.ts': { statements: 98, branches: 90, functions: 100, lines: 98 }, diff --git a/scripts/run-android-tests.cjs b/scripts/run-android-tests.cjs index 8dbce9f..2278161 100644 --- a/scripts/run-android-tests.cjs +++ b/scripts/run-android-tests.cjs @@ -9,7 +9,14 @@ const { const result = run( gradleCommand, - ['jacocoCoverageGate', '-Pstandalone', '--quiet'], + [ + 'jacocoCoverageGate', + '-Pstandalone', + // The standalone gate resolves react-android 0.87, whose metadata requires Kotlin 2.2. + // Consumer builds retain the package's 2.0.21 default and can keep overriding it normally. + '-PBundleDrop_kotlinVersion=2.2.0', + '--quiet', + ], { cwd: androidDir, env: createAndroidGradleEnv('corepack yarn test:android'), diff --git a/src/CLI/cli.ts b/src/CLI/cli.ts index 9ebd5eb..3e59b80 100644 --- a/src/CLI/cli.ts +++ b/src/CLI/cli.ts @@ -264,6 +264,10 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} .option('--token ', 'Personal Access Token (alternative to `bundle-drop login`)') .option('--project-type ', 'Force project type: expo or bare') .option('--dry-run', 'Preview setup and AI context without changing files') + .option( + '--migrate-code-push', + 'Explicitly remove react-native-code-push after reviewing its native migration', + ) .option('--migrate-expo-updates', 'Explicitly remove active expo-updates configuration and dependency') .option('--prebuild', 'Run a layered Expo prebuild for committed native directories') .option('--yes', 'Approve ordinary setup changes noninteractively') @@ -272,6 +276,7 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} token?: string; projectType?: ProjectType; dryRun?: boolean; + migrateCodePush?: boolean; migrateExpoUpdates?: boolean; prebuild?: boolean; yes?: boolean; @@ -306,6 +311,9 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} options: { ...options, projectType, + ...(configResult?.bootstrapContent + ? { runtimeDeliveryBootstrap: { content: configResult.bootstrapContent } } + : {}), ...( !hadConfig && configResult ? { @@ -381,6 +389,9 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} options: { ...options, projectType, + ...(configResult?.bootstrapContent + ? { runtimeDeliveryBootstrap: { content: configResult.bootstrapContent } } + : {}), ...( !hadConfig && configResult ? { @@ -400,6 +411,50 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} }); }); + program + .command('sync') + .option('--token ', 'Personal Access Token (alternative to `bundle-drop login`)') + .option('--dry-run', 'Validate and preview bootstrap synchronization without writing') + .description('Refresh the package-managed runtime delivery bootstrap') + .action(async (options: { token?: string; dryRun?: boolean }) => { + const initConfigModule = require('../CLI/scripts/init-config'); + if (!initConfigModule.hasExistingBundleDropConfig()) { + throw new Error('bundle-drop sync requires bundle.drop.config.js. Run `bundle-drop init` first.'); + } + + const authState = options.token ? null : readStoredAuthData(); + const token = options.token || authState?.data?.token; + if (!token) { + throw new Error('Not authenticated. Run `bundle-drop login` or pass --token.'); + } + const serverUrl = normalizeServerUrl( + authState?.data?.serverUrl || authState?.data?.baseUrl || process.env.BUNDLE_DROP_SERVER_URL, + ); + const result = await initConfigModule.initConfig({ + serverUrl, + projects: [], + organizations: [], + authToken: token, + dryRun: Boolean(options.dryRun), + }); + const deliveryDisabled = result?.bootstrapRetired === true; + if (!result?.bootstrapContent && !deliveryDisabled) { + throw new Error( + 'The backend did not return a valid runtime delivery bootstrap for this project. ' + + 'The existing bootstrap was preserved.', + ); + } + if (options.dryRun) { + console.log( + chalk.gray( + deliveryDisabled + ? 'Runtime delivery is disabled; the stale bootstrap would be removed. No files changed.' + : 'Runtime delivery bootstrap is valid. No files changed.', + ), + ); + } + }); + program .command('doctor') .option('--platform ', 'Limit checks to ios or android') @@ -477,6 +532,16 @@ ${chalk.bold('Examples:')} return program; }; +export const runCli = async (argv: string[] = process.argv): Promise => { + try { + await buildProgram().parseAsync(argv); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`❌ ${message}`)); + process.exitCode = 1; + } +}; + if (require.main === module) { - buildProgram().parse(); + void runCli(); } diff --git a/src/CLI/scripts/aipowered/apply-setup-plan.ts b/src/CLI/scripts/aipowered/apply-setup-plan.ts index 6cb51c2..33e6e50 100644 --- a/src/CLI/scripts/aipowered/apply-setup-plan.ts +++ b/src/CLI/scripts/aipowered/apply-setup-plan.ts @@ -1,6 +1,4 @@ import crypto from 'crypto'; -import fs from 'fs-extra'; -import path from 'path'; import { AiPatchPlan, AiSetupProjectType } from './types'; import { @@ -8,9 +6,15 @@ import { isPatchableNativeEntrypoint, isSafeRelativePath, } from './validate-plan'; +import { + createSafeBackupDirectory, + inspectProjectFile, + restoreProjectFile, + writeBackupFile, + writeProjectFileAtomically, +} from '../safe-file-transaction'; const sha256 = (content: string) => crypto.createHash('sha256').update(content).digest('hex'); -const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-'); export type SetupApplyResult = { projectRoot: string; @@ -25,10 +29,7 @@ const isAllowedSetupFile = (projectType: AiSetupProjectType, filePath: string) = export function restoreSetupBackups(result: SetupApplyResult) { for (const relativePath of result.changedFiles) { - const backupPath = path.join(result.backupDir, relativePath); - if (fs.existsSync(backupPath)) { - fs.copyFileSync(backupPath, path.join(result.projectRoot, relativePath)); - } + restoreProjectFile(result.projectRoot, result.backupDir, relativePath); } } @@ -39,7 +40,7 @@ export function applySetupPatchPlans(params: { }): SetupApplyResult { const result: SetupApplyResult = { projectRoot: params.projectRoot, - backupDir: path.join(params.projectRoot, '.bundledrop-backup', timestamp()), + backupDir: createSafeBackupDirectory(params.projectRoot, 'ai-setup'), changedFiles: [], }; @@ -48,20 +49,24 @@ export function applySetupPatchPlans(params: { if (!isSafeRelativePath(change.file) || !isAllowedSetupFile(params.projectType, change.file)) { throw new Error(`Refusing to write AI setup plan outside its allowlist: ${change.file}`); } - const targetPath = path.join(params.projectRoot, change.file); - const original = fs.readFileSync(targetPath, 'utf8'); - if (sha256(original) !== change.originalSha256) { + const target = inspectProjectFile(params.projectRoot, change.file); + if (!target.exists || sha256(target.content) !== change.originalSha256) { throw new Error(`File changed since AI setup scan: ${change.file}`); } - const backupPath = path.join(result.backupDir, change.file); - fs.ensureDirSync(path.dirname(backupPath)); - fs.copyFileSync(targetPath, backupPath); + writeBackupFile( + result.backupDir, + change.file, + target.content, + target.mode, + ); result.changedFiles.push(change.file); - - const temporaryPath = `${targetPath}.bundledrop-tmp`; - fs.writeFileSync(temporaryPath, change.updated, 'utf8'); - fs.renameSync(temporaryPath, targetPath); + writeProjectFileAtomically( + params.projectRoot, + change.file, + change.updated, + target.mode, + ); } return result; } catch (error) { diff --git a/src/CLI/scripts/aipowered/backend-client.ts b/src/CLI/scripts/aipowered/backend-client.ts index 88ae810..ac9653c 100644 --- a/src/CLI/scripts/aipowered/backend-client.ts +++ b/src/CLI/scripts/aipowered/backend-client.ts @@ -3,8 +3,31 @@ import { AiSetupPlanRequest, AiSetupPlanResponse, } from './types'; +import { findKnownBundleDropCredential } from './credential-safety'; +import { escapeTerminalControls } from './terminal-safety'; const DEFAULT_AI_INIT_TIMEOUT_MS = 180000; +const MAX_BACKEND_DIAGNOSTIC_LENGTH = 1000; + +const safeBackendDiagnostic = (value: unknown) => { + if (typeof value !== 'string') return null; + const diagnostic = value.trim(); + if ( + !diagnostic || + diagnostic.length > MAX_BACKEND_DIAGNOSTIC_LENGTH || + findKnownBundleDropCredential(diagnostic) + ) { + return null; + } + return escapeTerminalControls(diagnostic); +}; + +const safeBackendDetailReason = (details: unknown) => { + if (!details || typeof details !== 'object' || Array.isArray(details)) return null; + const descriptor = Object.getOwnPropertyDescriptor(details, 'reason'); + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) return null; + return safeBackendDiagnostic(descriptor.value); +}; const resolveTimeoutMs = () => { const value = Number(process.env.BUNDLE_DROP_AI_INIT_TIMEOUT_MS || ''); @@ -36,10 +59,12 @@ export async function requestAiSetupPlan(params: { 'AI setup planning timed out. Try again, or increase BUNDLE_DROP_AI_INIT_TIMEOUT_MS.', ); } - const message = - raw && typeof raw === 'object' - ? raw.error || JSON.stringify(raw) - : raw || error?.message || 'AI setup planning failed'; - throw new Error(`AI setup planning failed: ${message}`); + const diagnostic = raw && typeof raw === 'object' + ? safeBackendDetailReason(raw.details) || safeBackendDiagnostic(raw.error) + : safeBackendDiagnostic(raw); + const fallback = diagnostic || safeBackendDiagnostic(error?.message); + throw new Error(fallback + ? `AI setup planning failed: ${fallback}` + : 'AI setup planning failed'); } } diff --git a/src/CLI/scripts/aipowered/code-push-residue.ts b/src/CLI/scripts/aipowered/code-push-residue.ts new file mode 100644 index 0000000..7da7fdc --- /dev/null +++ b/src/CLI/scripts/aipowered/code-push-residue.ts @@ -0,0 +1,134 @@ +import fs from 'fs-extra'; +import type { Dirent, Stats } from 'fs'; +import path from 'path'; + +const MAX_VISITED_ENTRIES = 20_000; +const MAX_CANDIDATE_FILES = 500; +const MAX_FILE_BYTES = 1024 * 1024; +const MAX_TOTAL_BYTES = 5 * 1024 * 1024; + +const SKIPPED_DIRECTORIES = new Set([ + '.bundle-drop', + '.bundledrop-backup', + '.expo', + '.git', + '.gradle', + 'build', + 'coverage', + 'deriveddata', + 'dist', + 'generated', + 'lib', + 'node_modules', + 'pods', + 'vendor', +]); + +const JS_SOURCE_EXTENSION = /\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx)$/i; +const CODE_PUSH_REFERENCE = /\b(?:react-native-code-push|code[\s_.-]*push|com\.microsoft\.codepush\.react)/i; + +const toPosix = (filePath: string) => filePath.split(path.sep).join('/'); + +const isCodePushResidueCandidate = (relativePath: string) => { + const parts = relativePath.split('/'); + const basename = parts[parts.length - 1]; + + if (JS_SOURCE_EXTENSION.test(basename)) return true; + + if (parts[0] === 'android') { + if (/^MainApplication\.(?:java|kt)$/i.test(basename)) return false; + return /\.(?:gradle|gradle\.kts|java|kt|properties|xml)$/i.test(basename); + } + + if (parts[0] === 'ios') { + if (/^AppDelegate\.(?:m|mm|swift)$/i.test(basename)) return false; + return basename === 'Podfile' || + /\.(?:h|m|mm|pbxproj|plist|podspec|swift|xcconfig)$/i.test(basename); + } + + return false; +}; + +const scanLimitError = (detail: string) => new Error( + `CodePush residue validation could not safely complete (${detail}). ` + + 'Clean up CodePush manually or reduce the project scan surface, then retry. No files changed.', +); + +/** + * Finds CodePush ownership outside package files and provider-patched native entrypoints. + * Only relative paths are returned; file contents never leave this local validation boundary. + */ +export function findCodePushResiduePaths(projectRoot: string): string[] { + const pendingDirectories = ['']; + const residuePaths: string[] = []; + let visitedEntries = 0; + let candidateFiles = 0; + let totalBytes = 0; + + while (pendingDirectories.length) { + const relativeDirectory = pendingDirectories.pop()!; + const absoluteDirectory = path.join(projectRoot, relativeDirectory); + let entries: Dirent[]; + try { + entries = fs.readdirSync(absoluteDirectory, { withFileTypes: true }); + } catch { + throw scanLimitError(`cannot inspect ${toPosix(relativeDirectory) || '.'}`); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + visitedEntries += 1; + if (visitedEntries > MAX_VISITED_ENTRIES) { + throw scanLimitError(`more than ${MAX_VISITED_ENTRIES} filesystem entries`); + } + + const relativePath = relativeDirectory + ? path.join(relativeDirectory, entry.name) + : entry.name; + const absolutePath = path.join(projectRoot, relativePath); + let stat: Stats; + try { + stat = fs.lstatSync(absolutePath); + } catch { + throw scanLimitError(`cannot inspect ${toPosix(relativePath)}`); + } + if (SKIPPED_DIRECTORIES.has(entry.name.toLowerCase())) continue; + if (stat.isSymbolicLink()) { + throw scanLimitError( + `relevant project path ${toPosix(relativePath)} is a symbolic link`, + ); + } + + if (stat.isDirectory()) { + pendingDirectories.push(relativePath); + continue; + } + if (!stat.isFile()) continue; + + const posixPath = toPosix(relativePath); + if (!isCodePushResidueCandidate(posixPath)) continue; + + candidateFiles += 1; + if (candidateFiles > MAX_CANDIDATE_FILES) { + throw scanLimitError(`more than ${MAX_CANDIDATE_FILES} relevant files`); + } + if (stat.size > MAX_FILE_BYTES) { + throw scanLimitError(`${posixPath} exceeds the per-file limit`); + } + totalBytes += stat.size; + if (totalBytes > MAX_TOTAL_BYTES) { + throw scanLimitError(`relevant files exceed ${MAX_TOTAL_BYTES} bytes`); + } + + let content: string; + try { + content = fs.readFileSync(absolutePath, 'utf8'); + } catch { + throw scanLimitError(`cannot read ${posixPath}`); + } + if (CODE_PUSH_REFERENCE.test(content)) residuePaths.push(posixPath); + } + } + + return residuePaths.sort(); +} diff --git a/src/CLI/scripts/aipowered/credential-safety.ts b/src/CLI/scripts/aipowered/credential-safety.ts new file mode 100644 index 0000000..35d565f --- /dev/null +++ b/src/CLI/scripts/aipowered/credential-safety.ts @@ -0,0 +1,10 @@ +const BUNDLE_DROP_PROJECT_KEY = /\bbdp_proj_[A-Za-z0-9_-]{32,}\b/; +const BUNDLE_DROP_PERSONAL_ACCESS_TOKEN = /\bbdp_pat_[A-Za-z0-9_-]{32,}\b/; + +export const findKnownBundleDropCredential = (value: string): string | null => { + if (BUNDLE_DROP_PROJECT_KEY.test(value)) return 'Bundle Drop project key'; + if (BUNDLE_DROP_PERSONAL_ACCESS_TOKEN.test(value)) { + return 'Bundle Drop personal access token'; + } + return null; +}; diff --git a/src/CLI/scripts/aipowered/diff-preview.ts b/src/CLI/scripts/aipowered/diff-preview.ts index 7f61875..a95ebc6 100644 --- a/src/CLI/scripts/aipowered/diff-preview.ts +++ b/src/CLI/scripts/aipowered/diff-preview.ts @@ -1,30 +1,44 @@ import { createTwoFilesPatch } from 'diff'; import chalk from 'chalk'; import { AiPatchPlan } from './types'; +import { escapeTerminalControls } from './terminal-safety'; + +const BUNDLE_DROP_CONFIG_PATH = /^bundle\.drop\.config\.(js|cjs)$/; +const API_KEY_LITERAL = /(\bapiKey\s*:\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/g; + +const redactPreviewSecrets = (file: string, content: string) => + BUNDLE_DROP_CONFIG_PATH.test(file) + ? content.replace(API_KEY_LITERAL, '$1""') + : content; export function buildUnifiedDiff(params: { projectRoot: string; originals: Map; changes: AiPatchPlan[]; }) { - return params.changes + const diff = params.changes .map(change => { - const original = params.originals.get(change.file) ?? ''; + const original = redactPreviewSecrets( + change.file, + params.originals.get(change.file) ?? '', + ); + const updated = redactPreviewSecrets(change.file, change.updated); return createTwoFilesPatch( `a/${change.file}`, `b/${change.file}`, original, - change.updated, + updated, '', '', { context: 3 } ); }) .join('\n'); + return escapeTerminalControls(diff); } export function colorizeUnifiedDiff(diff: string) { - return diff + return escapeTerminalControls(diff) .split('\n') .map(line => { if (line.startsWith('+++') || line.startsWith('---')) return chalk.bold(line); diff --git a/src/CLI/scripts/aipowered/init-project-config.ts b/src/CLI/scripts/aipowered/init-project-config.ts index f2c4dde..2a3be0e 100644 --- a/src/CLI/scripts/aipowered/init-project-config.ts +++ b/src/CLI/scripts/aipowered/init-project-config.ts @@ -17,15 +17,20 @@ import { setBundleDropProjectType, } from '../expo/configure-expo'; import { + codePushRemovalCommand, detectPackageManager, expoUpdatesRemovalCommand, + removeCodePushWithPackageManager, removeExpoUpdatesWithPackageManager, restoreDependencyMigration, } from '../expo/package-manager'; import { applySetupPatchPlans, restoreSetupBackups } from './apply-setup-plan'; import { requestAiSetupPlan } from './backend-client'; +import { findCodePushResiduePaths } from './code-push-residue'; import { buildUnifiedDiff, colorizeUnifiedDiff } from './diff-preview'; +import { assertSafeProviderPlan, escapeTerminalControls } from './terminal-safety'; import { + authoritativeDynamicExpoConfigFile, findProjectRoot, isBundleDropHostedAiPlanningServer, scanProjectForAiSetup, @@ -36,15 +41,35 @@ import { validateSetupChangesBeforeApply, } from './validate-plan'; import { startLoadingStatus } from '../../utils/ui'; +import { + createSafeBackupDirectory, + inspectProjectDirectory, + inspectProjectFile, + writeProjectFileAtomically, +} from '../safe-file-transaction'; +import { + addRuntimeDeliveryBootstrapGitignoreRules, + RUNTIME_DELIVERY_BOOTSTRAP_PATH, +} from '../../../runtime-delivery/bootstrapConfig'; const PACKAGE_NAME = '@gfean/react-native-bundle-drop'; const DOCS_MANUAL_SETUP_URL = 'https://bundledrop.app/docs/manual-setup'; +const DOCS_RUNTIME_INITIALIZATION_URL = + 'https://bundledrop.app/docs/installation#initialize-bundle-drop-in-javascript'; const EXPO_RUNTIME_AUTHORITY_PATTERN = /runtimeVersion\s*:\s*\{\s*source\s*:\s*['"]expo['"]\s*\}/; +const logRuntimeInitializationNextStep = () => { + console.log(chalk.cyan( + 'JavaScript entry point: call BundleDrop.init once before rendering your app. ' + + DOCS_RUNTIME_INITIALIZATION_URL, + )); +}; + export type InitProjectOptions = { projectType?: ProjectType; dryRun?: boolean; + migrateCodePush?: boolean; migrateExpoUpdates?: boolean; prebuild?: boolean; yes?: boolean; @@ -55,14 +80,106 @@ export type InitProjectOptions = { projectSlug: string; authToken: string; }; + runtimeDeliveryBootstrap?: { + content: string; + }; }; const sha256 = (content: string) => crypto.createHash('sha256').update(content).digest('hex'); +const inspectPlanningFile = (projectRoot: string, relativePath: string) => { + if (!fs.existsSync(projectRoot)) return { exists: false, content: '', mode: 0o666 }; + return inspectProjectFile(projectRoot, relativePath); +}; + +const planRuntimeDeliveryBootstrap = ( + projectRoot: string, + bootstrap?: InitProjectOptions['runtimeDeliveryBootstrap'], +) => { + if (!bootstrap) return null; + const bootstrapFile = inspectPlanningFile(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH); + const original = bootstrapFile.exists ? bootstrapFile.content : null; + if (original === bootstrap.content) return null; + return { + file: RUNTIME_DELIVERY_BOOTSTRAP_PATH, + original, + updated: bootstrap.content, + reason: 'Pin the authenticated runtime delivery bootstrap for this project.', + }; +}; + +const planRuntimeDeliveryGitignore = ( + projectRoot: string, + bootstrap?: InitProjectOptions['runtimeDeliveryBootstrap'], +) => { + if (!bootstrap) return null; + const file = '.gitignore'; + const gitignoreFile = inspectPlanningFile(projectRoot, file); + const original = gitignoreFile.exists ? gitignoreFile.content : null; + const updated = addRuntimeDeliveryBootstrapGitignoreRules(original || ''); + if (updated === original) return null; + return { + file, + original, + updated, + reason: 'Keep the pinned runtime delivery bootstrap reproducible in clean builds.', + }; +}; + const shouldApplyAiChange = (change: AiPatchPlan) => change.confidence !== 'low' && (change.decisionType === 'safe_auto_patch' || change.decisionType === 'review_only_patch'); +const isDynamicExpoConfigChange = (change: AiPatchPlan) => + /^app\.config\.(?:js|ts|cjs|mjs)$/.test(change.file); + +const reviewOnlyChanges = (changes: AiPatchPlan[]) => + changes.filter(change => + change.confidence !== 'low' && + (change.decisionType === 'review_only_patch' || isDynamicExpoConfigChange(change)) + ); + +async function confirmReviewOnlyChanges(params: { + projectRoot: string; + originals: Map; + changes: AiPatchPlan[]; + yes?: boolean; + dryRun?: boolean; +}): Promise { + const changesRequiringReview = reviewOnlyChanges(params.changes); + if (params.dryRun || !changesRequiringReview.length) return true; + + if (params.yes) { + console.log(chalk.yellow( + 'Review-only AI changes require explicit interactive approval. ' + + '--yes cannot approve them. No files changed.', + )); + return false; + } + + for (const change of changesRequiringReview) { + console.log(chalk.yellow(`\nReview-only proposed change: ${change.file}`)); + console.log(colorizeUnifiedDiff(buildUnifiedDiff({ + projectRoot: params.projectRoot, + originals: params.originals, + changes: [change], + }))); + const answer = await prompts({ + type: 'confirm', + name: 'approve', + message: `Apply this review-only AI change to ${escapeTerminalControls(change.file)}? ` + + escapeTerminalControls(change.reason), + initial: false, + }); + if (!(answer as { approve?: boolean }).approve) { + console.log(chalk.gray('Review-only AI change declined. No files changed.')); + return false; + } + } + + return true; +} + const hasCleanGitStatus = (projectRoot: string, command: string): boolean => { try { const status = execSync(command, { @@ -80,9 +197,9 @@ const usesStrictExpoRuntimeAuthority = ( projectRoot: string, virtualConfig?: InitProjectOptions['virtualConfig'], ): boolean => { - const configPath = path.join(projectRoot, 'bundle.drop.config.js'); - const configContent = fs.existsSync(configPath) - ? fs.readFileSync(configPath, 'utf8') + const configFile = inspectPlanningFile(projectRoot, 'bundle.drop.config.js'); + const configContent = configFile.exists + ? configFile.content : virtualConfig?.content; return Boolean(configContent && EXPO_RUNTIME_AUTHORITY_PATTERN.test(configContent)); }; @@ -97,14 +214,19 @@ type NativePrebuildBackup = { }; const restoreNativePrebuild = (backup: NativePrebuildBackup, reason: string) => { + const failedRoot = path.join(backup.backupDir, reason); + fs.mkdirSync(failedRoot); for (const directory of ['ios', 'android']) { const current = path.join(backup.projectRoot, directory); - const failed = path.join(backup.backupDir, reason, directory); - if (fs.existsSync(current)) { - fs.ensureDirSync(path.dirname(failed)); - fs.moveSync(current, failed, { overwrite: true }); + const failed = path.join(failedRoot, directory); + try { + fs.lstatSync(current); + fs.renameSync(current, failed); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } if (backup.existingDirectories.includes(directory)) { + inspectProjectDirectory(backup.backupDir, directory); fs.copySync(path.join(backup.backupDir, directory), current, { preserveTimestamps: true }); } } @@ -112,13 +234,9 @@ const restoreNativePrebuild = (backup: NativePrebuildBackup, reason: string) => const runLayeredPrebuild = (projectRoot: string): NativePrebuildBackup => { const expoCli = require.resolve('expo/bin/cli', { paths: [projectRoot] }); - const backupDir = path.join( - projectRoot, - '.bundledrop-backup', - `prebuild-${new Date().toISOString().replace(/[:.]/g, '-')}`, - ); + const backupDir = createSafeBackupDirectory(projectRoot, 'prebuild'); const existingDirectories = ['ios', 'android'].filter(directory => - fs.existsSync(path.join(projectRoot, directory)), + inspectProjectDirectory(projectRoot, directory), ); const backup = { projectRoot, backupDir, existingDirectories }; for (const directory of existingDirectories) { @@ -159,27 +277,11 @@ const assertBundleDropPluginPresent = (projectRoot: string) => { } }; -const isSummarizedSetupContext = (kind: string) => - kind === 'package_manifest' || kind === 'bundle_drop_config'; - -const authoritativeDynamicExpoConfigFile = ( - projectRoot: string, - candidate = evaluateExpoConfig(projectRoot).dynamicConfigPath, -): string => { - if (typeof candidate !== 'string' || !candidate.trim()) { - throw new Error('Expo did not identify the authoritative dynamic app config path. No files changed.'); - } - const absolutePath = path.isAbsolute(candidate) ? candidate : path.resolve(projectRoot, candidate); - const relativePath = path.relative(projectRoot, absolutePath).split(path.sep).join('/'); - if ( - relativePath.startsWith('../') || - path.isAbsolute(relativePath) || - !/^app\.config\.(js|ts|cjs|mjs)$/.test(relativePath) - ) { - throw new Error(`Expo reported an unsafe dynamic app config path: ${candidate}. No files changed.`); - } - return relativePath; -}; +const isSummarizedSetupContext = (file: { kind: string; path: string }) => + file.kind === 'package_manifest' || + file.kind === 'bundle_drop_config' || + file.kind === 'metro_config' || + file.path === 'app.json'; const describeAiDestination = (serverUrl: string) => { const parsed = new URL(serverUrl); @@ -193,14 +295,19 @@ const describeAiDestination = (serverUrl: string) => { }; async function requestContextConsent( - files: Array<{ path: string; kind: string }>, + files: Array<{ path: string; kind: string; content: string }>, serverUrl: string, yes?: boolean, ) { console.log(chalk.gray(`AI setup destination: ${describeAiDestination(serverUrl)}`)); + const contextSize = files.reduce( + (total, file) => total + Buffer.byteLength(file.content, 'utf8'), + 0, + ); + console.log(chalk.gray(`Provider context size: ${contextSize} bytes.`)); console.log(chalk.gray('Files proposed for AI setup planning:')); files.forEach(file => { - const disclosure = isSummarizedSetupContext(file.kind) ? 'summary only' : 'full content'; + const disclosure = isSummarizedSetupContext(file) ? 'summary only' : 'full content'; console.log(chalk.gray(` - ${file.path} (${file.kind}, ${disclosure})`)); }); if (yes) return true; @@ -241,14 +348,43 @@ const existingSetupPassesDoctor = async ( } }; +const assertNoCodePushMigrationResidue = (projectRoot: string) => { + const residuePaths = findCodePushResiduePaths(projectRoot); + if (!residuePaths.length) return; + + const displayedPaths = residuePaths.slice(0, 20).map(filePath => ` - ${filePath}`); + if (residuePaths.length > displayedPaths.length) { + displayedPaths.push(` - and ${residuePaths.length - displayedPaths.length} more path(s)`); + } + throw new Error( + 'CodePush migration cannot continue while local CodePush references remain outside the ' + + `provider-patched native entrypoints:\n${displayedPaths.join('\n')}\n` + + 'Remove the listed JS wrappers/imports, custom native references, build hooks, and ' + + 'deployment-key configuration manually, then rerun Bundle Drop setup. No files changed.', + ); +}; + export async function initProjectConfigAi(options: InitProjectOptions = {}): Promise { const projectRoot = findProjectRoot(process.cwd()); const projectType = detectProjectType({ projectRoot, explicitType: options.projectType }); const scan = scanProjectForAiSetup(projectType, projectRoot, options.virtualConfig); + const bootstrapChange = planRuntimeDeliveryBootstrap( + projectRoot, + options.runtimeDeliveryBootstrap, + ); + const bootstrapGitignoreChange = planRuntimeDeliveryGitignore( + projectRoot, + options.runtimeDeliveryBootstrap, + ); assertSetupOwnershipCompatible(projectType, scan.request.detected.expoUpdatesStatus); - if (await existingSetupPassesDoctor(projectRoot, projectType, scan.request.detected)) { + if ( + !bootstrapChange && + !bootstrapGitignoreChange && + await existingSetupPassesDoctor(projectRoot, projectType, scan.request.detected) + ) { console.log(chalk.green('✅ Bundle Drop setup is already complete. No AI planning is required.')); await runDoctor({ projectType, cwd: projectRoot }); + logRuntimeInitializationNextStep(); return; } console.log(chalk.cyan(`🧠 Bundle Drop AI setup (${projectType})`)); @@ -268,19 +404,30 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro } finally { loading.stop(); } + assertSafeProviderPlan(plan); console.log(chalk.cyan('\nSetup summary:')); - console.log(plan.summary); - plan.warnings.forEach(warning => console.log(chalk.yellow(`⚠️ ${warning}`))); + console.log(escapeTerminalControls(plan.summary)); + plan.warnings.forEach(warning => + console.log(chalk.yellow(`⚠️ ${escapeTerminalControls(warning)}`)) + ); plan.actions.forEach(action => { const confirmation = action.requiresConfirmation ? ' (confirmation required)' : ''; - console.log(chalk.gray(` - ${action.type}${confirmation}: ${action.reason}`)); + console.log(chalk.gray( + ` - ${escapeTerminalControls(action.type)}${confirmation}: ` + + escapeTerminalControls(action.reason), + )); }); if (plan.confidence === 'low') { if (options.virtualConfig && !options.dryRun) { - const configPath = path.join(projectRoot, 'bundle.drop.config.js'); - if (!fs.existsSync(configPath)) { - fs.writeFileSync(configPath, options.virtualConfig.content, 'utf8'); + const configFile = inspectProjectFile(projectRoot, 'bundle.drop.config.js'); + if (!configFile.exists) { + writeProjectFileAtomically( + projectRoot, + 'bundle.drop.config.js', + options.virtualConfig.content, + ); + const configPath = path.join(projectRoot, 'bundle.drop.config.js'); console.log(chalk.green(`Project config retained at ${configPath}`)); } } @@ -305,15 +452,40 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro ); } } + if (projectType === 'bare' && scan.request.detected.codePushDetected) { + if (!options.migrateCodePush && !options.yes) { + const migrationAnswer = await prompts({ + type: 'confirm', + name: 'migrate', + message: 'Remove react-native-code-push and migrate native startup ownership to Bundle Drop?', + initial: false, + }); + options.migrateCodePush = Boolean((migrationAnswer as { migrate?: boolean }).migrate); + } + if (!options.migrateCodePush) { + throw new Error( + 'CodePush blocks Bundle Drop setup. Re-run with --migrate-code-push after reviewing ' + + 'the plan; a new native binary will be required.', + ); + } + } + const shouldMigrateCodePush = + projectType === 'bare' && + scan.request.detected.codePushDetected && + Boolean(options.migrateCodePush); + if (shouldMigrateCodePush) assertNoCodePushMigrationResidue(projectRoot); for (const action of plan.actions.filter(action => - action.requiresConfirmation && action.type !== 'migrate_expo_updates' + action.requiresConfirmation && + action.type !== 'migrate_expo_updates' && + action.type !== 'migrate_codepush' )) { if (options.yes) continue; const actionAnswer = await prompts({ type: 'confirm', name: 'proceed', - message: `Proceed with ${action.type}? ${action.reason}`, + message: `Proceed with ${escapeTerminalControls(action.type)}? ` + + escapeTerminalControls(action.reason), initial: false, }); if (!(actionAnswer as { proceed?: boolean }).proceed) { @@ -322,16 +494,30 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro } } - const aiChanges = plan.changes.filter(shouldApplyAiChange); const originals = new Map(scan.request.files.map(file => [file.path, file.content])); - validateSetupChangesBeforeApply({ projectType, originals, changes: aiChanges }); + if (!await confirmReviewOnlyChanges({ + projectRoot, + originals, + changes: plan.changes, + yes: options.yes, + dryRun: options.dryRun, + })) { + return; + } + const aiChanges = plan.changes.filter(shouldApplyAiChange); + validateSetupChangesBeforeApply({ + projectType, + originals, + changes: aiChanges, + migrateExpoUpdates: Boolean(options.migrateExpoUpdates), + }); if (projectType === 'bare') { const metroChange = planBareMetroConfig(projectRoot); - const bundleConfigPath = path.join(projectRoot, 'bundle.drop.config.js'); - const bundleConfigExists = fs.existsSync(bundleConfigPath); + const bundleConfigFile = inspectPlanningFile(projectRoot, 'bundle.drop.config.js'); + const bundleConfigExists = bundleConfigFile.exists; const bundleConfig = bundleConfigExists - ? fs.readFileSync(bundleConfigPath, 'utf8') + ? bundleConfigFile.content : options.virtualConfig?.content; const updatedBundleConfig = bundleConfig ? setBundleDropProjectType(bundleConfig, 'bare') @@ -345,12 +531,20 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro reason: 'Persist the reviewed bare React Native project type.', } : null; - const localChanges = [metroChange, bundleConfigChange].filter( + const localChanges = [ + metroChange, + bundleConfigChange, + bootstrapChange, + bootstrapGitignoreChange, + ].filter( (change): change is NonNullable => Boolean(change), ); - if (!aiChanges.length && !localChanges.length) { + if (!aiChanges.length && !localChanges.length && !shouldMigrateCodePush) { console.log(chalk.gray('No bare native changes are required.')); - if (!options.dryRun) await runDoctor({ projectType: 'bare', cwd: projectRoot }); + if (!options.dryRun) { + await runDoctor({ projectType: 'bare', cwd: projectRoot }); + logRuntimeInitializationNextStep(); + } return; } const bareOriginals = new Map(originals); @@ -372,6 +566,10 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro originals: bareOriginals, changes: previewChanges, }))); + if (shouldMigrateCodePush) { + const command = codePushRemovalCommand(detectPackageManager(projectRoot)); + console.log(chalk.yellow(`Dependency migration command: ${command.join(' ')}`)); + } if (options.dryRun) { console.log(chalk.gray('Dry run complete. No files changed.')); return; @@ -383,10 +581,21 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro console.log(chalk.gray('No files changed.')); return; } - const result = applySetupPatchPlans({ projectRoot, projectType, changes: aiChanges }); + let dependencyBackup: ReturnType | undefined; + let result: ReturnType | undefined; let metroResult: ReturnType | undefined; try { - validateAppliedSetupChanges({ projectRoot, projectType, changes: aiChanges }); + if (shouldMigrateCodePush) { + dependencyBackup = removeCodePushWithPackageManager(projectRoot); + } + result = applySetupPatchPlans({ projectRoot, projectType, changes: aiChanges }); + validateAppliedSetupChanges({ + projectRoot, + projectType, + changes: aiChanges, + migrateExpoUpdates: false, + originals, + }); if (localChanges.length) { metroResult = applyExpoConfigurationChanges({ projectRoot, @@ -396,10 +605,18 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro await runDoctor({ projectType: 'bare', cwd: projectRoot }); } catch (error) { if (metroResult) restoreExpoConfiguration(metroResult); - restoreSetupBackups(result); + if (result) restoreSetupBackups(result); + if (dependencyBackup) { + restoreDependencyMigration(dependencyBackup); + console.log(chalk.yellow( + 'Package files were restored; reinstall dependencies to repair node_modules.', + )); + } throw error; } + if (!result) throw new Error('Bare setup transaction completed without a backup result.'); console.log(chalk.green(`✅ Bare React Native setup complete. Backups: ${result.backupDir}`)); + logRuntimeInitializationNextStep(); return; } @@ -426,6 +643,11 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro projectRoot, evaluatedConfig.dynamicConfigPath, ); + if (!dynamicConfigFile) { + throw new Error( + 'Expo did not identify the authoritative dynamic app config path. No files changed.', + ); + } if ( !hasBundleDropPlugin(evaluatedConfig.exp) && !unusualAiChanges.some(change => change.file === dynamicConfigFile) @@ -438,6 +660,7 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro } const combinedChanges = [ ...deterministic, + ...(bootstrapChange ? [bootstrapChange] : []), ...unusualAiChanges.map(change => ({ file: change.file, original: originals.get(change.file) ?? null, @@ -508,6 +731,13 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro projectRoot, changes: combinedChanges.filter(change => change.file !== 'package.json'), }); + validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: unusualAiChanges, + migrateExpoUpdates: Boolean(options.migrateExpoUpdates), + originals, + }); assertBundleDropPluginPresent(projectRoot); if (hasNativeDirectories && options.prebuild) { prebuildBackup = runLayeredPrebuild(projectRoot); @@ -524,6 +754,7 @@ export async function initProjectConfigAi(options: InitProjectOptions = {}): Pro throw error; } console.log(chalk.green(`✅ Expo setup complete. Backups: ${applyResult.backupDir}`)); + logRuntimeInitializationNextStep(); if (!hasNativeDirectories) { console.log(chalk.gray('Run expo run:*, explicit prebuild/manual build, or EAS Build to generate native integration.')); } diff --git a/src/CLI/scripts/aipowered/scanner.ts b/src/CLI/scripts/aipowered/scanner.ts index ce39a1c..c30be05 100644 --- a/src/CLI/scripts/aipowered/scanner.ts +++ b/src/CLI/scripts/aipowered/scanner.ts @@ -8,15 +8,21 @@ import { hasBareAndroidStartupIntegration, hasBareIosStartupIntegration, } from '../native-setup-contract'; +import { inspectProjectDirectory, inspectProjectFile } from '../safe-file-transaction'; +import { findNativeEntrypointAuthorityIssue } from '../native-entrypoint-authority'; import { AiSetupPlanFile, AiSetupProjectType, AiSetupScannerResult, } from './types'; +import { hasUnsafeTerminalControl } from './terminal-safety'; +import { findKnownBundleDropCredential } from './credential-safety'; const MAX_UP = 12; const MAX_FILE_BYTES = 80 * 1024; -const MAX_TOTAL_BYTES = 350 * 1024; +const MAX_SUMMARIZED_SOURCE_BYTES = 1024 * 1024; +const MAX_TOTAL_BYTES = 128 * 1024; +const MAX_NATIVE_SCAN_ENTRIES = 5000; const TRUSTED_BUNDLE_DROP_AI_HOSTS = new Set(['api.bundledrop.app']); const LOCAL_AI_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '10.0.2.2']); const SKIP_DIRS = new Set([ @@ -64,6 +70,8 @@ const CREDENTIAL_LITERAL_PATTERNS: Array<{ name: string; pattern: RegExp }> = [ ]; export const findCredentialLikeLiteral = (content: string): string | null => { + const bundleDropCredential = findKnownBundleDropCredential(content); + if (bundleDropCredential) return bundleDropCredential; for (const candidate of CREDENTIAL_LITERAL_PATTERNS) { if (candidate.pattern.test(content)) return candidate.name; } @@ -71,6 +79,13 @@ export const findCredentialLikeLiteral = (content: string): string | null => { }; const assertSafeAiSetupContent = (relativePath: string, content: string) => { + if (hasUnsafeTerminalControl(content)) { + throw new Error( + `Refusing AI setup because ${relativePath} contains unsafe terminal or bidirectional ` + + 'control characters. Remove them manually, then retry. The file was not shared, ' + + 'and --yes cannot bypass this safety check.', + ); + } const finding = findCredentialLikeLiteral(content); if (!finding) return; throw new Error( @@ -110,15 +125,30 @@ const summarizeContextContent = (relativePath: string, rawContent: string, sizeB const signalLines = matchedSignals.length ? matchedSignals.map(signal => `- ${signal}`).join('\n') : '- none detected'; + const runtimeVersionBlock = relativePath.startsWith('bundle.drop.config.') + ? rawContent.match(/\bruntimeVersion\s*:\s*\{([\s\S]*?)\}/)?.[1] || '' + : ''; + const runtimeAuthority = runtimeVersionBlock + ? /\bsource\s*:\s*['"]expo['"]/.test(runtimeVersionBlock) + ? 'expo_source' + : [ + /\bios\s*:\s*['"][^'"]+['"]/.test(runtimeVersionBlock) ? 'ios_literal' : '', + /\bandroid\s*:\s*['"][^'"]+['"]/.test(runtimeVersionBlock) ? 'android_literal' : '', + ].filter(Boolean).join(',') || 'cli_validated' + : 'cli_validated'; + const runtimeAuthorityLine = relativePath.startsWith('bundle.drop.config.') + ? `runtimeVersionAuthority: ${runtimeAuthority}\n` + : ''; return [ `BundleDrop context summary for ${relativePath}`, 'Full content omitted to reduce setup-planning tokens; this context file is read-only.', `sizeBytes: ${sizeBytes}`, + runtimeAuthorityLine.trimEnd(), 'signals:', signalLines, '', - ].join('\n'); + ].filter(Boolean).join('\n'); }; export const isTrustedAiPlanningServer = (serverUrl: string) => { @@ -146,9 +176,8 @@ export const isBundleDropHostedAiPlanningServer = (serverUrl: string) => { } }; -const loadBundleDropConfig = (configPath: string) => { +const loadBundleDropConfig = (configPath: string, content: string) => { const moduleLike = { exports: {} as any }; - const content = fs.readFileSync(configPath, 'utf8'); const localRequire = createRequire(configPath); const load = new Function('module', 'exports', 'require', '__dirname', '__filename', content); load(moduleLike, moduleLike.exports, localRequire, path.dirname(configPath), configPath); @@ -170,13 +199,27 @@ const findFilesByName = (root: string, names: Set): string[] => { if (!fs.existsSync(root)) return []; const matches: string[] = []; const stack = [root]; + let visitedEntries = 0; while (stack.length) { const current = stack.pop()!; + visitedEntries += 1; + if (visitedEntries > MAX_NATIVE_SCAN_ENTRIES) { + throw new Error( + `AI setup native source scan exceeded ${MAX_NATIVE_SCAN_ENTRIES} filesystem entries. ` + + 'Reduce generated/source files or configure native startup manually. ' + + 'No context was shared and no files were changed.', + ); + } const stat = fs.lstatSync(current); - if (stat.isSymbolicLink()) continue; + if (SKIP_DIRS.has(path.basename(current))) continue; + if (stat.isSymbolicLink()) { + throw new Error( + `AI setup cannot safely inspect symbolic-link source path ${toPosix(path.relative(root, current))}. ` + + 'No context was shared and no files were changed.', + ); + } if (stat.isDirectory()) { - if (SKIP_DIRS.has(path.basename(current))) continue; for (const entry of fs.readdirSync(current)) { stack.push(path.join(current, entry)); } @@ -230,18 +273,74 @@ const setupFileKind = (relativePath: string): AiSetupPlanFile['kind'] => { return 'ios_entrypoint'; }; +const isRequiredPatchableSetupFile = (relativePath: string) => { + const kind = setupFileKind(relativePath); + return kind === 'android_entrypoint' || + kind === 'ios_entrypoint' || + relativePath.startsWith('app.config.'); +}; + +const isSummarizedSetupFile = (relativePath: string) => { + const kind = setupFileKind(relativePath); + return kind === 'package_manifest' || + kind === 'bundle_drop_config' || + kind === 'metro_config' || + relativePath === 'app.json'; +}; + +const setupContextLimitError = (relativePath: string, reason: string) => new Error( + `AI setup cannot safely inspect required setup file ${relativePath}: ${reason}. ` + + 'Reduce or split this file, or configure Bundle Drop manually, then retry. ' + + 'No context was shared and no files were changed.', +); + +export const authoritativeDynamicExpoConfigFile = ( + projectRoot: string, + candidate: unknown, +): string | null => { + if (candidate === undefined || candidate === null || candidate === '') return null; + if (typeof candidate !== 'string' || !candidate.trim()) { + throw new Error('Expo did not identify a usable authoritative dynamic app config path. No files changed.'); + } + const absolutePath = path.isAbsolute(candidate) ? candidate : path.resolve(projectRoot, candidate); + const relativePath = toPosix(path.relative(projectRoot, absolutePath)); + if ( + relativePath.startsWith('../') || + path.isAbsolute(relativePath) || + !/^app\.config\.(js|ts|cjs|mjs)$/.test(relativePath) + ) { + throw new Error(`Expo reported an unsafe dynamic app config path: ${candidate}. No files changed.`); + } + const configFile = inspectProjectFile(projectRoot, relativePath); + if (!configFile.exists) { + throw new Error(`Expo authoritative dynamic app config is missing: ${relativePath}. No files changed.`); + } + return relativePath; +}; + const readSetupFile = (projectRoot: string, relativePath: string): AiSetupPlanFile | null => { - const filePath = path.join(projectRoot, relativePath); - if (!fs.existsSync(filePath)) return null; - const stat = fs.lstatSync(filePath); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES) return null; - const rawContent = fs.readFileSync(filePath, 'utf8'); + let inspected; + try { + inspected = inspectProjectFile(projectRoot, relativePath); + } catch { + throw setupContextLimitError(relativePath, 'the path is not a regular project file'); + } + if (!inspected.exists) return null; + const sizeBytes = Buffer.byteLength(inspected.content, 'utf8'); + const summarized = isSummarizedSetupFile(relativePath); + if (sizeBytes > (summarized ? MAX_SUMMARIZED_SOURCE_BYTES : MAX_FILE_BYTES)) { + if (isRequiredPatchableSetupFile(relativePath)) { + throw setupContextLimitError(relativePath, `it exceeds the ${MAX_FILE_BYTES}-byte per-file limit`); + } + return null; + } + const rawContent = inspected.content; const kind = setupFileKind(relativePath); - if (kind !== 'package_manifest' && kind !== 'bundle_drop_config') { + if (!summarized) { assertSafeAiSetupContent(relativePath, rawContent); } - const content = kind === 'package_manifest' || kind === 'bundle_drop_config' - ? summarizeContextContent(relativePath, rawContent, stat.size) + const content = summarized + ? summarizeContextContent(relativePath, rawContent, sizeBytes) : rawContent; return { kind, @@ -254,8 +353,20 @@ const readSetupFile = (projectRoot: string, relativePath: string): AiSetupPlanFi const collectSetupFiles = ( projectRoot: string, projectType: AiSetupProjectType, + dynamicExpoConfigFile: string | null, ): AiSetupPlanFile[] => { - const relativePaths = ['package.json', ...SETUP_CONFIG_FILES]; + const relativePaths: string[] = [ + 'package.json', + ...SETUP_CONFIG_FILES.filter(relativePath => + projectType === 'expo' + ? !relativePath.startsWith('app.config.') && + (relativePath !== 'app.json' || !dynamicExpoConfigFile) + : relativePath !== 'app.json' && !relativePath.startsWith('app.config.') + ), + ]; + if (projectType === 'expo' && dynamicExpoConfigFile) { + relativePaths.push(dynamicExpoConfigFile); + } if (projectType === 'bare') { for (const srcDir of ['java', 'kotlin']) { relativePaths.push( @@ -275,11 +386,19 @@ const collectSetupFiles = ( const files: AiSetupPlanFile[] = []; let totalBytes = 0; - for (const relativePath of relativePaths) { + for (const relativePath of [...new Set(relativePaths)]) { const file = readSetupFile(projectRoot, relativePath); if (!file) continue; const bytes = Buffer.byteLength(file.content, 'utf8'); - if (totalBytes + bytes > MAX_TOTAL_BYTES) continue; + if (totalBytes + bytes > MAX_TOTAL_BYTES) { + if (isRequiredPatchableSetupFile(relativePath)) { + throw setupContextLimitError( + relativePath, + `including it would exceed the ${MAX_TOTAL_BYTES}-byte total context limit`, + ); + } + continue; + } totalBytes += bytes; files.push(file); } @@ -287,11 +406,12 @@ const collectSetupFiles = ( }; const readPackageSignals = (projectRoot: string) => { - const packagePath = path.join(projectRoot, 'package.json'); - const pkg = fs.existsSync(packagePath) ? readJsonFile(packagePath) : {}; + const packageFile = inspectProjectFile(projectRoot, 'package.json'); + const pkg = packageFile.exists ? JSON.parse(packageFile.content) : {}; const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), + ...(pkg.optionalDependencies || {}), ...(pkg.peerDependencies || {}), } as Record; return { pkg, dependencies }; @@ -329,11 +449,19 @@ const detectSetupSignals = ( const nativeEntrypoints = files.filter(file => file.kind === 'android_entrypoint' || file.kind === 'ios_entrypoint' ); - const hasBareNativeIntegration = nativeEntrypoints.length > 0 && nativeEntrypoints.every(file => - file.kind === 'android_entrypoint' - ? hasBareAndroidStartupIntegration(file.content) - : hasBareIosStartupIntegration(file.path, file.content), - ); + const androidEntrypoints = nativeEntrypoints.filter(file => file.kind === 'android_entrypoint'); + const iosEntrypoints = nativeEntrypoints.filter(file => file.kind === 'ios_entrypoint'); + const hasAndroidDirectory = inspectProjectDirectory(projectRoot, 'android'); + const hasIosDirectory = inspectProjectDirectory(projectRoot, 'ios'); + const hasBareNativeIntegration = + (hasAndroidDirectory || hasIosDirectory) && + (!hasAndroidDirectory || androidEntrypoints.length === 1) && + (!hasIosDirectory || iosEntrypoints.length === 1) && + nativeEntrypoints.every(file => + file.kind === 'android_entrypoint' + ? hasBareAndroidStartupIntegration(file.content) + : hasBareIosStartupIntegration(file.path, file.content), + ); const hasBundleDropIntegration = projectType === 'expo' ? hasBundleDropExpoPlugin : hasBareNativeIntegration; @@ -345,8 +473,8 @@ const detectSetupSignals = ( : 'unknown' as const; const signals = [ projectType === 'expo' ? 'expoProject' : 'bareProject', - fs.existsSync(path.join(projectRoot, 'ios')) ? 'iosDirectory' : '', - fs.existsSync(path.join(projectRoot, 'android')) ? 'androidDirectory' : '', + hasIosDirectory ? 'iosDirectory' : '', + hasAndroidDirectory ? 'androidDirectory' : '', expoUpdatesOwnership.packageIsInstalled || expoUpdatesOwnership.packageIsDeclared ? 'expoUpdatesDependency' : '', @@ -365,9 +493,7 @@ const detectSetupSignals = ( hasBundleDropDependency && hasBundleDropConfig && hasBundleDropIntegration ? 'configured' as const : 'partial' as const, - hasNativeDirectories: - fs.existsSync(path.join(projectRoot, 'ios')) || - fs.existsSync(path.join(projectRoot, 'android')), + hasNativeDirectories: hasIosDirectory || hasAndroidDirectory, usesExpoRouter: Boolean(pkg.main === 'expo-router/entry' || dependencies['expo-router']), jsEngine: configuredEngine, expoUpdatesStatus: expoUpdatesOwnership.state, @@ -400,11 +526,12 @@ export function scanProjectForAiSetup( ): AiSetupScannerResult { const projectRoot = findProjectRoot(startDir); const configPath = path.join(projectRoot, 'bundle.drop.config.js'); - if (!fs.existsSync(configPath) && !virtualConfig) { + const configFile = inspectProjectFile(projectRoot, 'bundle.drop.config.js'); + if (!configFile.exists && !virtualConfig) { throw new Error('bundle.drop.config.js not found; run `bundle-drop init` first.'); } - const config = virtualConfig ? null : loadBundleDropConfig(configPath); + const config = virtualConfig ? null : loadBundleDropConfig(configPath, configFile.content); const serverUrl = virtualConfig ? normalizeServerUrl(virtualConfig.serverUrl) : typeof config?.serverUrl === 'string' ? normalizeServerUrl(config.serverUrl) : ''; @@ -419,7 +546,48 @@ export function scanProjectForAiSetup( throw new Error(`Refusing to send project files to untrusted AI planning server: ${serverUrl}.`); } - const files = collectSetupFiles(projectRoot, projectType); + if (projectType === 'expo') { + for (const relativePath of ['package.json', 'app.json', ...SETUP_CONFIG_FILES.filter( + file => file.startsWith('app.config.'), + )]) { + inspectProjectFile(projectRoot, relativePath); + } + } + const expoEvaluation = projectType === 'expo' ? evaluateExpoConfig(projectRoot) : null; + const dynamicExpoConfigFile = authoritativeDynamicExpoConfigFile( + projectRoot, + expoEvaluation?.dynamicConfigPath, + ); + const hasDynamicExpoConfigCandidate = projectType === 'expo' && + SETUP_CONFIG_FILES.some(relativePath => + relativePath.startsWith('app.config.') && inspectProjectFile(projectRoot, relativePath).exists + ); + if (hasDynamicExpoConfigCandidate && !dynamicExpoConfigFile) { + throw new Error( + 'Expo config evaluation did not identify the authoritative dynamic app.config.* file. ' + + 'No context was shared and no files were changed.', + ); + } + const files = collectSetupFiles(projectRoot, projectType, dynamicExpoConfigFile); + if (projectType === 'bare') { + for (const platform of ['android', 'ios'] as const) { + const kind = platform === 'android' ? 'android_entrypoint' : 'ios_entrypoint'; + const entrypoints = files.filter(file => file.kind === kind).map(file => file.path); + if (entrypoints.length > 1) continue; + const authorityIssue = findNativeEntrypointAuthorityIssue( + projectRoot, + platform, + entrypoints, + ); + if (authorityIssue) { + throw new Error( + `AI setup cannot prove the ${platform} application entrypoint: ${authorityIssue} ` + + 'Resolve native startup ownership manually, then retry. ' + + 'No context was shared and no files were changed.', + ); + } + } + } if (virtualConfig && !files.some(file => file.kind === 'bundle_drop_config')) { const summarizedConfig = summarizeContextContent( 'bundle.drop.config.js', diff --git a/src/CLI/scripts/aipowered/terminal-safety.ts b/src/CLI/scripts/aipowered/terminal-safety.ts new file mode 100644 index 0000000..85de7e1 --- /dev/null +++ b/src/CLI/scripts/aipowered/terminal-safety.ts @@ -0,0 +1,125 @@ +import type { AiSetupPlanResponse } from './types'; +import { findKnownBundleDropCredential } from './credential-safety'; + +const CONFIDENCE_VALUES = new Set(['high', 'medium', 'low']); +const DECISION_VALUES = new Set([ + 'safe_auto_patch', + 'review_only_patch', + 'manual_fallback', + 'skip', +]); +const ACTION_VALUES = new Set([ + 'register_expo_plugin', + 'configure_bundle_drop', + 'preserve_expo_metro', + 'migrate_expo_updates', + 'configure_bare_native', + 'migrate_codepush', + 'require_native_rebuild', + 'run_doctor', +]); + +const BIDI_CONTROL = /[\u202A-\u202E\u2066-\u2069]/u; +const JAVASCRIPT_LINE_SEPARATOR = /[\u2028\u2029]/u; + +const isUnsafeControlAt = (value: string, index: number) => { + const code = value.charCodeAt(index); + if (code === 0x09 || code === 0x0a) return false; + if (code === 0x0d && value.charCodeAt(index + 1) === 0x0a) return false; + return code < 0x20 || (code >= 0x7f && code <= 0x9f); +}; + +export const hasUnsafeTerminalControl = (value: string) => { + if (BIDI_CONTROL.test(value) || JAVASCRIPT_LINE_SEPARATOR.test(value)) return true; + for (let index = 0; index < value.length; index += 1) { + if (isUnsafeControlAt(value, index)) return true; + } + return false; +}; + +export const escapeTerminalControls = (value: string) => { + let escaped = ''; + for (const character of value) { + const code = character.charCodeAt(0); + if (character === '\n' || character === '\t') { + escaped += character; + } else if (character === '\r') { + escaped += '\\r'; + } else if ( + code < 0x20 || + (code >= 0x7f && code <= 0x9f) || + code === 0x2028 || + code === 0x2029 || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ) { + escaped += code <= 0xff + ? `\\x${code.toString(16).padStart(2, '0')}` + : `\\u${code.toString(16).padStart(4, '0')}`; + } else { + escaped += character; + } + } + return escaped; +}; + +const requireSafeProviderText = (label: string, value: unknown) => { + if (typeof value !== 'string') { + throw new Error(`AI setup response contains a non-text ${label}. No files changed.`); + } + if (hasUnsafeTerminalControl(value)) { + throw new Error(`AI setup response contains unsafe terminal controls in ${label}. No files changed.`); + } + if (findKnownBundleDropCredential(value)) { + throw new Error(`AI setup response contains a private Bundle Drop credential in ${label}. No files changed.`); + } +}; + +export function assertSafeProviderPlan(plan: unknown): asserts plan is AiSetupPlanResponse { + if (!plan || typeof plan !== 'object') { + throw new Error('AI setup response is not an object. No files changed.'); + } + const candidate = plan as Partial; + if (!CONFIDENCE_VALUES.has(String(candidate.confidence))) { + throw new Error('AI setup response contains an invalid confidence. No files changed.'); + } + requireSafeProviderText('summary', candidate.summary); + if (!Array.isArray(candidate.warnings) || !Array.isArray(candidate.actions) || + !Array.isArray(candidate.changes)) { + throw new Error('AI setup response is missing typed action, warning, or change arrays. No files changed.'); + } + candidate.warnings.forEach((warning, index) => + requireSafeProviderText(`warning ${index + 1}`, warning) + ); + candidate.actions.forEach((action, index) => { + if (!action || typeof action !== 'object') { + throw new Error(`AI setup response contains an invalid action ${index + 1}. No files changed.`); + } + requireSafeProviderText(`action ${index + 1} type`, action.type); + requireSafeProviderText(`action ${index + 1} reason`, action.reason); + if (!ACTION_VALUES.has(action.type)) { + throw new Error(`AI setup response contains an unsupported action ${index + 1}. No files changed.`); + } + if (typeof action.requiresConfirmation !== 'boolean') { + throw new Error( + `AI setup response contains a non-boolean confirmation flag for action ${index + 1}. ` + + 'No files changed.', + ); + } + }); + candidate.changes.forEach((change, index) => { + if (!change || typeof change !== 'object') { + throw new Error(`AI setup response contains an invalid change ${index + 1}. No files changed.`); + } + requireSafeProviderText(`change ${index + 1} file`, change.file); + requireSafeProviderText(`change ${index + 1} original hash`, change.originalSha256); + requireSafeProviderText(`change ${index + 1} reason`, change.reason); + requireSafeProviderText(`change ${index + 1} content`, change.updated); + if (!CONFIDENCE_VALUES.has(String(change.confidence))) { + throw new Error(`AI setup response contains an invalid change confidence ${index + 1}. No files changed.`); + } + if (!DECISION_VALUES.has(String(change.decisionType))) { + throw new Error(`AI setup response contains an invalid change decision ${index + 1}. No files changed.`); + } + }); +} diff --git a/src/CLI/scripts/aipowered/validate-plan.ts b/src/CLI/scripts/aipowered/validate-plan.ts index 313888d..7c250b6 100644 --- a/src/CLI/scripts/aipowered/validate-plan.ts +++ b/src/CLI/scripts/aipowered/validate-plan.ts @@ -1,13 +1,16 @@ import crypto from 'crypto'; -import fs from 'fs-extra'; -import path from 'path'; +import { inspectProjectFile } from '../safe-file-transaction'; +import { findNativeEntrypointAuthorityIssue } from '../native-entrypoint-authority'; import { AiPatchPlan } from './types'; import { AiSetupProjectType } from './types'; import { + findMissingBareNativeStartupStructure, hasBareAndroidStartupIntegration, hasBareIosStartupIntegration, + stripComments, stripCommentsAndStrings, } from '../native-setup-contract'; +import { hasUnsafeTerminalControl } from './terminal-safety'; const sha256 = (content: string) => crypto.createHash('sha256').update(content).digest('hex'); @@ -26,12 +29,15 @@ export const isPatchableExpoConfig = (filePath: string) => /^app\.config\.(js|ts|cjs|mjs)$/.test(filePath) || /^metro\.config\.(js|ts|cjs|mjs)$/.test(filePath); -const hasBalancedPairs = (content: string) => { +const hasBalancedPairs = (content: string, isDynamicExpoConfig: boolean) => { const pairs: Record = { ')': '(', '}': '{', ']': '[' }; const opens = new Set(Object.values(pairs)); const stack: string[] = []; + const structure = isDynamicExpoConfig + ? maskStringAndRegexLiterals(stripComments(content)) + : stripCommentsAndStrings(content); - for (const char of stripCommentsAndStrings(content)) { + for (const char of structure) { if (opens.has(char)) stack.push(char); if (pairs[char] && stack.pop() !== pairs[char]) return false; } @@ -47,6 +53,1135 @@ const hasIosBundleDropIntegration = (filePath: string, content: string) => { return hasBareIosStartupIntegration(filePath, content); }; +const findBalancedDelimiterEnd = ( + source: string, + openingIndex: number, + opening: string, + closing: string, +) => { + let depth = 0; + let quote = ''; + let escaped = false; + for (let index = openingIndex; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'" || character === '`') { + quote = character; + continue; + } + if (character === opening) depth += 1; + if (character === closing) depth -= 1; + if (depth === 0) return index; + } + return -1; +}; + +const topLevelCommaRanges = (source: string, start: number, end: number) => { + const ranges: Array<{ start: number; end: number }> = []; + let entryStart = start; + const stack: string[] = []; + let quote = ''; + let escaped = false; + for (let index = start; index < end; index += 1) { + const character = source[index]; + if (quote) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'" || character === '`') { + quote = character; + continue; + } + if ('[({'.includes(character)) stack.push(character); + else if (']})'.includes(character)) stack.pop(); + else if (character === ',' && !stack.length) { + ranges.push({ start: entryStart, end: index + 1 }); + entryStart = index + 1; + } + } + ranges.push({ start: entryStart, end }); + return ranges; +}; + +const canStartRegexLiteral = (source: string, slashIndex: number) => { + const prefix = source.slice(0, slashIndex).trimEnd(); + if (!prefix) return true; + const previousCharacter = prefix[prefix.length - 1]; + if (/^[=([{,:;!?&|+\-*%^~<>]$/.test(previousCharacter)) return true; + const previousWord = prefix.match(/([A-Za-z_$][\w$]*)$/)?.[1]; + return Boolean(previousWord && [ + 'await', + 'case', + 'delete', + 'do', + 'else', + 'in', + 'instanceof', + 'new', + 'of', + 'return', + 'throw', + 'typeof', + 'void', + 'yield', + ].includes(previousWord)); +}; + +const maskStringAndRegexLiterals = (source: string) => { + const characters = source.split(''); + let quote = ''; + let escaped = false; + let regexCharacterClass = false; + let templateExpressionDepth = 0; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (quote === '/') { + if (character !== '\n') characters[index] = ' '; + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '[') regexCharacterClass = true; + else if (character === ']') regexCharacterClass = false; + else if (character === '/' && !regexCharacterClass) quote = ''; + continue; + } + if (quote === '`') { + if (character !== '\n') characters[index] = ' '; + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '`') quote = ''; + else if (character === '$' && source[index + 1] === '{') { + characters[index + 1] = ' '; + templateExpressionDepth += 1; + quote = ''; + index += 1; + } + continue; + } + if (quote) { + if (character !== '\n') characters[index] = ' '; + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (templateExpressionDepth > 0 && character === '{') { + templateExpressionDepth += 1; + continue; + } + if (templateExpressionDepth > 0 && character === '}') { + templateExpressionDepth -= 1; + if (templateExpressionDepth === 0) { + characters[index] = ' '; + quote = '`'; + } + continue; + } + if (character === '"' || character === "'" || character === '`') { + quote = character; + characters[index] = ' '; + continue; + } + if (character === '/' && canStartRegexLiteral(source, index)) { + quote = '/'; + regexCharacterClass = false; + characters[index] = ' '; + } + } + return characters.join(''); +}; + +const maskCommentsPreservingLength = (source: string) => { + const characters = source.split(''); + let state: 'code' | 'line-comment' | 'block-comment' | 'single' | 'double' | + 'template' | 'regex' = 'code'; + let escaped = false; + let blockCommentDepth = 0; + let regexCharacterClass = false; + let templateExpressionDepth = 0; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const nextCharacter = source[index + 1]; + if (state === 'line-comment') { + if (character === '\n') state = 'code'; + else if (character !== '\r') characters[index] = ' '; + continue; + } + if (state === 'block-comment') { + if (character !== '\n' && character !== '\r') characters[index] = ' '; + if (character === '/' && nextCharacter === '*') { + characters[index + 1] = ' '; + blockCommentDepth += 1; + index += 1; + } else if (character === '*' && nextCharacter === '/') { + characters[index + 1] = ' '; + blockCommentDepth -= 1; + index += 1; + if (blockCommentDepth === 0) state = 'code'; + } + continue; + } + if (state === 'regex') { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '[') regexCharacterClass = true; + else if (character === ']') regexCharacterClass = false; + else if (character === '/' && !regexCharacterClass) state = 'code'; + continue; + } + if (state === 'template') { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '`') state = 'code'; + else if (character === '$' && nextCharacter === '{') { + templateExpressionDepth = 1; + state = 'code'; + index += 1; + } + continue; + } + if (state === 'single' || state === 'double') { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if ( + (state === 'single' && character === "'") || + (state === 'double' && character === '"') + ) { + state = 'code'; + } + continue; + } + if (templateExpressionDepth > 0 && character === '{') { + templateExpressionDepth += 1; + continue; + } + if (templateExpressionDepth > 0 && character === '}') { + templateExpressionDepth -= 1; + if (templateExpressionDepth === 0) state = 'template'; + continue; + } + if (character === '/' && nextCharacter === '/') { + characters[index] = ' '; + characters[index + 1] = ' '; + state = 'line-comment'; + index += 1; + continue; + } + if (character === '/' && nextCharacter === '*') { + characters[index] = ' '; + characters[index + 1] = ' '; + state = 'block-comment'; + blockCommentDepth = 1; + index += 1; + continue; + } + if (character === "'") state = 'single'; + else if (character === '"') state = 'double'; + else if (character === '`') state = 'template'; + else if (character === '/' && canStartRegexLiteral(source, index)) { + state = 'regex'; + regexCharacterClass = false; + } + } + + return characters.join(''); +}; + +const skipWhitespace = (source: string, start: number) => { + let cursor = start; + while (/\s/.test(source[cursor] || '')) cursor += 1; + return cursor; +}; + +const CANONICAL_ARROW_FUNCTION_SIGNATURE = + /^(?:async\s*)?(?:[A-Za-z_$][\w$]*|\([\s\S]*\))(?:\s*:\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*<[^;={}]*>)?)?$/; + +const endsAtTerminalStatement = (structure: string, start: number) => { + let cursor = skipWhitespace(structure, start); + if (structure[cursor] === ';') cursor = skipWhitespace(structure, cursor + 1); + return cursor === structure.length; +}; + +const isUnconditionalTopLevelExport = ( + source: string, + structure: string, + exportIndex: number, +) => { + const stack: string[] = []; + let statementStart = 0; + for (let index = 0; index < exportIndex; index += 1) { + const character = structure[index]; + if ('[({'.includes(character)) stack.push(character); + else if (']})'.includes(character)) stack.pop(); + else if (character === ';' && !stack.length) statementStart = index + 1; + else if (character === '\n' && !stack.length) { + const statement = structure.slice(statementStart, index).trim(); + const sourceStatement = source.slice(statementStart, index).trim(); + let nextTokenIndex = index + 1; + while (/\s/.test(source[nextTokenIndex] || '')) nextTokenIndex += 1; + if ( + /^(?:const|let|var|import)\b/.test(statement) && + !/[=([{,:!?&|+\-*%^~<>.]$/.test(sourceStatement) && + !startsJavaScriptExpressionContinuation(source, nextTokenIndex) + ) { + statementStart = index + 1; + } + } + } + return !stack.length && !structure.slice(statementStart, exportIndex).trim(); +}; + +const returnedObjectFromArrowBlock = ( + structure: string, + blockOpening: number, +) => { + const blockClosing = findBalancedDelimiterEnd(structure, blockOpening, '{', '}'); + if (blockClosing < 0) return null; + + const stack: string[] = []; + const returnIndices: number[] = []; + let statementStart = blockOpening + 1; + for (let index = blockOpening + 1; index < blockClosing; index += 1) { + const character = structure[index]; + if ('[({'.includes(character)) stack.push(character); + else if (']})'.includes(character)) stack.pop(); + else if (character === ';' && !stack.length) statementStart = index + 1; + else if ( + !stack.length && + structure.startsWith('return', index) && + !/[\w$]/.test(structure[index - 1] || '') && + !/[\w$]/.test(structure[index + 'return'.length] || '') && + !structure.slice(statementStart, index).trim() + ) { + returnIndices.push(index); + } + } + if (returnIndices.length !== 1) return null; + + let cursor = skipWhitespace(structure, returnIndices[0] + 'return'.length); + let parentheses = 0; + while (structure[cursor] === '(') { + parentheses += 1; + cursor = skipWhitespace(structure, cursor + 1); + } + if (structure[cursor] !== '{') return null; + const opening = cursor; + const closing = findBalancedDelimiterEnd(structure, opening, '{', '}'); + if (closing < 0 || closing > blockClosing) return null; + + cursor = skipWhitespace(structure, closing + 1); + while (parentheses > 0 && structure[cursor] === ')') { + parentheses -= 1; + cursor = skipWhitespace(structure, cursor + 1); + } + if (parentheses > 0) return null; + if (structure[cursor] === ';') cursor = skipWhitespace(structure, cursor + 1); + if (cursor !== blockClosing) return null; + return { opening, closing, blockClosing }; +}; + +const rootConfigObjectRange = (source: string) => { + const structure = maskStringAndRegexLiterals(source); + const exportAssignments = [...structure.matchAll( + /\b(?:export\s+default|module\s*\.\s*exports\s*=)/g, + )]; + if (exportAssignments.length !== 1) return null; + const [exportAssignment] = exportAssignments; + const exportIndex = exportAssignment.index || 0; + if (!isUnconditionalTopLevelExport(source, structure, exportIndex)) return null; + let cursor = (exportAssignment.index || 0) + exportAssignment[0].length; + cursor = skipWhitespace(structure, cursor); + + if (structure[cursor] === '{') { + const closing = findBalancedDelimiterEnd(structure, cursor, '{', '}'); + return closing >= 0 && endsAtTerminalStatement(structure, closing + 1) + ? { opening: cursor, closing } + : null; + } + + const stack: string[] = []; + let arrow = -1; + for (let index = cursor; index < structure.length - 1; index += 1) { + const character = structure[index]; + if ('[({'.includes(character)) stack.push(character); + else if (']})'.includes(character)) stack.pop(); + else if (character === ';' && !stack.length) return null; + else if (character === '=' && structure[index + 1] === '>' && !stack.length) { + arrow = index; + break; + } + } + if (arrow < 0) return null; + const parameters = structure.slice(cursor, arrow).trim(); + if (!CANONICAL_ARROW_FUNCTION_SIGNATURE.test(parameters)) return null; + + cursor = skipWhitespace(structure, arrow + 2); + if (structure[cursor] === '{') { + const returned = returnedObjectFromArrowBlock(structure, cursor); + if (!returned || !endsAtTerminalStatement(structure, returned.blockClosing + 1)) return null; + return { opening: returned.opening, closing: returned.closing }; + } + + let parentheses = 0; + while (structure[cursor] === '(') { + parentheses += 1; + cursor = skipWhitespace(structure, cursor + 1); + } + if (!parentheses || structure[cursor] !== '{') return null; + const opening = cursor; + const closing = findBalancedDelimiterEnd(structure, opening, '{', '}'); + if (closing < 0) return null; + cursor = skipWhitespace(structure, closing + 1); + while (parentheses > 0 && structure[cursor] === ')') { + parentheses -= 1; + cursor = skipWhitespace(structure, cursor + 1); + } + return parentheses === 0 && endsAtTerminalStatement(structure, cursor) + ? { opening, closing } + : null; +}; + +const decodeJavaScriptStringLiteral = (expression: string) => { + const quote = expression[0]; + if (!['"', "'", '`'].includes(quote) || expression[expression.length - 1] !== quote) { + return null; + } + const body = expression.slice(1, -1); + if (quote === '`') { + for (let index = 0; index < body.length - 1; index += 1) { + if (body[index] !== '$' || body[index + 1] !== '{') continue; + let backslashes = 0; + for (let cursor = index - 1; cursor >= 0 && body[cursor] === '\\'; cursor -= 1) { + backslashes += 1; + } + if (backslashes % 2 === 0) return null; + } + } + + let decoded = ''; + for (let index = 0; index < body.length; index += 1) { + const character = body[index]; + if (character === quote) return null; + if (character !== '\\') { + decoded += character; + continue; + } + index += 1; + if (index >= body.length) return null; + const escaped = body[index]; + if (escaped === '\n') continue; + if (escaped === '\r') { + if (body[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'u') { + const braced = /^\{([0-9A-Fa-f]{1,6})\}/.exec(body.slice(index + 1)); + if (braced) { + const codePoint = Number.parseInt(braced[1], 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index += braced[0].length; + continue; + } + const unicode = /^[0-9A-Fa-f]{4}/.exec(body.slice(index + 1)); + if (!unicode) return null; + decoded += String.fromCharCode(Number.parseInt(unicode[0], 16)); + index += unicode[0].length; + continue; + } + if (escaped === 'x') { + const hex = /^[0-9A-Fa-f]{2}/.exec(body.slice(index + 1)); + if (!hex) return null; + decoded += String.fromCharCode(Number.parseInt(hex[0], 16)); + index += hex[0].length; + continue; + } + if (/[0-7]/.test(escaped)) { + const octal = new RegExp(`^${escaped}[0-7]{0,2}`).exec(body.slice(index)); + if (!octal) return null; + decoded += String.fromCharCode(Number.parseInt(octal[0], 8)); + index += octal[0].length - 1; + continue; + } + const simpleEscapes: Record = { + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + }; + decoded += simpleEscapes[escaped] ?? escaped; + } + return decoded; +}; + +const resolveConstantStringExpression = ( + expression: string, + bindings: Map, +): string | null => { + let candidate = expression.trim(); + candidate = candidate.replace( + /\s+(?:(?:as\s+(?:const|string))|(?:satisfies\s+string))\s*$/, + '', + ).trim(); + while (candidate.startsWith('(')) { + const structure = maskStringAndRegexLiterals(candidate); + const closing = findBalancedDelimiterEnd(structure, 0, '(', ')'); + if (closing !== candidate.length - 1) break; + candidate = candidate.slice(1, -1).trim(); + } + + const literal = decodeJavaScriptStringLiteral(candidate); + if (literal !== null) return literal; + if (/^[A-Za-z_$][\w$]*$/.test(candidate)) return bindings.get(candidate) ?? null; + + const structure = maskStringAndRegexLiterals(candidate); + const ranges: Array<{ start: number; end: number }> = []; + const stack: string[] = []; + let start = 0; + for (let index = 0; index < structure.length; index += 1) { + const character = structure[index]; + if ('[({'.includes(character)) stack.push(character); + else if (']})'.includes(character)) stack.pop(); + else if (character === '+' && !stack.length) { + ranges.push({ start, end: index }); + start = index + 1; + } + } + if (!ranges.length) return null; + ranges.push({ start, end: candidate.length }); + const values = ranges.map(range => + resolveConstantStringExpression(candidate.slice(range.start, range.end), bindings) + ); + return values.every((value): value is string => value !== null) ? values.join('') : null; +}; + +const startsJavaScriptExpressionContinuation = (source: string, start: number) => { + const remainder = source.slice(start); + if (/^(?:instanceof|in|as|satisfies)\b/.test(remainder)) return true; + return /^(?:\?\.|\?\?|&&|\|\||\*\*|===|!==|==|!=|=>|<<|>>>?|<=|>=|[+\-*/%&|^<>=?.[(,`])/.test( + remainder, + ); +}; + +const collectConstantStringBindings = (commentFreeSource: string) => { + const structure = maskStringAndRegexLiterals(commentFreeSource); + const values = new Map(); + const ambiguousAuthorityNames = new Set(); + for (const match of structure.matchAll( + /\bconst\s+([A-Za-z_$][\w$]*)(?:\s*:\s*string)?\s*=/g, + )) { + const matchIndex = match.index || 0; + const stack: string[] = []; + for (let index = 0; index < matchIndex; index += 1) { + const character = structure[index]; + if ('[({'.includes(character)) stack.push(character); + else if (']})'.includes(character)) stack.pop(); + } + if (stack.length) continue; + + const expressionStart = matchIndex + match[0].length; + let expressionEnd = structure.length; + const expressionStack: string[] = []; + for (let index = expressionStart; index < structure.length; index += 1) { + const character = structure[index]; + if ('[({'.includes(character)) expressionStack.push(character); + else if (']})'.includes(character)) expressionStack.pop(); + else if (character === ';' && !expressionStack.length) { + expressionEnd = index; + break; + } else if (character === '\n' && !expressionStack.length) { + const expression = commentFreeSource.slice(expressionStart, index).trim(); + let nextTokenIndex = index + 1; + while (/\s/.test(commentFreeSource[nextTokenIndex] || '')) nextTokenIndex += 1; + const continuesExpression = startsJavaScriptExpressionContinuation( + commentFreeSource, + nextTokenIndex, + ); + if ( + expression && + !/[=([{,:!?&|+\-*%^~<>.]$/.test(expression) && + !continuesExpression + ) { + expressionEnd = index; + break; + } + } + } + const expression = commentFreeSource.slice(expressionStart, expressionEnd); + const value = resolveConstantStringExpression(expression, values); + if (value !== null) values.set(match[1], value); + else if ( + decodedExpressionMayReferenceSetupAuthority(expression) || + [...ambiguousAuthorityNames].some(name => { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[^\\w$])${escapedName}([^\\w$]|$)`).test(expression); + }) + ) { + ambiguousAuthorityNames.add(match[1]); + } + } + return { values, ambiguousAuthorityNames }; +}; + +const pluginEntryExpression = (entry: string) => { + if (!entry.startsWith('[')) return entry; + const structure = maskStringAndRegexLiterals(entry); + const closing = findBalancedDelimiterEnd(structure, 0, '[', ']'); + if (closing !== entry.length - 1) return null; + const [firstTupleEntry] = topLevelCommaRanges(structure, 1, closing); + return firstTupleEntry + ? entry.slice(firstTupleEntry.start, firstTupleEntry.end).replace(/,\s*$/, '').trim() + : null; +}; + +const decodedExpressionMayReferenceSetupAuthority = (expression: string) => { + const decodedEscapes = expression + .replace(/\\u\{([0-9A-Fa-f]{1,6})\}/g, (_match, value) => + String.fromCodePoint(Number.parseInt(value, 16))) + .replace(/\\u([0-9A-Fa-f]{4})/g, (_match, value) => + String.fromCharCode(Number.parseInt(value, 16))) + .replace(/\\x([0-9A-Fa-f]{2})/g, (_match, value) => + String.fromCharCode(Number.parseInt(value, 16))) + .replace(/\\([0-7]{1,3})/g, (_match, value) => + String.fromCharCode(Number.parseInt(value, 8))); + const compact = decodedEscapes.toLowerCase().replace(/[\s'"`+${}()[\]\\]/g, ''); + return compact.includes('expo-updates') || + compact.includes('@gfean/react-native-bundle-drop'); +}; + +const resolvePluginEntry = ( + entry: string, + bindings: ReturnType, +) => { + const expression = pluginEntryExpression(entry); + if (!expression) return { packageName: null, ambiguousAuthority: true }; + const packageName = resolveConstantStringExpression(expression, bindings.values); + return { + packageName, + ambiguousAuthority: + packageName === null && ( + decodedExpressionMayReferenceSetupAuthority(expression) || + bindings.ambiguousAuthorityNames.has(expression) + ), + }; +}; + +const isCanonicalExistingPluginsSpread = (entry: string) => + /^\.\.\.\s*\(\s*config\s*\.\s*plugins\s*(?:\|\||\?\?)\s*\[\s*\]\s*\)$/.test(entry); + +const decodeJavaScriptIdentifier = (identifier: string) => { + const decoded = identifier + .replace(/\\u\{([0-9A-Fa-f]{1,6})\}/g, (_match, value) => { + const codePoint = Number.parseInt(value, 16); + return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : ''; + }) + .replace(/\\u([0-9A-Fa-f]{4})/g, (_match, value) => + String.fromCharCode(Number.parseInt(value, 16))); + return /^[A-Za-z_$][\w$]*$/.test(decoded) ? decoded : null; +}; + +const directPropertyDefinition = (field: string) => { + const leadingWhitespace = field.match(/^\s*/)?.[0].length ?? 0; + const candidate = field.slice(leadingWhitespace); + const named = /^((?:[A-Za-z_$]|\\u(?:\{[0-9A-Fa-f]{1,6}\}|[0-9A-Fa-f]{4}))(?:[\w$]|\\u(?:\{[0-9A-Fa-f]{1,6}\}|[0-9A-Fa-f]{4}))*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*')\s*:/.exec( + candidate, + ); + if (named) { + const name = /^['"]/.test(named[1]) + ? decodeJavaScriptStringLiteral(named[1]) + : decodeJavaScriptIdentifier(named[1]); + return { + name, + computed: false, + valueStart: leadingWhitespace + named[0].length, + }; + } + if (!candidate.startsWith('[')) return null; + const structure = maskStringAndRegexLiterals(candidate); + const closing = findBalancedDelimiterEnd(structure, 0, '[', ']'); + if (closing < 0) return null; + const colon = /^\s*:/.exec(candidate.slice(closing + 1)); + if (!colon) return null; + const propertyExpression = candidate.slice(1, closing).trim(); + const staticLiteral = /^(?:"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*')$/.test( + propertyExpression, + ); + return { + name: staticLiteral ? decodeJavaScriptStringLiteral(propertyExpression) : null, + computed: true, + valueStart: leadingWhitespace + closing + 1 + colon[0].length, + }; +}; + +const resolvedDirectPropertyName = (field: string) => { + const definition = directPropertyDefinition(field); + if (definition) return definition.name; + const candidate = field.replace(/,\s*$/, '').trim(); + const shorthand = /^((?:[A-Za-z_$]|\\u(?:\{[0-9A-Fa-f]{1,6}\}|[0-9A-Fa-f]{4}))(?:[\w$]|\\u(?:\{[0-9A-Fa-f]{1,6}\}|[0-9A-Fa-f]{4}))*)$/.exec( + candidate, + ); + return shorthand ? decodeJavaScriptIdentifier(shorthand[1]) : null; +}; + +const directPropertyContainerRange = ( + source: string, + structure: string, + range: { start: number; end: number }, + expectedName: string, + openingCharacter: '{' | '[', + closingCharacter: '}' | ']', +) => { + const entry = source.slice(range.start, range.end); + const definition = directPropertyDefinition(entry); + if (!definition || definition.name !== expectedName) return null; + const localOpening = skipWhitespace(entry, definition.valueStart); + if (entry[localOpening] !== openingCharacter) return null; + const opening = range.start + localOpening; + const closing = findBalancedDelimiterEnd( + structure, + opening, + openingCharacter, + closingCharacter, + ); + if (closing < 0 || closing > range.end) return null; + const remainder = source.slice(closing + 1, range.end).replace(/,\s*$/, '').trim(); + return remainder ? null : { opening, closing, computed: definition.computed }; +}; + +const objectAccessorOrMethodDefinition = (entry: string) => { + const candidate = entry.trimStart(); + const prefix = /^(?:(?:get|set)\s+|(?:async\s+)?\*?\s*)/.exec(candidate)?.[0] ?? ''; + const nameSource = candidate.slice(prefix.length); + if (nameSource.startsWith('[')) { + const structure = maskStringAndRegexLiterals(nameSource); + const closing = findBalancedDelimiterEnd(structure, 0, '[', ']'); + if (closing < 0 || !/^\s*\(/.test(nameSource.slice(closing + 1))) return null; + const propertyExpression = nameSource.slice(1, closing).trim(); + const staticLiteral = /^(?:"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*')$/.test( + propertyExpression, + ); + return { + name: staticLiteral ? decodeJavaScriptStringLiteral(propertyExpression) : null, + }; + } + const method = /^((?:[A-Za-z_$]|\\u(?:\{[0-9A-Fa-f]{1,6}\}|[0-9A-Fa-f]{4}))(?:[\w$]|\\u(?:\{[0-9A-Fa-f]{1,6}\}|[0-9A-Fa-f]{4}))*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*')\s*\(/.exec( + nameSource, + ); + if (!method) return null; + const propertyName = /^['"]/.test(method[1]) + ? decodeJavaScriptStringLiteral(method[1]) + : decodeJavaScriptIdentifier(method[1]); + return { name: propertyName }; +}; + +const hasAmbiguousAuthorityAccessorOrMethod = ( + entry: string, + authorityNames: string[], +) => { + const method = objectAccessorOrMethodDefinition(entry); + return Boolean(method && (!method.name || authorityNames.includes(method.name))); +}; + +const authoritativeExpoConfigRange = (source: string) => { + const exportedRoot = rootConfigObjectRange(source); + if (!exportedRoot) return null; + const structure = maskStringAndRegexLiterals(source); + const rootEntries = topLevelCommaRanges( + structure, + exportedRoot.opening + 1, + exportedRoot.closing, + ); + const rootEntrySources = rootEntries.map(range => source.slice(range.start, range.end)); + const rootDefinitions = rootEntrySources.map(directPropertyDefinition); + const rootPropertyNames = rootEntrySources.map(resolvedDirectPropertyName); + const directExpoObjects = rootEntries.flatMap((range, entryIndex) => { + if (rootDefinitions[entryIndex]?.name !== 'expo') return []; + const property = directPropertyContainerRange( + source, + structure, + range, + 'expo', + '{', + '}', + ); + return property + ? [{ ...property, entryIndex }] + : []; + }); + const expoNamedPropertyCount = rootPropertyNames.filter(name => name === 'expo').length; + if (!directExpoObjects.length) { + return expoNamedPropertyCount ? null : exportedRoot; + } + if ( + directExpoObjects.length !== 1 || + expoNamedPropertyCount !== 1 || + rootEntrySources.some((entry, entryIndex) => + Boolean(rootDefinitions[entryIndex] && !rootPropertyNames[entryIndex]) || + (/^\s*\.\.\./.test(entry) && entryIndex > directExpoObjects[0].entryIndex) || + hasAmbiguousAuthorityAccessorOrMethod(entry, ['expo', 'plugins', 'updates']) || + ['plugins', 'updates'].includes(rootPropertyNames[entryIndex] || '') + ) + ) { + return null; + } + return directExpoObjects[0]; +}; + +type SourceRange = { start: number; end: number }; + +const removableCommaRange = (source: string, range: SourceRange): SourceRange => { + let end = range.end; + while (end > range.start && /\s/.test(source[end - 1])) end -= 1; + if (source[end - 1] === ',') return { start: range.start, end }; + + let start = range.start - 1; + while (start >= 0 && /\s/.test(source[start])) start -= 1; + return source[start] === ',' + ? { start, end } + : { start: range.start, end }; +}; + +const removeSourceRanges = (source: string, ranges: SourceRange[]) => { + const ordered = [...ranges].sort((left, right) => right.start - left.start); + let result = source; + for (const range of ordered) { + result = result.slice(0, range.start) + result.slice(range.end); + } + return result; +}; + +const removeTopLevelExpoUpdatesPluginEntries = (source: string) => { + const structuralSource = maskCommentsPreservingLength(source); + const root = authoritativeExpoConfigRange(structuralSource); + if (!root) return source; + const structure = maskStringAndRegexLiterals(structuralSource); + const bindings = collectConstantStringBindings(structuralSource); + const rangesToRemove: SourceRange[] = []; + const rootEntries = topLevelCommaRanges(structure, root.opening + 1, root.closing); + for (const entryRange of rootEntries) { + const property = directPropertyContainerRange( + structuralSource, + structure, + entryRange, + 'plugins', + '[', + ']', + ); + if (!property) continue; + for (const range of topLevelCommaRanges( + structure, + property.opening + 1, + property.closing, + )) { + const plugin = structuralSource + .slice(range.start, range.end) + .replace(/,\s*$/, '') + .trim(); + if (!plugin) continue; + if (resolvePluginEntry(plugin, bindings).packageName !== 'expo-updates') continue; + rangesToRemove.push(removableCommaRange(source, range)); + } + } + return removeSourceRanges(source, rangesToRemove); +}; + +const removeTopLevelBundleDropRegistration = ( + source: string, + removeNewPluginProperty: boolean, +) => { + const structuralSource = maskCommentsPreservingLength(source); + const root = authoritativeExpoConfigRange(structuralSource); + if (!root) return source; + const structure = maskStringAndRegexLiterals(structuralSource); + const rangesToRemove: SourceRange[] = []; + const rootEntries = topLevelCommaRanges(structure, root.opening + 1, root.closing); + for (const entryRange of rootEntries) { + const entry = structuralSource.slice(entryRange.start, entryRange.end); + const property = directPropertyContainerRange( + structuralSource, + structure, + entryRange, + 'plugins', + '[', + ']', + ); + if (!property) continue; + if ( + removeNewPluginProperty && + !/^\s*(?:plugins|"plugins"|'plugins')\s*:/.test(entry) + ) { + continue; + } + const pluginRanges = topLevelCommaRanges( + structure, + property.opening + 1, + property.closing, + ); + const substantiveRanges = pluginRanges.filter(range => + structuralSource.slice(range.start, range.end).replace(/[,\s]/g, '') + ); + const bundleDropRanges = substantiveRanges.filter(range => { + const plugin = structuralSource + .slice(range.start, range.end) + .replace(/,\s*$/, '') + .trim(); + return /^(?:"@gfean\/react-native-bundle-drop"|'@gfean\/react-native-bundle-drop')$/.test( + plugin, + ); + }); + if (bundleDropRanges.length !== 1) continue; + const removesWholePluginProperty = removeNewPluginProperty && substantiveRanges.length === 1; + const authorizedRanges = removesWholePluginProperty + ? [entryRange] + : bundleDropRanges; + const removesOnlyRootProperty = removesWholePluginProperty && + rootEntries.filter(range => + structuralSource.slice(range.start, range.end).replace(/[,\s]/g, '') + ).length === 1; + rangesToRemove.push( + ...authorizedRanges.map(range => removesOnlyRootProperty + ? range + : removableCommaRange(source, range)), + ); + } + return removeSourceRanges(source, rangesToRemove); +}; + +const hasTopLevelPluginsProperty = (source: string) => { + const structuralSource = stripComments(source); + const root = authoritativeExpoConfigRange(structuralSource); + if (!root) return null; + const structure = maskStringAndRegexLiterals(structuralSource); + return topLevelCommaRanges(structure, root.opening + 1, root.closing).some(range => + directPropertyDefinition(structuralSource.slice(range.start, range.end))?.name === + 'plugins' + ); +}; + +const removeTopLevelExpoUpdatesFields = (source: string) => { + const structuralSource = maskCommentsPreservingLength(source); + const root = authoritativeExpoConfigRange(structuralSource); + if (!root) return source; + const structure = maskStringAndRegexLiterals(structuralSource); + const rangesToRemove: SourceRange[] = []; + const rootEntries = topLevelCommaRanges(structure, root.opening + 1, root.closing); + for (const entryRange of rootEntries) { + const property = directPropertyContainerRange( + structuralSource, + structure, + entryRange, + 'updates', + '{', + '}', + ); + if (!property) continue; + const fieldRanges = topLevelCommaRanges( + structure, + property.opening + 1, + property.closing, + ); + const removable = fieldRanges.filter(range => { + const field = structuralSource + .slice(range.start, range.end) + .replace(/,\s*$/, '') + .trim(); + return ['enabled', 'url'].includes(resolvedDirectPropertyName(field) || ''); + }); + if (!removable.length) continue; + const substantiveFields = fieldRanges.filter(range => + structuralSource.slice(range.start, range.end).replace(/[,\s]/g, '') + ); + const authorizedRanges = removable.length === substantiveFields.length + ? [entryRange] + : removable; + rangesToRemove.push( + ...authorizedRanges.map(range => removableCommaRange(source, range)), + ); + } + return removeSourceRanges(source, rangesToRemove); +}; + +const inspectAuthoritativeExpoConfig = (content: string) => { + const source = stripComments(content); + const structure = maskStringAndRegexLiterals(source); + const bindings = collectConstantStringBindings(source); + const root = authoritativeExpoConfigRange(source); + if (!root) return null; + + const rootEntries = topLevelCommaRanges(structure, root.opening + 1, root.closing); + const rootEntrySources = rootEntries.map(range => source.slice(range.start, range.end)); + const rootDefinitions = rootEntrySources.map(directPropertyDefinition); + if (rootEntrySources.some((entry, index) => + Boolean(rootDefinitions[index] && !rootDefinitions[index]?.name) || + hasAmbiguousAuthorityAccessorOrMethod(entry, ['plugins', 'updates']) || + ( + ['plugins', 'updates'].includes(resolvedDirectPropertyName(entry) || '') && + !rootDefinitions[index] + ) + )) { + return null; + } + const pluginProperties = rootEntries.filter((_range, index) => + rootDefinitions[index]?.name === 'plugins' + ); + if (pluginProperties.length !== 1) return null; + + const pluginProperty = pluginProperties[0]; + const pluginArray = directPropertyContainerRange( + source, + structure, + pluginProperty, + 'plugins', + '[', + ']', + ); + if (!pluginArray) return null; + const pluginOpening = pluginArray.opening; + const pluginClosing = pluginArray.closing; + + const updatesProperties = rootEntries.filter((_range, index) => + rootDefinitions[index]?.name === 'updates' + ); + if (updatesProperties.length > 1) return null; + + const pluginPropertyIndex = rootEntries.indexOf(pluginProperty); + const updatesPropertyIndex = updatesProperties.length + ? rootEntries.indexOf(updatesProperties[0]) + : -1; + const firstAuthorityIndex = updatesPropertyIndex < 0 + ? pluginPropertyIndex + : Math.min(pluginPropertyIndex, updatesPropertyIndex); + if (rootEntrySources.some((entry, index) => + index > firstAuthorityIndex && /^\s*\.\.\./.test(entry) + )) { + return null; + } + + let bundleDropPluginCount = 0; + let expoUpdatesPluginCount = 0; + let hasAmbiguousPluginAuthority = false; + let hasUnresolvedPluginExpression = false; + let supportedExistingPluginsSpreadCount = 0; + const pluginEntries = topLevelCommaRanges(structure, pluginOpening + 1, pluginClosing); + let bundleDropPluginIndex = -1; + for (const [index, range] of pluginEntries.entries()) { + const entry = source.slice(range.start, range.end).replace(/,\s*$/, '').trim(); + if (!entry) continue; + const resolvedPlugin = resolvePluginEntry(entry, bindings); + if (resolvedPlugin.packageName === null) { + if (isCanonicalExistingPluginsSpread(entry)) { + supportedExistingPluginsSpreadCount += 1; + } else { + hasUnresolvedPluginExpression = true; + } + } + if (resolvedPlugin.packageName === '@gfean/react-native-bundle-drop') { + bundleDropPluginCount += 1; + bundleDropPluginIndex = index; + } + if (resolvedPlugin.packageName === 'expo-updates') expoUpdatesPluginCount += 1; + if (resolvedPlugin.ambiguousAuthority) hasAmbiguousPluginAuthority = true; + if ( + /^\.\.\./.test(entry) && + decodedExpressionMayReferenceSetupAuthority(entry.slice(3)) + ) { + hasAmbiguousPluginAuthority = true; + } + } + if ( + bundleDropPluginIndex >= 0 && + pluginEntries.some((range, index) => + index > bundleDropPluginIndex && + /^\s*\.\.\./.test(source.slice(range.start, range.end)) + ) + ) { + expoUpdatesPluginCount += 1; + } + + let hasActiveExpoUpdatesField = false; + if (updatesProperties.length === 1) { + const updatesProperty = updatesProperties[0]; + const updatesObject = directPropertyContainerRange( + source, + structure, + updatesProperty, + 'updates', + '{', + '}', + ); + if (!updatesObject) return null; + const updateFields = topLevelCommaRanges( + structure, + updatesObject.opening + 1, + updatesObject.closing, + ); + for (const range of updateFields) { + const field = source.slice(range.start, range.end).replace(/,\s*$/, '').trim(); + if (/^\.\.\./.test(field)) { + hasActiveExpoUpdatesField = true; + continue; + } + if ( + ['enabled', 'url'].includes(resolvedDirectPropertyName(field) || '') || + Boolean(objectAccessorOrMethodDefinition(field)) || + /^\[/.test(field) + ) { + hasActiveExpoUpdatesField = true; + } + } + } + + return { + bundleDropPluginCount, + expoUpdatesPluginCount, + hasAmbiguousPluginAuthority, + hasUnresolvedPluginExpression, + supportedExistingPluginsSpreadCount, + hasActiveExpoUpdatesField, + }; +}; + +const withoutOptionalTerminalNewline = (content: string) => + content.endsWith('\r\n') + ? content.slice(0, -2) + : content.endsWith('\n') + ? content.slice(0, -1) + : content; + +const preservesOnlyAuthorizedExpoChanges = ( + original: string, + updated: string, + migrateExpoUpdates: boolean, +) => { + const originalHasPlugins = hasTopLevelPluginsProperty(original); + if (originalHasPlugins === null) return false; + const originalWithoutExactBundleDrop = removeTopLevelBundleDropRegistration( + original, + false, + ); + const originalHasExactBundleDrop = originalWithoutExactBundleDrop !== original; + let normalizedOriginal = original; + if (migrateExpoUpdates) { + normalizedOriginal = removeTopLevelExpoUpdatesFields( + removeTopLevelExpoUpdatesPluginEntries(normalizedOriginal), + ); + } + const normalizedUpdated = originalHasExactBundleDrop + ? updated + : removeTopLevelBundleDropRegistration(updated, !originalHasPlugins); + return withoutOptionalTerminalNewline(normalizedOriginal) === + withoutOptionalTerminalNewline(normalizedUpdated); +}; + const validateCommonSetupChange = ( change: AiPatchPlan, originals: Map, @@ -71,35 +1206,74 @@ const validateCommonSetupChange = ( if (change.updated.includes('TODO_BUNDLEDROP') || change.updated.includes('; changes: AiPatchPlan[]; + migrateExpoUpdates?: boolean; }) { const seen = new Set(); for (const change of params.changes) { validateCommonSetupChange(change, params.originals, seen); if (params.projectType === 'expo') { - if (!isPatchableExpoConfig(change.file)) { - throw new Error(`AI Expo setup may not modify ${change.file}`); + if (!change.file.startsWith('app.config.') || !isPatchableExpoConfig(change.file)) { + throw new Error( + `AI Expo setup may modify only a dynamic root app.config.* file: ${change.file}`, + ); + } + if (change.decisionType !== 'review_only_patch') { + throw new Error( + `AI dynamic Expo config updates require explicit review-only approval: ${change.file}`, + ); } + const expoConfig = inspectAuthoritativeExpoConfig(change.updated); + const originalExpoConfig = inspectAuthoritativeExpoConfig( + params.originals.get(change.file)!, + ); if ( - change.file.startsWith('metro.config.') && - !change.updated.includes('bundle-drop-config') && - !change.updated.includes('withBundleDropExpo') + !expoConfig || + expoConfig.bundleDropPluginCount !== 1 || + expoConfig.hasAmbiguousPluginAuthority ) { - throw new Error(`AI Expo Metro update is missing the Bundle Drop wrapper: ${change.file}`); + throw new Error( + `AI Expo config update must contain exactly one Bundle Drop plugin in the exported root plugins property: ${change.file}`, + ); } if ( - (change.file === 'app.json' || change.file.startsWith('app.config.')) && - !change.updated.includes('@gfean/react-native-bundle-drop') + params.migrateExpoUpdates && + ( + expoConfig.expoUpdatesPluginCount > 0 || + expoConfig.hasActiveExpoUpdatesField || + expoConfig.hasUnresolvedPluginExpression || + expoConfig.supportedExistingPluginsSpreadCount > + (originalExpoConfig?.supportedExistingPluginsSpreadCount ?? 0) + ) ) { - throw new Error(`AI Expo config update is missing the Bundle Drop plugin: ${change.file}`); + throw new Error( + `AI Expo config update did not fully remove active Expo Updates configuration: ${change.file}`, + ); + } + if (!preservesOnlyAuthorizedExpoChanges( + params.originals.get(change.file)!, + change.updated, + Boolean(params.migrateExpoUpdates), + )) { + throw new Error( + `AI dynamic Expo config update changed code outside authorized setup fields: ${change.file}`, + ); } continue; } @@ -107,6 +1281,11 @@ export function validateSetupChangesBeforeApply(params: { if (!isPatchableNativeEntrypoint(change.file)) { throw new Error(`AI bare setup may not modify ${change.file}`); } + if (change.decisionType !== 'review_only_patch') { + throw new Error( + `AI bare native updates require explicit review-only approval: ${change.file}`, + ); + } if (change.file.includes('MainApplication.') && !hasAndroidBundleDropIntegration(change.updated)) { throw new Error(`AI plan Android update does not contain BundleDrop resolver: ${change.file}`); } @@ -116,6 +1295,17 @@ export function validateSetupChangesBeforeApply(params: { ) { throw new Error(`AI plan iOS update does not contain BundleDrop locator: ${change.file}`); } + const missingStructure = findMissingBareNativeStartupStructure( + change.file, + params.originals.get(change.file)!, + change.updated, + ); + if (missingStructure.length) { + throw new Error( + `AI plan removed native startup structure from ${change.file}: ` + + missingStructure.join(', '), + ); + } } } @@ -123,20 +1313,44 @@ export function validateAppliedSetupChanges(params: { projectRoot: string; projectType: AiSetupProjectType; changes: AiPatchPlan[]; + migrateExpoUpdates?: boolean; + originals?: Map; }) { - const originals = new Map( - params.changes.map(change => [ - change.file, - fs.readFileSync(path.join(params.projectRoot, change.file), 'utf8'), - ]), + const appliedFiles = new Map( + params.changes.map(change => { + const applied = inspectProjectFile(params.projectRoot, change.file); + if (!applied.exists) { + throw new Error(`Applied AI setup file is missing: ${change.file}`); + } + return [change.file, applied.content]; + }), ); + const originals = params.originals || appliedFiles; + if (params.projectType === 'bare') { + for (const platform of ['android', 'ios'] as const) { + const entrypoints = params.changes + .map(change => change.file) + .filter(file => platform === 'android' + ? file.includes('/android/') || file.startsWith('android/') + : file.includes('/ios/') || file.startsWith('ios/')); + const authorityIssue = findNativeEntrypointAuthorityIssue( + params.projectRoot, + platform, + entrypoints, + ); + if (authorityIssue) { + throw new Error(`Applied ${platform} entrypoint authority is invalid: ${authorityIssue}`); + } + } + } validateSetupChangesBeforeApply({ projectType: params.projectType, originals, + migrateExpoUpdates: params.migrateExpoUpdates, changes: params.changes.map(change => ({ ...change, originalSha256: sha256(originals.get(change.file)!), - updated: originals.get(change.file)!, + updated: appliedFiles.get(change.file)!, })), }); } diff --git a/src/CLI/scripts/bare-metro-config.ts b/src/CLI/scripts/bare-metro-config.ts index d91c217..42f5090 100644 --- a/src/CLI/scripts/bare-metro-config.ts +++ b/src/CLI/scripts/bare-metro-config.ts @@ -1,56 +1,59 @@ -import fs from 'fs-extra'; -import path from 'path'; +import { inspectProjectFile } from './safe-file-transaction'; +import { + assertCommonJsMetroConfig, + findSingleMetroConfig, + hasAuthoritativeMetroWrapper, + hasExecutableMetroWrapperReference, + newCommonJsMetroConfigFile, +} from './metro-config-authority'; const APPEND_SNIPPET = ` -// Bundle Drop: ensure bundle.drop.config.js is resolvable from node_modules -(() => { - const path = require('path'); - module.exports = module.exports || {}; - module.exports.resolver = module.exports.resolver || {}; - module.exports.resolver.extraNodeModules = { - ...(module.exports.resolver.extraNodeModules || {}), - 'bundle-drop-config': path.resolve(__dirname, 'bundle.drop.config.js'), - }; -})(); +// Bundle Drop: merge package-managed runtime delivery bootstrap into Metro. +const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro'); +module.exports = withBundleDrop(module.exports || {}, { projectRoot: __dirname }); `; -const NEW_METRO_TEMPLATE = `const path = require('path'); -const { getDefaultConfig } = require('@react-native/metro-config'); +const NEW_METRO_TEMPLATE = `const { getDefaultConfig } = require('@react-native/metro-config'); +const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro'); const config = getDefaultConfig(__dirname); -config.resolver = config.resolver || {}; -config.resolver.extraNodeModules = { - ...(config.resolver.extraNodeModules || {}), - 'bundle-drop-config': path.resolve(__dirname, 'bundle.drop.config.js'), -}; - -module.exports = config; +module.exports = withBundleDrop(config, { projectRoot: __dirname }); `; export type MetroConfigChange = { - file: 'metro.config.js'; + file: string; original: string | null; updated: string; reason: string; }; export function planBareMetroConfig(projectRoot: string): MetroConfigChange | null { - const metroPath = path.join(projectRoot, 'metro.config.js'); - if (!fs.existsSync(metroPath)) { + const metroConfigPath = findSingleMetroConfig(projectRoot); + if (!metroConfigPath) { return { - file: 'metro.config.js', + file: newCommonJsMetroConfigFile(projectRoot), original: null, updated: `${NEW_METRO_TEMPLATE.trim()}\n`, - reason: 'Create the bare React Native Metro alias for bundle.drop.config.js.', + reason: 'Create the bare React Native Metro wrapper for Bundle Drop.', }; } - const content = fs.readFileSync(metroPath, 'utf8'); - if (content.includes('bundle-drop-config')) return null; + const metroFile = inspectProjectFile(projectRoot, metroConfigPath); + const content = metroFile.content; + if (hasAuthoritativeMetroWrapper(content, 'withBundleDrop')) return null; + if (hasExecutableMetroWrapperReference(content, 'withBundleDrop')) { + throw new Error( + `${metroConfigPath} contains a non-authoritative withBundleDrop reference. ` + + 'Remove the dead, aliased, or malformed wrapper before rerunning setup.', + ); + } + assertCommonJsMetroConfig(projectRoot, metroConfigPath); return { - file: 'metro.config.js', + file: metroConfigPath, original: content, updated: `${content.trim()}\n${APPEND_SNIPPET}`, - reason: 'Add the Bundle Drop alias without replacing the existing Metro config.', + reason: content.includes('bundle-drop-config') + ? 'Migrate the legacy Bundle Drop alias to the package-managed Metro wrapper.' + : 'Add the Bundle Drop Metro wrapper without replacing the existing config.', }; } diff --git a/src/CLI/scripts/doctor.ts b/src/CLI/scripts/doctor.ts index 442040f..0499a07 100644 --- a/src/CLI/scripts/doctor.ts +++ b/src/CLI/scripts/doctor.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; +import { spawnSync } from 'child_process'; import { hasBareAndroidStartupIntegration, hasBareIosStartupIntegration, @@ -19,12 +20,19 @@ import { resolveExpoUploadIdentity, } from './expo/build-receipt'; import { findProjectRoot } from './aipowered/scanner'; +import { readGeneratedRuntimeDeliveryBootstrap } from '../../runtime-delivery/bootstrapConfig'; +import { findNativeEntrypointAuthorityIssue } from './native-entrypoint-authority'; +import { + findSingleMetroConfig, + hasAuthoritativeMetroWrapper, + hasExecutableMetroModuleReference, +} from './metro-config-authority'; +import { inspectProjectFile } from './safe-file-transaction'; const PACKAGE_NAME = '@gfean/react-native-bundle-drop'; const ANDROID_MARKER = 'com.bundledrop.EXPO_ENABLED'; const IOS_MARKER = 'BundleDropExpoEnabled'; const IOS_RECEIPT_PHASE = 'Bundle Drop: Write iOS build identity'; -const METRO_CONFIG_EXTENSIONS = ['js', 'ts', 'cjs', 'mjs']; export type DoctorCheck = { name: string; @@ -65,23 +73,135 @@ const checkExpoUpdates = (projectRoot: string, exp: Record): Doctor }; }; -const findFiles = (directory: string, suffix: string): string[] => { +const findFiles = ( + directory: string, + suffix: string, + budget = { visitedEntries: 0 }, +): string[] => { const files: string[] = []; if (fs.existsSync(directory)) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + budget.visitedEntries += 1; + if (budget.visitedEntries > 5000) { + throw new Error('Native doctor source scan exceeded 5000 filesystem entries.'); + } if (entry.name === 'Pods' || entry.name === 'build') continue; const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) files.push(...findFiles(entryPath, suffix)); + if (entry.isDirectory()) files.push(...findFiles(entryPath, suffix, budget)); else if (entry.name.endsWith(suffix)) files.push(entryPath); } } return files; }; -const findMetroConfig = (projectRoot: string): string | undefined => - METRO_CONFIG_EXTENSIONS - .map(extension => path.join(projectRoot, `metro.config.${extension}`)) - .find(fs.existsSync); +const inspectMetroConfig = (projectRoot: string) => { + try { + const file = findSingleMetroConfig(projectRoot); + return file + ? { file, content: inspectProjectFile(projectRoot, file).content, issue: null } + : { file: undefined, content: '', issue: 'No Metro config file was found.' }; + } catch (error) { + return { file: undefined, content: '', issue: (error as Error).message }; + } +}; + +const runtimeDeliveryBootstrapGitState = ( + projectRoot: string, +): 'ignored' | 'tracked' | 'untracked' | null => { + const relativePath = path.join('.bundle-drop', 'runtime-delivery.generated.json'); + const runGit = (args: string[]) => spawnSync('git', args, { + cwd: projectRoot, + stdio: 'ignore', + }); + const repository = runGit(['rev-parse', '--is-inside-work-tree']); + if (repository.error || repository.status !== 0) return null; + if (runGit(['check-ignore', '--no-index', '-q', '--', relativePath]).status === 0) { + return 'ignored'; + } + return runGit(['ls-files', '--error-unmatch', '--', relativePath]).status === 0 + ? 'tracked' + : 'untracked'; +}; + +const checkRuntimeDeliveryBootstrap = (projectRoot: string): DoctorCheck => { + const configPath = path.join(projectRoot, 'bundle.drop.config.js'); + if (!fs.existsSync(configPath)) { + return { + name: 'Runtime delivery bootstrap', + status: 'error', + message: 'bundle.drop.config.js is required before runtime delivery can be validated.', + }; + } + try { + delete require.cache[require.resolve(configPath)]; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const config = require(configPath) as { + serverUrl?: string; + org?: { slug?: string }; + project?: { slug?: string }; + runtimeDelivery?: unknown; + }; + if (!config.serverUrl || !config.org?.slug || !config.project?.slug) { + throw new Error('bundle.drop.config.js is missing serverUrl, org.slug, or project.slug.'); + } + const bootstrap = readGeneratedRuntimeDeliveryBootstrap({ + projectRoot, + expectedIdentity: { + serverUrl: config.serverUrl, + orgSlug: config.org.slug, + projectSlug: config.project.slug, + }, + }); + if (bootstrap) { + const gitState = runtimeDeliveryBootstrapGitState(projectRoot); + if (gitState === 'ignored') { + return { + name: 'Runtime delivery bootstrap', + status: 'error', + message: + 'The runtime delivery bootstrap is ignored by Git and will be missing from clean builds. ' + + 'Run `bundle-drop sync` to repair .gitignore.', + }; + } + if (gitState === 'untracked') { + return { + name: 'Runtime delivery bootstrap', + status: 'warning', + message: + `Runtime delivery bootstrap is valid with ` + + `${Object.keys(bootstrap.runtimeDelivery.publicKeys).length} public key(s), ` + + 'but it is not committed yet.', + }; + } + return { + name: 'Runtime delivery bootstrap', + status: 'pass', + message: `Runtime delivery bootstrap is pinned with ${Object.keys(bootstrap.runtimeDelivery.publicKeys).length} public key(s).`, + }; + } + if (config.runtimeDelivery) { + return { + name: 'Runtime delivery bootstrap', + status: 'warning', + message: + 'Stale inline runtime delivery config is ignored. Remove runtimeDelivery from ' + + 'bundle.drop.config.js, migrate Metro with `bundle-drop init`, and use `bundle-drop sync` ' + + 'for package-managed trust.', + }; + } + return { + name: 'Runtime delivery bootstrap', + status: 'warning', + message: 'No runtime delivery bootstrap is pinned. Run `bundle-drop sync` to create or repair it.', + }; + } catch (error) { + return { + name: 'Runtime delivery bootstrap', + status: 'error', + message: `${(error as Error).message} Run \`bundle-drop sync\` to repair it.`, + }; + } +}; const checkCommittedNativeIntegration = ( projectRoot: string, @@ -129,6 +249,7 @@ async function inspectExpoProject( platforms: MobilePlatform[], ): Promise { const checks: DoctorCheck[] = []; + checks.push(checkRuntimeDeliveryBootstrap(projectRoot)); const { exp } = evaluateExpoConfig(projectRoot); const plugins = (exp.plugins || []).map(pluginName); const bundleDropPluginCount = plugins.filter(name => name === PACKAGE_NAME).length; @@ -171,14 +292,16 @@ async function inspectExpoProject( if (nativeCheck) checks.push(nativeCheck); } - const metroFile = findMetroConfig(projectRoot); - const metroContent = metroFile ? fs.readFileSync(metroFile, 'utf8') : ''; + const metro = inspectMetroConfig(projectRoot); + const hasExpoMetroWrapper = !metro.issue && + hasAuthoritativeMetroWrapper(metro.content, 'withBundleDropExpo') && + hasExecutableMetroModuleReference(metro.content, 'expo/metro-config'); checks.push({ name: 'Expo Metro wrapper', - status: metroContent.includes('withBundleDropExpo') ? 'pass' : 'error', - message: metroContent.includes('withBundleDropExpo') + status: hasExpoMetroWrapper ? 'pass' : 'error', + message: hasExpoMetroWrapper ? 'Existing Expo Metro configuration is preserved through withBundleDropExpo.' - : 'Metro must use withBundleDropExpo and preserve expo/metro-config.', + : metro.issue || 'Metro must export withBundleDropExpo(...) and preserve expo/metro-config.', }); const runtimeAuthorities = new Map( @@ -349,18 +472,35 @@ const checkBareStartupIntegration = ( ? ['MainApplication.kt', 'MainApplication.java'] : ['AppDelegate.swift', 'AppDelegate.mm', 'AppDelegate.m']; const entrypoints = entrypointNames.flatMap(name => findFiles(nativeRoot, name)); - const hasIntegration = entrypoints.some(filePath => { + const relativeEntrypoints = entrypoints.map(filePath => + path.relative(projectRoot, filePath).split(path.sep).join('/') + ); + let authorityIssue: string | null = null; + try { + authorityIssue = findNativeEntrypointAuthorityIssue( + projectRoot, + platform, + relativeEntrypoints, + ); + } catch (error) { + authorityIssue = (error as Error).message; + } + const hasIntegration = entrypoints.length === 1 && entrypoints.every(filePath => { const content = fs.readFileSync(filePath, 'utf8'); return platform === 'android' ? hasBareAndroidStartupIntegration(content) : hasBareIosStartupIntegration(filePath, content); - }); + }) && !authorityIssue; return { name: `${platform} OTA startup ownership`, status: hasIntegration ? 'pass' : 'error', - message: hasIntegration - ? `The ${platform} application entrypoint asks Bundle Drop for the cold-start bundle.` - : `The ${platform} application entrypoint does not hand cold-start bundle resolution to Bundle Drop.`, + message: entrypoints.length > 1 + ? `Multiple ${platform} application entrypoints were found; resolve startup ownership manually.` + : authorityIssue + ? `Native application entrypoint authority is ambiguous: ${authorityIssue}` + : hasIntegration + ? `The ${platform} application entrypoint asks Bundle Drop for the cold-start bundle.` + : `The ${platform} application entrypoint does not hand cold-start bundle resolution to Bundle Drop.`, }; }; @@ -445,8 +585,9 @@ function inspectBareProject( platforms: MobilePlatform[], ): DoctorCheck[] { const configPath = path.join(projectRoot, 'bundle.drop.config.js'); - const metroPath = findMetroConfig(projectRoot); - const metroContent = metroPath ? fs.readFileSync(metroPath, 'utf8') : ''; + const metro = inspectMetroConfig(projectRoot); + const hasBareMetroWrapper = !metro.issue && + hasAuthoritativeMetroWrapper(metro.content, 'withBundleDrop'); const checks: DoctorCheck[] = [ { name: 'Bundle Drop config', @@ -457,11 +598,12 @@ function inspectBareProject( }, { name: 'Bare Metro alias', - status: metroContent.includes('bundle-drop-config') ? 'pass' : 'error', - message: metroContent.includes('bundle-drop-config') - ? 'Metro resolves bundle-drop-config.' - : 'Metro does not contain the bundle-drop-config alias.', + status: hasBareMetroWrapper ? 'pass' : 'error', + message: hasBareMetroWrapper + ? 'Metro uses the package-managed Bundle Drop wrapper.' + : metro.issue || 'Metro must export withBundleDrop(...) so generated trust data is bundled.', }, + checkRuntimeDeliveryBootstrap(projectRoot), checkBarePackageMetadata(projectRoot), ]; for (const platform of platforms) { diff --git a/src/CLI/scripts/expo/configure-expo.ts b/src/CLI/scripts/expo/configure-expo.ts index 65869a0..e6f2ff5 100644 --- a/src/CLI/scripts/expo/configure-expo.ts +++ b/src/CLI/scripts/expo/configure-expo.ts @@ -1,7 +1,22 @@ import crypto from 'crypto'; -import fs from 'fs-extra'; import path from 'path'; import { setBundleDropProjectType } from '../../../expo/projectType'; +import { addRuntimeDeliveryBootstrapGitignoreRules } from '../../../runtime-delivery/bootstrapConfig'; +import { + createSafeBackupDirectory, + inspectProjectFile, + removeProjectFile, + restoreProjectFile, + writeBackupFile, + writeProjectFileAtomically, +} from '../safe-file-transaction'; +import { + assertCommonJsMetroConfig, + findSingleMetroConfig, + hasAuthoritativeMetroWrapper, + hasExecutableMetroWrapperReference, + newCommonJsMetroConfigFile, +} from '../metro-config-authority'; export { setBundleDropProjectType } from '../../../expo/projectType'; @@ -87,7 +102,7 @@ const updateBundleDropConfig = (content: string): string => { }; const findFirstExisting = (projectRoot: string, candidates: string[]) => - candidates.find(candidate => fs.existsSync(path.join(projectRoot, candidate))); + candidates.find(candidate => inspectProjectFile(projectRoot, candidate).exists); export function planExpoProjectConfiguration(params: { projectRoot: string; @@ -101,10 +116,10 @@ export function planExpoProjectConfiguration(params: { 'app.config.cjs', 'app.config.mjs', ]); - const appJsonPath = path.join(params.projectRoot, 'app.json'); + const appJson = inspectProjectFile(params.projectRoot, 'app.json'); - if (!appConfigFile && fs.existsSync(appJsonPath)) { - const original = fs.readFileSync(appJsonPath, 'utf8'); + if (!appConfigFile && appJson.exists) { + const original = appJson.content; const updated = updateAppJson(original, params.migrateExpoUpdates); if (updated !== original) { changes.push({ @@ -116,15 +131,17 @@ export function planExpoProjectConfiguration(params: { } } - const metroFile = findFirstExisting(params.projectRoot, [ - 'metro.config.js', - 'metro.config.ts', - 'metro.config.cjs', - 'metro.config.mjs', - ]); + const metroFile = findSingleMetroConfig(params.projectRoot); if (metroFile) { - const original = fs.readFileSync(path.join(params.projectRoot, metroFile), 'utf8'); - if (!original.includes('withBundleDropExpo')) { + const original = inspectProjectFile(params.projectRoot, metroFile).content; + if (!hasAuthoritativeMetroWrapper(original, 'withBundleDropExpo')) { + if (hasExecutableMetroWrapperReference(original, 'withBundleDropExpo')) { + throw new Error( + `${metroFile} contains a non-authoritative withBundleDropExpo reference. ` + + 'Remove the dead, aliased, or malformed wrapper before rerunning setup.', + ); + } + assertCommonJsMetroConfig(params.projectRoot, metroFile); changes.push({ file: metroFile, original, @@ -133,18 +150,19 @@ export function planExpoProjectConfiguration(params: { }); } } else { + const newMetroFile = newCommonJsMetroConfigFile(params.projectRoot); changes.push({ - file: 'metro.config.js', + file: newMetroFile, original: null, updated: NEW_EXPO_METRO_CONFIG, reason: 'Create an Expo Metro config with the Bundle Drop wrapper.', }); } - const bundleConfigPath = path.join(params.projectRoot, 'bundle.drop.config.js'); - const bundleConfigExists = fs.existsSync(bundleConfigPath); + const bundleConfigFile = inspectProjectFile(params.projectRoot, 'bundle.drop.config.js'); + const bundleConfigExists = bundleConfigFile.exists; const bundleConfig = bundleConfigExists - ? fs.readFileSync(bundleConfigPath, 'utf8') + ? bundleConfigFile.content : params.bundleConfigContent; if (!bundleConfig) { throw new Error('bundle.drop.config.js is required before Expo configuration can be planned.'); @@ -160,8 +178,9 @@ export function planExpoProjectConfiguration(params: { } if (params.migrateExpoUpdates) { - const packagePath = path.join(params.projectRoot, 'package.json'); - const packageJson = fs.readFileSync(packagePath, 'utf8'); + const packageFile = inspectProjectFile(params.projectRoot, 'package.json'); + if (!packageFile.exists) throw new Error('package.json is required for expo-updates migration.'); + const packageJson = packageFile.content; const updatedPackageJson = updatePackageJson(packageJson); if (updatedPackageJson !== packageJson) { changes.push({ @@ -173,17 +192,15 @@ export function planExpoProjectConfiguration(params: { } } - const fingerprintIgnorePath = path.join(params.projectRoot, '.fingerprintignore'); - const fingerprintIgnore = fs.existsSync(fingerprintIgnorePath) - ? fs.readFileSync(fingerprintIgnorePath, 'utf8') - : ''; + const fingerprintIgnoreFile = inspectProjectFile(params.projectRoot, '.fingerprintignore'); + const fingerprintIgnore = fingerprintIgnoreFile.exists ? fingerprintIgnoreFile.content : ''; if ( EXPO_RUNTIME_SOURCE_PATTERN.test(updatedBundleConfig) && !fingerprintIgnore.split(/\r?\n/).includes(TRANSIENT_GRADLE_KOTLIN_FINGERPRINT_PATTERN) ) { changes.push({ file: '.fingerprintignore', - original: fs.existsSync(fingerprintIgnorePath) ? fingerprintIgnore : null, + original: fingerprintIgnoreFile.exists ? fingerprintIgnore : null, updated: `${fingerprintIgnore.trimEnd()}${fingerprintIgnore.trim() ? '\n' : ''}` + `${TRANSIENT_GRADLE_KOTLIN_FINGERPRINT_PATTERN}\n`, @@ -191,14 +208,15 @@ export function planExpoProjectConfiguration(params: { }); } - const gitignorePath = path.join(params.projectRoot, '.gitignore'); - const gitignore = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : ''; - if (!gitignore.split(/\r?\n/).includes('.bundle-drop/')) { + const gitignoreFile = inspectProjectFile(params.projectRoot, '.gitignore'); + const gitignore = gitignoreFile.exists ? gitignoreFile.content : ''; + const updatedGitignore = addRuntimeDeliveryBootstrapGitignoreRules(gitignore); + if (updatedGitignore !== gitignore) { changes.push({ file: '.gitignore', - original: fs.existsSync(gitignorePath) ? gitignore : null, - updated: `${gitignore.trimEnd()}${gitignore.trim() ? '\n' : ''}.bundle-drop/\n`, - reason: 'Ignore generated build identity and Metro configuration artifacts.', + original: gitignoreFile.exists ? gitignore : null, + updated: updatedGitignore, + reason: 'Commit the public trust bootstrap while ignoring generated runtime artifacts.', }); } @@ -223,6 +241,7 @@ const assertSetupPathAllowed = (file: string) => { file === 'package.json' || file === '.fingerprintignore' || file === '.gitignore' || + file === '.bundle-drop/runtime-delivery.generated.json' || file === 'bundle.drop.config.js' || file === 'app.json' || /^app\.config\.(js|ts|cjs|mjs)$/.test(file) || @@ -234,20 +253,15 @@ const assertSetupPathAllowed = (file: string) => { export function restoreExpoConfiguration(result: ExpoSetupApplyResult) { for (const changed of [...result.changedFiles].reverse()) { - const targetPath = path.join(result.projectRoot, changed.file); - const backupPath = path.join(result.backupDir, changed.file); if (changed.existed) { - fs.copyFileSync(backupPath, targetPath); - } else if (fs.existsSync(targetPath)) { - fs.unlinkSync(targetPath); + restoreProjectFile(result.projectRoot, result.backupDir, changed.file); + } else { + removeProjectFile(result.projectRoot, changed.file); } } if (result.buildReceiptInvalidated) { const receiptFile = path.join('.bundle-drop', 'build-identity.json'); - const targetPath = path.join(result.projectRoot, receiptFile); - const backupPath = path.join(result.backupDir, receiptFile); - fs.ensureDirSync(path.dirname(targetPath)); - fs.copyFileSync(backupPath, targetPath); + restoreProjectFile(result.projectRoot, result.backupDir, receiptFile); } } @@ -257,46 +271,48 @@ export function applyExpoConfigurationChanges(params: { }): ExpoSetupApplyResult { const result: ExpoSetupApplyResult = { projectRoot: params.projectRoot, - backupDir: path.join( - params.projectRoot, - '.bundledrop-backup', - new Date().toISOString().replace(/[:.]/g, '-'), - ), + backupDir: createSafeBackupDirectory(params.projectRoot, 'expo-setup'), changedFiles: [], buildReceiptInvalidated: false, }; try { const receiptFile = path.join('.bundle-drop', 'build-identity.json'); - const receiptPath = path.join(params.projectRoot, receiptFile); - if (fs.existsSync(receiptPath)) { - const backupPath = path.join(result.backupDir, receiptFile); - fs.ensureDirSync(path.dirname(backupPath)); - fs.copyFileSync(receiptPath, backupPath); - fs.unlinkSync(receiptPath); + const receipt = inspectProjectFile(params.projectRoot, receiptFile); + if (receipt.exists) { + writeBackupFile( + result.backupDir, + receiptFile, + receipt.content, + receipt.mode, + ); + removeProjectFile(params.projectRoot, receiptFile); result.buildReceiptInvalidated = true; } for (const change of params.changes) { assertSetupPathAllowed(change.file); - const targetPath = path.join(params.projectRoot, change.file); - const exists = fs.existsSync(targetPath); - if (exists !== (change.original !== null)) { + const target = inspectProjectFile(params.projectRoot, change.file); + if (target.exists !== (change.original !== null)) { throw new Error(`File existence changed since Expo setup preview: ${change.file}`); } - if (exists) { - const current = fs.readFileSync(targetPath, 'utf8'); - if (sha256(current) !== sha256(change.original || '')) { + if (target.exists) { + if (sha256(target.content) !== sha256(change.original || '')) { throw new Error(`File changed since Expo setup preview: ${change.file}`); } - const backupPath = path.join(result.backupDir, change.file); - fs.ensureDirSync(path.dirname(backupPath)); - fs.copyFileSync(targetPath, backupPath); + writeBackupFile( + result.backupDir, + change.file, + target.content, + target.mode, + ); } - result.changedFiles.push({ file: change.file, existed: exists }); - fs.ensureDirSync(path.dirname(targetPath)); - const temporaryPath = `${targetPath}.bundledrop-tmp`; - fs.writeFileSync(temporaryPath, change.updated, 'utf8'); - fs.renameSync(temporaryPath, targetPath); + result.changedFiles.push({ file: change.file, existed: target.exists }); + writeProjectFileAtomically( + params.projectRoot, + change.file, + change.updated, + target.mode, + ); } return result; } catch (error) { diff --git a/src/CLI/scripts/expo/package-manager.ts b/src/CLI/scripts/expo/package-manager.ts index 3c84ecb..f586130 100644 --- a/src/CLI/scripts/expo/package-manager.ts +++ b/src/CLI/scripts/expo/package-manager.ts @@ -1,6 +1,11 @@ -import fs from 'fs-extra'; -import path from 'path'; import { spawnSync } from 'child_process'; +import { + createSafeBackupDirectory, + inspectProjectFile, + removeProjectFile, + restoreProjectFile, + writeBackupFile, +} from '../safe-file-transaction'; export type SupportedPackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; @@ -11,25 +16,47 @@ const LOCKFILES: Record = { bun: ['bun.lock', 'bun.lockb'], }; +const inspectPackageManagerInputs = (projectRoot: string) => { + const packageFile = inspectProjectFile(projectRoot, 'package.json'); + if (!packageFile.exists) throw new Error('package.json is required for dependency migration.'); + const lockfiles = new Set(); + for (const lockfile of [...new Set(Object.values(LOCKFILES).flat())]) { + if (inspectProjectFile(projectRoot, lockfile).exists) lockfiles.add(lockfile); + } + return { + packageJson: JSON.parse(packageFile.content) as { packageManager?: string }, + lockfiles, + }; +}; + export function detectPackageManager(projectRoot: string): SupportedPackageManager { - const packageJson = JSON.parse( - fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'), - ) as { packageManager?: string }; + const { packageJson, lockfiles } = inspectPackageManagerInputs(projectRoot); const declared = packageJson.packageManager?.split('@')[0]; if (declared && declared in LOCKFILES) return declared as SupportedPackageManager; for (const manager of ['pnpm', 'yarn', 'bun', 'npm'] as const) { - if (LOCKFILES[manager].some(lockfile => fs.existsSync(path.join(projectRoot, lockfile)))) { + if (LOCKFILES[manager].some(lockfile => lockfiles.has(lockfile))) { return manager; } } return 'npm'; } -export function expoUpdatesRemovalCommand(manager: SupportedPackageManager): string[] { +const dependencyRemovalCommand = ( + manager: SupportedPackageManager, + dependency: string, +): string[] => { return manager === 'npm' - ? ['npm', 'uninstall', 'expo-updates', '--legacy-peer-deps'] - : [manager, 'remove', 'expo-updates']; + ? ['npm', 'uninstall', dependency, '--legacy-peer-deps'] + : [manager, 'remove', dependency]; +}; + +export function expoUpdatesRemovalCommand(manager: SupportedPackageManager): string[] { + return dependencyRemovalCommand(manager, 'expo-updates'); +} + +export function codePushRemovalCommand(manager: SupportedPackageManager): string[] { + return dependencyRemovalCommand(manager, 'react-native-code-push'); } export type DependencyMigrationBackup = { @@ -41,36 +68,36 @@ export type DependencyMigrationBackup = { export function restoreDependencyMigration(backup: DependencyMigrationBackup) { for (const file of backup.files) { - fs.copyFileSync(path.join(backup.backupDir, file), path.join(backup.projectRoot, file)); + restoreProjectFile(backup.projectRoot, backup.backupDir, file); } for (const file of backup.possibleCreatedFiles) { - const targetPath = path.join(backup.projectRoot, file); - if (!backup.files.includes(file) && fs.existsSync(targetPath)) { - fs.unlinkSync(targetPath); + if (!backup.files.includes(file)) { + removeProjectFile(backup.projectRoot, file); } } } -export function removeExpoUpdatesWithPackageManager(projectRoot: string): DependencyMigrationBackup { +const removeDependencyWithPackageManager = (params: { + projectRoot: string; + dependency: string; + backupName: string; +}): DependencyMigrationBackup => { + const { projectRoot, dependency, backupName } = params; const manager = detectPackageManager(projectRoot); - const command = expoUpdatesRemovalCommand(manager); + const command = dependencyRemovalCommand(manager, dependency); const files = ['package.json', ...LOCKFILES[manager]].filter(file => - fs.existsSync(path.join(projectRoot, file)), + inspectProjectFile(projectRoot, file).exists, ); const backup: DependencyMigrationBackup = { projectRoot, - backupDir: path.join( - projectRoot, - '.bundledrop-backup', - `expo-updates-${new Date().toISOString().replace(/[:.]/g, '-')}`, - ), + backupDir: createSafeBackupDirectory(projectRoot, backupName), files, possibleCreatedFiles: LOCKFILES[manager], }; for (const file of files) { - const backupPath = path.join(backup.backupDir, file); - fs.ensureDirSync(path.dirname(backupPath)); - fs.copyFileSync(path.join(projectRoot, file), backupPath); + const target = inspectProjectFile(projectRoot, file); + if (!target.exists) throw new Error(`Package file disappeared before migration: ${file}`); + writeBackupFile(backup.backupDir, file, target.content, target.mode); } const result = spawnSync(command[0], command.slice(1), { @@ -81,24 +108,46 @@ export function removeExpoUpdatesWithPackageManager(projectRoot: string): Depend if (result.error || result.status !== 0) { restoreDependencyMigration(backup); throw new Error( - `Failed to remove expo-updates with ${manager}. Package files were restored; ` + + `Failed to remove ${dependency} with ${manager}. Package files were restored; ` + 'run your package manager install if node_modules needs repair.', ); } - const packageJson = JSON.parse( - fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'), - ) as Record | undefined>; + const migratedPackage = inspectProjectFile(projectRoot, 'package.json'); + if (!migratedPackage.exists) { + restoreDependencyMigration(backup); + throw new Error(`${manager} completed but package.json is missing.`); + } + const packageJson = JSON.parse(migratedPackage.content) as Record< + string, + Record | undefined + >; const stillDeclared = [ packageJson.dependencies, packageJson.devDependencies, packageJson.optionalDependencies, packageJson.peerDependencies, - ].some(dependencies => Boolean(dependencies?.['expo-updates'])); + ].some(dependencies => Boolean(dependencies?.[dependency])); if (stillDeclared) { restoreDependencyMigration(backup); - throw new Error(`${manager} completed but expo-updates is still declared in package.json.`); + throw new Error(`${manager} completed but ${dependency} is still declared in package.json.`); } return backup; +}; + +export function removeExpoUpdatesWithPackageManager(projectRoot: string): DependencyMigrationBackup { + return removeDependencyWithPackageManager({ + projectRoot, + dependency: 'expo-updates', + backupName: 'expo-updates', + }); +} + +export function removeCodePushWithPackageManager(projectRoot: string): DependencyMigrationBackup { + return removeDependencyWithPackageManager({ + projectRoot, + dependency: 'react-native-code-push', + backupName: 'code-push', + }); } diff --git a/src/CLI/scripts/init-config.ts b/src/CLI/scripts/init-config.ts index 1651904..e8e0eb1 100644 --- a/src/CLI/scripts/init-config.ts +++ b/src/CLI/scripts/init-config.ts @@ -1,17 +1,43 @@ import axios from 'axios'; import chalk from 'chalk'; import fs from 'fs-extra'; +import { createRequire } from 'module'; import path from 'path'; import prompts from 'prompts'; import type { ProjectType } from '../../expo'; +import { + createGeneratedRuntimeDeliveryBootstrap, + ensureRuntimeDeliveryBootstrapGitignore, + normalizeRuntimeDeliveryBootstrap, + removeGeneratedRuntimeDeliveryBootstrap, + runtimeDeliveryBootstrapPath, + serializeGeneratedRuntimeDeliveryBootstrap, + writeGeneratedRuntimeDeliveryBootstrap, + type GeneratedRuntimeDeliveryBootstrap, +} from '../../runtime-delivery/bootstrapConfig'; +import { + inspectProjectFile, + writeProjectFileAtomically, +} from './safe-file-transaction'; const DOCS_PROJECT_CREATION_URL = 'https://bundledrop.app/docs/project-creation'; const DOCS_INSTALLATION_URL = 'https://bundledrop.app/docs/installation'; type Project = { name: string; slug: string; orgId: string }; type Org = { slug: string; orgId: string; name: string }; -type ProjectCredentials = { projectSlug?: string; downloadApiKey?: string; downloadKeyHint?: string }; +type LegacyRuntimeDeliveryMode = 'v1' | 'shadow' | 'v2'; +type ProjectCredentials = { + projectId: string; + projectSlug: string; + orgId: string; + orgSlug: string; + /** Temporary compatibility field returned by older backends. */ + runtimeDeliveryMode?: LegacyRuntimeDeliveryMode; + downloadApiKey?: string; + downloadKeyHint?: string | null; + runtimeDelivery?: unknown | null; +}; type BundleDropConfigValues = { projectType?: ProjectType; @@ -22,10 +48,86 @@ type BundleDropConfigValues = { apiKey: string; }; +export { normalizeRuntimeDeliveryBootstrap } from '../../runtime-delivery/bootstrapConfig'; + function normalizeServerUrl(url: string): string { return url.replace(/\/$/, ''); } +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function validateProjectCredentials( + value: unknown, + expected: { orgSlug: string; projectSlug: string }, +): ProjectCredentials { + if (!isRecord(value)) { + throw new Error( + 'Project credentials response is malformed. Existing local credentials were preserved.', + ); + } + + const projectId = typeof value.projectId === 'string' ? value.projectId.trim() : ''; + const projectSlug = typeof value.projectSlug === 'string' ? value.projectSlug.trim() : ''; + const orgId = typeof value.orgId === 'string' ? value.orgId.trim() : ''; + const orgSlug = typeof value.orgSlug === 'string' ? value.orgSlug.trim() : ''; + const runtimeDeliveryMode = value.runtimeDeliveryMode; + const downloadApiKey = value.downloadApiKey; + const downloadKeyHint = value.downloadKeyHint; + if (!projectId || !projectSlug || !orgId || !orgSlug) { + throw new Error( + 'Project credentials response is missing its authoritative project identity. ' + + 'Existing local credentials were preserved.', + ); + } + if ( + runtimeDeliveryMode !== undefined && + !['v1', 'shadow', 'v2'].includes(runtimeDeliveryMode as string) + ) { + throw new Error( + 'Project credentials response contains an invalid legacy runtime delivery mode. ' + + 'Existing local credentials were preserved.', + ); + } + if (projectSlug !== expected.projectSlug || orgSlug !== expected.orgSlug) { + throw new Error( + `Project credentials identity mismatch: expected ${expected.orgSlug}/${expected.projectSlug}, ` + + `received ${orgSlug}/${projectSlug}. Existing local credentials were preserved.`, + ); + } + if (downloadApiKey !== undefined && typeof downloadApiKey !== 'string') { + throw new Error( + 'Project credentials response contains an invalid download key. Existing local credentials were preserved.', + ); + } + if ( + downloadKeyHint !== undefined && + downloadKeyHint !== null && + typeof downloadKeyHint !== 'string' + ) { + throw new Error( + 'Project credentials response contains an invalid download key hint. Existing local credentials were preserved.', + ); + } + + return { + projectId, + projectSlug, + orgId, + orgSlug, + runtimeDeliveryMode: runtimeDeliveryMode as LegacyRuntimeDeliveryMode | undefined, + downloadApiKey: typeof downloadApiKey === 'string' ? downloadApiKey : undefined, + downloadKeyHint: + typeof downloadKeyHint === 'string' + ? downloadKeyHint + : downloadKeyHint === null + ? null + : undefined, + runtimeDelivery: value.runtimeDelivery, + }; +} + function createBundleDropConfig(values: BundleDropConfigValues): string { const projectTypeConfig = values.projectType ? ` projectType: ${JSON.stringify(values.projectType)},\n` @@ -78,20 +180,23 @@ ${projectTypeConfig} serverUrl: ${JSON.stringify(values.serverUrl)}, async function fetchProjectCredentials(params: { serverUrl: string; + orgSlug: string; projectSlug: string; authToken: string; }): Promise { const baseUrl = normalizeServerUrl(params.serverUrl); - const url = `${baseUrl}/projects/${encodeURIComponent(params.projectSlug)}/credentials`; + const url = + `${baseUrl}/projects/${encodeURIComponent(params.projectSlug)}/credentials` + + `?orgSlug=${encodeURIComponent(params.orgSlug)}`; + let response: { data?: unknown }; try { - const res = await axios.get(url, { + response = await axios.get(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${params.authToken}`, }, timeout: 15000, }); - return res.data || null; } catch (err) { console.log( chalk.yellow( @@ -101,6 +206,111 @@ async function fetchProjectCredentials(params: { ); return null; } + return validateProjectCredentials(response.data, { + orgSlug: params.orgSlug, + projectSlug: params.projectSlug, + }); +} + +type RuntimeDeliveryBootstrapResult = { + bootstrapPath?: string; + bootstrapContent?: string; + bootstrap?: GeneratedRuntimeDeliveryBootstrap; + runtimeDeliveryAvailable?: boolean; + bootstrapRetired?: boolean; +}; + +function createBootstrapResult(params: { + projectRoot: string; + serverUrl: string; + orgSlug: string; + projectSlug: string; + credentials: ProjectCredentials | null; +}): RuntimeDeliveryBootstrapResult { + if (!params.credentials) return {}; + const legacyMode = params.credentials.runtimeDeliveryMode; + if ( + params.credentials.runtimeDelivery === null || + legacyMode === 'v1' || + legacyMode === 'shadow' + ) { + return { + runtimeDeliveryAvailable: false, + bootstrapRetired: true, + }; + } + const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ + identity: { + serverUrl: params.serverUrl, + orgSlug: params.orgSlug, + projectSlug: params.projectSlug, + projectId: params.credentials.projectId, + orgId: params.credentials.orgId, + }, + runtimeDelivery: params.credentials.runtimeDelivery, + }); + if (!bootstrap) { + return legacyMode === 'v2' || params.credentials.runtimeDelivery !== undefined + ? { runtimeDeliveryAvailable: true } + : {}; + } + return { + bootstrap, + bootstrapPath: runtimeDeliveryBootstrapPath(params.projectRoot), + bootstrapContent: serializeGeneratedRuntimeDeliveryBootstrap(bootstrap), + runtimeDeliveryAvailable: true, + }; +} + +async function persistBootstrap( + projectRoot: string, + result: RuntimeDeliveryBootstrapResult, +): Promise { + if (result.bootstrap) { + const bootstrapPath = await writeGeneratedRuntimeDeliveryBootstrap({ + projectRoot, + bootstrap: result.bootstrap, + }); + await ensureRuntimeDeliveryBootstrapGitignore(projectRoot); + console.log(chalk.green(`✅ Synced Bundle Drop runtime delivery bootstrap at ${bootstrapPath}`)); + return; + } + if (result.bootstrapRetired) { + const bootstrapPath = await removeGeneratedRuntimeDeliveryBootstrap(projectRoot); + console.log( + chalk.green( + bootstrapPath + ? `✅ Removed the runtime delivery bootstrap because delivery is disabled for this project: ${bootstrapPath}` + : '✅ Runtime delivery is disabled for this project; no bootstrap is present.', + ), + ); + } +} + +function loadExistingConfig(configPath: string, content: string): BundleDropConfigValues | null { + try { + const moduleLike = { exports: {} as Record }; + const localRequire = createRequire(configPath); + const load = new Function('module', 'exports', 'require', '__dirname', '__filename', content); + load(moduleLike, moduleLike.exports, localRequire, path.dirname(configPath), configPath); + const config = moduleLike.exports as { + projectType?: ProjectType; + serverUrl?: string; + org?: { slug?: string }; + project?: { name?: string; slug?: string; apiKey?: string }; + }; + if (!config.serverUrl || !config.org?.slug || !config.project?.slug) return null; + return { + projectType: config.projectType, + serverUrl: normalizeServerUrl(config.serverUrl), + orgSlug: config.org.slug, + projectName: config.project.name || '', + projectSlug: config.project.slug, + apiKey: config.project.apiKey || '', + }; + } catch { + return null; + } } function findProjectRoot(startDir: string): string { @@ -134,18 +344,46 @@ export async function initConfig(params: { projectType?: ProjectType; }) { const configPath = getBundleDropConfigPath(); + const projectRoot = path.dirname(configPath); + const existingConfigFile = inspectProjectFile(projectRoot, 'bundle.drop.config.js'); - if (fs.existsSync(configPath)) { + if (existingConfigFile.exists) { + const existing = loadExistingConfig(configPath, existingConfigFile.content); + let bootstrapResult: RuntimeDeliveryBootstrapResult = {}; + if (existing && params.authToken) { + const credentials = await fetchProjectCredentials({ + serverUrl: existing.serverUrl, + orgSlug: existing.orgSlug, + projectSlug: existing.projectSlug, + authToken: params.authToken, + }); + bootstrapResult = createBootstrapResult({ + projectRoot, + serverUrl: existing.serverUrl, + orgSlug: existing.orgSlug, + projectSlug: existing.projectSlug, + credentials, + }); + if (!params.dryRun) await persistBootstrap(projectRoot, bootstrapResult); + } console.log( chalk.yellow( - `ℹ️ bundle.drop.config.js already exists at ${configPath}. If this is accidental, delete it and rerun the init/login.\n` + - `See ${DOCS_INSTALLATION_URL}`, + `ℹ️ Preserving existing bundle.drop.config.js at ${configPath}.` + + (bootstrapResult.bootstrap + ? ' Runtime delivery bootstrap is ready to sync.' + : bootstrapResult.bootstrapRetired + ? ' Runtime delivery setup is synchronized.' + : ` No valid runtime delivery bootstrap was returned; see ${DOCS_INSTALLATION_URL}`), ), ); return { configPath, - content: fs.readFileSync(configPath, 'utf8'), + content: existingConfigFile.content, created: false, + serverUrl: existing?.serverUrl, + orgSlug: existing?.orgSlug, + projectSlug: existing?.projectSlug, + ...bootstrapResult, }; } @@ -222,10 +460,12 @@ export async function initConfig(params: { } let resolvedServerUrl = normalizeServerUrl(params.serverUrl); - let apiKey = params.downloadApiKey || ''; + let apiKey = params.authToken ? '' : params.downloadApiKey || ''; + let credentials: ProjectCredentials | null = null; if (projectSlug && params.authToken) { - const credentials = await fetchProjectCredentials({ + credentials = await fetchProjectCredentials({ serverUrl: resolvedServerUrl, + orgSlug, projectSlug, authToken: params.authToken, }); @@ -263,9 +503,17 @@ export async function initConfig(params: { }); console.log(chalk.cyan(`Dry-run bundle.drop.config.js preview:\n${previewContent}`)); } else { - await fs.writeFile(configPath, content, 'utf8'); + writeProjectFileAtomically(projectRoot, 'bundle.drop.config.js', content); console.log(chalk.green(`✅ Created bundle.drop.config.js at ${configPath}`)); } + const bootstrapResult = createBootstrapResult({ + projectRoot, + serverUrl: resolvedServerUrl, + orgSlug, + projectSlug, + credentials, + }); + if (!params.dryRun) await persistBootstrap(projectRoot, bootstrapResult); return { configPath, content, @@ -273,6 +521,7 @@ export async function initConfig(params: { serverUrl: resolvedServerUrl, orgSlug, projectSlug, + ...bootstrapResult, }; } diff --git a/src/CLI/scripts/login-cli.ts b/src/CLI/scripts/login-cli.ts index 21954d5..a6644fa 100644 --- a/src/CLI/scripts/login-cli.ts +++ b/src/CLI/scripts/login-cli.ts @@ -9,7 +9,7 @@ import { AddressInfo } from 'net'; import { spawn } from 'child_process'; import { Socket } from 'net'; -import { getBundleDropConfigPath, hasExistingBundleDropConfig, initConfig } from './init-config'; +import { hasExistingBundleDropConfig, initConfig } from './init-config'; import { runPostInitPrompts } from './post-init'; import { detectProjectType } from '../../expo'; @@ -390,29 +390,49 @@ const login = async () => { chalk.cyan(`${authFile.user.firstName} ${authFile.user.lastName}`) ); - if (hasExistingBundleDropConfig()) { - console.log( - chalk.gray( - `ℹ️ Found existing bundle.drop.config.js at ${getBundleDropConfigPath()}. Skipping setup prompts.` - ) - ); + const hadConfig = hasExistingBundleDropConfig(); + if (hadConfig) { + await initConfig({ + serverUrl: baseUrl, + projects: authFile.projects, + organizations: authFile.organizations, + downloadApiKey: authFile.downloadApiKey, + authToken: authFile.token, + dryRun: false, + }); return; } const projectType = detectProjectType({ projectRoot: process.cwd() }); - const configResult = await initConfig({ serverUrl: baseUrl, projects: authFile.projects, organizations: authFile.organizations, downloadApiKey: authFile.downloadApiKey, authToken: authFile.token, + dryRun: true, projectType, }); - if (configResult?.created) { + if (configResult && fs.existsSync(configResult.configPath)) { createdConfigPath = configResult.configPath; } - await runPostInitPrompts({ projectType }); + await runPostInitPrompts({ + projectType, + ...(configResult?.bootstrapContent + ? { runtimeDeliveryBootstrap: { content: configResult.bootstrapContent } } + : {}), + ...(configResult + ? { + virtualConfig: { + content: configResult.content, + serverUrl: configResult.serverUrl, + orgSlug: configResult.orgSlug, + projectSlug: configResult.projectSlug, + authToken: authFile.token, + }, + } + : {}), + }); } catch (error) { const message = axios.isAxiosError(error) diff --git a/src/CLI/scripts/metro-config-authority.ts b/src/CLI/scripts/metro-config-authority.ts new file mode 100644 index 0000000..8a8efb9 --- /dev/null +++ b/src/CLI/scripts/metro-config-authority.ts @@ -0,0 +1,365 @@ +import path from 'path'; + +import { stripCommentsAndStrings } from './native-setup-contract'; +import { inspectProjectFile } from './safe-file-transaction'; + +export const METRO_CONFIG_FILES = [ + 'metro.config.js', + 'metro.config.cjs', + 'metro.config.mjs', + 'metro.config.ts', +] as const; + +export type MetroWrapper = 'withBundleDrop' | 'withBundleDropExpo'; + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const stripJavaScriptComments = (source: string) => { + let output = ''; + let quote = ''; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const next = source[index + 1]; + if (quote) { + output += character; + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'" || character === '`') { + quote = character; + output += character; + continue; + } + if (character === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') index += 1; + output += '\n'; + continue; + } + if (character === '/' && next === '*') { + index += 2; + while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) { + if (source[index] === '\n') output += '\n'; + index += 1; + } + index += 1; + continue; + } + output += character; + } + return output; +}; + +const javascriptStringRanges = (source: string) => { + const ranges: Array<{ start: number; end: number }> = []; + let quote = ''; + let start = -1; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (!quote && (character === '"' || character === "'" || character === '`')) { + quote = character; + start = index; + continue; + } + if (!quote) continue; + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) { + ranges.push({ start, end: index + 1 }); + quote = ''; + start = -1; + } + } + if (quote) ranges.push({ start, end: source.length }); + return ranges; +}; + +const executableMatches = (source: string, pattern: RegExp) => { + const ranges = javascriptStringRanges(source); + return [...source.matchAll(pattern)].filter(match => { + const position = match.index || 0; + return ranges.every(range => position < range.start || position >= range.end); + }); +}; + +const structuralSource = (source: string) => { + const characters = [...source]; + for (const range of javascriptStringRanges(source)) { + for (let index = range.start; index < range.end; index += 1) { + if (characters[index] !== '\n') characters[index] = ' '; + } + } + return characters.join(''); +}; + +const isUnconditionalTopLevel = (source: string, position: number) => { + const structure = structuralSource(source); + const prefix = structure.slice(0, position); + const depth = [...prefix].reduce((value, character) => { + if ('{[('.includes(character)) return value + 1; + if ('}])'.includes(character)) return value - 1; + return value; + }, 0); + if (depth !== 0) return false; + const statementPrefix = prefix.slice(Math.max( + prefix.lastIndexOf('\n'), + prefix.lastIndexOf(';'), + prefix.lastIndexOf('}'), + ) + 1); + return !/\b(?:if|for|while|switch|catch)\s*\(|=>/.test(statementPrefix); +}; + +const hasExactBindingEntry = (bindingBody: string, name: string) => + bindingBody.split(',').map(entry => entry.trim()).includes(name); + +const topLevelNamedBindingCount = ( + source: string, + name: string, + moduleNames: string[], +) => { + const withoutComments = stripJavaScriptComments(source); + let count = 0; + for (const moduleName of moduleNames) { + const escapedModule = escapeRegExp(moduleName); + const commonJs = new RegExp( + `\\b(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*` + + `require\\(\\s*(['"])${escapedModule}\\2\\s*\\)`, + 'g', + ); + const moduleImport = new RegExp( + `\\bimport\\s*\\{([^}]*)\\}\\s*from\\s*(['"])${escapedModule}\\2`, + 'g', + ); + for (const match of [ + ...executableMatches(withoutComments, commonJs), + ...executableMatches(withoutComments, moduleImport), + ]) { + if ( + isUnconditionalTopLevel(withoutComments, match.index || 0) && + hasExactBindingEntry(match[1], name) + ) { + count += 1; + } + } + } + return count; +}; + +const hasPackageWrapperBinding = (source: string, wrapper: MetroWrapper) => + topLevelNamedBindingCount( + source, + wrapper, + ['@gfean/react-native-bundle-drop/metro'], + ) === 1; + +export const hasExecutableMetroWrapperReference = ( + source: string, + wrapper: MetroWrapper, +) => new RegExp(`\\b${escapeRegExp(wrapper)}\\b`).test(stripCommentsAndStrings(source)); + +export const hasExecutableMetroModuleReference = (source: string, moduleName: string) => { + const withoutComments = stripJavaScriptComments(source); + const escapedModule = escapeRegExp(moduleName); + return executableMatches( + withoutComments, + new RegExp( + `\\brequire\\(\\s*(['"])${escapedModule}\\1\\s*\\)|` + + `\\bfrom\\s*(['"])${escapedModule}\\2`, + 'g', + ), + ).some(match => isUnconditionalTopLevel(withoutComments, match.index || 0)); +}; + +const findBalancedParenthesis = (source: string, opening: number) => { + let depth = 0; + for (let index = opening; index < source.length; index += 1) { + if (source[index] === '(') depth += 1; + if (source[index] === ')') depth -= 1; + if (depth === 0) return index; + } + return -1; +}; + +const splitTopLevelArguments = (source: string) => { + const argumentsList: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index < source.length; index += 1) { + if ('{[('.includes(source[index])) depth += 1; + else if ('}])'.includes(source[index])) depth -= 1; + else if (source[index] === ',' && depth === 0) { + argumentsList.push(source.slice(start, index).trim()); + start = index + 1; + } + } + argumentsList.push(source.slice(start).trim()); + return argumentsList; +}; + +const expressionEnd = (source: string, start: number) => { + let depth = 0; + for (let index = start; index < source.length; index += 1) { + const character = source[index]; + if ('{[('.includes(character)) depth += 1; + else if ('}])'.includes(character)) depth -= 1; + else if ((character === ';' || character === '\n') && depth === 0) return index; + } + return source.length; +}; + +const topLevelVariableInitializer = (source: string, name: string, before: number) => { + const structure = structuralSource(source); + const pattern = new RegExp(`\\b(?:const|let|var)\\s+${escapeRegExp(name)}\\s*=`, 'g'); + const declarations = [...structure.matchAll(pattern)].filter(match => + (match.index || 0) < before && isUnconditionalTopLevel(structure, match.index || 0) + ); + if (declarations.length !== 1) return null; + const initializerStart = (declarations[0].index || 0) + declarations[0][0].length; + return structure.slice(initializerStart, expressionEnd(structure, initializerStart)).trim(); +}; + +const hasSupportedMetroExpression = ( + source: string, + expression: string, + before: number, + visited = new Set(), +): boolean => { + const value = expression.trim(); + if (!value || /^(?:null|undefined|false|true)$/.test(value)) return false; + if (/^\{[\s\S]*\}$/.test(value)) return true; + if (/^getDefaultConfig\s*\([^)]*\)$/.test(value)) { + return topLevelNamedBindingCount( + source, + 'getDefaultConfig', + ['@react-native/metro-config', 'expo/metro-config'], + ) === 1; + } + if (/^mergeConfig\s*\([\s\S]*\)$/.test(value)) { + if (topLevelNamedBindingCount( + source, + 'mergeConfig', + ['@react-native/metro-config'], + ) !== 1) return false; + const opening = value.indexOf('('); + const closing = findBalancedParenthesis(value, opening); + if (opening < 0 || closing !== value.length - 1) return false; + const argumentsList = splitTopLevelArguments(value.slice(opening + 1, closing)); + return Boolean(argumentsList[0]) && hasSupportedMetroExpression( + source, + argumentsList[0], + before, + visited, + ); + } + if (/^[A-Za-z_$][\w$]*$/.test(value)) { + if (visited.has(value)) return false; + const initializer = topLevelVariableInitializer(source, value, before); + return initializer !== null && hasSupportedMetroExpression( + source, + initializer, + before, + new Set([...visited, value]), + ); + } + return false; +}; + +const hasSupportedEarlierExport = (source: string, before: number) => { + const structure = structuralSource(source); + const exports = [...structure.matchAll(/\bmodule\s*\.\s*exports\s*=\s*/g)].filter(match => + (match.index || 0) < before && isUnconditionalTopLevel(structure, match.index || 0) + ); + if (exports.length !== 1) return false; + const expressionStart = (exports[0].index || 0) + exports[0][0].length; + const expression = structure.slice( + expressionStart, + expressionEnd(structure, expressionStart), + ); + return hasSupportedMetroExpression(source, expression, exports[0].index || 0); +}; + +const hasSupportedMetroBase = ( + source: string, + base: string, + exportPosition: number, +) => { + if (/^module\s*\.\s*exports\s*\|\|\s*\{\s*\}$/.test(base)) return true; + if (/^module\s*\.\s*exports$/.test(base)) { + return hasSupportedEarlierExport(source, exportPosition); + } + return hasSupportedMetroExpression(source, base, exportPosition); +}; + +export const hasAuthoritativeMetroWrapper = ( + source: string, + wrapper: MetroWrapper, +) => { + if (!hasPackageWrapperBinding(source, wrapper)) return false; + const executable = stripJavaScriptComments(source); + const structure = structuralSource(executable); + const exports = [ + ...structure.matchAll(/\bmodule\s*\.\s*exports\s*=\s*/g), + ...structure.matchAll(/\bexport\s+default\s+/g), + ].filter(match => isUnconditionalTopLevel(structure, match.index || 0)) + .sort((left, right) => (left.index || 0) - (right.index || 0)); + const wrapperExports = exports.filter(match => { + const expressionStart = (match.index || 0) + match[0].length; + return new RegExp(`^\\s*${escapeRegExp(wrapper)}\\s*\\(`).test( + structure.slice(expressionStart), + ); + }); + if (wrapperExports.length !== 1 || wrapperExports[0] !== exports[exports.length - 1]) { + return false; + } + const wrapperExport = wrapperExports[0]; + const expressionStart = (wrapperExport.index || 0) + wrapperExport[0].length; + const opening = structure.indexOf('(', expressionStart + wrapper.length); + const closing = opening < 0 ? -1 : findBalancedParenthesis(structure, opening); + if (closing < 0) return false; + if (!/^\s*;?\s*$/.test(structure.slice(closing + 1))) return false; + const argumentsList = splitTopLevelArguments(structure.slice(opening + 1, closing)); + if (!argumentsList[0]) return false; + return hasSupportedMetroBase( + executable, + argumentsList[0], + wrapperExport.index || 0, + ); +}; + +export const findSingleMetroConfig = (projectRoot: string) => { + const existing = METRO_CONFIG_FILES.filter(file => inspectProjectFile(projectRoot, file).exists); + if (existing.length > 1) { + throw new Error( + `Multiple Metro config files were found (${existing.join(', ')}). ` + + 'Keep one authoritative Metro config before running Bundle Drop setup.', + ); + } + return existing[0]; +}; + +const packageUsesEsm = (projectRoot: string) => { + const packageFile = inspectProjectFile(projectRoot, 'package.json'); + if (!packageFile.exists) return false; + const manifest = JSON.parse(packageFile.content) as { type?: unknown }; + return manifest.type === 'module'; +}; + +export const newCommonJsMetroConfigFile = (projectRoot: string) => + packageUsesEsm(projectRoot) ? 'metro.config.cjs' : 'metro.config.js'; + +export const assertCommonJsMetroConfig = (projectRoot: string, relativePath: string) => { + const extension = path.extname(relativePath); + if ( + extension === '.mjs' || + extension === '.ts' || + (extension === '.js' && packageUsesEsm(projectRoot)) + ) { + throw new Error( + `${relativePath} uses ESM or TypeScript syntax. Bundle Drop will not append CommonJS ` + + 'to it automatically; wrap its exported config manually, then rerun setup.', + ); + } +}; diff --git a/src/CLI/scripts/native-entrypoint-authority.ts b/src/CLI/scripts/native-entrypoint-authority.ts new file mode 100644 index 0000000..0447478 --- /dev/null +++ b/src/CLI/scripts/native-entrypoint-authority.ts @@ -0,0 +1,376 @@ +import fs from 'fs'; +import path from 'path'; + +import { inspectProjectFile } from './safe-file-transaction'; +import { stripCommentsAndStrings } from './native-setup-contract'; + +export type NativeEntrypointPlatform = 'android' | 'ios'; + +const toPosix = (filePath: string) => filePath.split(path.sep).join('/'); + +const maskXmlComments = (source: string) => { + let masked = ''; + let cursor = 0; + + while (cursor < source.length) { + const commentStart = source.indexOf('', commentStart + 4); + const maskedEnd = commentEnd < 0 ? source.length : commentEnd + 3; + masked += ' '.repeat(maskedEnd - commentStart); + cursor = maskedEnd; + } + + return masked; +}; + +const androidPackageFromPath = (relativePath: string) => { + const match = relativePath.match( + /^android\/app\/src\/main\/(?:java|kotlin)\/(.+)\/MainApplication\.(?:java|kt)$/, + ); + return match ? match[1].split('/').join('.') : ''; +}; + +const androidEntrypointClass = (relativePath: string, content: string) => { + const declaredPackage = content.match(/(?:^|\n)\s*package\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*;?/)?.[1]; + const packageName = declaredPackage || androidPackageFromPath(relativePath); + return packageName ? `${packageName}.MainApplication` : 'MainApplication'; +}; + +const androidManifestFiles = (projectRoot: string) => { + const sourceRoot = path.join(projectRoot, 'android/app/src'); + let sourceSets: string[]; + try { + const stat = fs.lstatSync(sourceRoot); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error('Android source-set root is not a regular project directory.'); + } + sourceSets = fs.readdirSync(sourceRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + return sourceSets.flatMap(sourceSet => { + if (/^(?:androidTest|benchmark|test|testFixtures|unitTest)$/i.test(sourceSet)) return []; + const relativeDirectory = `android/app/src/${sourceSet}`; + const directoryStat = fs.lstatSync(path.join(projectRoot, relativeDirectory)); + if (directoryStat.isSymbolicLink()) { + throw new Error(`Android source-set path is a symbolic link: ${relativeDirectory}`); + } + if (!directoryStat.isDirectory()) return []; + const relativeManifest = `${relativeDirectory}/AndroidManifest.xml`; + return inspectProjectFile(projectRoot, relativeManifest).exists ? [relativeManifest] : []; + }).sort(); +}; + +const androidGradleNamespace = (projectRoot: string) => { + for (const file of ['android/app/build.gradle', 'android/app/build.gradle.kts']) { + const buildFile = inspectProjectFile(projectRoot, file); + if (!buildFile.exists) continue; + const namespace = buildFile.content.match( + /(?:^|\n)\s*namespace\s*(?:=\s*)?["']([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)["']/, + )?.[1]; + if (namespace) return namespace; + } + return ''; +}; + +const androidAuthorityIssue = (projectRoot: string, entrypoint: string): string | null => { + const entrypointFile = inspectProjectFile(projectRoot, entrypoint); + if (!entrypointFile.exists) return `Android application entrypoint is missing: ${entrypoint}`; + const entrypointClass = androidEntrypointClass(entrypoint, entrypointFile.content); + const manifests = androidManifestFiles(projectRoot); + const mainManifest = 'android/app/src/main/AndroidManifest.xml'; + if (!manifests.includes(mainManifest)) { + return 'The main AndroidManifest.xml is missing; application startup authority is unknown.'; + } + const gradleNamespace = androidGradleNamespace(projectRoot); + for (const manifestPath of manifests) { + const manifest = inspectProjectFile(projectRoot, manifestPath); + const manifestSource = maskXmlComments(manifest.content); + const applicationTags = [...manifestSource.matchAll(/]*>/gi)]; + if (applicationTags.length > 1) { + return `${manifestPath} has multiple application declarations.`; + } + const applicationTag = applicationTags[0]?.[0]; + if (!applicationTag) { + if (manifestPath === mainManifest) { + return `${manifestPath} has no application declaration to bind the native entrypoint.`; + } + continue; + } + const applicationNames = [...applicationTag.matchAll( + /(?:^|\s)android:name\s*=\s*["']([^"']+)["']/gi, + )].map(match => match[1].trim()); + if (applicationNames.length > 1) { + return `${manifestPath} has multiple android:name application authorities.`; + } + if (!applicationNames.length) { + if (manifestPath === mainManifest) { + return `${manifestPath} does not explicitly name the application class.`; + } + continue; + } + const applicationName = applicationNames[0]; + if (/\$\{|[^A-Za-z0-9_.$]/.test(applicationName)) { + return `${manifestPath} application class is not statically resolvable: ${applicationName}`; + } + const manifestPackage = manifestSource.match( + /]*\s+package\s*=\s*["']([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)["']/i, + )?.[1] || ''; + const namespace = manifestPackage || gradleNamespace; + if ((!applicationName.includes('.') || applicationName.startsWith('.')) && !namespace) { + return `${manifestPath} uses a relative application class without a manifest package or Gradle namespace.`; + } + const expectedClass = applicationName.startsWith('.') + ? `${namespace}${applicationName}` + : applicationName.includes('.') + ? applicationName + : `${namespace}.${applicationName}`; + if (expectedClass !== entrypointClass) { + return `${manifestPath} starts ${expectedClass}, not ${entrypointClass}.`; + } + } + return null; +}; + +const findIosAuthoritySources = (projectRoot: string) => { + const iosRoot = path.join(projectRoot, 'ios'); + try { + const rootStat = fs.lstatSync(iosRoot); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + throw new Error('iOS source root is not a regular project directory.'); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { mainFiles: [], swiftFiles: [] }; + } + throw error; + } + + const pending = ['ios']; + const mainFiles: string[] = []; + const swiftFiles: string[] = []; + let visited = 0; + while (pending.length) { + const relativeDirectory = pending.pop()!; + for (const entry of fs.readdirSync(path.join(projectRoot, relativeDirectory), { withFileTypes: true })) { + visited += 1; + if (visited > 5000) throw new Error('iOS principal source scan exceeded 5000 entries.'); + if (entry.name === 'Pods' || entry.name === 'build' || entry.name === 'DerivedData') continue; + const relativePath = toPosix(path.join(relativeDirectory, entry.name)); + const stat = fs.lstatSync(path.join(projectRoot, relativePath)); + if (stat.isSymbolicLink()) { + throw new Error(`iOS principal source path is a symbolic link: ${relativePath}`); + } + if (stat.isDirectory()) pending.push(relativePath); + else if (stat.isFile()) { + if (/\.swift$/i.test(entry.name)) swiftFiles.push(relativePath); + if (/^main\.(?:m|mm|swift)$/i.test(entry.name)) mainFiles.push(relativePath); + } + } + } + return { mainFiles: mainFiles.sort(), swiftFiles: swiftFiles.sort() }; +}; + +const findBalancedCall = (source: string, name: string): string[] => { + const calls: string[] = []; + let searchFrom = 0; + while (searchFrom < source.length) { + const nameIndex = source.indexOf(name, searchFrom); + if (nameIndex < 0) break; + const previousCharacter = source[nameIndex - 1] || ''; + const nextCharacter = source[nameIndex + name.length] || ''; + if (/\w/.test(previousCharacter) || /\w/.test(nextCharacter)) { + searchFrom = nameIndex + name.length; + continue; + } + const opening = source.indexOf('(', nameIndex + name.length); + if (opening < 0) break; + let depth = 0; + let quote = ''; + let escaped = false; + for (let index = opening; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === '(') depth += 1; + if (character === ')') depth -= 1; + if (depth === 0) { + calls.push(source.slice(opening + 1, index)); + searchFrom = index + 1; + break; + } + } + if (depth !== 0) break; + } + return calls; +}; + +const stripCComments = (source: string) => { + let output = ''; + let quote = ''; + let escaped = false; + let blockCommentDepth = 0; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const next = source[index + 1]; + if (quote) { + output += character; + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'") { + quote = character; + output += character; + continue; + } + if (character === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') index += 1; + output += '\n'; + continue; + } + if (character === '/' && next === '*') { + blockCommentDepth = 1; + index += 2; + while (index < source.length && blockCommentDepth > 0) { + if (source[index] === '/' && source[index + 1] === '*') { + blockCommentDepth += 1; + index += 2; + continue; + } + if (source[index] === '*' && source[index + 1] === '/') { + blockCommentDepth -= 1; + index += 2; + continue; + } + if (source[index] === '\n') output += '\n'; + index += 1; + } + index -= 1; + continue; + } + output += character; + } + return output; +}; + +const topLevelCallArguments = (body: string) => { + const argumentsList: string[] = []; + const stack: string[] = []; + let quote = ''; + let escaped = false; + let start = 0; + for (let index = 0; index < body.length; index += 1) { + const character = body[index]; + if (quote) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'") quote = character; + else if ('([{'.includes(character)) stack.push(character); + else if (')]}'.includes(character)) stack.pop(); + else if (character === ',' && !stack.length) { + argumentsList.push(body.slice(start, index).trim()); + start = index + 1; + } + } + argumentsList.push(body.slice(start).trim()); + return argumentsList; +}; + +const exactPrincipalFromCall = (call: string, swift: boolean) => { + const argumentsList = topLevelCallArguments(call); + if (argumentsList.length !== 4) return null; + const principal = argumentsList[3]; + if (swift) { + if (/^NSStringFromClass\s*\(\s*AppDelegate\.self\s*\)$/.test(principal)) { + return 'AppDelegate'; + } + return /^"AppDelegate"$/.test(principal) ? 'AppDelegate' : null; + } + if (/^NSStringFromClass\s*\(\s*\[\s*AppDelegate\s+class\s*\]\s*\)$/.test(principal)) { + return 'AppDelegate'; + } + return /^@"AppDelegate"$/.test(principal) ? 'AppDelegate' : null; +}; + +const objcAuthorityIssue = (projectRoot: string, mainFiles: string[]): string | null => { + if (mainFiles.length !== 1 || !/\.m{1,2}$/i.test(mainFiles[0])) { + return mainFiles.length + ? 'Multiple or conflicting iOS application principal sources were found.' + : 'Objective-C main/UIApplicationMain principal source is missing.'; + } + const mainFile = inspectProjectFile(projectRoot, mainFiles[0]); + const calls = findBalancedCall(stripCComments(mainFile.content), 'UIApplicationMain'); + if (calls.length !== 1) return 'Exactly one UIApplicationMain call is required.'; + return exactPrincipalFromCall(calls[0], false) + ? null + : `UIApplicationMain argument 4 does not select AppDelegate.`; +}; + +const swiftAuthorityIssue = ( + projectRoot: string, + entrypoint: string, + mainFiles: string[], + swiftFiles: string[], +) => { + const principals = swiftFiles.flatMap(file => { + const source = stripCommentsAndStrings(inspectProjectFile(projectRoot, file).content); + return [...source.matchAll( + /@(?:main|UIApplicationMain)\b[\s\n]*(?:(?:final|public|private|internal|open)\s+)*(?:class|struct)\s+([A-Za-z_$][\w$]*)/g, + )].map(match => ({ file, name: match[1] })); + }); + if ( + principals.length === 1 && + principals[0].name === 'AppDelegate' && + principals[0].file === entrypoint + ) { + return mainFiles.length + ? 'An annotated Swift AppDelegate conflicts with an external main source.' + : null; + } + if (principals.length) return 'Swift principal annotation does not uniquely select AppDelegate.'; + if (mainFiles.length !== 1 || !mainFiles[0].endsWith('/main.swift')) { + return mainFiles.length + ? 'Multiple or conflicting iOS application principal sources were found.' + : 'Swift @main/UIApplicationMain principal is missing.'; + } + const mainFile = inspectProjectFile(projectRoot, mainFiles[0]); + const calls = findBalancedCall(stripCComments(mainFile.content), 'UIApplicationMain'); + if (calls.length !== 1 || !exactPrincipalFromCall(calls[0], true)) { + return 'Swift main UIApplicationMain argument 4 does not select AppDelegate.'; + } + return null; +}; + +export const findNativeEntrypointAuthorityIssue = ( + projectRoot: string, + platform: NativeEntrypointPlatform, + entrypoints: string[], +): string | null => { + if (!entrypoints.length) return null; + if (entrypoints.length > 1) { + return `Multiple ${platform} application entrypoints were found; startup authority is ambiguous.`; + } + if (platform === 'android') return androidAuthorityIssue(projectRoot, entrypoints[0]); + const { mainFiles, swiftFiles } = findIosAuthoritySources(projectRoot); + if (entrypoints[0].endsWith('.swift')) { + return swiftAuthorityIssue(projectRoot, entrypoints[0], mainFiles, swiftFiles); + } + return objcAuthorityIssue(projectRoot, mainFiles); +}; diff --git a/src/CLI/scripts/native-setup-contract.ts b/src/CLI/scripts/native-setup-contract.ts index f9c59ab..1283edf 100644 --- a/src/CLI/scripts/native-setup-contract.ts +++ b/src/CLI/scripts/native-setup-contract.ts @@ -3,10 +3,19 @@ export const ANDROID_BUNDLE_DROP_PATHS = 'com.bundledrop.BundleDropNativePaths'; export const IOS_BUNDLE_DROP_MODULE = 'BundleDrop'; export const IOS_BUNDLE_DROP_LOCATOR_HEADER = 'BundleDrop/BundleDropLocator.h'; -export const stripCommentsAndStrings = (content: string) => { +const sanitizeSource = (content: string, preserveStrings: boolean) => { let code = ''; - let state: 'code' | 'line-comment' | 'block-comment' | 'single' | 'double' | 'template' = 'code'; + let state: + | 'code' + | 'line-comment' + | 'block-comment' + | 'single' + | 'double' + | 'triple-double' + | 'template' = 'code'; let escaped = false; + let blockCommentDepth = 0; + let tripleQuoteHashes = ''; for (let index = 0; index < content.length; index += 1) { const character = content[index]; @@ -19,18 +28,38 @@ export const stripCommentsAndStrings = (content: string) => { continue; } if (state === 'block-comment') { + if (character === '/' && nextCharacter === '*') { + blockCommentDepth += 1; + index += 1; + continue; + } if (character === '*' && nextCharacter === '/') { - state = 'code'; + blockCommentDepth -= 1; + if (blockCommentDepth === 0) state = 'code'; index += 1; } continue; } + if (state === 'triple-double') { + const closingDelimiter = `"""${tripleQuoteHashes}`; + if (content.startsWith(closingDelimiter, index)) { + if (preserveStrings) code += closingDelimiter; + index += closingDelimiter.length - 1; + state = 'code'; + tripleQuoteHashes = ''; + } else if (preserveStrings) { + code += character; + } + continue; + } if (state !== 'code') { if (escaped) { + if (preserveStrings) code += character; escaped = false; continue; } if (character === '\\') { + if (preserveStrings) code += character; escaped = true; continue; } @@ -38,6 +67,7 @@ export const stripCommentsAndStrings = (content: string) => { (state === 'single' && character === "'") || (state === 'double' && character === '"') || (state === 'template' && character === '`'); + if (preserveStrings) code += character; if (closesState) state = 'code'; continue; } @@ -48,19 +78,38 @@ export const stripCommentsAndStrings = (content: string) => { } if (character === '/' && nextCharacter === '*') { state = 'block-comment'; + blockCommentDepth = 1; index += 1; continue; } + const swiftRawTripleQuote = content.slice(index).match(/^(#+)"""/); + if (character === '"' && content.startsWith('"""', index)) { + state = 'triple-double'; + tripleQuoteHashes = ''; + if (preserveStrings) code += '"""'; + index += 2; + continue; + } + if (swiftRawTripleQuote) { + state = 'triple-double'; + tripleQuoteHashes = swiftRawTripleQuote[1]; + if (preserveStrings) code += swiftRawTripleQuote[0]; + index += swiftRawTripleQuote[0].length - 1; + continue; + } if (character === "'") { state = 'single'; + if (preserveStrings) code += character; continue; } if (character === '"') { state = 'double'; + if (preserveStrings) code += character; continue; } if (character === '`') { state = 'template'; + if (preserveStrings) code += character; continue; } code += character; @@ -69,55 +118,11 @@ export const stripCommentsAndStrings = (content: string) => { return code; }; -const hasJavaOrKotlinReference = ( - code: string, - qualifiedName: string, - member: string, -) => { - const escapedName = qualifiedName.replace(/\./g, '\\.'); - return ( - new RegExp(`\\bimport\\s+${escapedName}\\s*;?`).test(code) || - code.includes(`${qualifiedName}.${member}`) - ); -}; +export const stripCommentsAndStrings = (content: string) => sanitizeSource(content, false); +export const stripComments = (content: string) => sanitizeSource(content, true); -export const hasBareAndroidStartupIntegration = (code: string) => { - const nativeCode = stripCommentsAndStrings(code); - const usesModuleResolver = - nativeCode.includes('getJSBundleFile') && - nativeCode.includes('BundleDropModule.resolveJSBundleFile') && - hasJavaOrKotlinReference( - nativeCode, - ANDROID_BUNDLE_DROP_MODULE, - 'resolveJSBundleFile', - ); - const usesNativePaths = - nativeCode.includes('BundleDropNativePaths.getDownloadedBundlePath') && - hasJavaOrKotlinReference( - nativeCode, - ANDROID_BUNDLE_DROP_PATHS, - 'getDownloadedBundlePath', - ); - return usesModuleResolver || usesNativePaths; -}; - -export const hasBareIosStartupIntegration = ( - filePath: string, - code: string, -) => { - const nativeCode = stripCommentsAndStrings(code); - if (filePath.endsWith('.swift')) { - const usesLocator = - nativeCode.includes('bundleURL') && - nativeCode.includes('BundleDropLocator.bundleURL()'); - return usesLocator && - new RegExp(`\\bimport\\s+${IOS_BUNDLE_DROP_MODULE}\\b`).test(nativeCode); - } - - const usesLocator = - (nativeCode.includes('sourceURLForBridge') || nativeCode.includes('bundleURL')) && - nativeCode.includes('[BundleDropLocator bundleURL]'); - const escapedHeader = IOS_BUNDLE_DROP_LOCATOR_HEADER.replace(/[/.]/g, '\\$&'); - return usesLocator && - new RegExp(`#import\\s*[<\"]${escapedHeader}[>\"]`).test(nativeCode); -}; +export { + findMissingBareNativeStartupStructure, + hasBareAndroidStartupIntegration, + hasBareIosStartupIntegration, +} from './native-startup-validator'; diff --git a/src/CLI/scripts/native-startup-validator.ts b/src/CLI/scripts/native-startup-validator.ts new file mode 100644 index 0000000..9294a9c --- /dev/null +++ b/src/CLI/scripts/native-startup-validator.ts @@ -0,0 +1,1258 @@ +import { + ANDROID_BUNDLE_DROP_MODULE, + ANDROID_BUNDLE_DROP_PATHS, + IOS_BUNDLE_DROP_LOCATOR_HEADER, + IOS_BUNDLE_DROP_MODULE, + stripCommentsAndStrings, +} from './native-setup-contract'; + +type DeclaredMethod = { + declarationStart: number; + declaration: string; + body: string; +}; + +type SourceType = { + name: string; + declaration: string; + declarationStart: number; + openingBrace: number; + closingBrace: number; +}; + +type ObjcImplementation = { + name: string; + category?: string; + bodyStart: number; + bodyEnd: number; +}; + +type NamedSourceBlock = { + name: string; + declaration: string; + declarationStart: number; + bodyStart: number; + bodyEnd: number; +}; + +const findBalancedBlockEnd = ( + code: string, + openingIndex: number, + openingCharacter = '{', + closingCharacter = '}', +) => { + let depth = 0; + for (let index = openingIndex; index < code.length; index += 1) { + if (code[index] === openingCharacter) depth += 1; + if (code[index] === closingCharacter) depth -= 1; + if (depth === 0) return index; + } + return -1; +}; + +const extractBalancedBlock = (code: string, openingBrace: number) => { + const closingBrace = findBalancedBlockEnd(code, openingBrace); + return closingBrace < 0 ? '' : code.slice(openingBrace + 1, closingBrace); +}; + +const extractExpressionBody = (code: string, equals: number, firstLineEnd: number) => { + let expressionEnd = firstLineEnd; + let braceDepth = [...code.slice(equals + 1, firstLineEnd)] + .reduce((depth, character) => { + if (character === '{') return depth + 1; + if (character === '}') return depth - 1; + return depth; + }, 0); + let nextLineStart = firstLineEnd < code.length ? firstLineEnd + 1 : code.length; + while (nextLineStart < code.length) { + const nextLineEnd = code.indexOf('\n', nextLineStart); + const lineEnd = nextLineEnd < 0 ? code.length : nextLineEnd; + const line = code.slice(nextLineStart, lineEnd).trim(); + if ( + braceDepth === 0 && ( + line === '}' || + /^(?:@|init\b|class\b|object\b|interface\b|companion\s+object\b)/.test(line) || + /^(?:(?:override|private|protected|public|internal|final|open)\s+)*(?:fun|val|var)\b/.test(line) + ) + ) { + break; + } + expressionEnd = lineEnd; + braceDepth = [...code.slice(nextLineStart, lineEnd)] + .reduce((depth, character) => { + if (character === '{') return depth + 1; + if (character === '}') return depth - 1; + return depth; + }, braceDepth); + nextLineStart = nextLineEnd < 0 ? code.length : nextLineEnd + 1; + } + return code.slice(equals + 1, expressionEnd); +}; + +const findDeclaredMethods = (code: string, declarationPattern: RegExp): DeclaredMethod[] => + [...code.matchAll(declarationPattern)].map(match => { + const delimiter = match[1]; + const declarationStart = match.index || 0; + const delimiterIndex = declarationStart + match[0].lastIndexOf(delimiter); + const lineEndIndex = code.indexOf('\n', delimiterIndex); + const lineEnd = lineEndIndex < 0 ? code.length : lineEndIndex; + return { + declarationStart, + declaration: match[0], + body: delimiter === '{' + ? extractBalancedBlock(code, delimiterIndex) + : extractExpressionBody(code, delimiterIndex, lineEnd), + }; + }); + +const KOTLIN_JS_BUNDLE_METHOD = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|protected|internal|override|final|open)\s+)*fun\s+getJSBundleFile\s*\(\s*\)\s*(?::\s*[^=\{\n]+)?\s*([={])/g; +const JAVA_JS_BUNDLE_METHOD = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|protected|final|synchronized)\s+)*(?:[\w$.<>\[\]?@]+\s+)+getJSBundleFile\s*\(\s*\)\s*(?:throws\s+[^\{\n]+)?\s*(\{)/g; +const ANDROID_ON_CREATE_METHOD = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|protected|internal|override|final|open)\s+)*(?:fun\s+onCreate|void\s+onCreate)\s*\(\s*\)\s*(\{)/g; +const SWIFT_BUNDLE_URL_METHOD = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|internal|fileprivate|open|override|final)\s+)*func\s+bundleURL\s*\(\s*\)\s*(?:async\s+)?(?:throws\s+)?(?:->\s*[^\{\n]+)?\s*(\{)/g; +const SWIFT_APP_LAUNCH_METHOD = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|internal|fileprivate|open|override|final)\s+)*func\s+application\s*\(\s*_\s+[A-Za-z_]\w*\s*:\s*UIApplication\s*,\s*didFinishLaunchingWithOptions\s+[A-Za-z_]\w*\s*:[\s\S]*?\)\s*(?:async\s+)?(?:throws\s+)?->\s*Bool\s*(\{)/g; +const SWIFT_SOURCE_URL_METHOD = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|internal|fileprivate|open|override|final)\s+)*func\s+sourceURL\s*\(\s*for\s+[A-Za-z_]\w*\s*:\s*RCTBridge\s*\)\s*(?:async\s+)?(?:throws\s+)?(?:->\s*[^\{\n]+)?\s*(\{)/g; +const OBJC_SOURCE_URL_METHOD = + /(?:^|\n)\s*-\s*\([^\n)]*\)\s*sourceURLForBridge\s*:\s*\([^\n)]*\)\s*\w+\s*(\{)/g; +const OBJC_BUNDLE_URL_METHOD = + /(?:^|\n)\s*-\s*\([^\n)]*\)\s*bundleURL\s*(\{)/g; +const OBJC_SELF_BUNDLE_URL_DELEGATION = + /\[\s*self\s+bundleURL\s*\]|\bself\s*\.\s*bundleURL\b/; +const KOTLIN_REACT_HOST_PROPERTY = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|protected|internal|override|final|open)\s+)*val\s+(reactHost)\s*:\s*ReactHost\s+by\s+lazy\s*(\{)/g; +const KOTLIN_LEGACY_NATIVE_HOST_INITIALIZER = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|protected|internal|override|final|open)\s+)*(?:val|var)\s+([A-Za-z_]\w*)\s*:\s*ReactNativeHost\s*=\s*object\s*:\s*(?:Default)?ReactNativeHost\s*\([^\n{]*\)\s*(\{)/g; +const JAVA_LEGACY_NATIVE_HOST_INITIALIZER = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|protected|static|final|volatile|transient)\s+)*ReactNativeHost\s+([A-Za-z_]\w*)\s*=\s*new\s+(?:Default)?ReactNativeHost\s*\([^\n{]*\)\s*(\{)/g; +const JAVA_GET_REACT_NATIVE_HOST_METHOD = + /(?:^|\n)\s*(?:@\w+(?:\([^\n)]*\))?\s*)*(?:(?:public|private|protected|final|synchronized)\s+)*ReactNativeHost\s+getReactNativeHost\s*\(\s*\)\s*(\{)/g; + +const findSourceTypes = (code: string): SourceType[] => + [...code.matchAll(/\b(?:class|object)\s+([A-Za-z_]\w*)[^{}]*\{/g)].map(match => { + const openingBrace = (match.index || 0) + match[0].lastIndexOf('{'); + return { + name: match[1], + declaration: match[0], + declarationStart: match.index || 0, + openingBrace, + closingBrace: findBalancedBlockEnd(code, openingBrace), + }; + }).filter(type => type.closingBrace >= 0); + +const findContainingType = (code: string, position: number): SourceType | null => + findSourceTypes(code) + .filter(type => type.openingBrace < position && position < type.closingBrace) + .sort((left, right) => + (left.closingBrace - left.openingBrace) - (right.closingBrace - right.openingBrace) + )[0] || null; + +const isDirectMemberOfType = ( + code: string, + position: number, + owner: SourceType, +) => { + if (position <= owner.openingBrace || position >= owner.closingBrace) return false; + let braceDepth = 0; + for (let index = owner.openingBrace + 1; index < position; index += 1) { + if (code[index] === '{') braceDepth += 1; + else if (code[index] === '}') braceDepth -= 1; + } + return braceDepth === 0; +}; + +const findNamedType = (code: string, name: string) => { + const matches = findSourceTypes(code).filter(type => type.name === name); + return matches.length === 1 ? matches[0] : null; +}; + +const findNamedSourceBlocks = ( + code: string, + declarationPattern: RegExp, +): NamedSourceBlock[] => [...code.matchAll(declarationPattern)].flatMap(match => { + const openingBrace = (match.index || 0) + match[0].lastIndexOf('{'); + const closingBrace = findBalancedBlockEnd(code, openingBrace); + return closingBrace < 0 ? [] : [{ + name: match[1], + declaration: match[0], + declarationStart: match.index || 0, + bodyStart: openingBrace + 1, + bodyEnd: closingBrace, + }]; +}); + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const trimTrailingClosingBraces = (value: string) => { + let suffix = value.trim(); + while (suffix.endsWith('}')) { + suffix = suffix.slice(0, -1).trimEnd(); + } + return suffix.trim(); +}; + +const findObjcImplementations = (code: string): ObjcImplementation[] => + [...code.matchAll(/@implementation\s+([A-Za-z_]\w*)(?:\s*\(([^)]*)\))?/g)] + .flatMap(match => { + const bodyStart = (match.index || 0) + match[0].length; + const endPattern = /@end\b/g; + endPattern.lastIndex = bodyStart; + const endMatch = endPattern.exec(code); + if (!endMatch) return []; + return [{ + name: match[1], + category: match[2]?.trim() || undefined, + bodyStart, + bodyEnd: endMatch.index, + }]; + }); + +const methodBelongsToObjcImplementation = ( + method: DeclaredMethod, + implementation: ObjcImplementation, +) => implementation.bodyStart <= method.declarationStart && + method.declarationStart < implementation.bodyEnd; + +const findCallSites = (code: string, functionName: string) => { + const calls: Array<{ argumentsBody: string; start: number; end: number }> = []; + const callPattern = new RegExp(`\\b${escapeRegExp(functionName)}\\s*\\(`, 'g'); + for (const match of code.matchAll(callPattern)) { + const openingParenthesis = (match.index || 0) + match[0].lastIndexOf('('); + const closingParenthesis = findBalancedBlockEnd(code, openingParenthesis, '(', ')'); + if (closingParenthesis >= 0) { + calls.push({ + argumentsBody: code.slice(openingParenthesis + 1, closingParenthesis), + start: match.index || 0, + end: closingParenthesis + 1, + }); + } + } + return calls; +}; + +const hasAuthoritativeLazyHostCall = ( + body: string, + acceptsArguments: (argumentsBody: string) => boolean, +) => findCallSites(body, 'getDefaultReactHost').some(call => { + if (!acceptsArguments(call.argumentsBody)) return false; + const before = body.slice(0, call.start); + const after = body.slice(call.end); + if (/\breturn\s*@\s*lazy\b/.test(before)) return false; + if (!after.trim()) return true; + + const assignment = /\b(?:val|var)\s+([A-Za-z_]\w*)\s*=\s*$/.exec(before); + return Boolean(assignment && new RegExp( + `^\\s*;?\\s*${escapeRegExp(assignment[1])}\\s*;?\\s*$`, + ).test(after)); +}); + +const hasExactAndroidModuleReference = (code: string) => { + const escapedName = escapeRegExp(ANDROID_BUNDLE_DROP_MODULE); + return new RegExp(`(?:^|\\n)[ \\t]*import\\s+${escapedName}[ \\t]*;?[ \\t]*(?:\\n|$)`).test(code) || + code.includes(`${ANDROID_BUNDLE_DROP_MODULE}.resolveJSBundleFile`); +}; + +const hasExactAndroidNativePathsReference = (code: string) => { + const escapedName = escapeRegExp(ANDROID_BUNDLE_DROP_PATHS); + return new RegExp(`\\bimport\\s+${escapedName}\\s*;?`).test(code) || + code.includes(`${ANDROID_BUNDLE_DROP_PATHS}.getDownloadedBundlePath`); +}; + +const hasNativeCodePushResidue = (content: string) => { + const code = stripCommentsAndStrings(content); + return /\bCodePush\b|\bcom\.microsoft\.codepush\.react\b/i.test(code); +}; + +const androidStartupMethod = (nativeCode: string) => { + const kotlinMethods = findDeclaredMethods(nativeCode, KOTLIN_JS_BUNDLE_METHOD) + .map(method => ({ ...method, language: 'kotlin' as const })); + const javaMethods = findDeclaredMethods(nativeCode, JAVA_JS_BUNDLE_METHOD) + .map(method => ({ ...method, language: 'java' as const })); + const methods = [...new Map( + [...kotlinMethods, ...javaMethods].map(method => [method.declarationStart, method]), + ).values()]; + if (methods.length !== 1) return null; + + const method = methods[0]; + const owner = findContainingType(nativeCode, method.declarationStart); + if (owner?.name !== 'MainApplication') return null; + return { method, owner }; +}; + +const methodsOwnedBy = ( + code: string, + declarationPattern: RegExp, + ownerName: string, +) => { + const owner = findNamedType(code, ownerName); + if (!owner) return []; + return findDeclaredMethods(code, declarationPattern).filter(method => + findContainingType(code, method.declarationStart)?.declarationStart === + owner.declarationStart && + isDirectMemberOfType(code, method.declarationStart, owner) + ); +}; + +const hasAndroidLifecycleIntegrity = (nativeCode: string, owner: SourceType) => { + const ownerBody = nativeCode.slice(owner.openingBrace + 1, owner.closingBrace); + const lifecycleSignals = [ + 'super.onCreate', + 'loadReactNative', + 'SoLoader.init', + 'DefaultNewArchitectureEntryPoint.load', + ].filter(signal => ownerBody.includes(signal)); + if (!lifecycleSignals.length) return true; + + const onCreateMethods = methodsOwnedBy( + nativeCode, + ANDROID_ON_CREATE_METHOD, + 'MainApplication', + ); + return onCreateMethods.length === 1 && + lifecycleSignals.every(signal => onCreateMethods[0].body.includes(signal)); +}; + +const authoritativeReactHostBlocks = (nativeCode: string) => { + const mainApplication = findNamedType(nativeCode, 'MainApplication'); + if (!mainApplication) return []; + const blocks = findNamedSourceBlocks(nativeCode, KOTLIN_REACT_HOST_PROPERTY).filter(block => + /\boverride\s+val\s+reactHost\b/.test(block.declaration) && + findContainingType(nativeCode, block.declarationStart)?.declarationStart === + mainApplication.declarationStart && + isDirectMemberOfType(nativeCode, block.declarationStart, mainApplication) + ); + return blocks.length === 1 ? blocks : []; +}; + +const hasDirectReactHostConnection = (nativeCode: string) => { + const [reactHost] = authoritativeReactHostBlocks(nativeCode); + if (!reactHost) return false; + const body = nativeCode.slice(reactHost.bodyStart, reactHost.bodyEnd); + return hasAuthoritativeLazyHostCall(body, argumentsBody => + /\bjsBundleFilePath\s*=\s*(?:this\.)?getJSBundleFile\s*\(\s*\)/.test(argumentsBody) + ); +}; + +const NATIVE_PATHS_CONTEXT = + '(?:this(?:@MainApplication)?|[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*)'; +const NATIVE_PATHS_CALL = + `(?:com\\.bundledrop\\.)?BundleDropNativePaths\\s*\\.\\s*` + + `getDownloadedBundlePath\\s*\\(\\s*${NATIVE_PATHS_CONTEXT}\\s*\\)`; + +const hasDirectNativePathsHostConnection = (nativeCode: string) => { + const [reactHost] = authoritativeReactHostBlocks(nativeCode); + if (!reactHost) return false; + const body = nativeCode.slice(reactHost.bodyStart, reactHost.bodyEnd); + return hasAuthoritativeLazyHostCall(body, argumentsBody => + new RegExp(`\\bjsBundleFilePath\\s*=\\s*${NATIVE_PATHS_CALL}`).test(argumentsBody) + ); +}; + +const hasNativePathsResolverCall = (body: string) => new RegExp(`\\b${NATIVE_PATHS_CALL}`).test(body); + +const isAndroidNativeOverride = ( + startup: NonNullable>, +) => startup.method.language === 'kotlin' + ? /\boverride\s+fun\b/.test(startup.method.declaration) + : /@Override\b/.test(startup.method.declaration) || + /\b(?:public|protected)\b/.test(startup.method.declaration); + +const legacyAndroidHostBlocks = (nativeCode: string) => [ + ...findNamedSourceBlocks(nativeCode, KOTLIN_LEGACY_NATIVE_HOST_INITIALIZER), + ...findNamedSourceBlocks(nativeCode, JAVA_LEGACY_NATIVE_HOST_INITIALIZER), +]; + +const isAuthoritativeLegacyAndroidHost = ( + nativeCode: string, + host: NamedSourceBlock, +) => { + const mainApplication = findNamedType(nativeCode, 'MainApplication'); + if ( + !mainApplication || + findContainingType(nativeCode, host.declarationStart)?.declarationStart !== + mainApplication.declarationStart || + !isDirectMemberOfType(nativeCode, host.declarationStart, mainApplication) + ) { + return false; + } + if ( + host.name === 'reactNativeHost' && + /\boverride\s+(?:val|var)\s+reactNativeHost\b/.test(host.declaration) + ) { + return true; + } + const kotlinGetters = [...nativeCode.matchAll( + /(?:^|\n)\s*override\s+(?:val|var)\s+reactNativeHost\s*:\s*ReactNativeHost\s*\n?\s*get\s*\(\s*\)\s*=\s*(?:this\.)?([A-Za-z_]\w*)\b/g, + )].filter(match => + findContainingType(nativeCode, match.index || 0)?.declarationStart === + mainApplication.declarationStart && + isDirectMemberOfType(nativeCode, match.index || 0, mainApplication) + ); + if (kotlinGetters.length === 1 && kotlinGetters[0][1] === host.name) return true; + + const javaGetters = methodsOwnedBy( + nativeCode, + JAVA_GET_REACT_NATIVE_HOST_METHOD, + 'MainApplication', + ); + if (javaGetters.length !== 1) return false; + const returnedHosts = [...javaGetters[0].body.matchAll( + /\breturn\s+(?:this\.)?([A-Za-z_]\w*)\s*;/g, + )].map(match => match[1]); + return returnedHosts.length === 1 && returnedHosts[0] === host.name; +}; + +const isResolverMethodConnectedToAndroidStartup = ( + nativeCode: string, + startup: NonNullable>, +) => { + const containingLegacyHosts = legacyAndroidHostBlocks(nativeCode).filter(host => + host.bodyStart <= startup.method.declarationStart && + startup.method.declarationStart < host.bodyEnd + ); + if (containingLegacyHosts.length) { + const authoritativeHosts = legacyAndroidHostBlocks(nativeCode).filter(host => + isAuthoritativeLegacyAndroidHost(nativeCode, host) + ); + return containingLegacyHosts.length === 1 && + authoritativeHosts.length === 1 && + authoritativeHosts[0].bodyStart === containingLegacyHosts[0].bodyStart && + isAndroidNativeOverride(startup); + } + return startup.method.language === 'kotlin' && + /\bprivate\s+fun\b/.test(startup.method.declaration) && + hasDirectReactHostConnection(nativeCode); +}; + +type SourceRange = { start: number; end: number }; +type ResolverLanguage = 'kotlin' | 'java' | 'swift' | 'objc'; + +const findBuildConfigDebugOnlyRanges = (source: string): SourceRange[] => { + const ranges: SourceRange[] = []; + const conditions = /\bif\s*\(\s*(!\s*)?BuildConfig\.DEBUG\s*\)/g; + for (const match of source.matchAll(conditions)) { + const negated = Boolean(match[1]); + const conditionEnd = (match.index || 0) + match[0].length; + let branchStart = conditionEnd; + while (/\s/.test(source[branchStart] || '')) branchStart += 1; + + let trueStart = branchStart; + let trueEnd = branchStart; + let elseStart = -1; + let elseEnd = -1; + if (source[branchStart] === '{') { + const closingBrace = findBalancedBlockEnd(source, branchStart); + if (closingBrace < 0) continue; + trueStart = branchStart + 1; + trueEnd = closingBrace; + const elseMatch = /^\s*else\b/.exec(source.slice(closingBrace + 1)); + if (elseMatch) { + let cursor = closingBrace + 1 + elseMatch[0].length; + while (/\s/.test(source[cursor] || '')) cursor += 1; + if (source[cursor] === '{') { + const closingElse = findBalancedBlockEnd(source, cursor); + if (closingElse >= 0) { + elseStart = cursor + 1; + elseEnd = closingElse; + } + } else { + elseStart = cursor; + const end = source.slice(cursor).search(/[;\n]/); + elseEnd = end < 0 ? source.length : cursor + end; + } + } + } else { + const tail = source.slice(branchStart); + const elseMatch = /\belse\b/.exec(tail); + const statementEnd = tail.search(/[;\n]/); + trueEnd = elseMatch + ? branchStart + elseMatch.index + : statementEnd < 0 ? source.length : branchStart + statementEnd; + if (elseMatch) { + elseStart = branchStart + elseMatch.index + elseMatch[0].length; + const end = source.slice(elseStart).search(/[;\n]/); + elseEnd = end < 0 ? source.length : elseStart + end; + } + } + if (!negated) ranges.push({ start: trueStart, end: trueEnd }); + if (negated && elseStart >= 0) ranges.push({ start: elseStart, end: elseEnd }); + } + return ranges; +}; + +const sourceWithoutDebugPreprocessorBranches = (source: string) => { + const output: string[] = []; + const stack: Array<{ initial: 'debug' | 'release' | 'other'; debugOnly: boolean }> = []; + for (const line of source.split('\n')) { + const directive = /^\s*#\s*(if|ifdef|ifndef|elseif|elif|else|endif)\b(.*)$/i.exec(line); + const kind = directive?.[1].toLowerCase(); + const condition = directive?.[2] || ''; + const debugCondition = /\bDEBUG\b/i.test(condition) + ? /!\s*(?:defined\s*\()?\s*DEBUG\b|\bifndef\b/i.test(`${kind} ${condition}`) + ? 'release' as const + : 'debug' as const + : 'other' as const; + if (kind === 'if' || kind === 'ifdef' || kind === 'ifndef') { + stack.push({ initial: debugCondition, debugOnly: debugCondition === 'debug' }); + continue; + } + if ((kind === 'elseif' || kind === 'elif') && stack.length) { + stack[stack.length - 1].debugOnly = debugCondition === 'debug'; + continue; + } + if (kind === 'else' && stack.length) { + stack[stack.length - 1].debugOnly = stack[stack.length - 1].initial === 'release'; + continue; + } + if (kind === 'endif' && stack.length) { + stack.pop(); + continue; + } + if (stack.every(frame => !frame.debugOnly)) { + output.push(line); + } + } + return output.join('\n'); +}; + +const releaseSource = (body: string) => { + const hasCompoundDebugDirective = body.split('\n').some(line => { + const directive = /^\s*#\s*(if|ifdef|ifndef|elseif|elif)\b(.*)$/i.exec(line); + if (!directive || !/\bDEBUG\b/i.test(directive[2])) return false; + const condition = directive[2].trim(); + return !/^(?:!\s*)?(?:defined\s*\(\s*)?DEBUG\s*\)?$/i.test(condition); + }); + if (hasCompoundDebugDirective) return ''; + const withoutPreprocessorDebug = sourceWithoutDebugPreprocessorBranches(body); + const characters = [...withoutPreprocessorDebug]; + for (const condition of withoutPreprocessorDebug.matchAll( + /\bif\s*\(\s*!?\s*BuildConfig\.DEBUG\s*\)/g, + )) { + const start = condition.index || 0; + for (let index = start; index < start + condition[0].length; index += 1) { + if (characters[index] !== '\n') characters[index] = ' '; + } + } + for (const range of findBuildConfigDebugOnlyRanges(withoutPreprocessorDebug)) { + for (let index = range.start; index < range.end; index += 1) { + if (characters[index] !== '\n') characters[index] = ' '; + } + } + const projected = characters.join(''); + return /\bBuildConfig\.DEBUG\b/.test(projected) ? '' : projected; +}; + +const resolverCallRanges = (source: string, resolver: string): SourceRange[] => { + const ranges: SourceRange[] = []; + let resolverStart = source.indexOf(resolver); + while (resolverStart >= 0) { + const openingParenthesis = source.indexOf('(', resolverStart + resolver.length); + const closingParenthesis = openingParenthesis < 0 + ? -1 + : findBalancedBlockEnd(source, openingParenthesis, '(', ')'); + if (closingParenthesis >= 0) ranges.push({ start: resolverStart, end: closingParenthesis + 1 }); + resolverStart = source.indexOf(resolver, resolverStart + resolver.length); + } + return ranges; +}; + +const objcLocatorRanges = (source: string): SourceRange[] => [...source.matchAll( + /\[\s*BundleDropLocator\s+bundleURL\s*\]/g, +)].map(match => ({ start: match.index || 0, end: (match.index || 0) + match[0].length })); + +const objcSelfBundleUrlRanges = (source: string): SourceRange[] => [...source.matchAll( + /\[\s*self\s+bundleURL\s*\]|\bself\s*\.\s*bundleURL\b/g, +)].map(match => ({ start: match.index || 0, end: (match.index || 0) + match[0].length })); + +const splitTopLevelArguments = (body: string) => { + const parts: string[] = []; + const stack: string[] = []; + let start = 0; + for (let index = 0; index < body.length; index += 1) { + const character = body[index]; + if ('([{'.includes(character)) stack.push(character); + else if (')]}'.includes(character)) stack.pop(); + else if (character === ',' && !stack.length) { + parts.push(body.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(body.slice(start).trim()); + if ( + parts.length > 2 && + body.trimEnd().endsWith(',') && + parts[parts.length - 1] === '' + ) { + parts.pop(); + } + return parts; +}; + +const hasValidAndroidModuleResolverCall = ( + nativeCode: string, + startup: NonNullable>, +) => { + const calls = findCallSites(startup.method.body, 'BundleDropModule.resolveJSBundleFile'); + if (calls.length !== 1) return false; + const argumentsList = splitTopLevelArguments(calls[0].argumentsBody); + if (argumentsList.length !== 2) return false; + if (startup.method.language === 'java') { + return /^(?:MainApplication\.this|getApplicationContext\(\s*\))$/.test(argumentsList[0]); + } + const insideLegacyHost = legacyAndroidHostBlocks(nativeCode).some(host => + host.bodyStart <= startup.method.declarationStart && + startup.method.declarationStart < host.bodyEnd + ); + return insideLegacyHost + ? /^(?:this@MainApplication|applicationContext|getApplicationContext\(\s*\))$/.test( + argumentsList[0], + ) + : /^(?:this(?:@MainApplication)?|applicationContext|getApplicationContext\(\s*\))$/.test( + argumentsList[0], + ); +}; + +const canContinueValueExpressionAcrossNewline = (prefix: string) => { + if (!prefix.includes('\n')) return true; + const completedLines = prefix.split('\n').slice(0, -1); + const precedingLine = [...completedLines].reverse().find(line => line.trim())?.trim() || ''; + return !precedingLine || + /(?:\breturn|\belse|[=?:({,+\-*/.!])$/.test(precedingLine); +}; + +const hasCanonicalGuardedResolverFallback = (source: string, resolver: string) => { + if (resolver === 'BundleDropLocator.bundleURL') { + const match = /^\s*if\s+let\s+([A-Za-z_]\w*)\s*=\s*BundleDropLocator\.bundleURL\s*\(\s*\)\s*\{\s*return\s+\1\s*;?\s*\}\s*return\s+[^;{}\n]+\s*;?\s*$/.exec(source); + return Boolean(match); + } + if (resolver === '[BundleDropLocator bundleURL]') { + const match = /^\s*NSURL\s*\*\s*([A-Za-z_]\w*)\s*=\s*\[\s*BundleDropLocator\s+bundleURL\s*\]\s*;\s*if\s*\(\s*\1\s*(?:!=\s*nil)?\s*\)\s*\{\s*return\s+\1\s*;\s*\}\s*return\s+[^;{}]+;\s*$/.exec(source); + return Boolean(match); + } + return false; +}; + +const hasCanonicalReturnedResolverSuffix = ( + afterResolver: string, + resolver: string, + language: ResolverLanguage, + requiresNonNullResult: boolean, +) => { + const suffix = trimTrailingClosingBraces(afterResolver); + if (!suffix || suffix === ';') return !requiresNonNullResult; + if (language === 'kotlin' && resolver === 'BundleDropModule.resolveJSBundleFile') { + return /^!!\s*;?$/.test(suffix) || + /^\?:\s*(?:super\.getJSBundleFile\s*\(\s*\)|[A-Za-z_]\w*)\s*;?$/.test(suffix); + } + if (language === 'swift' && resolver === 'BundleDropLocator.bundleURL') { + return new RegExp( + '^\\?\\?\\s*(?:' + + 'RCTBundleURLProvider\\.sharedSettings\\s*\\(\\s*\\)' + + '\\.jsBundleURL\\s*\\([^)]*\\)|' + + 'Bundle\\.main\\.url\\s*\\([^)]*\\)|' + + '[A-Za-z_]\\w*' + + ')\\s*;?$', + ).test(suffix); + } + if (language === 'objc' && resolver === '[BundleDropLocator bundleURL]') { + return /^\?:\s*(?:\[[^\]\n]+\]|[A-Za-z_]\w*)\s*;?$/.test(suffix); + } + return false; +}; + +const isSupportedJavaTernaryPart = (source: string) => { + const expression = source.trim(); + if (!expression || /\b(?:break|continue|else|for|if|return|switch|throw|while)\b/.test(expression)) { + return false; + } + const stack: string[] = []; + for (const character of expression) { + if ('(['.includes(character)) stack.push(character); + else if (')]'.includes(character)) { + const opening = stack.pop(); + if ( + (character === ')' && opening !== '(') || + (character === ']' && opening !== '[') + ) { + return false; + } + } else if (character === ',' && !stack.length) { + return false; + } + } + return stack.length === 0; +}; + +const hasCanonicalConditionalLocalFallback = ( + source: string, + resolver: string, + language: ResolverLanguage, +) => { + if (resolver !== 'BundleDropModule.resolveJSBundleFile') return false; + const calls = findCallSites(source, resolver); + if (calls.length !== 1) return false; + const argumentsList = splitTopLevelArguments(calls[0].argumentsBody); + if (argumentsList.length !== 2 || !/^[A-Za-z_]\w*$/.test(argumentsList[1])) { + return false; + } + const fallback = argumentsList[1]; + if (language === 'java') { + const selection = new RegExp( + `^\\s*(?:final\\s+)?String\\s+${escapeRegExp(fallback)}\\s*=\\s*` + + '([^?;{}]+)\\?([^?:;{}]+):([^?:;{}]+);\\s*return\\s*$', + ).exec(source.slice(0, calls[0].start)); + if (!selection || selection.slice(1).some(part => + part.includes(resolver) || !isSupportedJavaTernaryPart(part) + )) { + return false; + } + return /^\s*;?\s*$/.test(source.slice(calls[0].end)); + } + if (language !== 'kotlin') return false; + const assignment = new RegExp( + `^\\s*(?:val|var)\\s+${escapeRegExp(fallback)}` + + `(?:\\s*:\\s*[^=\\n]+)?\\s*=\\s*if\\s*\\(`, + ).exec(source); + if (!assignment) return false; + const conditionOpening = assignment[0].lastIndexOf('('); + const conditionClosing = findBalancedBlockEnd(source, conditionOpening, '(', ')'); + if (conditionClosing < 0) return false; + let cursor = conditionClosing + 1; + while (/\s/.test(source[cursor] || '')) cursor += 1; + if (source[cursor] !== '{') return false; + const trueBranchClosing = findBalancedBlockEnd(source, cursor); + if (trueBranchClosing < 0) return false; + const trueBranch = source.slice(cursor + 1, trueBranchClosing); + cursor = trueBranchClosing + 1; + while (/\s/.test(source[cursor] || '')) cursor += 1; + if (!source.startsWith('else', cursor) || /\w/.test(source[cursor + 4] || '')) return false; + cursor += 4; + while (/\s/.test(source[cursor] || '')) cursor += 1; + if (source[cursor] !== '{') return false; + const falseBranchClosing = findBalancedBlockEnd(source, cursor); + if (falseBranchClosing < 0) return false; + const falseBranch = source.slice(cursor + 1, falseBranchClosing); + const beforeResolver = source.slice(falseBranchClosing + 1, calls[0].start); + if (!/^\s*;?\s*return\s*$/.test(beforeResolver)) return false; + if ( + /\breturn\b/.test(trueBranch) || + /\breturn\b/.test(falseBranch) || + trueBranch.includes(resolver) || + falseBranch.includes(resolver) + ) { + return false; + } + return new RegExp(`^\\s*\\?:\\s*${escapeRegExp(fallback)}\\s*;?\\s*$`).test( + source.slice(calls[0].end), + ); +}; + +const resolverFeedsReturnedValue = ( + body: string, + resolver: string, + expressionBody: boolean, + language: ResolverLanguage, + requiresNonNullResult = false, +) => { + const source = releaseSource(body); + if (hasCanonicalGuardedResolverFallback(source, resolver)) return true; + if (hasCanonicalConditionalLocalFallback(source, resolver, language)) return true; + const sourceWithoutFallbackOperators = source.replace(/\?\?|\?:/g, ''); + if (/\b(?:if|when|switch)\b/.test(source) || sourceWithoutFallbackOperators.includes('?')) { + return false; + } + const ranges = resolver === '[BundleDropLocator bundleURL]' + ? objcLocatorRanges(source) + : resolver === '[self bundleURL]' || resolver === 'self.bundleURL' + ? objcSelfBundleUrlRanges(source) + : resolverCallRanges(source, resolver); + const returnIndices = [...source.matchAll(/\breturn\b/g)].map(match => match.index || 0); + const controlledReturns = new Set(); + let hasValueFlow = false; + for (const range of ranges) { + const before = source.slice(0, range.start); + const after = source.slice(range.end); + const statementStart = Math.max(before.lastIndexOf(';'), before.lastIndexOf('{')) + 1; + const statementPrefix = before.slice(statementStart); + const returnedExpression = /\breturn\b([\s\S]*)$/.exec(statementPrefix); + if ( + returnedExpression && + canContinueValueExpressionAcrossNewline(returnedExpression[1]) && + hasCanonicalReturnedResolverSuffix(after, resolver, language, requiresNonNullResult) + ) { + controlledReturns.add(statementStart + (returnedExpression.index || 0)); + hasValueFlow = true; + continue; + } + + const assignment = /\b(?:val|var|let|NSURL\s*\*?|URL\s*\??|String\s*\??)\s*([A-Za-z_]\w*)\s*=\s*[^;{}]*$/.exec( + statementPrefix, + ); + const assignedExpression = assignment?.[0].slice(assignment[0].indexOf('=') + 1) || ''; + if ( + assignment && + canContinueValueExpressionAcrossNewline(assignedExpression) && + new RegExp(`\\breturn\\s+${escapeRegExp(assignment[1])}\\b`).test(after) + ) { + for (const returnedVariable of after.matchAll( + new RegExp(`\\breturn\\s+${escapeRegExp(assignment[1])}\\b`, 'g'), + )) { + controlledReturns.add(range.end + (returnedVariable.index || 0)); + } + hasValueFlow = true; + continue; + } + + const allowsImplicitSingleExpression = expressionBody || + resolver === 'BundleDropLocator.bundleURL'; + if (allowsImplicitSingleExpression) { + const prefix = statementPrefix.trim(); + const allowedQualifiedPrefix = !prefix || + prefix === 'else' || + prefix === 'com.bundledrop.' || + prefix === 'else com.bundledrop.'; + if (allowedQualifiedPrefix && hasCanonicalReturnedResolverSuffix( + after, + resolver, + language, + requiresNonNullResult, + )) { + hasValueFlow = true; + } + continue; + } + } + return hasValueFlow && returnIndices.every(index => controlledReturns.has(index)); +}; + +export const hasBareAndroidStartupIntegration = (code: string) => { + if (hasNativeCodePushResidue(code)) return false; + const nativeCode = stripCommentsAndStrings(code); + const mainApplication = findNamedType(nativeCode, 'MainApplication'); + if ( + mainApplication && + hasExactAndroidNativePathsReference(nativeCode) && + hasDirectNativePathsHostConnection(nativeCode) && + hasAndroidLifecycleIntegrity(nativeCode, mainApplication) + ) { + return true; + } + const startup = androidStartupMethod(nativeCode); + if ( + startup && + hasExactAndroidNativePathsReference(nativeCode) && + hasNativePathsResolverCall(startup.method.body) && + resolverFeedsReturnedValue( + startup.method.body, + 'BundleDropNativePaths.getDownloadedBundlePath', + startup.method.declaration.trimEnd().endsWith('='), + startup.method.language, + ) && + hasAndroidLifecycleIntegrity(nativeCode, startup.owner) && + isResolverMethodConnectedToAndroidStartup(nativeCode, startup) + ) { + return true; + } + if (!hasExactAndroidModuleReference(nativeCode)) return false; + + if ( + !startup || + !hasValidAndroidModuleResolverCall(nativeCode, startup) || + !resolverFeedsReturnedValue( + startup.method.body, + 'BundleDropModule.resolveJSBundleFile', + startup.method.declaration.trimEnd().endsWith('='), + startup.method.language, + startup.method.language === 'kotlin' && + /:\s*(?:kotlin\.)?String\s*(?=[={])/.test(startup.method.declaration), + ) + ) { + return false; + } + if (!hasAndroidLifecycleIntegrity(nativeCode, startup.owner)) return false; + + return isResolverMethodConnectedToAndroidStartup(nativeCode, startup); +}; + +const swiftPrincipalTypes = (nativeCode: string) => findSourceTypes(nativeCode).filter(type => { + const prefix = nativeCode.slice(Math.max(0, type.declarationStart - 120), type.declarationStart); + return /@(?:main|UIApplicationMain)\b[\s\n]*(?:final\s+)?$/.test(prefix); +}); + +const hasConnectedSwiftFactoryDelegate = ( + nativeCode: string, + delegateType: SourceType, +) => { + const appDelegate = findNamedType(nativeCode, 'AppDelegate'); + if (!appDelegate) return false; + const principals = swiftPrincipalTypes(nativeCode); + if ( + principals.length > 1 || + (principals.length === 1 && principals[0].declarationStart !== appDelegate.declarationStart) + ) { + return false; + } + if (!/:\s*[^\{]*(?:UIApplicationDelegate|RCTAppDelegate)\b/.test(appDelegate.declaration)) { + return false; + } + const launchMethods = methodsOwnedBy( + nativeCode, + SWIFT_APP_LAUNCH_METHOD, + 'AppDelegate', + ); + if (launchMethods.length !== 1) return false; + const launchBody = launchMethods[0].body; + const factoryCalls = [...launchBody.matchAll(/\bRCTReactNativeFactory\s*\(/g)]; + const startCalls = [...launchBody.matchAll(/\b([A-Za-z_]\w*)\s*\.\s*startReactNative\s*\(/g)]; + if (factoryCalls.length !== 1 || startCalls.length !== 1) return false; + const startPosition = startCalls[0].index || 0; + const nestingDepth = [...launchBody.slice(0, startPosition)].reduce((depth, character) => { + if (character === '{') return depth + 1; + if (character === '}') return depth - 1; + return depth; + }, 0); + if (nestingDepth !== 0) return false; + + const delegateName = escapeRegExp(delegateType.name); + const delegateDeclarations = new RegExp( + `\\b(?:let|var)\\s+([A-Za-z_]\\w*)\\s*(?::\\s*${delegateName})?` + + `\\s*=\\s*${delegateName}\\s*\\(\\s*\\)`, + 'g', + ); + return [...launchBody.matchAll(delegateDeclarations)].some(delegateMatch => { + const delegateVariable = escapeRegExp(delegateMatch[1]); + const factoryDeclaration = new RegExp( + `\\b(?:let|var)\\s+([A-Za-z_]\\w*)\\s*=\\s*RCTReactNativeFactory\\s*` + + `\\(\\s*delegate\\s*:\\s*${delegateVariable}\\s*\\)`, + ).exec(launchBody); + if (!factoryDeclaration) return false; + return startCalls[0][1] === factoryDeclaration[1]; + }); +}; + +const factoryDelegateSourceUrlFeedsBundleUrl = ( + nativeCode: string, + delegateType: SourceType, +) => { + const sourceMethods = findDeclaredMethods(nativeCode, SWIFT_SOURCE_URL_METHOD).filter(method => + findContainingType(nativeCode, method.declarationStart)?.declarationStart === + delegateType.declarationStart + ); + if (sourceMethods.length !== 1) return false; + const source = releaseSource(sourceMethods[0].body).trim(); + return /^(?:return\s+)?(?:self\s*\.\s*)?bundleURL\s*\(\s*\)\s*;?$/.test(source); +}; + +const appDelegateSourceUrlPreservesBundleAuthority = ( + nativeCode: string, + appDelegate: SourceType, +) => { + const sourceMethods = findDeclaredMethods(nativeCode, SWIFT_SOURCE_URL_METHOD).filter(method => + findContainingType(nativeCode, method.declarationStart)?.declarationStart === + appDelegate.declarationStart + ); + if (!sourceMethods.length) return true; + if (sourceMethods.length !== 1) return false; + const source = releaseSource(sourceMethods[0].body).trim(); + return /^(?:return\s+)?(?:self\s*\.\s*)?bundleURL\s*\(\s*\)\s*;?$/.test(source) || + resolverFeedsReturnedValue( + sourceMethods[0].body, + 'BundleDropLocator.bundleURL', + false, + 'swift', + ); +}; + +const swiftStartupMethod = (nativeCode: string) => { + const methods = findDeclaredMethods(nativeCode, SWIFT_BUNDLE_URL_METHOD); + if (methods.length !== 1) return null; + const method = methods[0]; + const owner = findContainingType(nativeCode, method.declarationStart); + const principals = swiftPrincipalTypes(nativeCode); + if (principals.length > 1) return null; + if (owner?.name === 'AppDelegate') { + const ownsPrincipal = !principals.length || + principals[0].declarationStart === owner.declarationStart; + return ownsPrincipal && + /:\s*[^\{]*\bRCTAppDelegate\b/.test(owner.declaration) && + /\boverride\s+func\s+bundleURL\b/.test(method.declaration) && + appDelegateSourceUrlPreservesBundleAuthority(nativeCode, owner) + ? method + : null; + } + + const factoryDelegates = findSourceTypes(nativeCode).filter(type => + /:\s*RCTDefaultReactNativeFactoryDelegate\b/.test(type.declaration) + ); + return factoryDelegates.length === 1 && + owner?.name === factoryDelegates[0].name && + hasConnectedSwiftFactoryDelegate(nativeCode, factoryDelegates[0]) && + factoryDelegateSourceUrlFeedsBundleUrl(nativeCode, factoryDelegates[0]) + ? method + : null; +}; + +const objcStartupMethods = (nativeCode: string) => { + const implementations = findObjcImplementations(nativeCode); + const appDelegateImplementations = implementations.filter(implementation => + implementation.name === 'AppDelegate' && !implementation.category + ); + if (appDelegateImplementations.length !== 1) return null; + const appDelegate = appDelegateImplementations[0]; + const allSourceMethods = findDeclaredMethods(nativeCode, OBJC_SOURCE_URL_METHOD); + const allBundleMethods = findDeclaredMethods(nativeCode, OBJC_BUNDLE_URL_METHOD); + const sourceMethods = allSourceMethods.filter(method => + methodBelongsToObjcImplementation(method, appDelegate) + ); + const bundleMethods = allBundleMethods.filter(method => + methodBelongsToObjcImplementation(method, appDelegate) + ); + if (allSourceMethods.length !== sourceMethods.length || allBundleMethods.length !== bundleMethods.length) { + return null; + } + if (sourceMethods.length > 1 || bundleMethods.length > 1) return null; + return { sourceMethod: sourceMethods[0] || null, bundleMethod: bundleMethods[0] || null }; +}; + +export const hasBareIosStartupIntegration = ( + filePath: string, + code: string, +) => { + if (hasNativeCodePushResidue(code)) return false; + const nativeCode = stripCommentsAndStrings(code); + if (filePath.endsWith('.swift')) { + const startupMethod = swiftStartupMethod(nativeCode); + return Boolean( + startupMethod && + resolverFeedsReturnedValue( + startupMethod.body, + 'BundleDropLocator.bundleURL', + false, + 'swift', + ) && + new RegExp(`\\bimport\\s+${IOS_BUNDLE_DROP_MODULE}\\b`).test(nativeCode) + ); + } + + const escapedHeader = escapeRegExp(IOS_BUNDLE_DROP_LOCATOR_HEADER); + if (!new RegExp(`#import\\s*[<"]${escapedHeader}[>"]`).test(nativeCode)) return false; + const startup = objcStartupMethods(nativeCode); + if (!startup) return false; + + const locatorCall = '[BundleDropLocator bundleURL]'; + const bundleHasLocator = startup.bundleMethod + ? resolverFeedsReturnedValue(startup.bundleMethod.body, locatorCall, false, 'objc') + : false; + if (!startup.sourceMethod) return bundleHasLocator; + return resolverFeedsReturnedValue(startup.sourceMethod.body, locatorCall, false, 'objc') || + (bundleHasLocator && ( + resolverFeedsReturnedValue(startup.sourceMethod.body, '[self bundleURL]', false, 'objc') || + resolverFeedsReturnedValue(startup.sourceMethod.body, 'self.bundleURL', false, 'objc') + )); +}; + +const preservationSignals = ( + originalBody: string, + updatedBody: string, + signals: Array<{ label: string; token: string }>, +) => signals + .filter(signal => originalBody.includes(signal.token) && !updatedBody.includes(signal.token)) + .map(signal => signal.label); + +const findCodePushAliases = (content: string) => { + const aliases = new Set(); + for (const match of content.matchAll( + /^\s*import\s+com\.microsoft\.codepush\.react\.CodePush\s+as\s+([A-Za-z_]\w*)\s*;?\s*$/gim, + )) { + aliases.add(match[1]); + } + for (const match of content.matchAll( + /^\s*#\s*define\s+([A-Za-z_]\w*)\s+CodePush\s*$/gim, + )) { + aliases.add(match[1]); + } + return [...aliases]; +}; + +const withoutReplaceableCodePushReferences = ( + content: string, + knownAliases: string[] = [], +) => { + const aliases = [...new Set([...findCodePushAliases(content), ...knownAliases])]; + const resolverOwners = [ + 'com\\.microsoft\\.codepush\\.react\\.CodePush', + 'CodePush', + ...aliases.map(escapeRegExp), + ].join('|'); + return content + .replace( + /^\s*import\s+(?:com\.microsoft\.codepush\.react\.CodePush(?:\s+as\s+[A-Za-z_]\w*)?|CodePush)\s*;?\s*$/gim, + '', + ) + .replace(/^\s*#\s*import\s*[<"][^>"\n]*CodePush[^>"\n]*[>"]\s*$/gim, '') + .replace(/^\s*#\s*define\s+[A-Za-z_]\w*\s+CodePush\s*$/gim, '') + .replace( + new RegExp( + `\\b(?:${resolverOwners})\\s*\\.\\s*getJSBundleFile\\s*\\([^)]*\\)`, + 'gi', + ), + '', + ) + .replace( + new RegExp(`\\b(?:${resolverOwners})\\s*\\.\\s*bundleURL\\s*\\([^)]*\\)`, 'gi'), + '', + ) + .replace( + new RegExp(`\\[\\s*(?:${resolverOwners})\\s+bundleURL\\s*\\]`, 'gi'), + '', + ); +}; + +const substantiveNativeTokens = (content: string, codePushAliases: string[] = []) => { + const tokens: string[] = []; + const tokenPattern = + /\/\/[^\n]*|\/\*[\s\S]*?\*\/|@?"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|[A-Za-z_]\w*|\d+(?:\.\d+)?/g; + for (const match of withoutReplaceableCodePushReferences(content, codePushAliases) + .matchAll(tokenPattern)) { + const token = match[0]; + if (token.startsWith('//') || token.startsWith('/*')) continue; + tokens.push(token); + } + return tokens; +}; + +const preservesSubstantiveNativeTokens = ( + original: string, + updated: string, + originalCodePushAliases: string[] = [], +) => { + const originalTokens = substantiveNativeTokens(original, originalCodePushAliases); + const updatedTokens = substantiveNativeTokens(updated); + let originalIndex = 0; + for (const token of updatedTokens) { + if (token === originalTokens[originalIndex]) originalIndex += 1; + } + return originalIndex === originalTokens.length; +}; + +export const findMissingBareNativeStartupStructure = ( + filePath: string, + original: string, + updated: string, +): string[] => { + const originalCode = stripCommentsAndStrings(original); + const updatedCode = stripCommentsAndStrings(updated); + const originalCodePushAliases = findCodePushAliases(original); + const retainedCodePushAliases = originalCodePushAliases.filter(alias => + new RegExp(`\\b${escapeRegExp(alias)}\\b`).test(updatedCode) + ); + const substantiveMissing = preservesSubstantiveNativeTokens(original, updated) + ? [] + : ['substantive native code or ordering']; + if (retainedCodePushAliases.length) { + substantiveMissing.push('CodePush alias residue'); + } + if (filePath.includes('MainApplication.')) { + const missing = [...substantiveMissing]; + const originalStartup = androidStartupMethod(originalCode); + if (originalStartup) { + const updatedStartup = androidStartupMethod(updatedCode); + if (!updatedStartup) { + missing.push('getJSBundleFile'); + } else if (!preservesSubstantiveNativeTokens( + originalStartup.method.body, + updatedStartup.method.body, + originalCodePushAliases, + )) { + missing.push('getJSBundleFile/non-CodePush fallback'); + } + } + + const originalOwner = findNamedType(originalCode, 'MainApplication'); + if (!originalOwner) return missing; + const updatedOwner = findNamedType(updatedCode, 'MainApplication'); + if (!updatedOwner) return [...missing, 'MainApplication']; + + const originalOnCreate = methodsOwnedBy( + originalCode, + ANDROID_ON_CREATE_METHOD, + 'MainApplication', + )[0]; + if (!originalOnCreate) return missing; + const updatedOnCreate = methodsOwnedBy( + updatedCode, + ANDROID_ON_CREATE_METHOD, + 'MainApplication', + )[0]; + if (!updatedOnCreate) return [...missing, 'onCreate']; + if (!preservesSubstantiveNativeTokens( + originalOnCreate.body, + updatedOnCreate.body, + originalCodePushAliases, + )) { + missing.push('onCreate/substantive behavior'); + } + return [...missing, ...preservationSignals(originalOnCreate.body, updatedOnCreate.body, [ + { label: 'onCreate/super.onCreate', token: 'super.onCreate' }, + { label: 'onCreate/loadReactNative', token: 'loadReactNative' }, + { label: 'onCreate/SoLoader.init', token: 'SoLoader.init' }, + { + label: 'onCreate/DefaultNewArchitectureEntryPoint.load', + token: 'DefaultNewArchitectureEntryPoint.load', + }, + ])]; + } + + if (filePath.endsWith('.swift')) { + const originalMethod = swiftStartupMethod(originalCode); + if (!originalMethod) return substantiveMissing; + const updatedMethod = swiftStartupMethod(updatedCode); + if (!updatedMethod) return [...substantiveMissing, 'bundleURL']; + const missing = [...substantiveMissing]; + if (!preservesSubstantiveNativeTokens( + originalMethod.body, + updatedMethod.body, + originalCodePushAliases, + )) { + missing.push('bundleURL/non-CodePush fallback'); + } + return [...missing, ...preservationSignals(originalMethod.body, updatedMethod.body, [ + { label: 'bundleURL/RCTBundleURLProvider', token: 'RCTBundleURLProvider' }, + { label: 'bundleURL/Bundle.main.url', token: 'Bundle.main.url' }, + ])]; + } + + const originalStartup = objcStartupMethods(originalCode); + if (!originalStartup) return substantiveMissing; + const updatedStartup = objcStartupMethods(updatedCode); + if (!updatedStartup) return [...substantiveMissing, 'sourceURLForBridge/bundleURL']; + const originalBody = [originalStartup.sourceMethod?.body, originalStartup.bundleMethod?.body] + .filter(Boolean) + .join('\n'); + const updatedBody = [updatedStartup.sourceMethod?.body, updatedStartup.bundleMethod?.body] + .filter(Boolean) + .join('\n'); + const missing = [...substantiveMissing]; + if (!preservesSubstantiveNativeTokens( + originalBody, + updatedBody, + originalCodePushAliases, + )) { + missing.push('sourceURLForBridge/bundleURL non-CodePush fallback'); + } + missing.push(...preservationSignals(originalBody, updatedBody, [ + { label: 'sourceURL/RCTBundleURLProvider', token: 'RCTBundleURLProvider' }, + { label: 'sourceURL/NSBundle fallback', token: 'NSBundle mainBundle' }, + ])); + if ( + originalStartup.sourceMethod?.body.match(OBJC_SELF_BUNDLE_URL_DELEGATION) && + !updatedStartup.sourceMethod?.body.match(OBJC_SELF_BUNDLE_URL_DELEGATION) + ) { + missing.push('sourceURLForBridge/bundleURL delegation'); + } + return missing; +}; diff --git a/src/CLI/scripts/safe-file-transaction.ts b/src/CLI/scripts/safe-file-transaction.ts new file mode 100644 index 0000000..62d5f7b --- /dev/null +++ b/src/CLI/scripts/safe-file-transaction.ts @@ -0,0 +1,187 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +export type InspectedFile = { + exists: boolean; + content: string; + mode: number; +}; + +const assertSafeRelativePath = (relativePath: string) => { + if ( + !relativePath || + path.isAbsolute(relativePath) || + relativePath.includes('\\') || + relativePath.split('/').includes('..') + ) { + throw new Error(`Refusing unsafe transaction path: ${relativePath}`); + } +}; + +const assertRegularDirectory = (directory: string) => { + const stat = fs.lstatSync(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Refusing symlinked or non-directory transaction path: ${directory}`); + } +}; + +const lstatIfPresent = (targetPath: string) => { + try { + return fs.lstatSync(targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +}; + +const ensureSafeParentDirectory = (root: string, relativePath: string) => { + assertSafeRelativePath(relativePath); + assertRegularDirectory(root); + const parentParts = path.dirname(relativePath).split('/').filter(part => part !== '.'); + let current = root; + for (const part of parentParts) { + current = path.join(current, part); + if (!lstatIfPresent(current)) { + fs.mkdirSync(current); + } + assertRegularDirectory(current); + } +}; + +const hasSafeParentDirectory = (root: string, relativePath: string) => { + assertSafeRelativePath(relativePath); + assertRegularDirectory(root); + const parentParts = path.dirname(relativePath).split('/').filter(part => part !== '.'); + let current = root; + for (const part of parentParts) { + current = path.join(current, part); + if (!lstatIfPresent(current)) return false; + assertRegularDirectory(current); + } + return true; +}; + +const readRegularFile = (filePath: string) => { + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + throw new Error(`Refusing symlinked or non-regular transaction target: ${filePath}`); + } + return { + content: fs.readFileSync(descriptor, 'utf8'), + mode: stat.mode & 0o777, + }; + } finally { + fs.closeSync(descriptor); + } +}; + +export const inspectProjectFile = (root: string, relativePath: string): InspectedFile => { + if (!hasSafeParentDirectory(root, relativePath)) { + return { exists: false, content: '', mode: 0o666 }; + } + const filePath = path.join(root, relativePath); + const stat = lstatIfPresent(filePath); + if (!stat) return { exists: false, content: '', mode: 0o666 }; + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Refusing symlinked or non-regular transaction target: ${relativePath}`); + } + return { exists: true, ...readRegularFile(filePath) }; +}; + +export const inspectProjectDirectory = (root: string, relativePath: string) => { + if (!hasSafeParentDirectory(root, relativePath)) return false; + const directoryPath = path.join(root, relativePath); + const stat = lstatIfPresent(directoryPath); + if (!stat) return false; + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Refusing symlinked or non-directory transaction target: ${relativePath}`); + } + return true; +}; + +const writeExclusiveFile = ( + filePath: string, + content: string, + mode: number, +) => { + const descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW, + mode, + ); + try { + fs.writeFileSync(descriptor, content, 'utf8'); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +}; + +export const writeProjectFileAtomically = ( + root: string, + relativePath: string, + content: string, + mode = 0o666, +) => { + const current = inspectProjectFile(root, relativePath); + ensureSafeParentDirectory(root, relativePath); + const targetPath = path.join(root, relativePath); + const temporaryName = + `.${path.basename(relativePath)}.bundledrop-${crypto.randomBytes(12).toString('hex')}.tmp`; + const temporaryPath = path.join(path.dirname(targetPath), temporaryName); + writeExclusiveFile(temporaryPath, content, current.exists ? current.mode : mode); + try { + fs.renameSync(temporaryPath, targetPath); + } catch (error) { + if (lstatIfPresent(temporaryPath)) fs.unlinkSync(temporaryPath); + throw error; + } +}; + +export const createSafeBackupDirectory = (projectRoot: string, label: string) => { + ensureSafeParentDirectory(projectRoot, '.bundledrop-backup/transaction'); + const backupRoot = path.join(projectRoot, '.bundledrop-backup'); + const backupDirectory = fs.mkdtempSync(path.join(backupRoot, `${label}-`)); + assertRegularDirectory(backupDirectory); + return backupDirectory; +}; + +export const writeBackupFile = ( + backupRoot: string, + relativePath: string, + content: string, + mode: number, +) => { + ensureSafeParentDirectory(backupRoot, relativePath); + writeExclusiveFile(path.join(backupRoot, relativePath), content, mode); +}; + +export const restoreProjectFile = ( + projectRoot: string, + backupRoot: string, + relativePath: string, +) => { + const backup = inspectProjectFile(backupRoot, relativePath); + if (!backup.exists) throw new Error(`Missing transaction backup: ${relativePath}`); + inspectProjectFile(projectRoot, relativePath); + writeProjectFileAtomically( + projectRoot, + relativePath, + backup.content, + backup.mode, + ); +}; + +export const removeProjectFile = (projectRoot: string, relativePath: string) => { + const target = inspectProjectFile(projectRoot, relativePath); + if (target.exists) fs.unlinkSync(path.join(projectRoot, relativePath)); +}; diff --git a/src/api/clientApi.ts b/src/api/clientApi.ts index 5b8c3a9..e207e6b 100644 --- a/src/api/clientApi.ts +++ b/src/api/clientApi.ts @@ -10,8 +10,38 @@ import { ReportLocalRollbackPayload, BundleListParams, BundleListResponse, + OtaArtifactAuthorizationRequest, + OtaActiveInstallHeartbeat, } from './types'; +export function postOtaActiveInstallHeartbeat( + projectSlug: string, + payload: OtaActiveInstallHeartbeat, +): Promise> { + return apiClient.post( + `/projects/${encodeURIComponent(projectSlug)}/ota/active-install`, + payload, + { + headers: { Accept: 'application/json' }, + timeout: 3000, + }, + ); +} + +export function postOtaArtifactAuthorization( + projectSlug: string, + payload: OtaArtifactAuthorizationRequest, +): Promise> { + return apiClient.post( + `/projects/${encodeURIComponent(projectSlug)}/ota/artifacts/authorize`, + payload, + { + headers: { Accept: 'application/json' }, + timeout: 15000, + }, + ); +} + export function postOtaResolve( projectSlug: string, payload: OtaResolveRequest, diff --git a/src/api/types.ts b/src/api/types.ts index 156862a..a159432 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -14,9 +14,36 @@ export type OtaResolveRequest = { manifestVersion: 1; patchAlgorithms: string[]; supportsContentAddressedAssets: boolean; + /** Present only when this runtime sends the dedicated active-install heartbeat. */ + activeInstallHeartbeatVersion?: 1; }; }; +export type OtaArtifactAuthorizationRequest = { + channelName: string; + platform: string; + runtimeVersion: string; + generation: number; + targetReleaseRef: string; + targetHash: string; + mode: 'full' | 'patch'; + patchArtifactRef: string | null; + currentHash: string | null; + rejectedHashes: string[]; + installId: string; + transport: OtaResolveRequest['transport']; +}; + +export type OtaActiveInstallHeartbeat = { + channelName: string; + platform: string; + runtimeVersion: string; + installId: string; + currentHash: string | null; + environment?: string; + userProperties?: UserProperties; +}; + export type OtaInstallTarget = { bundleHash: string; downloadUrl?: string; @@ -117,6 +144,20 @@ export type UpdateCheckResponse = { requestedRuntimeVersion?: string; /** Latest runtime version available on the channel when the current runtime is incompatible. */ latestRuntimeVersionOnChannel?: string; + /** Internal runtime-delivery authorization context. */ + runtimeDelivery?: { + generation: number; + targetReleaseRef: string; + selectedMode: 'full' | 'patch'; + baseHash?: string; + patchAlgorithm?: string; + patchSetHash?: string; + patchArtifactRef?: string; + missingAssetsHash?: string | null; + manifestHash?: string; + jsBundleHash?: string; + fullBundleHash?: string; + }; }; export type ReportPatchApplyFailurePayload = { diff --git a/src/index.tsx b/src/index.tsx index 0711fd7..14a0e36 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,6 +30,13 @@ export type { BundleListItem } from './api/types'; export type { BundleDropProjectConfig } from './loadConfig'; export type { BundleDropConfig } from './context'; export type { BundleDropInitOptions, BundleDropRuntimeConfig } from './runtime/initState'; +export { getRuntimeDeliveryDiagnosticCounters } from './runtime-delivery/diagnostics'; +export type { + RuntimeDeliveryDiagnosticCounters, + RuntimeDeliveryDiagnosticDetails, + RuntimeDeliveryDiagnosticEvent, + RuntimeDeliveryDiagnosticName, +} from './runtime-delivery/diagnostics'; export { useBundleDrop } from './useBundleDrop'; export type { UseBundleDropReturn } from './useBundleDrop'; export { bundleDropConfig } from './context'; diff --git a/src/install/installFromZip.ts b/src/install/installFromZip.ts index d9fe3d2..aac843b 100644 --- a/src/install/installFromZip.ts +++ b/src/install/installFromZip.ts @@ -19,10 +19,21 @@ type InstallFromZipParams = { hash?: string; platform?: 'ios' | 'android'; statusCb?: (status: string) => void; + expectedArchiveHash?: string; + expectedManifestHash?: string; + expectedJsBundleHash?: string; }; export async function installFromZip(params: InstallFromZipParams): Promise { - const { downloadUrl, hash, statusCb, platform = devicePlatform } = params; + const { + downloadUrl, + hash, + statusCb, + platform = devicePlatform, + expectedArchiveHash, + expectedManifestHash, + expectedJsBundleHash, + } = params; if (!hash) { throw new Error('Missing bundle hash'); @@ -43,6 +54,9 @@ export async function installFromZip(params: InstallFromZipParams): Promise; +}; + +/** Returns true only for a complete package-managed trust configuration. */ +export function isRuntimeDeliveryConfigured(value: unknown): value is RuntimeDeliveryConfig { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const candidate = value as Partial & { mode?: unknown }; + if (candidate.mode === 'v1' || candidate.mode === 'shadow') return false; + return ( + typeof candidate.manifestBaseUrl === 'string' && + Boolean(candidate.manifestBaseUrl) && + typeof candidate.manifestAccessId === 'string' && + Boolean(candidate.manifestAccessId) && + Boolean(candidate.publicKeys) && + typeof candidate.publicKeys === 'object' && + !Array.isArray(candidate.publicKeys) + ); +} + export type BundleDropProjectConfig = { /** Persisted project shape used to keep Expo and bare runtime behavior distinct. */ projectType?: 'expo' | 'bare'; @@ -50,7 +77,12 @@ export type BundleDropProjectConfig = { }; }; -export function loadConfig(): BundleDropProjectConfig { +/** Internal Metro-resolved config. Generated trust data is deliberately absent from the public type. */ +export type ResolvedBundleDropProjectConfig = BundleDropProjectConfig & { + runtimeDelivery?: RuntimeDeliveryConfig; +}; + +export function loadConfig(): ResolvedBundleDropProjectConfig { try { // ✅ Resolved via Metro alias in the host app (extraNodeModules) // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/manager/downloadAndInstall.ts b/src/manager/downloadAndInstall.ts index ec86ce9..1e2fd7c 100644 --- a/src/manager/downloadAndInstall.ts +++ b/src/manager/downloadAndInstall.ts @@ -12,10 +12,11 @@ import { import { installFromZip } from '../install/installFromZip'; import { tryInstallPatchTransport } from '../patch-engine/patchTransport'; import { getDownloadedBundlePathNative } from '../native/bundleDropNative'; -import { checkForUpdate } from './updateCheck'; +import { authorizeRuntimeDeliveryUpdate, checkForUpdate } from './updateCheck'; import { BundleDropError, isInstallPhaseError } from '../errors'; import { isBundleHashFailed, markCandidateActivated } from './rollbackState'; -import type { OtaPatchSet } from '../api/types'; +import type { OtaPatchSet, UpdateCheckResponse } from '../api/types'; +import { isArtifactCapabilityRejected } from '../runtime-delivery/artifactCapability'; async function restoreCurrentPointer(pointer: BundlePointer | null): Promise { if (pointer) { @@ -37,7 +38,7 @@ type DownloadOptions = { channelName?: string; resolvedTarget?: { hash: string; - downloadUrl: string; + downloadUrl?: string; bundleVersion?: number; version?: string; runtimeVersion?: string; @@ -49,9 +50,40 @@ type DownloadOptions = { mode: 'full'; downloadUrl: string; }; + runtimeDelivery?: UpdateCheckResponse['runtimeDelivery']; }; }; +function isSameRuntimeDeliverySelection( + selected: UpdateCheckResponse, + refreshed: UpdateCheckResponse | null, +): refreshed is UpdateCheckResponse { + if ( + !refreshed || + refreshed.action !== 'INSTALL' || + refreshed.hash !== selected.hash || + refreshed.runtimeVersion !== selected.runtimeVersion || + !selected.runtimeDelivery || + !refreshed.runtimeDelivery + ) { + return false; + } + + const before = selected.runtimeDelivery; + const after = refreshed.runtimeDelivery; + return before.generation === after.generation && + before.targetReleaseRef === after.targetReleaseRef && + before.selectedMode === after.selectedMode && + (before.baseHash ?? null) === (after.baseHash ?? null) && + (before.patchAlgorithm ?? null) === (after.patchAlgorithm ?? null) && + (before.patchSetHash ?? null) === (after.patchSetHash ?? null) && + (before.patchArtifactRef ?? null) === (after.patchArtifactRef ?? null) && + (before.missingAssetsHash ?? null) === (after.missingAssetsHash ?? null) && + (before.manifestHash ?? null) === (after.manifestHash ?? null) && + (before.jsBundleHash ?? null) === (after.jsBundleHash ?? null) && + (before.fullBundleHash ?? null) === (after.fullBundleHash ?? null); +} + async function downloadAndStageUpdate( options?: DownloadOptions, onStatusUpdate?: (status: string) => void, @@ -63,9 +95,10 @@ async function downloadAndStageUpdate( const resolvedTarget = options?.resolvedTarget; // Resolve target via /ota/resolve unless the caller already provided it. - const checkResult = resolvedTarget + const unresolvedCheckResult = resolvedTarget ? { action: 'INSTALL' as const, + channelName, hash: resolvedTarget.hash, bundleHash: resolvedTarget.hash, downloadUrl: resolvedTarget.downloadUrl, @@ -77,8 +110,12 @@ async function downloadAndStageUpdate( baseHash: resolvedTarget.baseHash, patchSet: resolvedTarget.patchSet, fallback: resolvedTarget.fallback, + runtimeDelivery: resolvedTarget.runtimeDelivery, } : await checkForUpdate(channelName, statusCb); + let checkResult = unresolvedCheckResult + ? await authorizeRuntimeDeliveryUpdate(unresolvedCheckResult) + : null; if (!checkResult) { throw new BundleDropError({ @@ -119,10 +156,6 @@ async function downloadAndStageUpdate( const serverBundleVersion = checkResult.bundleVersion; const serverVersion = checkResult.version; const serverRuntimeVersion = checkResult.runtimeVersion; - const fullBundleZipUrl = checkResult.mode === 'patch' - ? checkResult.fallback?.downloadUrl || null - : checkResult.downloadUrl || null; - try { if (!serverHash) { throw new BundleDropError({ @@ -133,19 +166,6 @@ async function downloadAndStageUpdate( }); } - if (!fullBundleZipUrl) { - throw new BundleDropError({ - message: 'Missing downloadUrl from ota resolve response', - code: 'DOWNLOAD_URL_MISSING', - step: 'resolve', - context: { - channelName, - platform, - projectSlug: project.slug, - }, - }); - } - const hash = serverHash; if (await isBundleHashFailed(hash)) { statusCb?.('✅ Current bundle retained; selected update previously failed on this device'); @@ -157,40 +177,95 @@ async function downloadAndStageUpdate( }; } - let installResult = await tryInstallPatchTransport({ - target: { - mode: checkResult.mode, - hash, - manifestUrl: checkResult.manifestUrl, - baseHash: checkResult.baseHash, - patchSet: checkResult.patchSet, - }, - projectSlug: project.slug, - platform, - runtimeVersionValue: serverRuntimeVersion, - statusCb, - }); + let installResult; + let capabilityRefreshAttempted = false; + let patchFallbackSelected = false; + while (true) { + const fullBundleZipUrl = checkResult.mode === 'patch' + ? checkResult.fallback?.downloadUrl || null + : checkResult.downloadUrl || null; + if (!fullBundleZipUrl) { + throw new BundleDropError({ + message: 'Missing downloadUrl from ota resolve response', + code: 'DOWNLOAD_URL_MISSING', + step: 'resolve', + context: { channelName, platform, projectSlug: project.slug }, + }); + } - try { - if (!installResult) { - statusCb?.('⬇️ Downloading full bundle ZIP!...'); - installResult = await installFromZip({ - downloadUrl: fullBundleZipUrl, - hash, + try { + installResult = patchFallbackSelected ? null : await tryInstallPatchTransport({ + target: { + mode: checkResult.mode, + hash, + manifestUrl: checkResult.manifestUrl, + baseHash: checkResult.baseHash, + patchSet: checkResult.patchSet, + ...(checkResult.runtimeDelivery?.manifestHash + ? { expectedManifestHash: checkResult.runtimeDelivery.manifestHash } + : {}), + ...(checkResult.runtimeDelivery?.jsBundleHash + ? { expectedJsBundleHash: checkResult.runtimeDelivery.jsBundleHash } + : {}), + }, + projectSlug: project.slug, platform, + runtimeVersionValue: serverRuntimeVersion, statusCb, }); + + if (!installResult) { + patchFallbackSelected = checkResult.mode === 'patch'; + statusCb?.('⬇️ Downloading full bundle ZIP!...'); + installResult = await installFromZip({ + downloadUrl: fullBundleZipUrl, + hash, + platform, + statusCb, + ...(checkResult.runtimeDelivery?.fullBundleHash + ? { expectedArchiveHash: checkResult.runtimeDelivery.fullBundleHash } + : {}), + ...(checkResult.runtimeDelivery?.manifestHash + ? { expectedManifestHash: checkResult.runtimeDelivery.manifestHash } + : {}), + ...(checkResult.runtimeDelivery?.jsBundleHash + ? { expectedJsBundleHash: checkResult.runtimeDelivery.jsBundleHash } + : {}), + }); + } + break; + } catch (e) { + if ( + !capabilityRefreshAttempted && + unresolvedCheckResult.runtimeDelivery && + isArtifactCapabilityRejected(e) + ) { + capabilityRefreshAttempted = true; + statusCb?.('🔐 Download authorization expired; refreshing once...'); + const refreshed = await authorizeRuntimeDeliveryUpdate(unresolvedCheckResult); + if (!isSameRuntimeDeliverySelection(unresolvedCheckResult, refreshed)) { + throw new BundleDropError({ + message: 'Refreshed download authorization changed update identity', + code: 'DOWNLOAD_FAILED', + step: 'download', + context: { channelName, platform, hash }, + cause: e, + }); + } + checkResult = refreshed; + continue; + } + + const phaseErr = isInstallPhaseError(e); + const isDownload = phaseErr && e.phase === 'download'; + throw new BundleDropError({ + message: isDownload ? 'Failed to download update ZIP' : 'Failed to install update ZIP', + code: isDownload ? 'DOWNLOAD_FAILED' : 'INSTALL_FAILED', + step: isDownload ? 'download' : 'install', + context: { channelName, platform, hash }, + cause: phaseErr ? e.originalCause : e, + }); } - } catch (e) { - const phaseErr = isInstallPhaseError(e); - const isDownload = phaseErr && e.phase === 'download'; - throw new BundleDropError({ - message: isDownload ? 'Failed to download update ZIP' : 'Failed to install update ZIP', - code: isDownload ? 'DOWNLOAD_FAILED' : 'INSTALL_FAILED', - step: isDownload ? 'download' : 'install', - context: { channelName, platform, hash }, - cause: phaseErr ? e.originalCause : e, - }); } const { bundlePath, metadataFromZip } = installResult; diff --git a/src/manager/rollbackState.ts b/src/manager/rollbackState.ts index 5e0da20..e031c2f 100644 --- a/src/manager/rollbackState.ts +++ b/src/manager/rollbackState.ts @@ -243,7 +243,9 @@ export function getRollbackPolicy(): Required { return bundleDropConfig.rollback; } -export async function rollbackToPreviousOrNative(): Promise<{ rolledBack: boolean; toNative?: boolean }> { +export async function rollbackToPreviousOrNative( + options: { forceNative?: boolean } = {}, +): Promise<{ rolledBack: boolean; toNative?: boolean }> { const [current, previous, state] = await Promise.all([ readCurrentBundlePointer(), readPreviousBundlePointer(), @@ -251,7 +253,7 @@ export async function rollbackToPreviousOrNative(): Promise<{ rolledBack: boolea ]); const previousIsFailed = previous ? !!state?.failedBundles?.[previous.hash] : false; - if (previous && previous.hash !== current?.hash && !previousIsFailed) { + if (!options.forceNative && previous && previous.hash !== current?.hash && !previousIsFailed) { await writeCurrentBundlePointer({ ...previous, updatedAt: new Date().toISOString() }); const metadata = await readBundleMetadata(previous.bundlePath); await updateBundleInfo({ diff --git a/src/manager/updateCheck.ts b/src/manager/updateCheck.ts index 9bed487..08e49dd 100644 --- a/src/manager/updateCheck.ts +++ b/src/manager/updateCheck.ts @@ -2,8 +2,18 @@ import type { AxiosResponse } from 'axios'; import { config, platform, runtimeVersion } from '../context'; import { BundleInfo, readBundleInfo } from '../bundleInfo'; -import { getPublicChannels, postOtaResolve, getBundleList } from '../api/clientApi'; -import type { UpdateCheckResponse, BundleListItem, BundleListResponse } from '../api/types'; +import { + getPublicChannels, + postOtaArtifactAuthorization, + postOtaResolve, + getBundleList, +} from '../api/clientApi'; +import type { + UpdateCheckResponse, + BundleListItem, + BundleListResponse, + OtaResolveResponse, +} from '../api/types'; import { defaultChannel } from '../context'; import { readCurrentBundlePointer } from '../fs/bundlePointer'; import { getOrCreateInstallId } from '../fs/installId'; @@ -12,6 +22,22 @@ import { getBundleDropRuntimeConfig } from '../runtime/initState'; import { getFailedBundleHashes, isBundleHashFailed } from './rollbackState'; import { getDownloadedBundlePathNative } from '../native/bundleDropNative'; import { advertisedPatchAlgorithms } from '../patch-engine/patchOperations'; +import { + fetchRuntimeDeliveryManifest, + reportActiveInstall, + resolveFromRuntimeDeliveryManifest, + shouldRollbackFromLastKnownRevocations, + type RuntimeDeliveryResolveContext, +} from '../runtime-delivery/runtimeDelivery'; +import { recordRuntimeDeliveryDiagnostic } from '../runtime-delivery/diagnostics'; +import { RuntimeDeliveryManifestError } from '../runtime-delivery/manifestVerifier'; +import { isRuntimeDeliveryConfigured } from '../loadConfig'; + +const ACTIVE_INSTALL_HEARTBEAT_VERSION = 1 as const; + +type ServerResolveOptions = { + activeInstallHeartbeatVersion?: typeof ACTIVE_INSTALL_HEARTBEAT_VERSION; +}; function assertInstallDecisionShape( decision: Extract>['data'], { action: 'INSTALL' }>, @@ -72,109 +98,256 @@ export async function getAvailableChannels(): Promise { } } +async function readResolveContext(channelName: string): Promise { + const [currentPtr, nativeBundlePath, userProperties, installId, rejectedHashes] = await Promise.all([ + readCurrentBundlePointer(), + getDownloadedBundlePathNative(), + getCurrentUserProperties(), + getOrCreateInstallId(), + getFailedBundleHashes(), + ]); + const supportsXdelta = await import('../native/fs') + .then(module => module.default.supportsXdelta()) + .catch(() => false); + return { + channelName, + currentHash: nativeBundlePath && currentPtr?.hash ? currentPtr.hash : null, + rejectedHashes, + installId, + patchAlgorithms: advertisedPatchAlgorithms(supportsXdelta), + supportsContentAddressedAssets: true, + environment: getBundleDropRuntimeConfig()?.environment ?? null, + userProperties, + }; +} + +async function mapServerDecision( + decision: OtaResolveResponse, + channelName: string, + onStatusUpdate?: (status: string) => void, +): Promise { + if (decision.action === 'NOOP') { + const incompatible = decision.reason === 'NO_COMPATIBLE_BUNDLE'; + onStatusUpdate?.(incompatible + ? '⛔️ No compatible update for this binary' + : '✅ You have the latest version'); + return { + action: 'NOOP', + upToDate: !incompatible, + channelName, + reason: decision.reason, + incompatible: incompatible || undefined, + requestedRuntimeVersion: decision.requestedRuntimeVersion, + latestRuntimeVersionOnChannel: decision.latestRuntimeVersionOnChannel, + }; + } + if (decision.action === 'ROLLBACK') { + onStatusUpdate?.('↩️ Rollback requested'); + return { action: 'ROLLBACK', channelName, reason: decision.reason }; + } + assertInstallDecisionShape(decision); + const targetHash = decision.target.bundleHash; + if (await isBundleHashFailed(targetHash)) { + onStatusUpdate?.('✅ Current bundle retained; latest update previously failed on this device'); + return { + action: 'NOOP', + upToDate: false, + channelName, + reason: 'BUNDLE_PREVIOUSLY_FAILED', + skippedFailedBundle: true, + skippedHash: targetHash, + }; + } + onStatusUpdate?.('⬇️ Update available'); + return { + action: 'INSTALL', + upToDate: false, + channelName, + hash: targetHash, + bundleHash: targetHash, + mode: decision.mode, + baseHash: decision.mode === 'patch' ? decision.baseHash : undefined, + patchSet: decision.mode === 'patch' ? decision.patchSet : undefined, + fallback: decision.mode === 'patch' ? decision.fallback : undefined, + downloadUrl: decision.target.downloadUrl, + manifestUrl: decision.target.manifestUrl, + bundleVersion: decision.target.bundleVersion, + version: decision.target.version, + runtimeVersion: decision.target.runtimeVersion, + }; +} + +async function resolveWithServer( + context: RuntimeDeliveryResolveContext, + onStatusUpdate?: (status: string) => void, + options: ServerResolveOptions = {}, +): Promise { + const response = await postOtaResolve(config.project.slug, { + channelName: context.channelName, + platform, + runtimeVersion: runtimeVersion ?? null, + environment: context.environment, + currentHash: context.currentHash, + currentUserProperties: context.userProperties, + rejectedHashes: context.rejectedHashes, + installId: context.installId, + transport: { + manifestVersion: 1, + patchAlgorithms: context.patchAlgorithms, + supportsContentAddressedAssets: context.supportsContentAddressedAssets, + ...(options.activeInstallHeartbeatVersion + ? { activeInstallHeartbeatVersion: options.activeInstallHeartbeatVersion } + : {}), + }, + }); + return mapServerDecision(response.data, context.channelName, onStatusUpdate); +} + +function emitLocalDecisionStatus( + decision: UpdateCheckResponse, + onStatusUpdate?: (status: string) => void, +): void { + if (decision.action === 'INSTALL') onStatusUpdate?.('⬇️ Update available'); + else if (decision.action === 'ROLLBACK') onStatusUpdate?.('↩️ Rollback requested'); + else onStatusUpdate?.('✅ You have the latest version'); +} + export async function checkForUpdate( channelName = defaultChannel, onStatusUpdate?: (status: string) => void, ): Promise { - const { project } = config; - + if (!config.project?.slug) { + const error = new Error('Missing project slug in bundle.drop.config.js'); + console.warn('⚠️ checkForUpdate failed:', error.toString()); + return null; + } + onStatusUpdate?.('🔍 Checking for updates...'); + let context: RuntimeDeliveryResolveContext | null = null; try { - if (!project?.slug) { - throw new Error('Missing project slug in bundle.drop.config.js'); + context = await readResolveContext(channelName); + if (!isRuntimeDeliveryConfigured(config.runtimeDelivery) || !runtimeVersion) { + return await resolveWithServer(context, onStatusUpdate); } - onStatusUpdate?.('🔍 Checking for updates...'); - - const [currentPtr, nativeBundlePath, currentUserProperties, installId, rejectedHashes] = await Promise.all([ - readCurrentBundlePointer(), - getDownloadedBundlePathNative(), - getCurrentUserProperties(), - getOrCreateInstallId(), - getFailedBundleHashes(), - ]); - const currentHash = nativeBundlePath && currentPtr?.hash ? currentPtr.hash : null; - const appEnvironment = getBundleDropRuntimeConfig()?.environment ?? null; - const supportsXdelta = await import('../native/fs') - .then(module => module.default.supportsXdelta()) - .catch(() => false); - const res = await postOtaResolve(project.slug, { - channelName, - platform, - runtimeVersion: runtimeVersion ?? null, - environment: appEnvironment, - currentHash, - currentUserProperties, - rejectedHashes, - installId, - transport: { - manifestVersion: 1, - patchAlgorithms: advertisedPatchAlgorithms(supportsXdelta), - supportsContentAddressedAssets: true, - }, - }); - - const decision = res.data; - if (decision.action === 'NOOP') { - const incompatible = decision.reason === 'NO_COMPATIBLE_BUNDLE'; - if (incompatible) { - onStatusUpdate?.('⛔️ No compatible update for this binary'); - } else { - onStatusUpdate?.('✅ You have the latest version'); + reportActiveInstall(context); + try { + const manifest = await fetchRuntimeDeliveryManifest(channelName); + if (manifest.resolutionMode === 'dynamic') { + recordRuntimeDeliveryDiagnostic('origin_fallback', { + channelName, + reason: `dynamic:${manifest.dynamicReason ?? 'unspecified'}`, + }); + return await resolveWithServer(context, onStatusUpdate, { + activeInstallHeartbeatVersion: ACTIVE_INSTALL_HEARTBEAT_VERSION, + }); } - return { - action: 'NOOP', - upToDate: !incompatible, + const local = await resolveFromRuntimeDeliveryManifest(manifest, context); + emitLocalDecisionStatus(local, onStatusUpdate); + return local; + } catch (manifestError) { + recordRuntimeDeliveryDiagnostic('origin_fallback', { channelName, - reason: decision.reason, - incompatible: incompatible || undefined, - requestedRuntimeVersion: decision.requestedRuntimeVersion, - latestRuntimeVersionOnChannel: decision.latestRuntimeVersionOnChannel, - }; + reason: manifestError instanceof RuntimeDeliveryManifestError + ? manifestError.code + : 'manifest_error', + }); + console.warn('[BundleDrop] manifest delivery unavailable; falling back to /resolve:', manifestError); + return await resolveWithServer(context, onStatusUpdate, { + activeInstallHeartbeatVersion: ACTIVE_INSTALL_HEARTBEAT_VERSION, + }); } - if (decision.action === 'ROLLBACK') { + } catch (error) { + if ( + isRuntimeDeliveryConfigured(config.runtimeDelivery) && + context && + await shouldRollbackFromLastKnownRevocations(channelName, context.currentHash).catch(() => false) + ) { onStatusUpdate?.('↩️ Rollback requested'); - return { action: 'ROLLBACK', channelName, reason: decision.reason }; - } - - assertInstallDecisionShape(decision); - - // INSTALL - const targetHash = decision.target.bundleHash; - if (await isBundleHashFailed(targetHash)) { - onStatusUpdate?.('✅ Current bundle retained; latest update previously failed on this device'); return { - action: 'NOOP', - upToDate: false, + action: 'ROLLBACK', channelName, - reason: 'BUNDLE_PREVIOUSLY_FAILED', - skippedFailedBundle: true, - skippedHash: targetHash, + reason: 'CURRENT_REVOKED_ORIGIN_UNAVAILABLE', }; } - - onStatusUpdate?.('⬇️ Update available'); - return { - action: 'INSTALL', - upToDate: false, - channelName, - hash: targetHash, - bundleHash: targetHash, - mode: decision.mode, - baseHash: decision.mode === 'patch' ? decision.baseHash : undefined, - patchSet: decision.mode === 'patch' ? decision.patchSet : undefined, - fallback: decision.mode === 'patch' ? decision.fallback : undefined, - downloadUrl: decision.target.downloadUrl, - manifestUrl: decision.target.manifestUrl, - bundleVersion: decision.target.bundleVersion, - version: decision.target.version, - runtimeVersion: decision.target.runtimeVersion, - }; - } catch (e) { - console.warn('⚠️ checkForUpdate failed:', e?.toString?.() || e); + console.warn('⚠️ checkForUpdate failed:', error?.toString?.() || error); return null; } } +export async function authorizeRuntimeDeliveryUpdate( + decision: UpdateCheckResponse, +): Promise { + if (decision.action !== 'INSTALL' || !decision.runtimeDelivery || !decision.channelName) { + return decision; + } + const context = await readResolveContext(decision.channelName); + try { + if (!runtimeVersion) throw new Error('Runtime version is required for artifact authorization'); + const response = await postOtaArtifactAuthorization(config.project.slug, { + channelName: decision.channelName, + platform, + runtimeVersion, + generation: decision.runtimeDelivery.generation, + targetReleaseRef: decision.runtimeDelivery.targetReleaseRef, + targetHash: decision.hash!, + mode: decision.runtimeDelivery.selectedMode, + patchArtifactRef: decision.runtimeDelivery.patchArtifactRef ?? null, + currentHash: context.currentHash, + rejectedHashes: context.rejectedHashes, + installId: context.installId, + transport: { + manifestVersion: 1, + patchAlgorithms: context.patchAlgorithms, + supportsContentAddressedAssets: context.supportsContentAddressedAssets, + }, + }); + const authorized = await mapServerDecision(response.data, decision.channelName); + if (authorized.action === 'INSTALL') { + if (authorized.hash !== decision.hash || authorized.runtimeVersion !== runtimeVersion) { + throw new Error('Artifact authorization returned a different target identity'); + } + const selection = decision.runtimeDelivery; + if (authorized.mode === 'patch') { + if ( + selection.selectedMode !== 'patch' || + authorized.baseHash !== selection.baseHash || + authorized.patchSet?.algorithm !== selection.patchAlgorithm || + authorized.patchSet.patchSetHash !== selection.patchSetHash || + (authorized.patchSet.assets?.missingAssetsHash ?? null) !== + (selection.missingAssetsHash ?? null) + ) { + throw new Error('Artifact authorization returned an unsafe patch selection'); + } + } + return { ...authorized, runtimeDelivery: selection }; + } + return authorized; + } catch (error) { + recordRuntimeDeliveryDiagnostic('origin_fallback', { + channelName: decision.channelName, + reason: 'artifact_authorization_failed', + }); + console.warn('[BundleDrop] artifact authorization failed; falling back to /resolve:', error); + try { + return await resolveWithServer(context, undefined, { + activeInstallHeartbeatVersion: ACTIVE_INSTALL_HEARTBEAT_VERSION, + }); + } catch { + if (await shouldRollbackFromLastKnownRevocations( + decision.channelName, + context.currentHash, + ).catch(() => false)) { + return { + action: 'ROLLBACK', + channelName: decision.channelName, + reason: 'CURRENT_REVOKED_ORIGIN_UNAVAILABLE', + }; + } + return null; + } + } +} + export async function getInstalledBundleInfo(): Promise { return readBundleInfo(); } diff --git a/src/metro.ts b/src/metro.ts index d60ff06..30bdaad 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -4,6 +4,10 @@ import path from 'path'; import { resolveExpoMetroRuntimeVersion } from './expo'; import type { ExpoMetroRuntimeVersion } from './expo'; import type { ExpoBuildIdentityReceipt } from './expo/buildReceipt'; +import { + readGeneratedRuntimeDeliveryBootstrap, + type RuntimeDeliveryConfig, +} from './runtime-delivery/bootstrapConfig'; export type { ExpoBuildIdentityReceipt } from './expo/buildReceipt'; @@ -15,32 +19,76 @@ export type BundleDropMetroConfig = { [key: string]: unknown; }; -export type WithBundleDropExpoOptions = { +export type WithBundleDropOptions = { projectRoot?: string; }; +export type WithBundleDropExpoOptions = WithBundleDropOptions; + +type BaseBundleDropConfig = { + serverUrl?: string; + org?: { slug?: string }; + project?: { slug?: string }; +}; + +const loadBaseConfig = (projectRoot: string): BaseBundleDropConfig => { + const configPath = path.join(projectRoot, 'bundle.drop.config.js'); + if (!fs.existsSync(configPath)) { + throw new Error('Bundle Drop Metro setup requires bundle.drop.config.js in the project root.'); + } + delete require.cache[require.resolve(configPath)]; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const config = require(configPath) as BaseBundleDropConfig; + if (!config?.serverUrl || !config.org?.slug || !config.project?.slug) { + throw new Error( + 'bundle.drop.config.js must define serverUrl, org.slug, and project.slug before Metro setup.', + ); + } + return config; +}; + +const resolveRuntimeDelivery = ( + projectRoot: string, + baseConfig: BaseBundleDropConfig, +): RuntimeDeliveryConfig | undefined => { + const generated = readGeneratedRuntimeDeliveryBootstrap({ + projectRoot, + expectedIdentity: { + serverUrl: baseConfig.serverUrl!, + orgSlug: baseConfig.org!.slug!, + projectSlug: baseConfig.project!.slug!, + }, + }); + return generated?.runtimeDelivery; +}; + const writeGeneratedRuntimeConfig = (params: { projectRoot: string; - ios: ExpoMetroRuntimeVersion; - android: ExpoMetroRuntimeVersion; + runtimeDelivery?: RuntimeDeliveryConfig | Record; + runtimeVersion?: { + ios: ExpoMetroRuntimeVersion; + android: ExpoMetroRuntimeVersion; + }; }): string => { const generatedDirectory = path.join(params.projectRoot, '.bundle-drop', 'generated'); const generatedConfigPath = path.join(generatedDirectory, 'bundle.drop.config.js'); fs.ensureDirSync(generatedDirectory); - const baseConfigPath = path.join(params.projectRoot, 'bundle.drop.config.js'); - if (!fs.existsSync(baseConfigPath)) { - throw new Error('Bundle Drop Expo Metro setup requires bundle.drop.config.js in the project root.'); + const generatedFields: string[] = []; + if (params.runtimeVersion) { + generatedFields.push(` runtimeVersion: ${JSON.stringify(params.runtimeVersion)},`); + } + if (params.runtimeDelivery) { + generatedFields.push(` runtimeDelivery: ${JSON.stringify(params.runtimeDelivery)},`); } const content = [ "'use strict';", '', "const baseConfig = require('../../bundle.drop.config.js');", + 'const resolvedConfig = { ...baseConfig };', + 'delete resolvedConfig.runtimeDelivery;', 'module.exports = {', - ' ...baseConfig,', - ` runtimeVersion: ${JSON.stringify({ - ios: params.ios, - android: params.android, - })},`, + ' ...resolvedConfig,', + ...generatedFields, '};', '', ].join('\n'); @@ -48,6 +96,39 @@ const writeGeneratedRuntimeConfig = (params: { return generatedConfigPath; }; +const mergeMetroAlias = ( + config: T, + generatedConfigPath: string, +): T => ({ + ...config, + resolver: { + ...(config.resolver || {}), + extraNodeModules: { + ...(config.resolver?.extraNodeModules || {}), + 'bundle-drop-config': generatedConfigPath, + }, + }, +}); + +/** + * Preserves a bare React Native Metro config and resolves Bundle Drop through + * the project-owned, package-validated generated bootstrap. + */ +export function withBundleDrop( + config: T, + options: WithBundleDropOptions = {}, +): T { + const projectRoot = path.resolve(options.projectRoot || process.cwd()); + const baseConfig = loadBaseConfig(projectRoot); + const generatedConfigPath = writeGeneratedRuntimeConfig({ + projectRoot, + runtimeDelivery: resolveRuntimeDelivery(projectRoot, baseConfig), + }); + return mergeMetroAlias(config, generatedConfigPath); +} + +export const withBundleDropBare = withBundleDrop; + /** * Preserves the caller's Expo Metro config and only adds Bundle Drop's config * alias after resolving the same concrete identity used by build and upload. @@ -62,18 +143,14 @@ export async function withBundleDropExpo( resolveExpoMetroRuntimeVersion(projectRoot, 'ios'), resolveExpoMetroRuntimeVersion(projectRoot, 'android'), ]); - const generatedConfigPath = writeGeneratedRuntimeConfig({ projectRoot, ios, android }); + const baseConfig = loadBaseConfig(projectRoot); + const generatedConfigPath = writeGeneratedRuntimeConfig({ + projectRoot, + runtimeDelivery: resolveRuntimeDelivery(projectRoot, baseConfig), + runtimeVersion: { ios, android }, + }); - return { - ...config, - resolver: { - ...(config.resolver || {}), - extraNodeModules: { - ...(config.resolver?.extraNodeModules || {}), - 'bundle-drop-config': generatedConfigPath, - }, - }, - }; + return mergeMetroAlias(config, generatedConfigPath); } export default withBundleDropExpo; diff --git a/src/native/bundleDropNative.ts b/src/native/bundleDropNative.ts index 11ef614..a361c87 100644 --- a/src/native/bundleDropNative.ts +++ b/src/native/bundleDropNative.ts @@ -7,7 +7,8 @@ export function isBundleDropNativeAvailable(): boolean { } export function isExpoOtaStartupEnabledNative(): boolean { - return BundleDropExpoIdentity?.otaStartupEnabled === true; + const nativeValue = BundleDropExpoIdentity?.otaStartupEnabled; + return nativeValue === true || nativeValue === 1; } export async function getDownloadedBundlePathNative(): Promise { diff --git a/src/native/fs.ts b/src/native/fs.ts index 372c110..e2f897e 100644 --- a/src/native/fs.ts +++ b/src/native/fs.ts @@ -56,11 +56,52 @@ async function downloadFile(url: string, destPath: string): Promise { return BundleDrop.fsDownloadFile(url, destPath); } +async function downloadFileBounded( + url: string, + destPath: string, + maxBytes: number, + timeoutMs: number, +): Promise { + assertModule(); + if (typeof BundleDrop.fsDownloadFileBounded !== 'function') { + throw new Error( + 'BundleDrop native module is outdated. Rebuild the app for bounded manifest downloads.', + ); + } + return BundleDrop.fsDownloadFileBounded(url, destPath, maxBytes, timeoutMs); +} + async function sha256File(path: string): Promise { assertModule(); return BundleDrop.fsSha256File(path); } +async function sha256String(value: string): Promise { + assertModule(); + if (typeof BundleDrop.fsSha256String !== 'function') { + throw new Error('BundleDrop native module is outdated. Rebuild the app for runtime delivery.'); + } + return BundleDrop.fsSha256String(value); +} + +async function verifyEs256Signature( + signingInput: string, + signatureBase64Url: string, + xBase64Url: string, + yBase64Url: string, +): Promise { + assertModule(); + if (typeof BundleDrop.fsVerifyEs256Signature !== 'function') { + throw new Error('BundleDrop native module is outdated. Rebuild the app for runtime delivery.'); + } + return BundleDrop.fsVerifyEs256Signature( + signingInput, + signatureBase64Url, + xBase64Url, + yBase64Url, + ); +} + async function fileSize(path: string): Promise { assertModule(); return BundleDrop.fsFileSize(path); @@ -104,7 +145,10 @@ const BundleDropFS = { moveFile, unzip, downloadFile, + downloadFileBounded, sha256File, + sha256String, + verifyEs256Signature, fileSize, copyFile, applyXdelta, diff --git a/src/patch-engine/installFromPatchSet.ts b/src/patch-engine/installFromPatchSet.ts index 50fb90c..8030c90 100644 --- a/src/patch-engine/installFromPatchSet.ts +++ b/src/patch-engine/installFromPatchSet.ts @@ -3,7 +3,7 @@ import RNFS from '../native/fs'; import { BUNDLE_DROP_ROOT, platform as devicePlatform } from '../context'; import { ensureDir } from '../fs/fsUtils'; import { readCurrentBundlePointer } from '../fs/bundlePointer'; -import { InstallPhaseError } from '../errors'; +import { InstallPhaseError, isInstallPhaseError } from '../errors'; import { BUNDLE_MANIFEST, BundleManifestFile, @@ -38,6 +38,8 @@ type InstallFromPatchSetParams = { algorithm: SupportedPatchAlgorithm; platform?: 'ios' | 'android'; statusCb?: (status: string) => void; + expectedManifestHash?: string; + expectedJsBundleHash?: string; }; const reconstructPatchTarget = async ( @@ -82,6 +84,8 @@ export async function installFromPatchSet(params: InstallFromPatchSetParams): Pr algorithm, statusCb, platform = devicePlatform, + expectedManifestHash, + expectedJsBundleHash, } = params; assertCanonicalBundleHash(baseHash, 'base hash'); assertCanonicalBundleHash(targetHash, 'target hash'); @@ -141,13 +145,17 @@ export async function installFromPatchSet(params: InstallFromPatchSetParams): Pr } const assetsZipPath = `${BUNDLE_DROP_ROOT}/bundles/_patch_assets_${targetHash}.zip`; try { - await RNFS.downloadFile(missingAssetsUrl, assetsZipPath); + try { + await RNFS.downloadFile(missingAssetsUrl, assetsZipPath); + } catch (e) { + throw new InstallPhaseError('download', e); + } if ((await RNFS.sha256File(assetsZipPath)) !== missingAssetsHash) { throw new Error('Missing assets archive hash mismatch'); } await RNFS.unzip(assetsZipPath, `${patchDir}/missing-assets`); } catch (e) { - throw new InstallPhaseError('install', e); + throw isInstallPhaseError(e) ? e : new InstallPhaseError('install', e); } finally { try { await RNFS.unlink(assetsZipPath); } catch { /* best-effort cleanup */ } } @@ -156,6 +164,12 @@ export async function installFromPatchSet(params: InstallFromPatchSetParams): Pr try { targetManifest = await readJsonFile(`${patchDir}/${BUNDLE_MANIFEST}`); assertValidBundleManifest(targetManifest, targetHash, platform); + if (expectedManifestHash && targetManifest.manifestHash !== expectedManifestHash) { + throw new Error('Signed manifest hash does not match patch target manifest'); + } + if (expectedJsBundleHash && targetManifest.jsBundleHash !== expectedJsBundleHash) { + throw new Error('Signed JavaScript bundle hash does not match patch target manifest'); + } await reconstructPatchTarget(baseDir, baseHash, platform, patchDir, targetTempDir, targetManifest, algorithm); await RNFS.writeFile( `${targetTempDir}/${BUNDLE_MANIFEST}`, diff --git a/src/patch-engine/patchTransport.ts b/src/patch-engine/patchTransport.ts index f49fe51..848894a 100644 --- a/src/patch-engine/patchTransport.ts +++ b/src/patch-engine/patchTransport.ts @@ -5,6 +5,7 @@ import type { OtaPatchSet } from '../api/types'; import type { InstallResult } from '../install/bundleInstallShared'; import { installFromPatchSet } from './installFromPatchSet'; import { isSupportedPatchAlgorithm, type SupportedPatchAlgorithm } from './patchOperations'; +import { isArtifactCapabilityRejected } from '../runtime-delivery/artifactCapability'; export type PatchTransportTarget = { mode?: 'full' | 'patch'; @@ -12,6 +13,8 @@ export type PatchTransportTarget = { manifestUrl?: string; baseHash?: string; patchSet?: OtaPatchSet; + expectedManifestHash?: string; + expectedJsBundleHash?: string; }; type TryInstallPatchTransportParams = { @@ -103,10 +106,17 @@ export const tryInstallPatchTransport = async ({ baseHash: target.baseHash, targetHash: target.hash, algorithm: target.patchSet.algorithm, + ...(target.expectedManifestHash + ? { expectedManifestHash: target.expectedManifestHash } + : {}), + ...(target.expectedJsBundleHash + ? { expectedJsBundleHash: target.expectedJsBundleHash } + : {}), platform, statusCb, }); } catch (e) { + if (isArtifactCapabilityRejected(e)) throw e; await reportPatchInstallFailureBeforeFallback({ projectSlug, platform, diff --git a/src/runtime-delivery/artifactCapability.ts b/src/runtime-delivery/artifactCapability.ts new file mode 100644 index 0000000..5c6e2d1 --- /dev/null +++ b/src/runtime-delivery/artifactCapability.ts @@ -0,0 +1,30 @@ +import { isInstallPhaseError } from '../errors'; + +const CAPABILITY_HTTP_STATUS = /(?:^|\b)HTTP\s+(401|403)(?:\b|:)/i; + +function readHttpStatus(error: unknown, depth = 0): number | undefined { + if (depth > 5 || !error) return undefined; + if (typeof error === 'string') { + const match = CAPABILITY_HTTP_STATUS.exec(error); + return match ? Number(match[1]) : undefined; + } + if (typeof error !== 'object') return undefined; + + const value = error as Record; + for (const candidate of [value.status, value.statusCode, value.httpStatus]) { + if (candidate === 401 || candidate === 403) return candidate; + } + const messageStatus = readHttpStatus(value.message, depth + 1); + if (messageStatus) return messageStatus; + + const nested = isInstallPhaseError(error) + ? error.originalCause + : value.cause ?? value.error ?? value.userInfo; + return readHttpStatus(nested, depth + 1); +} + +export function isArtifactCapabilityRejected(error: unknown): boolean { + return isInstallPhaseError(error) && + error.phase === 'download' && + Boolean(readHttpStatus(error.originalCause)); +} diff --git a/src/runtime-delivery/authorityLeaseVerifier.ts b/src/runtime-delivery/authorityLeaseVerifier.ts new file mode 100644 index 0000000..66a3fbb --- /dev/null +++ b/src/runtime-delivery/authorityLeaseVerifier.ts @@ -0,0 +1,153 @@ +import { decodeBase64UrlUtf8 } from './encoding'; +import { + RuntimeDeliveryManifestError, + verifyRuntimeDeliverySignedPayload, +} from './manifestVerifier'; +import { + RUNTIME_DELIVERY_AUTHORITY_LEASE_JWS_TYPE, + type RuntimeDeliveryAuthorityLease, + type RuntimeDeliveryAuthorityLeaseV1, + type RuntimeDeliveryPublicKey, +} from './types'; + +export const MAX_RUNTIME_DELIVERY_AUTHORITY_LEASE_MS = 15_000; +const MAX_CLOCK_SKEW_MS = 5_000; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +function normalizeDnsOrIpv4Host(value: string): string | undefined { + const host = value.toLowerCase(); + if (host.length > 253 || !/^[a-z0-9.-]+$/.test(host)) return undefined; + + const labels = host.split('.'); + if (labels.some(label => + !label || + label.length > 63 || + label.startsWith('-') || + label.endsWith('-') + )) { + return undefined; + } + + if (labels.every(label => /^\d+$/.test(label))) { + if (labels.length !== 4 || labels.some(label => Number(label) > 255)) return undefined; + } + return host; +} + +function normalizeManifestOrigin(value: string): string | undefined { + const match = /^https:\/\/([^/?#@]+)\/?$/i.exec(value); + if (!match) return undefined; + + const authority = match[1]; + const authorityMatch = /^([^:]+)(?::(\d{1,5}))?$/.exec(authority); + if (!authorityMatch) return undefined; + + const host = normalizeDnsOrIpv4Host(authorityMatch[1]); + const port = authorityMatch[2] ? Number(authorityMatch[2]) : 443; + if (!host || port < 1 || port > 65_535) return undefined; + + return `https://${host}${port === 443 ? '' : `:${port}`}`; +} + +function authorityError( + error: unknown, + fallbackMessage: string, +): RuntimeDeliveryManifestError { + if (error instanceof RuntimeDeliveryManifestError) { + const mapped = { + body_too_large: 'authority_body_too_large', + invalid_signature: 'authority_invalid_signature', + unknown_key: 'authority_unknown_key', + }[error.code] as + | 'authority_body_too_large' + | 'authority_invalid_signature' + | 'authority_unknown_key' + | undefined; + if (mapped) return new RuntimeDeliveryManifestError(mapped, error.message, { cause: error }); + } + return new RuntimeDeliveryManifestError('authority_invalid', fallbackMessage, { cause: error }); +} + +export async function verifyRuntimeDeliveryAuthorityLease( + serializedJws: string, + expectedManifestOrigin: string, + publicKeys: Record, + now = Date.now(), +): Promise { + let payloadValue: string; + try { + payloadValue = await verifyRuntimeDeliverySignedPayload( + serializedJws, + RUNTIME_DELIVERY_AUTHORITY_LEASE_JWS_TYPE, + publicKeys, + ); + } catch (error) { + throw authorityError(error, 'Runtime delivery authority lease signature is invalid'); + } + + let payload: unknown; + try { + payload = JSON.parse(decodeBase64UrlUtf8(payloadValue)); + } catch (error) { + throw authorityError(error, 'Runtime delivery authority lease payload is invalid'); + } + const schemaVersion = isRecord(payload) ? payload.schemaVersion : undefined; + const expectedKeys = schemaVersion === 2 + ? ['clientAuthority', 'expiresAt', 'generatedAt', 'manifestOrigin', 'schemaVersion', 'type'] + : ['expiresAt', 'generatedAt', 'manifestOrigin', 'schemaVersion', 'type']; + if ( + !isRecord(payload) || + Object.keys(payload).sort().join(',') !== expectedKeys.join(',') || + (payload.schemaVersion !== 1 && payload.schemaVersion !== 2) || + payload.type !== 'publisher-lease' || + typeof payload.manifestOrigin !== 'string' || + typeof payload.generatedAt !== 'string' || + typeof payload.expiresAt !== 'string' || + (payload.schemaVersion === 2 && + payload.clientAuthority !== 'enabled' && + payload.clientAuthority !== 'disabled') + ) { + throw new RuntimeDeliveryManifestError( + 'authority_invalid', + 'Runtime delivery authority lease payload is invalid', + ); + } + + const manifestOrigin = normalizeManifestOrigin(payload.manifestOrigin); + const expectedOrigin = normalizeManifestOrigin(expectedManifestOrigin); + if (!manifestOrigin || !expectedOrigin || manifestOrigin !== expectedOrigin) { + throw new RuntimeDeliveryManifestError( + 'authority_origin_mismatch', + 'Runtime delivery authority lease manifest origin mismatch', + ); + } + const generatedAt = Date.parse(payload.generatedAt); + const expiresAt = Date.parse(payload.expiresAt); + if ( + !Number.isFinite(generatedAt) || + !Number.isFinite(expiresAt) || + expiresAt <= generatedAt || + expiresAt - generatedAt > MAX_RUNTIME_DELIVERY_AUTHORITY_LEASE_MS || + generatedAt > now + MAX_CLOCK_SKEW_MS + ) { + throw new RuntimeDeliveryManifestError( + 'authority_invalid', + 'Runtime delivery authority lease validity window is invalid', + ); + } + if (expiresAt <= now) { + throw new RuntimeDeliveryManifestError( + 'authority_expired', + 'Runtime delivery authority lease is expired', + ); + } + if (payload.schemaVersion === 2 && payload.clientAuthority === 'disabled') { + throw new RuntimeDeliveryManifestError( + 'authority_disabled', + 'Runtime delivery client authority is disabled by the operator', + ); + } + return payload as RuntimeDeliveryAuthorityLease | RuntimeDeliveryAuthorityLeaseV1; +} diff --git a/src/runtime-delivery/bootstrapConfig.ts b/src/runtime-delivery/bootstrapConfig.ts new file mode 100644 index 0000000..3a70ed6 --- /dev/null +++ b/src/runtime-delivery/bootstrapConfig.ts @@ -0,0 +1,284 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { + inspectProjectFile, + removeProjectFile, + writeProjectFileAtomically, +} from '../CLI/scripts/safe-file-transaction'; + +export const RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION = 1; +export const RUNTIME_DELIVERY_BOOTSTRAP_PATH = path.join( + '.bundle-drop', + 'runtime-delivery.generated.json', +); +export const RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER = + '!.bundle-drop/runtime-delivery.generated.json'; + +const RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_BLOCK = [ + '# Bundle Drop: commit the public trust bootstrap; ignore generated runtime artifacts.', + '!.bundle-drop/', + '.bundle-drop/*', + RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER, +].join('\n'); + +export type RuntimeDeliveryPublicKey = { + kty: 'EC'; + crv: 'P-256'; + x: string; + y: string; +}; + +export type RuntimeDeliveryConfig = { + manifestBaseUrl: string; + manifestAccessId: string; + publicKeys: Record; +}; + +export type RuntimeDeliveryProjectIdentity = { + serverUrl: string; + orgSlug: string; + projectSlug: string; + projectId?: string; + orgId?: string; +}; + +export type GeneratedRuntimeDeliveryBootstrap = { + schemaVersion: typeof RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION; + project: RuntimeDeliveryProjectIdentity; + runtimeDelivery: RuntimeDeliveryConfig; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const normalizeUrl = (value: string): string => value.trim().replace(/\/+$/, ''); + +const isP256Coordinate = (value: unknown): value is string => + typeof value === 'string' && + /^[A-Za-z0-9_-]{43}$/.test(value) && + Buffer.from(value, 'base64url').length === 32; + +const normalizeHttpUrl = (value: unknown): string | null => { + if (typeof value !== 'string' || !value.trim()) return null; + try { + const url = new URL(value); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + return normalizeUrl(value); + } catch { + return null; + } +}; + +const normalizePublicKeys = ( + value: unknown, +): RuntimeDeliveryConfig['publicKeys'] | null => { + if (!isRecord(value)) return null; + const entries = Object.entries(value); + if (!entries.length) return null; + + const publicKeys: RuntimeDeliveryConfig['publicKeys'] = {}; + for (const [kid, candidate] of entries) { + if (!kid.trim() || !isRecord(candidate)) return null; + if (Object.keys(candidate).sort().join(',') !== 'crv,kty,x,y') return null; + if ( + candidate.kty !== 'EC' || + candidate.crv !== 'P-256' || + !isP256Coordinate(candidate.x) || + !isP256Coordinate(candidate.y) + ) { + return null; + } + publicKeys[kid] = { + kty: 'EC', + crv: 'P-256', + x: candidate.x, + y: candidate.y, + }; + } + return publicKeys; +}; + +/** + * Accepts the current backend-authorized trust shape and the temporary legacy + * wire shape. Explicit legacy non-authoritative modes must never create a + * package-managed bootstrap. + */ +export function normalizeRuntimeDeliveryBootstrap( + value: unknown, +): RuntimeDeliveryConfig | undefined { + if (!isRecord(value)) return undefined; + if (value.mode !== undefined && value.mode !== 'v2') return undefined; + + const manifestBaseUrl = normalizeHttpUrl(value.manifestBaseUrl); + if (!manifestBaseUrl) return undefined; + if ( + typeof value.manifestAccessId !== 'string' || + !/^[A-Za-z0-9_-]{22,128}$/.test(value.manifestAccessId) + ) { + return undefined; + } + const publicKeys = normalizePublicKeys(value.publicKeys); + if (!publicKeys) return undefined; + + return { + manifestBaseUrl, + manifestAccessId: value.manifestAccessId, + publicKeys, + }; +} + +export function createGeneratedRuntimeDeliveryBootstrap(params: { + identity: RuntimeDeliveryProjectIdentity; + runtimeDelivery: unknown; +}): GeneratedRuntimeDeliveryBootstrap | undefined { + const runtimeDelivery = normalizeRuntimeDeliveryBootstrap(params.runtimeDelivery); + const serverUrl = normalizeHttpUrl(params.identity.serverUrl); + const orgSlug = params.identity.orgSlug.trim(); + const projectSlug = params.identity.projectSlug.trim(); + const projectId = params.identity.projectId?.trim(); + const orgId = params.identity.orgId?.trim(); + if (!runtimeDelivery || !serverUrl || !orgSlug || !projectSlug) return undefined; + if (Boolean(projectId) !== Boolean(orgId)) return undefined; + + return { + schemaVersion: RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION, + project: { + serverUrl, + orgSlug, + projectSlug, + ...(projectId && orgId ? { projectId, orgId } : {}), + }, + runtimeDelivery, + }; +} + +export function parseGeneratedRuntimeDeliveryBootstrap( + value: unknown, + expectedIdentity?: RuntimeDeliveryProjectIdentity, +): GeneratedRuntimeDeliveryBootstrap { + if (!isRecord(value) || value.schemaVersion !== RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION) { + throw new Error( + `Runtime delivery bootstrap must use schemaVersion ${RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION}.`, + ); + } + if (!isRecord(value.project)) { + throw new Error('Runtime delivery bootstrap is missing its project identity.'); + } + const hasProjectId = Object.prototype.hasOwnProperty.call(value.project, 'projectId'); + const hasOrgId = Object.prototype.hasOwnProperty.call(value.project, 'orgId'); + if ( + hasProjectId !== hasOrgId || + (hasProjectId && + (typeof value.project.projectId !== 'string' || + !value.project.projectId.trim() || + typeof value.project.orgId !== 'string' || + !value.project.orgId.trim())) + ) { + throw new Error('Runtime delivery bootstrap contains an invalid stable project identity.'); + } + + const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ + identity: { + serverUrl: typeof value.project.serverUrl === 'string' ? value.project.serverUrl : '', + orgSlug: typeof value.project.orgSlug === 'string' ? value.project.orgSlug : '', + projectSlug: typeof value.project.projectSlug === 'string' ? value.project.projectSlug : '', + projectId: typeof value.project.projectId === 'string' ? value.project.projectId : undefined, + orgId: typeof value.project.orgId === 'string' ? value.project.orgId : undefined, + }, + runtimeDelivery: value.runtimeDelivery, + }); + if (!bootstrap) { + throw new Error('Runtime delivery bootstrap contains invalid trust configuration.'); + } + + if (expectedIdentity) { + const expected = { + serverUrl: normalizeUrl(expectedIdentity.serverUrl), + orgSlug: expectedIdentity.orgSlug.trim(), + projectSlug: expectedIdentity.projectSlug.trim(), + }; + if ( + bootstrap.project.serverUrl !== expected.serverUrl || + bootstrap.project.orgSlug !== expected.orgSlug || + bootstrap.project.projectSlug !== expected.projectSlug || + (expectedIdentity.projectId !== undefined && + bootstrap.project.projectId !== expectedIdentity.projectId.trim()) || + (expectedIdentity.orgId !== undefined && + bootstrap.project.orgId !== expectedIdentity.orgId.trim()) + ) { + throw new Error( + 'Runtime delivery bootstrap belongs to a different server, organization, or project.', + ); + } + } + + return bootstrap; +} + +export function runtimeDeliveryBootstrapPath(projectRoot: string): string { + return path.join(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH); +} + +export function addRuntimeDeliveryBootstrapGitignoreRules(content: string): string { + if (content.includes(RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER)) return content; + const prefix = content.trimEnd(); + return `${prefix}${prefix ? '\n\n' : ''}${RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_BLOCK}\n`; +} + +export function readGeneratedRuntimeDeliveryBootstrap(params: { + projectRoot: string; + expectedIdentity?: RuntimeDeliveryProjectIdentity; +}): GeneratedRuntimeDeliveryBootstrap | null { + const bootstrapPath = runtimeDeliveryBootstrapPath(params.projectRoot); + if (!fs.existsSync(bootstrapPath)) return null; + + let value: unknown; + try { + value = fs.readJsonSync(bootstrapPath); + } catch { + throw new Error(`Runtime delivery bootstrap is not valid JSON: ${bootstrapPath}`); + } + return parseGeneratedRuntimeDeliveryBootstrap(value, params.expectedIdentity); +} + +export function serializeGeneratedRuntimeDeliveryBootstrap( + bootstrap: GeneratedRuntimeDeliveryBootstrap, +): string { + return `${JSON.stringify(bootstrap, null, 2)}\n`; +} + +export async function writeGeneratedRuntimeDeliveryBootstrap(params: { + projectRoot: string; + bootstrap: GeneratedRuntimeDeliveryBootstrap; +}): Promise { + const bootstrapPath = runtimeDeliveryBootstrapPath(params.projectRoot); + writeProjectFileAtomically( + params.projectRoot, + RUNTIME_DELIVERY_BOOTSTRAP_PATH, + serializeGeneratedRuntimeDeliveryBootstrap(params.bootstrap), + ); + return bootstrapPath; +} + +export async function removeGeneratedRuntimeDeliveryBootstrap( + projectRoot: string, +): Promise { + const bootstrapPath = runtimeDeliveryBootstrapPath(projectRoot); + if (!inspectProjectFile(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH).exists) return null; + removeProjectFile(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH); + return bootstrapPath; +} + +export async function ensureRuntimeDeliveryBootstrapGitignore( + projectRoot: string, +): Promise { + const gitignorePath = path.join(projectRoot, '.gitignore'); + const original = fs.existsSync(gitignorePath) + ? await fs.readFile(gitignorePath, 'utf8') + : ''; + const updated = addRuntimeDeliveryBootstrapGitignoreRules(original); + if (updated !== original) { + writeProjectFileAtomically(projectRoot, '.gitignore', updated); + } + return gitignorePath; +} diff --git a/src/runtime-delivery/diagnostics.ts b/src/runtime-delivery/diagnostics.ts new file mode 100644 index 0000000..965a293 --- /dev/null +++ b/src/runtime-delivery/diagnostics.ts @@ -0,0 +1,77 @@ +import { getBundleDropRuntimeConfig } from '../runtime/initState'; + +export const RUNTIME_DELIVERY_DIAGNOSTIC_NAMES = [ + 'manifest_hit', + 'dynamic_manifest', + 'origin_fallback', + 'invalid_signature', + 'unknown_key', + 'lane_mismatch', + 'generation_regression', + 'generation_equivocation', + 'manifest_http_error', + 'manifest_network_error', + 'manifest_timeout', + 'manifest_too_large', + 'manifest_invalid', + 'manifest_stream_unavailable', + 'authority_lease_http_error', + 'authority_lease_network_error', + 'authority_lease_timeout', + 'authority_lease_too_large', + 'authority_lease_invalid', + 'authority_lease_invalid_signature', + 'authority_lease_unknown_key', + 'authority_lease_expired', + 'authority_lease_origin_mismatch', + 'authority_lease_disabled', +] as const; + +export type RuntimeDeliveryDiagnosticName = typeof RUNTIME_DELIVERY_DIAGNOSTIC_NAMES[number]; + +export type RuntimeDeliveryDiagnosticDetails = { + channelName?: string; + reason?: string; + status?: number; +}; + +export type RuntimeDeliveryDiagnosticEvent = { + name: RuntimeDeliveryDiagnosticName; + count: number; + timestamp: string; + details?: RuntimeDeliveryDiagnosticDetails; +}; + +export type RuntimeDeliveryDiagnosticCounters = Record; + +const counters = Object.fromEntries( + RUNTIME_DELIVERY_DIAGNOSTIC_NAMES.map(name => [name, 0]), +) as RuntimeDeliveryDiagnosticCounters; + +export function recordRuntimeDeliveryDiagnostic( + name: RuntimeDeliveryDiagnosticName, + details?: RuntimeDeliveryDiagnosticDetails, +): void { + counters[name] += 1; + const listener = getBundleDropRuntimeConfig()?.onRuntimeDeliveryDiagnostic; + if (!listener) return; + + try { + listener({ + name, + count: counters[name], + timestamp: new Date().toISOString(), + ...(details ? { details } : {}), + }); + } catch (error) { + console.warn('[BundleDrop] runtime-delivery diagnostic listener failed:', error); + } +} + +export function getRuntimeDeliveryDiagnosticCounters(): RuntimeDeliveryDiagnosticCounters { + return { ...counters }; +} + +export function resetRuntimeDeliveryDiagnosticsForTests(): void { + for (const name of RUNTIME_DELIVERY_DIAGNOSTIC_NAMES) counters[name] = 0; +} diff --git a/src/runtime-delivery/encoding.ts b/src/runtime-delivery/encoding.ts new file mode 100644 index 0000000..4ceff5f --- /dev/null +++ b/src/runtime-delivery/encoding.ts @@ -0,0 +1,135 @@ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function utf8Bytes(value: string): number[] { + const bytes: number[] = []; + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if (codePoint <= 0x7f) { + bytes.push(codePoint); + } else if (codePoint <= 0x7ff) { + bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)); + } else if (codePoint <= 0xffff) { + bytes.push( + 0xe0 | (codePoint >> 12), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ); + } else { + bytes.push( + 0xf0 | (codePoint >> 18), + 0x80 | ((codePoint >> 12) & 0x3f), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ); + } + } + return bytes; +} + +function bytesToBase64(bytes: number[]): string { + let result = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index]; + const second = bytes[index + 1]; + const third = bytes[index + 2]; + const triple = (first << 16) | ((second || 0) << 8) | (third || 0); + result += BASE64_ALPHABET[(triple >> 18) & 0x3f]; + result += BASE64_ALPHABET[(triple >> 12) & 0x3f]; + result += second === undefined ? '=' : BASE64_ALPHABET[(triple >> 6) & 0x3f]; + result += third === undefined ? '=' : BASE64_ALPHABET[triple & 0x3f]; + } + return result; +} + +export function encodeBase64UrlUtf8(value: string): string { + return bytesToBase64(utf8Bytes(value)) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +export function decodeBase64UrlBytes(value: string): number[] { + if (!/^[A-Za-z0-9_-]*$/.test(value)) { + throw new Error('Invalid base64url value'); + } + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const bytes: number[] = []; + for (let index = 0; index < padded.length; index += 4) { + const a = BASE64_ALPHABET.indexOf(padded[index]); + const b = BASE64_ALPHABET.indexOf(padded[index + 1]); + const c = padded[index + 2] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 2]); + const d = padded[index + 3] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 3]); + if (a < 0 || b < 0 || c < 0 || d < 0) throw new Error('Invalid base64url value'); + const triple = (a << 18) | (b << 12) | (c << 6) | d; + bytes.push((triple >> 16) & 0xff); + if (padded[index + 2] !== '=') bytes.push((triple >> 8) & 0xff); + if (padded[index + 3] !== '=') bytes.push(triple & 0xff); + } + return bytes; +} + +export function decodeBase64UrlUtf8(value: string): string { + return decodeUtf8Bytes(decodeBase64UrlBytes(value)); +} + +export function decodeUtf8Bytes(bytes: ArrayLike): string { + let result = ''; + let segment = ''; + const flush = () => { + result += segment; + segment = ''; + }; + + for (let index = 0; index < bytes.length;) { + const first = bytes[index]; + let codePoint: number; + let width: number; + let minimum: number; + + if (first <= 0x7f) { + codePoint = first; + width = 1; + minimum = 0; + } else if (first >= 0xc2 && first <= 0xdf) { + codePoint = first & 0x1f; + width = 2; + minimum = 0x80; + } else if (first >= 0xe0 && first <= 0xef) { + codePoint = first & 0x0f; + width = 3; + minimum = 0x800; + } else if (first >= 0xf0 && first <= 0xf4) { + codePoint = first & 0x07; + width = 4; + minimum = 0x10000; + } else { + throw new Error('Invalid UTF-8 payload'); + } + + if (index + width > bytes.length) throw new Error('Invalid UTF-8 payload'); + for (let offset = 1; offset < width; offset += 1) { + const continuation = bytes[index + offset]; + if ((continuation & 0xc0) !== 0x80) throw new Error('Invalid UTF-8 payload'); + codePoint = (codePoint << 6) | (continuation & 0x3f); + } + if ( + codePoint < minimum || + codePoint > 0x10ffff || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) { + throw new Error('Invalid UTF-8 payload'); + } + + segment += String.fromCodePoint(codePoint); + if (segment.length >= 8192) flush(); + index += width; + } + + flush(); + return result; +} + +export function utf8ByteLength(value: string): number { + return utf8Bytes(value).length; +} diff --git a/src/runtime-delivery/heartbeat.ts b/src/runtime-delivery/heartbeat.ts new file mode 100644 index 0000000..8a0631d --- /dev/null +++ b/src/runtime-delivery/heartbeat.ts @@ -0,0 +1,101 @@ +import { postOtaActiveInstallHeartbeat } from '../api/clientApi'; +import type { OtaActiveInstallHeartbeat } from '../api/types'; +import { BUNDLE_DROP_ROOT } from '../context'; +import { atomicWriteJson } from '../fs/fsUtils'; +import RNFS from '../native/fs'; + +const HEARTBEAT_STATE_PATH = `${BUNDLE_DROP_ROOT}/runtime-delivery-heartbeats.json`; +const STABLE_HEARTBEAT_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; +const inFlight = new Set(); +let stateMutation: Promise = Promise.resolve(); + +type HeartbeatState = { + schemaVersion: 1; + reportedAt: Record; + fingerprints: Record; +}; + +async function readState(): Promise { + try { + const parsed = JSON.parse(await RNFS.readFile(HEARTBEAT_STATE_PATH, 'utf8')) as HeartbeatState; + if (parsed.schemaVersion === 1 && parsed.reportedAt && typeof parsed.reportedAt === 'object') { + return { + schemaVersion: 1, + reportedAt: parsed.reportedAt, + fingerprints: + parsed.fingerprints && typeof parsed.fingerprints === 'object' + ? parsed.fingerprints + : {}, + }; + } + } catch { + // A missing heartbeat cache means this install may report once. + } + return { schemaVersion: 1, reportedAt: {}, fingerprints: {} }; +} + +async function heartbeatFingerprint(payload: OtaActiveInstallHeartbeat): Promise { + const userProperties = payload.userProperties + ? Object.fromEntries( + Object.entries(payload.userProperties).sort(([left], [right]) => + left.localeCompare(right), + ), + ) + : null; + + return RNFS.sha256String(JSON.stringify({ + currentHash: payload.currentHash, + environment: payload.environment ?? null, + userProperties, + })); +} + +function heartbeatKey(projectSlug: string, payload: OtaActiveInstallHeartbeat): string { + return [ + projectSlug, + payload.channelName, + payload.platform, + payload.runtimeVersion, + payload.installId, + ].map(encodeURIComponent).join('/'); +} + +export function reportActiveInstallWhenDue( + projectSlug: string, + payload: OtaActiveInstallHeartbeat, +): void { + const key = heartbeatKey(projectSlug, payload); + if (inFlight.has(key)) return; + inFlight.add(key); + void (async () => { + try { + const state = await readState(); + const now = Date.now(); + const fingerprint = await heartbeatFingerprint(payload); + if ( + state.fingerprints[key] === fingerprint && + now - (state.reportedAt[key] || 0) < STABLE_HEARTBEAT_INTERVAL_MS + ) { + return; + } + await postOtaActiveInstallHeartbeat(projectSlug, payload); + const mutation = stateMutation.then(async () => { + const latest = await readState(); + latest.reportedAt[key] = now; + latest.fingerprints[key] = fingerprint; + await atomicWriteJson(HEARTBEAT_STATE_PATH, latest); + }); + stateMutation = mutation.then(() => undefined, () => undefined); + await mutation; + } catch (error) { + console.warn('⚠️ BundleDrop active-install heartbeat failed:', error); + } finally { + inFlight.delete(key); + } + })(); +} + +export function resetRuntimeDeliveryHeartbeatForTests(): void { + inFlight.clear(); + stateMutation = Promise.resolve(); +} diff --git a/src/runtime-delivery/localResolver.ts b/src/runtime-delivery/localResolver.ts new file mode 100644 index 0000000..dbb9708 --- /dev/null +++ b/src/runtime-delivery/localResolver.ts @@ -0,0 +1,109 @@ +import RNFS from '../native/fs'; +import type { + RuntimeDeliveryLaneManifest, + RuntimeDeliveryPatchEdge, + RuntimeDeliveryRelease, +} from './types'; + +export type LocalResolution = + | { action: 'NOOP'; reason: string } + | { action: 'ROLLBACK'; reason: string } + | { + action: 'INSTALL'; + target: RuntimeDeliveryRelease; + mode: 'full' | 'patch'; + patchEdge?: RuntimeDeliveryPatchEdge; + }; + +export type LocalResolutionInput = { + currentHash: string | null; + rejectedHashes: string[]; + installId: string; + patchAlgorithms: string[]; + supportsContentAddressedAssets: boolean; + now?: number; +}; + +function isUnexpired(expiresAt: string | null | undefined, now: number): boolean { + return !expiresAt || Date.parse(expiresAt) > now; +} + +export async function rolloutBucket(installId: string): Promise { + const digest = await RNFS.sha256String(installId); + if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error('Native SHA-256 returned an invalid digest'); + return Number.parseInt(digest.slice(0, 8), 16) % 100; +} + +function selectPatchEdge( + manifest: RuntimeDeliveryLaneManifest, + input: LocalResolutionInput, + target: RuntimeDeliveryRelease, + now: number, +): RuntimeDeliveryPatchEdge | undefined { + if (!manifest.patchPolicy.enabled || !input.currentHash) return undefined; + if (input.rejectedHashes.includes(input.currentHash)) return undefined; + return manifest.patchEdges.find(edge => + edge.baseHash === input.currentHash && + edge.targetHash === target.bundleHash && + input.patchAlgorithms.includes(edge.algorithm) && + (!edge.missingAssetsHash || input.supportsContentAddressedAssets) && + isUnexpired(edge.expiresAt, now) && + edge.patchSizeBytes <= edge.fullBundleSizeBytes * manifest.patchPolicy.maxPatchToFullRatio + ); +} + +export async function resolveRuntimeDeliveryLane( + manifest: RuntimeDeliveryLaneManifest, + input: LocalResolutionInput, +): Promise { + if (manifest.resolutionMode !== 'local') throw new Error('Dynamic lanes require server resolution'); + if (!manifest.candidateSetComplete) throw new Error('Incomplete candidate sets cannot resolve locally'); + + const now = input.now ?? Date.now(); + const rejected = new Set(input.rejectedHashes); + const revoked = new Set(manifest.revokedHashes); + const currentRevoked = !!input.currentHash && revoked.has(input.currentHash); + const releasesByRef = new Map(manifest.releases.map(release => [release.releaseRef, release])); + const isSafe = (release: RuntimeDeliveryRelease) => + release.available && + !rejected.has(release.bundleHash) && + !revoked.has(release.bundleHash) && + isUnexpired(release.expiresAt, now); + + let selected: RuntimeDeliveryRelease | undefined; + let noCandidateReason = 'NO_PUBLISHED_BUNDLE'; + if (manifest.publishingMode === 'automatic') { + selected = manifest.releases.find(isSafe); + noCandidateReason = 'NO_COMPATIBLE_BUNDLE'; + } else { + const bucket = await rolloutBucket(input.installId); + for (const rollout of manifest.publishedRollouts) { + const release = releasesByRef.get(rollout.releaseRef)!; + if (!isSafe(release)) continue; + const eligible = rollout.rolloutPercentage >= 100 || bucket < rollout.rolloutPercentage; + if (eligible) { + selected = release; + break; + } + noCandidateReason = 'ROLLOUT_NOT_ELIGIBLE'; + } + } + + if (!selected) { + return currentRevoked + ? { action: 'ROLLBACK', reason: 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET' } + : { action: 'NOOP', reason: noCandidateReason }; + } + if (selected.bundleHash === input.currentHash) { + return currentRevoked + ? { action: 'ROLLBACK', reason: 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET' } + : { action: 'NOOP', reason: 'UP_TO_DATE' }; + } + const patchEdge = currentRevoked ? undefined : selectPatchEdge(manifest, input, selected, now); + return { + action: 'INSTALL', + target: selected, + mode: patchEdge ? 'patch' : 'full', + patchEdge, + }; +} diff --git a/src/runtime-delivery/manifestState.ts b/src/runtime-delivery/manifestState.ts new file mode 100644 index 0000000..883e213 --- /dev/null +++ b/src/runtime-delivery/manifestState.ts @@ -0,0 +1,124 @@ +import { BUNDLE_DROP_ROOT } from '../context'; +import { atomicWriteJson } from '../fs/fsUtils'; +import RNFS from '../native/fs'; +import type { RuntimeDeliveryLaneIdentity, RuntimeDeliveryLaneManifest } from './types'; + +const STATE_PATH = `${BUNDLE_DROP_ROOT}/runtime-delivery-state.json`; +let stateMutation: Promise = Promise.resolve(); + +export type VerifiedLaneState = { + highestGeneration: number; + payloadSha256: string; + revokedHashes: string[]; + verifiedAt: string; +}; + +type RuntimeDeliveryState = { + schemaVersion: 1; + lanes: Record; +}; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function invalidState(): never { + throw new Error('Runtime delivery state is malformed or unsupported'); +} + +function parseState(raw: string): RuntimeDeliveryState { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return invalidState(); + } + if (!isRecord(parsed) || Object.keys(parsed).sort().join(',') !== 'lanes,schemaVersion') { + return invalidState(); + } + if (parsed.schemaVersion !== 1 || !isRecord(parsed.lanes)) return invalidState(); + + for (const [key, lane] of Object.entries(parsed.lanes)) { + if (!key || !isRecord(lane)) return invalidState(); + if ( + Object.keys(lane).sort().join(',') !== + 'highestGeneration,payloadSha256,revokedHashes,verifiedAt' + ) { + return invalidState(); + } + if (!Number.isSafeInteger(lane.highestGeneration) || (lane.highestGeneration as number) < 1) { + return invalidState(); + } + if (typeof lane.payloadSha256 !== 'string' || !SHA256_PATTERN.test(lane.payloadSha256)) { + return invalidState(); + } + if ( + !Array.isArray(lane.revokedHashes) || + lane.revokedHashes.some(hash => typeof hash !== 'string' || !SHA256_PATTERN.test(hash)) || + new Set(lane.revokedHashes).size !== lane.revokedHashes.length + ) { + return invalidState(); + } + if (typeof lane.verifiedAt !== 'string' || !Number.isFinite(Date.parse(lane.verifiedAt))) { + return invalidState(); + } + } + return parsed as RuntimeDeliveryState; +} + +function laneStateKey(identity: RuntimeDeliveryLaneIdentity): string { + return [identity.projectSlug, identity.channelName, identity.platform, identity.runtimeVersion] + .map(value => encodeURIComponent(value)) + .join('/'); +} + +async function readState(): Promise { + if (!await RNFS.exists(STATE_PATH)) return { schemaVersion: 1, lanes: {} }; + let raw: string; + try { + raw = await RNFS.readFile(STATE_PATH, 'utf8'); + } catch { + throw new Error('Unable to read existing runtime delivery state'); + } + return parseState(raw); +} + +export async function readVerifiedLaneState( + identity: RuntimeDeliveryLaneIdentity, +): Promise { + const state = await readState(); + return state.lanes[laneStateKey(identity)] || null; +} + +export async function recordVerifiedLaneManifest( + manifest: RuntimeDeliveryLaneManifest, + payloadSha256: string, +): Promise { + const mutation = stateMutation.then(async () => { + const identity: RuntimeDeliveryLaneIdentity = manifest; + const key = laneStateKey(identity); + const state = await readState(); + const existing = state.lanes[key]; + if (existing && existing.highestGeneration > manifest.generation) { + throw new Error('Manifest generation regressed while persisting verified state'); + } + if ( + existing && + existing.highestGeneration === manifest.generation && + existing.payloadSha256 !== payloadSha256 + ) { + throw new Error('Manifest generation equivocation detected'); + } + state.lanes[key] = { + highestGeneration: manifest.generation, + payloadSha256, + revokedHashes: [...manifest.revokedHashes], + verifiedAt: new Date().toISOString(), + }; + await atomicWriteJson(STATE_PATH, state); + }); + stateMutation = mutation.then(() => undefined, () => undefined); + return mutation; +} diff --git a/src/runtime-delivery/manifestVerifier.ts b/src/runtime-delivery/manifestVerifier.ts new file mode 100644 index 0000000..2af7aab --- /dev/null +++ b/src/runtime-delivery/manifestVerifier.ts @@ -0,0 +1,438 @@ +import RNFS from '../native/fs'; +import { decodeBase64UrlBytes, decodeBase64UrlUtf8, utf8ByteLength } from './encoding'; +import { readVerifiedLaneState, recordVerifiedLaneManifest } from './manifestState'; +import { + RUNTIME_DELIVERY_ROLLOUT_ALGORITHM, + RUNTIME_DELIVERY_MANIFEST_JWS_TYPE, + type RuntimeDeliveryJws, + type RuntimeDeliveryLaneIdentity, + type RuntimeDeliveryLaneManifest, + type RuntimeDeliveryPublicKey, +} from './types'; + +export const MAX_RUNTIME_MANIFEST_BYTES = 1024 * 1024; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +export type RuntimeDeliveryManifestFailureCode = + | 'body_too_large' + | 'http_error' + | 'network_error' + | 'timeout' + | 'stream_unavailable' + | 'invalid_manifest' + | 'invalid_signature' + | 'unknown_key' + | 'lane_mismatch' + | 'generation_regression' + | 'generation_equivocation' + | 'authority_body_too_large' + | 'authority_http_error' + | 'authority_network_error' + | 'authority_timeout' + | 'authority_stream_unavailable' + | 'authority_invalid' + | 'authority_invalid_signature' + | 'authority_unknown_key' + | 'authority_expired' + | 'authority_origin_mismatch' + | 'authority_disabled'; + +export class RuntimeDeliveryManifestError extends Error { + public readonly status?: number; + + constructor( + public readonly code: RuntimeDeliveryManifestFailureCode, + message: string, + options?: { cause?: unknown; status?: number }, + ) { + super(message); + this.name = 'RuntimeDeliveryManifestError'; + this.status = options?.status; + if (options && 'cause' in options) { + (this as Error & { cause?: unknown }).cause = options.cause; + } + } +} + +function requireRecord(value: unknown, name: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${name} must be an object`); + } + return value as Record; +} + +function requireExactKeys( + value: Record, + allowedKeys: readonly string[], + name: string, +): void { + const allowed = new Set(allowedKeys); + const unsupported = Object.keys(value).find(key => !allowed.has(key)); + if (unsupported) throw new Error(`${name} contains unsupported field ${unsupported}`); +} + +function requireString(value: unknown, name: string): string { + if (typeof value !== 'string' || !value) throw new Error(`${name} must be a non-empty string`); + return value; +} + +function requireHash(value: unknown, name: string): string { + const hash = requireString(value, name); + if (!SHA256_PATTERN.test(hash)) throw new Error(`${name} must be a lowercase SHA-256 hash`); + return hash; +} + +function requireInteger(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${name} must be a non-negative safe integer`); + } + return value as number; +} + +function requirePositiveInteger(value: unknown, name: string): number { + const integer = requireInteger(value, name); + if (integer < 1) throw new Error(`${name} must be at least 1`); + return integer; +} + +function requireFiniteNumber(value: unknown, name: string): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error(`${name} must be a non-negative finite number`); + } + return value; +} + +function requireTimestamp(value: unknown, name: string): string { + const timestamp = requireString(value, name); + if (!Number.isFinite(Date.parse(timestamp))) throw new Error(`${name} must be an ISO timestamp`); + return timestamp; +} + +function optionalTimestamp(value: unknown, name: string): string | null | undefined { + if (value === null) return null; + if (value === undefined) return undefined; + return requireTimestamp(value, name); +} + +function parseLaneManifest(payload: unknown): RuntimeDeliveryLaneManifest { + const lane = requireRecord(payload, 'Manifest payload'); + requireExactKeys(lane, [ + 'schemaVersion', 'type', 'projectSlug', 'channelName', 'platform', 'runtimeVersion', + 'generation', 'generatedAt', 'resolutionMode', 'dynamicReason', + 'publishingMode', 'rolloutAlgorithm', 'revokedHashes', 'releases', + 'publishedRollouts', 'patchPolicy', 'patchEdges', 'candidateSetComplete', + ], 'Manifest payload'); + if (lane.schemaVersion !== 3 || lane.type !== 'lane') { + throw new Error('Unsupported runtime manifest schema or type'); + } + const resolutionMode = lane.resolutionMode; + if (resolutionMode !== 'local' && resolutionMode !== 'dynamic') { + throw new Error('resolutionMode must be local or dynamic'); + } + const publishingMode = lane.publishingMode; + if (publishingMode !== 'automatic' && publishingMode !== 'managed') { + throw new Error('publishingMode must be automatic or managed'); + } + if (lane.rolloutAlgorithm !== RUNTIME_DELIVERY_ROLLOUT_ALGORITHM) { + throw new Error('Unsupported rollout algorithm'); + } + if (typeof lane.candidateSetComplete !== 'boolean') { + throw new Error('candidateSetComplete must be boolean'); + } + + const releaseValues = Array.isArray(lane.releases) ? lane.releases : null; + const rolloutValues = Array.isArray(lane.publishedRollouts) ? lane.publishedRollouts : null; + const patchValues = Array.isArray(lane.patchEdges) ? lane.patchEdges : null; + const revokedValues = Array.isArray(lane.revokedHashes) ? lane.revokedHashes : null; + if (!releaseValues || !rolloutValues || !patchValues || !revokedValues) { + throw new Error('Manifest candidate arrays are required'); + } + if (releaseValues.length > 21) { + throw new Error('Runtime manifest may contain at most 21 releases'); + } + + const releases = releaseValues.map((value, index) => { + const release = requireRecord(value, `releases[${index}]`); + requireExactKeys(release, [ + 'releaseRef', 'bundleHash', 'bundleVersion', 'version', 'runtimeVersion', + 'manifestHash', 'jsBundleHash', 'fullBundleHash', 'fullBundleSizeBytes', + 'available', 'expiresAt', + ], `releases[${index}]`); + if (typeof release.available !== 'boolean') { + throw new Error(`releases[${index}].available must be boolean`); + } + return { + releaseRef: requireString(release.releaseRef, `releases[${index}].releaseRef`), + bundleHash: requireHash(release.bundleHash, `releases[${index}].bundleHash`), + bundleVersion: requireInteger(release.bundleVersion, `releases[${index}].bundleVersion`), + version: release.version === undefined + ? undefined + : requireString(release.version, `releases[${index}].version`), + runtimeVersion: requireString(release.runtimeVersion, `releases[${index}].runtimeVersion`), + manifestHash: requireHash(release.manifestHash, `releases[${index}].manifestHash`), + jsBundleHash: requireHash(release.jsBundleHash, `releases[${index}].jsBundleHash`), + fullBundleHash: requireHash(release.fullBundleHash, `releases[${index}].fullBundleHash`), + fullBundleSizeBytes: requirePositiveInteger( + release.fullBundleSizeBytes, + `releases[${index}].fullBundleSizeBytes`, + ), + available: release.available, + expiresAt: optionalTimestamp(release.expiresAt, `releases[${index}].expiresAt`), + }; + }); + + const publishedRollouts = rolloutValues.map((value, index) => { + const rollout = requireRecord(value, `publishedRollouts[${index}]`); + requireExactKeys( + rollout, + ['releaseRef', 'rolloutPercentage', 'status'], + `publishedRollouts[${index}]`, + ); + const percentage = requireFiniteNumber( + rollout.rolloutPercentage, + `publishedRollouts[${index}].rolloutPercentage`, + ); + if (percentage > 100) throw new Error('rolloutPercentage must be at most 100'); + if (rollout.status !== 'active' && rollout.status !== 'completed') { + throw new Error(`publishedRollouts[${index}].status is invalid`); + } + const status: 'active' | 'completed' = rollout.status; + return { + releaseRef: requireString(rollout.releaseRef, `publishedRollouts[${index}].releaseRef`), + rolloutPercentage: percentage, + status, + }; + }); + + const patchPolicy = requireRecord(lane.patchPolicy, 'patchPolicy'); + requireExactKeys(patchPolicy, ['enabled', 'maxPatchToFullRatio'], 'patchPolicy'); + if (typeof patchPolicy.enabled !== 'boolean') throw new Error('patchPolicy.enabled must be boolean'); + const maxPatchToFullRatio = requireFiniteNumber( + patchPolicy.maxPatchToFullRatio, + 'patchPolicy.maxPatchToFullRatio', + ); + if (maxPatchToFullRatio > 1) throw new Error('maxPatchToFullRatio must be at most 1'); + + const patchEdges = patchValues.map((value, index) => { + const edge = requireRecord(value, `patchEdges[${index}]`); + requireExactKeys(edge, [ + 'baseHash', 'targetHash', 'algorithm', 'patchSetHash', 'patchArtifactRef', + 'patchSizeBytes', 'fullBundleSizeBytes', 'missingAssetsHash', 'expiresAt', + ], `patchEdges[${index}]`); + const missingAssetsHash = edge.missingAssetsHash === null + ? null + : edge.missingAssetsHash === undefined + ? undefined + : requireHash(edge.missingAssetsHash, `patchEdges[${index}].missingAssetsHash`); + return { + baseHash: requireHash(edge.baseHash, `patchEdges[${index}].baseHash`), + targetHash: requireHash(edge.targetHash, `patchEdges[${index}].targetHash`), + algorithm: requireString(edge.algorithm, `patchEdges[${index}].algorithm`), + patchSetHash: requireHash(edge.patchSetHash, `patchEdges[${index}].patchSetHash`), + patchArtifactRef: requireString(edge.patchArtifactRef, `patchEdges[${index}].patchArtifactRef`), + patchSizeBytes: requirePositiveInteger(edge.patchSizeBytes, `patchEdges[${index}].patchSizeBytes`), + fullBundleSizeBytes: requirePositiveInteger( + edge.fullBundleSizeBytes, + `patchEdges[${index}].fullBundleSizeBytes`, + ), + missingAssetsHash, + expiresAt: optionalTimestamp(edge.expiresAt, `patchEdges[${index}].expiresAt`), + }; + }); + + const generation = requireInteger(lane.generation, 'generation'); + if (generation < 1) throw new Error('generation must be at least 1'); + const manifest: RuntimeDeliveryLaneManifest = { + schemaVersion: 3, + type: 'lane', + projectSlug: requireString(lane.projectSlug, 'projectSlug'), + channelName: requireString(lane.channelName, 'channelName'), + platform: requireString(lane.platform, 'platform'), + runtimeVersion: requireString(lane.runtimeVersion, 'runtimeVersion'), + generation, + generatedAt: requireTimestamp(lane.generatedAt, 'generatedAt'), + resolutionMode, + dynamicReason: lane.dynamicReason === undefined + ? undefined + : requireString(lane.dynamicReason, 'dynamicReason'), + publishingMode, + rolloutAlgorithm: RUNTIME_DELIVERY_ROLLOUT_ALGORITHM, + revokedHashes: revokedValues.map((hash, index) => requireHash(hash, `revokedHashes[${index}]`)), + releases, + publishedRollouts, + patchPolicy: { enabled: patchPolicy.enabled, maxPatchToFullRatio }, + patchEdges, + candidateSetComplete: lane.candidateSetComplete, + }; + if (new Set(manifest.revokedHashes).size !== manifest.revokedHashes.length) { + throw new Error('revokedHashes must contain unique values'); + } + if (manifest.resolutionMode === 'local') { + if (!manifest.candidateSetComplete) { + throw new Error('Local runtime manifests require a complete candidate set'); + } + if (manifest.releases.some(release => !release.available)) { + throw new Error('Local runtime manifests require available releases'); + } + } else if ( + !manifest.dynamicReason || + manifest.candidateSetComplete || + manifest.releases.length || + manifest.publishedRollouts.length || + manifest.patchEdges.length + ) { + throw new Error('Dynamic runtime manifests require a reason and the safe empty candidate shape'); + } + validateCandidateConsistency(manifest); + return manifest; +} + +function validateCandidateConsistency(manifest: RuntimeDeliveryLaneManifest): void { + const releaseRefs = new Set(); + const releaseHashes = new Set(); + const releaseByHash = new Map(manifest.releases.map(release => [release.bundleHash, release])); + for (const release of manifest.releases) { + if (release.runtimeVersion !== manifest.runtimeVersion) throw new Error('Release runtime identity mismatch'); + if (releaseRefs.has(release.releaseRef) || releaseHashes.has(release.bundleHash)) { + throw new Error('Manifest contains duplicate release identity'); + } + releaseRefs.add(release.releaseRef); + releaseHashes.add(release.bundleHash); + } + const rolloutRefs = new Set(); + for (const rollout of manifest.publishedRollouts) { + if (!releaseRefs.has(rollout.releaseRef) || rolloutRefs.has(rollout.releaseRef)) { + throw new Error('Published rollout references an unknown or duplicate release'); + } + rolloutRefs.add(rollout.releaseRef); + } + const patchEdgeIdentities = new Set(); + for (const edge of manifest.patchEdges) { + const target = releaseByHash.get(edge.targetHash); + if (!target || edge.fullBundleSizeBytes !== target.fullBundleSizeBytes) { + throw new Error('Patch edge is inconsistent with its target release'); + } + const identity = `${edge.baseHash}\0${edge.targetHash}\0${edge.algorithm}`; + if (patchEdgeIdentities.has(identity)) { + throw new Error('Manifest contains duplicate patch edge identity'); + } + patchEdgeIdentities.add(identity); + } +} + +function assertLaneIdentity( + manifest: RuntimeDeliveryLaneManifest, + expected: RuntimeDeliveryLaneIdentity, +): void { + for (const field of ['projectSlug', 'channelName', 'platform', 'runtimeVersion'] as const) { + if (manifest[field] !== expected[field]) { + throw new RuntimeDeliveryManifestError( + 'lane_mismatch', + `Manifest ${field} identity mismatch`, + ); + } + } +} + +function assertPublicKey(key: RuntimeDeliveryPublicKey | undefined): asserts key is RuntimeDeliveryPublicKey { + if (!key || key.kty !== 'EC' || key.crv !== 'P-256') { + throw new RuntimeDeliveryManifestError('unknown_key', 'Unknown or invalid manifest signing key'); + } + if (decodeBase64UrlBytes(key.x).length !== 32 || decodeBase64UrlBytes(key.y).length !== 32) { + throw new RuntimeDeliveryManifestError( + 'unknown_key', + 'Manifest signing key coordinates must be 32 bytes', + ); + } +} + +export async function verifyRuntimeDeliveryManifest( + serializedJws: string, + expectedIdentity: RuntimeDeliveryLaneIdentity, + publicKeys: Record, +): Promise { + const payloadValue = await verifyRuntimeDeliverySignedPayload( + serializedJws, + RUNTIME_DELIVERY_MANIFEST_JWS_TYPE, + publicKeys, + ); + + const manifest = parseLaneManifest(JSON.parse(decodeBase64UrlUtf8(payloadValue))); + assertLaneIdentity(manifest, expectedIdentity); + const payloadSha256 = await RNFS.sha256String(payloadValue); + const previousState = await readVerifiedLaneState(expectedIdentity); + if (previousState && manifest.generation < previousState.highestGeneration) { + throw new RuntimeDeliveryManifestError( + 'generation_regression', + 'Runtime manifest generation regressed', + ); + } + if ( + previousState && + manifest.generation === previousState.highestGeneration && + previousState.payloadSha256 !== payloadSha256 + ) { + throw new RuntimeDeliveryManifestError( + 'generation_equivocation', + 'Runtime manifest generation equivocation detected', + ); + } + await recordVerifiedLaneManifest(manifest, payloadSha256); + return manifest; +} + +export async function verifyRuntimeDeliverySignedPayload( + serializedJws: string, + expectedType: string, + publicKeys: Record, +): Promise { + if (utf8ByteLength(serializedJws) > MAX_RUNTIME_MANIFEST_BYTES) { + throw new RuntimeDeliveryManifestError( + 'body_too_large', + 'Runtime manifest exceeds the 1 MB safety limit', + ); + } + const envelope = requireRecord(JSON.parse(serializedJws), 'JWS envelope') as RuntimeDeliveryJws; + if (Object.keys(envelope).sort().join(',') !== 'payload,protected,signature') { + throw new Error('JWS envelope contains unsupported fields'); + } + const protectedValue = requireString(envelope.protected, 'JWS protected'); + const payloadValue = requireString(envelope.payload, 'JWS payload'); + const signatureValue = requireString(envelope.signature, 'JWS signature'); + if (decodeBase64UrlBytes(signatureValue).length !== 64) { + throw new RuntimeDeliveryManifestError( + 'invalid_signature', + 'ES256 JWS signature must be 64-byte JOSE R||S', + ); + } + const protectedHeader = requireRecord( + JSON.parse(decodeBase64UrlUtf8(protectedValue)), + 'JWS protected header', + ); + if (Object.keys(protectedHeader).sort().join(',') !== 'alg,kid,typ') { + throw new Error('JWS protected header contains unsupported fields'); + } + if ( + protectedHeader.alg !== 'ES256' || + protectedHeader.typ !== expectedType || + typeof protectedHeader.kid !== 'string' || !protectedHeader.kid + ) { + throw new Error('Unsupported JWS protected header'); + } + const publicKey = publicKeys[protectedHeader.kid]; + assertPublicKey(publicKey); + const verified = await RNFS.verifyEs256Signature( + `${protectedValue}.${payloadValue}`, + signatureValue, + publicKey.x, + publicKey.y, + ); + if (!verified) { + throw new RuntimeDeliveryManifestError( + 'invalid_signature', + 'Runtime manifest signature verification failed', + ); + } + + return payloadValue; +} diff --git a/src/runtime-delivery/runtimeDelivery.ts b/src/runtime-delivery/runtimeDelivery.ts new file mode 100644 index 0000000..a835da0 --- /dev/null +++ b/src/runtime-delivery/runtimeDelivery.ts @@ -0,0 +1,500 @@ +import type { UpdateCheckResponse } from '../api/types'; +import { config, platform, runtimeVersion } from '../context'; +import type { UserProperties } from '../fs/userProperties'; +import { isRuntimeDeliveryConfigured } from '../loadConfig'; +import RNFS from '../native/fs'; +import { + decodeBase64UrlBytes, + decodeUtf8Bytes, + encodeBase64UrlUtf8, +} from './encoding'; +import { verifyRuntimeDeliveryAuthorityLease } from './authorityLeaseVerifier'; +import { + recordRuntimeDeliveryDiagnostic, + type RuntimeDeliveryDiagnosticName, +} from './diagnostics'; +import { reportActiveInstallWhenDue } from './heartbeat'; +import { resolveRuntimeDeliveryLane } from './localResolver'; +import { readVerifiedLaneState } from './manifestState'; +import { + MAX_RUNTIME_MANIFEST_BYTES, + RuntimeDeliveryManifestError, + type RuntimeDeliveryManifestFailureCode, + verifyRuntimeDeliveryManifest, +} from './manifestVerifier'; +import type { RuntimeDeliveryLaneIdentity, RuntimeDeliveryLaneManifest } from './types'; + +export type RuntimeDeliveryResolveContext = { + channelName: string; + currentHash: string | null; + rejectedHashes: string[]; + installId: string; + patchAlgorithms: string[]; + supportsContentAddressedAssets: boolean; + environment: string | null; + userProperties: UserProperties; +}; + +const diagnosticNameByFailureCode: Record< + RuntimeDeliveryManifestFailureCode, + RuntimeDeliveryDiagnosticName +> = { + body_too_large: 'manifest_too_large', + http_error: 'manifest_http_error', + network_error: 'manifest_network_error', + timeout: 'manifest_timeout', + stream_unavailable: 'manifest_stream_unavailable', + invalid_manifest: 'manifest_invalid', + invalid_signature: 'invalid_signature', + unknown_key: 'unknown_key', + lane_mismatch: 'lane_mismatch', + generation_regression: 'generation_regression', + generation_equivocation: 'generation_equivocation', + authority_body_too_large: 'authority_lease_too_large', + authority_http_error: 'authority_lease_http_error', + authority_network_error: 'authority_lease_network_error', + authority_timeout: 'authority_lease_timeout', + authority_stream_unavailable: 'authority_lease_invalid', + authority_invalid: 'authority_lease_invalid', + authority_invalid_signature: 'authority_lease_invalid_signature', + authority_unknown_key: 'authority_lease_unknown_key', + authority_expired: 'authority_lease_expired', + authority_origin_mismatch: 'authority_lease_origin_mismatch', + authority_disabled: 'authority_lease_disabled', +}; + +const RUNTIME_MANIFEST_TIMEOUT_MS = 5000; +const RUNTIME_MANIFEST_TOO_LARGE_MESSAGE = + 'Runtime manifest exceeds the 1 MB safety limit'; + +function normalizeManifestError( + error: unknown, + fallbackCode: RuntimeDeliveryManifestFailureCode, + fallbackMessage: string, +): RuntimeDeliveryManifestError { + return error instanceof RuntimeDeliveryManifestError + ? error + : new RuntimeDeliveryManifestError(fallbackCode, fallbackMessage, { cause: error }); +} + +async function readBoundedManifestBody( + response: Response, + abortTransfer?: () => void, +): Promise { + const reader = response.body?.getReader?.(); + const rawContentLength = response.headers?.get?.('content-length'); + if (rawContentLength && /^\d+$/.test(rawContentLength)) { + const contentLength = Number(rawContentLength); + if (contentLength > MAX_RUNTIME_MANIFEST_BYTES) { + abortTransfer?.(); + try { + await reader?.cancel(RUNTIME_MANIFEST_TOO_LARGE_MESSAGE).catch(() => undefined); + } finally { + reader?.releaseLock?.(); + } + throw new RuntimeDeliveryManifestError( + 'body_too_large', + RUNTIME_MANIFEST_TOO_LARGE_MESSAGE, + ); + } + } + + if (!reader) { + throw new RuntimeDeliveryManifestError( + 'stream_unavailable', + 'Runtime manifest response does not expose a readable byte stream', + ); + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + throw new RuntimeDeliveryManifestError( + 'invalid_manifest', + 'Runtime manifest stream returned a non-byte chunk', + ); + } + totalBytes += value.byteLength; + if (totalBytes > MAX_RUNTIME_MANIFEST_BYTES) { + await reader.cancel(RUNTIME_MANIFEST_TOO_LARGE_MESSAGE).catch(() => undefined); + throw new RuntimeDeliveryManifestError( + 'body_too_large', + RUNTIME_MANIFEST_TOO_LARGE_MESSAGE, + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock?.(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return decodeUtf8Bytes(bytes); + } catch (error) { + throw new RuntimeDeliveryManifestError( + 'invalid_manifest', + 'Runtime manifest body is not valid UTF-8', + { cause: error }, + ); + } +} + +function isReactNativeRuntime(): boolean { + return typeof navigator !== 'undefined' && navigator.product === 'ReactNative'; +} + +function nativeDownloadFailure(error: unknown): RuntimeDeliveryManifestError { + const nativeError = error as { code?: unknown; message?: unknown }; + const code = typeof nativeError?.code === 'string' ? nativeError.code : ''; + const message = typeof nativeError?.message === 'string' ? nativeError.message : ''; + if (code === 'ERR_DOWNLOAD_TOO_LARGE') { + return new RuntimeDeliveryManifestError( + 'body_too_large', + RUNTIME_MANIFEST_TOO_LARGE_MESSAGE, + { cause: error }, + ); + } + if (code === 'ERR_DOWNLOAD_TIMEOUT') { + return new RuntimeDeliveryManifestError( + 'timeout', + 'Runtime manifest request timed out', + { cause: error }, + ); + } + if (code === 'ERR_DOWNLOAD_HTTP') { + const status = /^HTTP (\d{3})(?:\b|:)/.exec(message)?.[1]; + const numericStatus = status ? Number(status) : undefined; + return new RuntimeDeliveryManifestError( + 'http_error', + numericStatus === undefined + ? 'Manifest request failed with an HTTP error' + : `Manifest request failed with HTTP ${numericStatus}`, + { cause: error, status: numericStatus }, + ); + } + return new RuntimeDeliveryManifestError( + 'network_error', + 'Runtime manifest request failed', + { cause: error }, + ); +} + +async function readBoundedManifestWithNativeDownload( + url: string, + artifactName: 'manifest' | 'authority-lease', +): Promise { + const tempPath = [ + RNFS.LibraryDirectoryPath, + 'bundle-drop', + 'runtime-delivery', + `${artifactName}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.jws`, + ].join('/'); + + try { + try { + await RNFS.downloadFileBounded( + url, + tempPath, + MAX_RUNTIME_MANIFEST_BYTES, + RUNTIME_MANIFEST_TIMEOUT_MS, + ); + } catch (error) { + throw nativeDownloadFailure(error); + } + + let encodedBody: string; + try { + encodedBody = await RNFS.readFile(tempPath, 'base64'); + } catch (error) { + throw new RuntimeDeliveryManifestError( + 'network_error', + 'Runtime manifest body read failed', + { cause: error }, + ); + } + + const bytes = decodeBase64UrlBytes( + encodedBody.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''), + ); + if (bytes.length > MAX_RUNTIME_MANIFEST_BYTES) { + throw new RuntimeDeliveryManifestError( + 'body_too_large', + RUNTIME_MANIFEST_TOO_LARGE_MESSAGE, + ); + } + try { + return decodeUtf8Bytes(bytes); + } catch (error) { + throw new RuntimeDeliveryManifestError( + 'invalid_manifest', + 'Runtime manifest body is not valid UTF-8', + { cause: error }, + ); + } + } finally { + await RNFS.unlink(tempPath).catch(() => undefined); + } +} + +function mapAuthorityTransportError(error: unknown): RuntimeDeliveryManifestError { + const normalized = normalizeManifestError( + error, + 'network_error', + 'Runtime delivery authority lease request failed', + ); + const code = { + body_too_large: 'authority_body_too_large', + http_error: 'authority_http_error', + network_error: 'authority_network_error', + timeout: 'authority_timeout', + stream_unavailable: 'authority_stream_unavailable', + }[normalized.code] as RuntimeDeliveryManifestFailureCode | undefined; + return code + ? new RuntimeDeliveryManifestError(code, normalized.message, { + cause: normalized, + status: normalized.status, + }) + : normalized; +} + +function recordManifestFailure(channelName: string, error: RuntimeDeliveryManifestError): void { + recordRuntimeDeliveryDiagnostic(diagnosticNameByFailureCode[error.code], { + channelName, + reason: error.code, + ...(error.status === undefined ? {} : { status: error.status }), + }); +} + +function laneIdentity(channelName: string): RuntimeDeliveryLaneIdentity { + if (!runtimeVersion) throw new Error('Runtime version is required for runtime delivery'); + return { + projectSlug: config.project.slug, + channelName, + platform, + runtimeVersion, + }; +} + +export function runtimeDeliveryManifestUrl(identity: RuntimeDeliveryLaneIdentity): string { + const delivery = config.runtimeDelivery; + if (!delivery) throw new Error('Runtime delivery is not configured'); + const base = delivery.manifestBaseUrl.replace(/\/+$/, ''); + return [ + base, + 'v2', + encodeURIComponent(delivery.manifestAccessId), + 'lanes', + encodeBase64UrlUtf8(identity.channelName), + encodeURIComponent(identity.platform), + encodeBase64UrlUtf8(identity.runtimeVersion), + 'current.json', + ].join('/'); +} + +export function runtimeDeliveryAuthorityLeaseUrl(): string { + const delivery = config.runtimeDelivery; + if (!delivery) throw new Error('Runtime delivery is not configured'); + return `${delivery.manifestBaseUrl.replace(/\/+$/, '')}/v2/_authority/publisher-lease.json`; +} + +async function fetchBoundedJws( + url: string, + artifactName: 'manifest' | 'authority-lease', + controller: AbortController | null, +): Promise { + if (isReactNativeRuntime()) { + return readBoundedManifestWithNativeDownload(url, artifactName); + } + + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/jose+json, application/json' }, + signal: controller?.signal, + }); + } catch (error) { + throw new RuntimeDeliveryManifestError( + controller?.signal.aborted ? 'timeout' : 'network_error', + controller?.signal.aborted + ? 'Runtime manifest request timed out' + : 'Runtime manifest request failed', + { cause: error }, + ); + } + if (!response.ok) { + throw new RuntimeDeliveryManifestError( + 'http_error', + `Manifest request failed with HTTP ${response.status}`, + { status: response.status }, + ); + } + try { + return await readBoundedManifestBody(response, () => controller?.abort()); + } catch (error) { + if (!(error instanceof RuntimeDeliveryManifestError) && controller?.signal.aborted) { + throw new RuntimeDeliveryManifestError( + 'timeout', + 'Runtime manifest request timed out', + { cause: error }, + ); + } + throw normalizeManifestError(error, 'network_error', 'Runtime manifest body read failed'); + } +} + +export async function fetchRuntimeDeliveryManifest( + channelName: string, +): Promise { + const delivery = config.runtimeDelivery; + if (!isRuntimeDeliveryConfigured(delivery)) throw new Error('Runtime delivery is not enabled'); + const identity = laneIdentity(channelName); + const manifestUrl = runtimeDeliveryManifestUrl(identity); + const authorityLeaseUrl = runtimeDeliveryAuthorityLeaseUrl(); + const useNativeDownload = isReactNativeRuntime(); + const controller = !useNativeDownload && typeof AbortController !== 'undefined' + ? new AbortController() + : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), RUNTIME_MANIFEST_TIMEOUT_MS) + : null; + try { + try { + const [leaseResult, manifestResult] = await Promise.allSettled([ + fetchBoundedJws(authorityLeaseUrl, 'authority-lease', controller), + fetchBoundedJws(manifestUrl, 'manifest', controller), + ]); + if (leaseResult.status === 'rejected') { + throw mapAuthorityTransportError(leaseResult.reason); + } + if (manifestResult.status === 'rejected') { + throw manifestResult.reason; + } + + await verifyRuntimeDeliveryAuthorityLease( + leaseResult.value, + delivery.manifestBaseUrl, + delivery.publicKeys, + ); + + let manifest: RuntimeDeliveryLaneManifest; + try { + manifest = await verifyRuntimeDeliveryManifest( + manifestResult.value, + identity, + delivery.publicKeys, + ); + } catch (error) { + throw normalizeManifestError(error, 'invalid_manifest', 'Runtime manifest validation failed'); + } + recordRuntimeDeliveryDiagnostic('manifest_hit', { channelName }); + if (manifest.resolutionMode === 'dynamic') { + recordRuntimeDeliveryDiagnostic('dynamic_manifest', { + channelName, + reason: manifest.dynamicReason, + }); + } + return manifest; + } catch (error) { + const manifestError = normalizeManifestError( + error, + 'invalid_manifest', + 'Runtime manifest processing failed', + ); + recordManifestFailure(channelName, manifestError); + throw manifestError; + } + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +export async function resolveFromRuntimeDeliveryManifest( + manifest: RuntimeDeliveryLaneManifest, + context: RuntimeDeliveryResolveContext, +): Promise { + const decision = await resolveRuntimeDeliveryLane(manifest, { + currentHash: context.currentHash, + rejectedHashes: context.rejectedHashes, + installId: context.installId, + patchAlgorithms: context.patchAlgorithms, + supportsContentAddressedAssets: context.supportsContentAddressedAssets, + }); + if (decision.action === 'NOOP') { + const incompatible = decision.reason === 'NO_COMPATIBLE_BUNDLE'; + return { + action: 'NOOP', + upToDate: decision.reason === 'UP_TO_DATE', + channelName: context.channelName, + reason: decision.reason, + incompatible: incompatible || undefined, + requestedRuntimeVersion: incompatible ? manifest.runtimeVersion : undefined, + runtimeVersion: manifest.runtimeVersion, + }; + } + if (decision.action === 'ROLLBACK') { + return { + action: 'ROLLBACK', + channelName: context.channelName, + reason: decision.reason, + runtimeVersion: manifest.runtimeVersion, + }; + } + return { + action: 'INSTALL', + upToDate: false, + channelName: context.channelName, + hash: decision.target.bundleHash, + bundleHash: decision.target.bundleHash, + bundleVersion: decision.target.bundleVersion, + version: decision.target.version, + runtimeVersion: decision.target.runtimeVersion, + mode: decision.mode, + baseHash: decision.patchEdge?.baseHash, + runtimeDelivery: { + generation: manifest.generation, + targetReleaseRef: decision.target.releaseRef, + selectedMode: decision.mode, + baseHash: decision.patchEdge?.baseHash, + patchAlgorithm: decision.patchEdge?.algorithm, + patchSetHash: decision.patchEdge?.patchSetHash, + patchArtifactRef: decision.patchEdge?.patchArtifactRef, + missingAssetsHash: decision.patchEdge?.missingAssetsHash, + manifestHash: decision.target.manifestHash, + jsBundleHash: decision.target.jsBundleHash, + fullBundleHash: decision.target.fullBundleHash, + }, + }; +} + +export function reportActiveInstall(context: RuntimeDeliveryResolveContext): void { + if (!runtimeVersion) return; + reportActiveInstallWhenDue(config.project.slug, { + channelName: context.channelName, + platform, + runtimeVersion, + installId: context.installId, + currentHash: context.currentHash, + environment: context.environment || undefined, + userProperties: Object.keys(context.userProperties).length ? context.userProperties : undefined, + }); +} + +export async function shouldRollbackFromLastKnownRevocations( + channelName: string, + currentHash: string | null, +): Promise { + if (!currentHash || !runtimeVersion) return false; + const state = await readVerifiedLaneState(laneIdentity(channelName)); + return !!state?.revokedHashes.includes(currentHash); +} diff --git a/src/runtime-delivery/types.ts b/src/runtime-delivery/types.ts new file mode 100644 index 0000000..16028cf --- /dev/null +++ b/src/runtime-delivery/types.ts @@ -0,0 +1,98 @@ +export const RUNTIME_DELIVERY_ROLLOUT_ALGORITHM = + 'sha256-install-id-uint32be-mod100-v1' as const; +export const RUNTIME_DELIVERY_MANIFEST_JWS_TYPE = 'bundledrop-manifest+jws' as const; +export const RUNTIME_DELIVERY_AUTHORITY_LEASE_JWS_TYPE = + 'bundledrop-authority-lease+jws' as const; + +export type RuntimeDeliveryPublicKey = { + kty: 'EC'; + crv: 'P-256'; + x: string; + y: string; +}; + +export type RuntimeDeliveryRelease = { + releaseRef: string; + bundleHash: string; + bundleVersion: number; + version?: string; + runtimeVersion: string; + manifestHash: string; + jsBundleHash: string; + fullBundleHash: string; + fullBundleSizeBytes: number; + available: boolean; + expiresAt?: string | null; +}; + +export type RuntimeDeliveryPublishedRollout = { + releaseRef: string; + rolloutPercentage: number; + status: 'active' | 'completed'; +}; + +export type RuntimeDeliveryPatchEdge = { + baseHash: string; + targetHash: string; + algorithm: string; + patchSetHash: string; + patchArtifactRef: string; + patchSizeBytes: number; + fullBundleSizeBytes: number; + missingAssetsHash?: string | null; + expiresAt?: string | null; +}; + +export type RuntimeDeliveryLaneManifest = { + schemaVersion: 3; + type: 'lane'; + projectSlug: string; + channelName: string; + platform: string; + runtimeVersion: string; + generation: number; + generatedAt: string; + resolutionMode: 'local' | 'dynamic'; + dynamicReason?: string; + publishingMode: 'automatic' | 'managed'; + rolloutAlgorithm: typeof RUNTIME_DELIVERY_ROLLOUT_ALGORITHM; + revokedHashes: string[]; + releases: RuntimeDeliveryRelease[]; + publishedRollouts: RuntimeDeliveryPublishedRollout[]; + patchPolicy: { + enabled: boolean; + maxPatchToFullRatio: number; + }; + patchEdges: RuntimeDeliveryPatchEdge[]; + candidateSetComplete: boolean; +}; + +export type RuntimeDeliveryJws = { + protected: string; + payload: string; + signature: string; +}; + +export type RuntimeDeliveryAuthorityLeaseV1 = { + schemaVersion: 1; + type: 'publisher-lease'; + manifestOrigin: string; + generatedAt: string; + expiresAt: string; +}; + +export type RuntimeDeliveryAuthorityLease = { + schemaVersion: 2; + type: 'publisher-lease'; + manifestOrigin: string; + generatedAt: string; + expiresAt: string; + clientAuthority: 'enabled' | 'disabled'; +}; + +export type RuntimeDeliveryLaneIdentity = { + projectSlug: string; + channelName: string; + platform: string; + runtimeVersion: string; +}; diff --git a/src/runtime/initState.ts b/src/runtime/initState.ts index fc51760..4cb56f3 100644 --- a/src/runtime/initState.ts +++ b/src/runtime/initState.ts @@ -1,5 +1,6 @@ import { defaultChannel } from '../context'; import type { UpdatePolicy } from '../types'; +import type { RuntimeDeliveryDiagnosticEvent } from '../runtime-delivery/diagnostics'; export const BUNDLE_DROP_NOT_INITIALIZED_MESSAGE = 'BundleDrop has not been initialized. Call BundleDrop.init({ environment, ... }) before using OTA APIs or useBundleDrop().'; @@ -20,6 +21,8 @@ export type BundleDropInitOptions = { policy?: UpdatePolicy; /** Optional listener for human-readable status messages emitted during checks, downloads, and apply flow. */ onStatusUpdate?: (status: string) => void; + /** Optional sink for structured runtime-delivery diagnostic counter events. */ + onRuntimeDeliveryDiagnostic?: (event: RuntimeDeliveryDiagnosticEvent) => void; /** When `true`, startup performs only a resolve/check and skips download/apply even if policy would normally do more. */ checkOnly?: boolean; }; @@ -38,6 +41,8 @@ export type BundleDropRuntimeConfig = { policy: UpdatePolicy; /** Optional listener receiving status messages from runtime actions. */ onStatusUpdate?: (status: string) => void; + /** Optional sink receiving structured runtime-delivery diagnostic counter events. */ + onRuntimeDeliveryDiagnostic?: (event: RuntimeDeliveryDiagnosticEvent) => void; /** Whether startup is limited to a resolve/check without downloading or applying. */ checkOnly: boolean; }; @@ -48,6 +53,7 @@ type BundleDropInitConfig = { initialChannelName: string; policy: UpdatePolicy; onStatusUpdate?: (status: string) => void; + onRuntimeDeliveryDiagnostic?: (event: RuntimeDeliveryDiagnosticEvent) => void; checkOnly: boolean; }; @@ -103,6 +109,7 @@ export function resolveBundleDropRuntimeConfig( channelName, policy: options.policy || 'manual', onStatusUpdate: options.onStatusUpdate, + onRuntimeDeliveryDiagnostic: options.onRuntimeDeliveryDiagnostic, checkOnly: !!options.checkOnly, }; } @@ -121,6 +128,7 @@ export function initializeBundleDropRuntime(options: BundleDropInitOptions): { initialChannelName: nextConfig.channelName, policy: nextConfig.policy, onStatusUpdate: nextConfig.onStatusUpdate, + onRuntimeDeliveryDiagnostic: nextConfig.onRuntimeDeliveryDiagnostic, checkOnly: nextConfig.checkOnly, }; runtimeConfigKey = nextKey; @@ -137,6 +145,7 @@ export function initializeBundleDropRuntime(options: BundleDropInitOptions): { initConfig = { ...initConfig, onStatusUpdate: nextConfig.onStatusUpdate, + onRuntimeDeliveryDiagnostic: nextConfig.onRuntimeDeliveryDiagnostic, }; return { config: assertBundleDropInitialized(), alreadyInitialized: true }; @@ -153,6 +162,7 @@ export function getBundleDropRuntimeConfig(): BundleDropRuntimeConfig | null { channelName: runtimeChannelName, policy: initConfig.policy, onStatusUpdate: initConfig.onStatusUpdate, + onRuntimeDeliveryDiagnostic: initConfig.onRuntimeDeliveryDiagnostic, checkOnly: initConfig.checkOnly, }; } diff --git a/src/runtime/service.ts b/src/runtime/service.ts index 1cd1358..dcd9c66 100644 --- a/src/runtime/service.ts +++ b/src/runtime/service.ts @@ -5,6 +5,7 @@ import { import type { BundleListItem, UpdateCheckResponse } from '../api/types'; import { BUNDLE_DROP_ROOT, config as projectConfig, defaultChannel } from '../context'; import { cleanOrphanedTempZips } from '../fs/fsUtils'; +import { isRuntimeDeliveryConfigured } from '../loadConfig'; import { getDownloadedBundlePathNative, isBundleDropNativeAvailable, @@ -52,6 +53,15 @@ const EXPO_DEVELOPMENT_FALLBACK_WARNING = '[BundleDrop] OTA startup is unavailable in this Expo runtime. ' + 'Expo Go and standard Debug/development-client builds keep Metro priority, so OTA features are disabled. ' + 'Use a non-Debug/Release native build to test Bundle Drop updates.'; +const CURRENT_REVOKED_ROLLBACK_REASONS = new Set([ + 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET', + 'CURRENT_REVOKED_NO_SAFE_TARGET', + 'CURRENT_REVOKED_ORIGIN_UNAVAILABLE', +]); +const MANAGED_LIST_INSTALL_NOT_AUTHORIZED_STATUS = + '⚠️ Selected bundle is not authorized by the current runtime delivery decision'; +const MANAGED_DIRECT_INSTALL_NOT_AUTHORIZED_STATUS = + '⚠️ Direct URL installs are unavailable with managed runtime delivery; use downloadUpdate or a bundle-list item'; type BundleDropRuntimeSnapshot = { status: string; @@ -159,6 +169,10 @@ function isSameStatus(current: string | undefined, next: string | undefined): bo return !!current && current === next; } +function isCurrentRevokedRollback(reason?: string): boolean { + return !!reason && CURRENT_REVOKED_ROLLBACK_REASONS.has(reason); +} + function getCheckStatus(response: UpdateCheckResponse | null): string { if (!response) return '⚠️ Unable to check for updates. Try again.'; if (response.skippedFailedBundle) { @@ -220,6 +234,15 @@ function requestRuntimeRestart() { restartReactNativeNative(); } +async function applyAuthoritativeRollback(reason?: string): Promise { + if (isCurrentRevokedRollback(reason)) { + await rollbackToPreviousOrNative({ forceNative: true }); + } else { + await rollbackToPreviousOrNative(); + } + requestRuntimeRestart(); +} + async function markActiveCandidateHealthy(expectedHash?: string): Promise { if (runtimeRestartRequested) { return false; @@ -384,8 +407,7 @@ async function runStartupFlow() { if (decision.action === 'ROLLBACK') { emitStatus('↩️ Server requested rollback...'); - await rollbackToPreviousOrNative(); - requestRuntimeRestart(); + await applyAuthoritativeRollback(decision.reason); return; } @@ -397,7 +419,7 @@ async function runStartupFlow() { ? decision.fallback?.downloadUrl : decision.downloadUrl; - if (!downloadUrl || !decision.hash) { + if ((!downloadUrl && !decision.runtimeDelivery) || !decision.hash) { emitStatus('⚠️ Update available but missing download URL'); return; } @@ -413,6 +435,7 @@ async function runStartupFlow() { baseHash: decision.baseHash, patchSet: decision.patchSet, fallback: decision.fallback, + ...(decision.runtimeDelivery ? { runtimeDelivery: decision.runtimeDelivery } : {}), }; const downloadResult = await downloadUpdateInternal( @@ -423,6 +446,12 @@ async function runStartupFlow() { statusCb, ); + if (downloadResult.status === 'rollback') { + emitStatus('↩️ Server requested rollback...'); + await applyAuthoritativeRollback(downloadResult.reason); + return; + } + if (runtime.policy === 'on-next-launch') { if (downloadResult.status === 'staged') { emitStatus('✅ Update downloaded for next launch'); @@ -585,6 +614,9 @@ export async function downloadAndStage(extraStatusHandler?: StatusHandler): Prom { channelName: runtime.channelName }, status => emitStatus(status, extraStatusHandler), ); + if (result.status === 'rollback') { + await applyAuthoritativeRollback(result.reason); + } const status = getDownloadStatus(result); if (status) { emitStatus(status, extraStatusHandler); @@ -696,10 +728,30 @@ export async function installBundleFromListItem( const resolvedDecision = await checkForUpdateInternal(runtime.channelName, emitStatus).catch( () => null, ); + const usesManagedRuntimeDelivery = isRuntimeDeliveryConfigured(projectConfig.runtimeDelivery); const canUseResolvedTransport = resolvedDecision?.action === 'INSTALL' && (resolvedDecision.bundleHash ?? resolvedDecision.hash) === bundle.hash; + if (usesManagedRuntimeDelivery && resolvedDecision?.action === 'ROLLBACK') { + const result: DownloadUpdateResult = { + status: 'rollback', + reason: resolvedDecision.reason, + }; + await applyAuthoritativeRollback(result.reason); + const status = getDownloadStatus(result); + if (status) emitStatus(status); + return { result, status }; + } + + if (usesManagedRuntimeDelivery && !canUseResolvedTransport) { + emitStatus(MANAGED_LIST_INSTALL_NOT_AUTHORIZED_STATUS); + return { + result: { status: 'incompatible' }, + status: MANAGED_LIST_INSTALL_NOT_AUTHORIZED_STATUS, + }; + } + const result = canUseResolvedTransport ? await downloadUpdateInternal( { @@ -718,6 +770,9 @@ export async function installBundleFromListItem( baseHash: resolvedDecision.baseHash, patchSet: resolvedDecision.patchSet, fallback: resolvedDecision.fallback, + ...(resolvedDecision.runtimeDelivery + ? { runtimeDelivery: resolvedDecision.runtimeDelivery } + : {}), }, }, emitStatus, @@ -734,6 +789,10 @@ export async function installBundleFromListItem( }, ); + if (usesManagedRuntimeDelivery && result.status === 'rollback') { + await applyAuthoritativeRollback(result.reason); + } + if (result.status === 'staged') { const status = `✅ v${bundle.bundleVersion} downloaded. Will apply on next launch or when you call applyUpdate.`; emitStatus(status); @@ -896,6 +955,11 @@ export async function installBundle( } await waitForStartupIfNeeded(); + if (isRuntimeDeliveryConfigured(projectConfig.runtimeDelivery)) { + emitStatus(MANAGED_DIRECT_INSTALL_NOT_AUTHORIZED_STATUS, onStatusUpdate); + return { status: 'incompatible' }; + } + return withBusy(async () => { const result = await installBundleInternal( hash, diff --git a/src/tests/CLI/cli.test.ts b/src/tests/CLI/cli.test.ts index 116d197..a681133 100644 --- a/src/tests/CLI/cli.test.ts +++ b/src/tests/CLI/cli.test.ts @@ -50,7 +50,7 @@ jest.mock('../../expo', () => ({ })); jest.mock('axios', () => require('../mocks/modules/axiosNode')); -import { buildProgram } from '../../CLI/cli'; +import { buildProgram, runCli } from '../../CLI/cli'; describe('CLI/cli', () => { const originalEnv = { ...process.env }; @@ -115,7 +115,7 @@ describe('CLI/cli', () => { ); }); - it('routes Expo setup escape hatches and exact build receipts', async () => { + it('routes explicit setup migration flags and exact build receipts', async () => { await parseCommand( 'init', '--token', @@ -123,6 +123,7 @@ describe('CLI/cli', () => { '--project-type', 'expo', '--dry-run', + '--migrate-code-push', '--migrate-expo-updates', '--prebuild', '--yes', @@ -130,6 +131,7 @@ describe('CLI/cli', () => { expect(mockRunPostInitPrompts).toHaveBeenCalledWith(expect.objectContaining({ projectType: 'expo', dryRun: true, + migrateCodePush: true, migrateExpoUpdates: true, prebuild: true, yes: true, @@ -402,6 +404,82 @@ describe('CLI/cli', () => { }); }); + it('syncs the authenticated v2 bootstrap without rerunning project setup', async () => { + mockInitConfig.mockResolvedValue({ + bootstrapContent: '{"schemaVersion":1}\n', + }); + + await parseCommand('sync', '--token', 'token-123'); + + expect(mockInitConfig).toHaveBeenCalledWith({ + serverUrl: 'https://api.bundledrop.app', + projects: [], + organizations: [], + authToken: 'token-123', + dryRun: false, + }); + expect(mockRunPostInitPrompts).not.toHaveBeenCalled(); + }); + + it('dry-runs sync from stored auth and reports missing authentication', async () => { + const authDir = path.join(tempHome, '.bundle-drop'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'auth.json'), + JSON.stringify({ token: 'stored-token', baseUrl: 'https://legacy.example.com/' }), + ); + mockInitConfig.mockResolvedValue({ bootstrapContent: '{"schemaVersion":1}\n' }); + + await parseCommand('sync', '--dry-run'); + + expect(mockInitConfig).toHaveBeenCalledWith(expect.objectContaining({ + serverUrl: 'https://legacy.example.com', + authToken: 'stored-token', + dryRun: true, + })); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('No files changed'), + ); + + fs.unlinkSync(path.join(authDir, 'auth.json')); + await expect(parseCommand('sync')).rejects.toThrow('Not authenticated'); + }); + + it('accepts explicit disabled convergence without requiring a bootstrap', async () => { + mockInitConfig.mockResolvedValue({ + runtimeDeliveryAvailable: false, + bootstrapRetired: true, + }); + + await expect(parseCommand('sync', '--token', 'token-123')).resolves.toBeUndefined(); + }); + + it('previews authoritative v1 retirement without writing', async () => { + mockInitConfig.mockResolvedValue({ + runtimeDeliveryAvailable: false, + bootstrapRetired: true, + }); + + await parseCommand('sync', '--token', 'token-123', '--dry-run'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('stale bootstrap would be removed. No files changed'), + ); + }); + + it('fails sync when setup is absent or the backend omits runtime delivery trust', async () => { + mockHasExistingBundleDropConfig.mockReturnValue(false); + await expect(parseCommand('sync', '--token', 'token-123')).rejects.toThrow( + 'requires bundle.drop.config.js', + ); + + mockHasExistingBundleDropConfig.mockReturnValue(true); + mockInitConfig.mockResolvedValue({}); + await expect(parseCommand('sync', '--token', 'token-123')).rejects.toThrow( + 'did not return a valid runtime delivery bootstrap', + ); + }); + it('reads stored auth for init and falls back to baseUrl when present', async () => { const authDir = path.join(tempHome, '.bundle-drop'); fs.mkdirSync(authDir, { recursive: true }); @@ -417,7 +495,7 @@ describe('CLI/cli', () => { 'utf8', ); - await parseCommand('init'); + await parseCommand('init', '--migrate-code-push'); expect(mockInitConfig).toHaveBeenCalledWith({ serverUrl: 'https://legacy.example.com', @@ -428,7 +506,9 @@ describe('CLI/cli', () => { dryRun: true, projectType: 'bare', }); - expect(mockRunPostInitPrompts).toHaveBeenCalledTimes(1); + expect(mockRunPostInitPrompts).toHaveBeenCalledWith(expect.objectContaining({ + migrateCodePush: true, + })); }); it('passes a newly selected stored-auth config to setup without writing it early', async () => { @@ -635,6 +715,61 @@ describe('CLI/cli', () => { await expect(parseCommand('login')).rejects.toThrow('login failed'); }); + it('prints executable command failures without an internal stack trace', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const originalExitCode = process.exitCode; + mockRunPostInitPrompts.mockRejectedValueOnce( + new Error('Refusing to send project files to untrusted AI planning server.'), + ); + + try { + await runCli([ + 'node', + 'bundle-drop', + 'init', + '--token', + 'token-123', + '--dry-run', + ]); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toBe( + '❌ Refusing to send project files to untrusted AI planning server.\n' + + 'Manual setup: https://bundledrop.app/docs/manual-setup', + ); + expect(consoleErrorSpy.mock.calls[0][0]).not.toContain('at runSetupWithManualFallback'); + expect(process.exitCode).toBe(1); + } finally { + consoleErrorSpy.mockRestore(); + process.exitCode = originalExitCode; + } + }); + + it('normalizes non-Error setup failures before linking to manual setup', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const originalExitCode = process.exitCode; + mockRunPostInitPrompts.mockRejectedValueOnce('setup unavailable'); + + try { + await runCli([ + 'node', + 'bundle-drop', + 'init', + '--token', + 'token-123', + '--dry-run', + ]); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '❌ setup unavailable\nManual setup: https://bundledrop.app/docs/manual-setup', + ); + expect(process.exitCode).toBe(1); + } finally { + consoleErrorSpy.mockRestore(); + process.exitCode = originalExitCode; + } + }); + it('prints whoami details from stored auth', async () => { const authDir = path.join(tempHome, '.bundle-drop'); fs.mkdirSync(authDir, { recursive: true }); diff --git a/src/tests/CLI/scripts/aipowered/apply-setup-plan.test.ts b/src/tests/CLI/scripts/aipowered/apply-setup-plan.test.ts index b5015b7..ff0b6ad 100644 --- a/src/tests/CLI/scripts/aipowered/apply-setup-plan.test.ts +++ b/src/tests/CLI/scripts/aipowered/apply-setup-plan.test.ts @@ -149,4 +149,78 @@ describe('CLI/scripts/aipowered/apply-setup-plan', () => { }), ).toThrow(); }); + + it('uses random exclusive temps and rejects target, parent, and backup symlink escapes', () => { + const outsideRoot = createTempProjectDir(); + const outsideSentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(outsideSentinel, 'outside-safe'); + try { + const original = '{"expo":{}}\n'; + const appPath = write('app.json', original); + fs.symlinkSync(outsideSentinel, `${appPath}.bundledrop-tmp`); + applySetupPatchPlans({ + projectRoot, + projectType: 'expo', + changes: [changeFor('app.json', original, '{"expo":{"plugins":[]}}\n')], + }); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + + fs.rmSync(path.join(projectRoot, '.bundledrop-backup'), { recursive: true }); + fs.symlinkSync(outsideRoot, path.join(projectRoot, '.bundledrop-backup')); + expect(() => applySetupPatchPlans({ + projectRoot, + projectType: 'expo', + changes: [changeFor('app.json', '{"expo":{"plugins":[]}}\n', original)], + })).toThrow('symlinked or non-directory'); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + + fs.unlinkSync(path.join(projectRoot, '.bundledrop-backup')); + fs.unlinkSync(appPath); + fs.symlinkSync(outsideSentinel, appPath); + expect(() => applySetupPatchPlans({ + projectRoot, + projectType: 'expo', + changes: [changeFor('app.json', 'outside-safe', original)], + })).toThrow('symlinked or non-regular'); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + + fs.unlinkSync(appPath); + fs.symlinkSync(outsideRoot, path.join(projectRoot, 'android')); + expect(() => applySetupPatchPlans({ + projectRoot, + projectType: 'bare', + changes: [changeFor( + 'android/app/src/main/java/demo/MainApplication.kt', + 'outside-safe', + 'updated', + )], + })).toThrow('symlinked or non-directory'); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + } finally { + removeTempDir(outsideRoot); + } + }); + + it('rolls back an earlier write when a later target is a symlink', () => { + const outsideRoot = createTempProjectDir(); + const outsideSentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(outsideSentinel, 'outside-safe'); + const originalApp = '{"expo":{}}\n'; + const appPath = write('app.json', originalApp); + fs.symlinkSync(outsideSentinel, path.join(projectRoot, 'metro.config.js')); + try { + expect(() => applySetupPatchPlans({ + projectRoot, + projectType: 'expo', + changes: [ + changeFor('app.json', originalApp, '{"expo":{"plugins":[]}}\n'), + changeFor('metro.config.js', 'outside-safe', 'module.exports = {};\n'), + ], + })).toThrow('symlinked or non-regular'); + expect(fs.readFileSync(appPath, 'utf8')).toBe(originalApp); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + } finally { + removeTempDir(outsideRoot); + } + }); }); diff --git a/src/tests/CLI/scripts/aipowered/backend-client.test.ts b/src/tests/CLI/scripts/aipowered/backend-client.test.ts index d82a994..51ddeb0 100644 --- a/src/tests/CLI/scripts/aipowered/backend-client.test.ts +++ b/src/tests/CLI/scripts/aipowered/backend-client.test.ts @@ -130,7 +130,7 @@ describe('CLI/scripts/aipowered/backend-client', () => { serverUrl: 'https://api.example.com', authToken: 'token', request: {} as any, - })).rejects.toThrow('{"code":"INVALID_SETUP"}'); + })).rejects.toThrow(/^AI setup planning failed$/); mockAxiosNodePost.mockRejectedValueOnce({ response: { data: 'maintenance' } }); await expect(requestAiSetupPlan({ @@ -146,4 +146,98 @@ describe('CLI/scripts/aipowered/backend-client', () => { request: {} as any, })).rejects.toThrow('AI setup planning failed'); }); + + it('prefers bounded sanitized backend details without exposing structured data', async () => { + mockAxiosNodePost.mockRejectedValueOnce({ + response: { + status: 424, + data: { + error: 'Invalid request.', + details: { + reason: 'AI setup plan removed existing native structure: RCTBundleURLProvider', + ignored: { arbitrary: 'must not be serialized' }, + }, + }, + }, + }); + await expect(requestAiSetupPlan({ + serverUrl: 'https://api.example.com', + authToken: 'token', + request: {} as any, + })).rejects.toThrow( + 'AI setup planning failed: AI setup plan removed existing native structure: RCTBundleURLProvider', + ); + + mockAxiosNodePost.mockRejectedValueOnce({ + response: { + data: { + error: 'Invalid request.', + details: { reason: 'Safe reason\u001b[2J\roverwrite\u202e' }, + }, + }, + }); + await expect(requestAiSetupPlan({ + serverUrl: 'https://api.example.com', + authToken: 'token', + request: {} as any, + })).rejects.toThrow('Safe reason\\x1b[2J\\roverwrite\\u202e'); + + mockAxiosNodePost.mockRejectedValueOnce({ + response: { + data: { + error: 'Invalid request.', + details: { reason: { message: 'AI setup plan removed a file' }, source: 'arbitrary' }, + }, + }, + }); + await expect(requestAiSetupPlan({ + serverUrl: 'https://api.example.com', + authToken: 'token', + request: {} as any, + })).rejects.toThrow('AI setup planning failed: Invalid request.'); + + const getterDetails = {}; + Object.defineProperty(getterDetails, 'reason', { + enumerable: true, + get: () => { + throw new Error('must not invoke backend object getters'); + }, + }); + mockAxiosNodePost.mockRejectedValueOnce({ + response: { data: { error: 'Invalid request.', details: getterDetails } }, + }); + await expect(requestAiSetupPlan({ + serverUrl: 'https://api.example.com', + authToken: 'token', + request: {} as any, + })).rejects.toThrow('AI setup planning failed: Invalid request.'); + + mockAxiosNodePost.mockRejectedValueOnce({ + response: { + data: { + error: 'Invalid request.', + details: { reason: 'bdp_proj_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG' }, + }, + }, + }); + await expect(requestAiSetupPlan({ + serverUrl: 'https://api.example.com', + authToken: 'token', + request: {} as any, + })).rejects.toThrow('AI setup planning failed: Invalid request.'); + + mockAxiosNodePost.mockRejectedValueOnce({ + response: { + data: { + error: 'Invalid request.', + details: { reason: 'x'.repeat(1001) }, + }, + }, + }); + await expect(requestAiSetupPlan({ + serverUrl: 'https://api.example.com', + authToken: 'token', + request: {} as any, + })).rejects.toThrow('AI setup planning failed: Invalid request.'); + }); }); diff --git a/src/tests/CLI/scripts/aipowered/code-push-residue.test.ts b/src/tests/CLI/scripts/aipowered/code-push-residue.test.ts new file mode 100644 index 0000000..7eeab06 --- /dev/null +++ b/src/tests/CLI/scripts/aipowered/code-push-residue.test.ts @@ -0,0 +1,242 @@ +import fs from 'fs-extra'; +import type { Dirent, Stats } from 'fs'; +import path from 'path'; + +import { findCodePushResiduePaths } from '../../../../CLI/scripts/aipowered/code-push-residue'; +import { createTempProjectDir, removeTempDir } from '../../../utils/tempDir'; + +describe('CLI/scripts/aipowered/code-push-residue', () => { + let projectRoot = ''; + + beforeEach(() => { + projectRoot = createTempProjectDir(); + }); + + afterEach(() => { + removeTempDir(projectRoot); + }); + + const writeFixture = (relativePath: string, content: string) => { + const filePath = path.join(projectRoot, relativePath); + fs.ensureDirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, content); + }; + + it('finds JS wrappers, Gradle hooks, and native deployment-key configuration', () => { + writeFixture( + 'src/App.tsx', + "import codePush from 'react-native-code-push';\nexport default codePush(App);\n", + ); + writeFixture( + 'android/app/build.gradle', + "apply from: '../../node_modules/react-native-code-push/android/codepush.gradle'\n", + ); + writeFixture( + 'android/app/src/main/res/values/strings.xml', + 'deployment-key\n', + ); + writeFixture( + 'ios/Demo/Info.plist', + 'CodePushDeploymentKeydeployment-key\n', + ); + writeFixture( + 'ios/Demo.xcodeproj/project.pbxproj', + 'CODEPUSH_KEY = deployment-key;\n', + ); + + expect(findCodePushResiduePaths(projectRoot)).toEqual([ + 'android/app/build.gradle', + 'android/app/src/main/res/values/strings.xml', + 'ios/Demo.xcodeproj/project.pbxproj', + 'ios/Demo/Info.plist', + 'src/App.tsx', + ]); + }); + + it('ignores package files, provider-patched entrypoints, and generated files but rejects symlinks', () => { + writeFixture( + 'package.json', + JSON.stringify({ dependencies: { 'react-native-code-push': '9.0.0' } }), + ); + writeFixture('yarn.lock', 'react-native-code-push@9.0.0\n'); + writeFixture( + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + 'fun getJSBundleFile() = CodePush.getJSBundleFile()\n', + ); + writeFixture( + 'ios/Demo/AppDelegate.mm', + 'return [CodePush bundleURL];\n', + ); + writeFixture('src/generated/OldApp.tsx', 'CodePush.sync();\n'); + writeFixture('node_modules/example/index.js', 'CodePush.sync();\n'); + writeFixture('vendor/legacy/Updater.ts', 'CodePush.sync();\n'); + const outsideFile = path.join(projectRoot, '..', `${path.basename(projectRoot)}-outside.ts`); + fs.writeFileSync(outsideFile, 'CodePush.sync();\n'); + fs.ensureDirSync(path.join(projectRoot, 'src')); + fs.symlinkSync(outsideFile, path.join(projectRoot, 'src', 'Linked.ts')); + + try { + expect(() => findCodePushResiduePaths(projectRoot)).toThrow( + 'src/Linked.ts is a symbolic link', + ); + } finally { + fs.removeSync(outsideFile); + } + }); + + it('finds custom native CodePush references outside provider-patched entrypoints', () => { + writeFixture( + 'android/app/src/main/java/com/demo/CustomBundleResolver.java', + 'return CodePush.getJSBundleFile();\n', + ); + writeFixture( + 'ios/Demo/CustomBundleResolver.mm', + 'return [CodePush bundleURL];\n', + ); + writeFixture( + 'ios/Podfile', + "pod 'CodePush', :path => '../node_modules/react-native-code-push'\n", + ); + writeFixture( + 'ios/Demo/AppDelegate.swift', + 'return CodePush.bundleURL()\n', + ); + + expect(findCodePushResiduePaths(projectRoot)).toEqual([ + 'android/app/src/main/java/com/demo/CustomBundleResolver.java', + 'ios/Demo/CustomBundleResolver.mm', + 'ios/Podfile', + ]); + }); + + it('does not traverse excluded vendor symlinks', () => { + const outsideRoot = createTempProjectDir(); + fs.writeFileSync(path.join(outsideRoot, 'Updater.ts'), 'CodePush.sync();\n'); + fs.symlinkSync(outsideRoot, path.join(projectRoot, 'vendor')); + try { + expect(findCodePushResiduePaths(projectRoot)).toEqual([]); + } finally { + removeTempDir(outsideRoot); + } + }); + + it('finds CodePush outside src and in Android manifest, properties, and RN config', () => { + writeFixture( + 'components/Updater.tsx', + "import codePush from 'react-native-code-push';\nexport default codePush(Updater);\n", + ); + writeFixture('app/services/updates.ts', 'CodePush.sync();\n'); + writeFixture('packages/mobile/js/update-client.js', 'code_push.restartApp();\n'); + writeFixture( + 'android/app/src/main/AndroidManifest.xml', + '\n', + ); + writeFixture('android/gradle.properties', 'CODEPUSH_KEY=key\n'); + writeFixture( + 'react-native.config.js', + 'module.exports = { codePush: { android: {} } };\n', + ); + + expect(findCodePushResiduePaths(projectRoot)).toEqual([ + 'android/app/src/main/AndroidManifest.xml', + 'android/gradle.properties', + 'app/services/updates.ts', + 'components/Updater.tsx', + 'packages/mobile/js/update-client.js', + 'react-native.config.js', + ]); + }); + + it('allows a clean JS and native configuration to proceed', () => { + writeFixture('index.js', "import { AppRegistry } from 'react-native';\n"); + writeFixture('src/App.tsx', 'export default function App() { return null; }\n'); + writeFixture('android/app/build.gradle', "apply plugin: 'com.android.application'\n"); + writeFixture('ios/Demo/Info.plist', 'CFBundleNameDemo\n'); + + expect(findCodePushResiduePaths(projectRoot)).toEqual([]); + }); + + it('fails closed when a relevant file exceeds the per-file scan limit', () => { + writeFixture('src/Updater.ts', 'x'.repeat(1024 * 1024 + 1)); + + expect(() => findCodePushResiduePaths(projectRoot)).toThrow('exceeds the per-file limit'); + }); + + it('fails closed when relevant files exceed the aggregate scan limit', () => { + for (let index = 0; index < 6; index += 1) { + writeFixture(`src/Updater${index}.ts`, 'x'.repeat(900 * 1024)); + } + + expect(() => findCodePushResiduePaths(projectRoot)).toThrow( + 'relevant files exceed 5242880 bytes', + ); + }); + + it('fails closed when more than 500 relevant files are present', () => { + for (let index = 0; index <= 500; index += 1) { + writeFixture(`src/generated-${index}.ts`, 'export {};\n'); + } + + expect(() => findCodePushResiduePaths(projectRoot)).toThrow( + 'more than 500 relevant files', + ); + }); + + it('fails closed when the filesystem traversal exceeds its entry cap', () => { + const entries = Array.from({ length: 20_001 }, (_value, index) => ({ + name: `entry-${String(index).padStart(5, '0')}`, + })) as Dirent[]; + const readdir = jest.spyOn(fs, 'readdirSync').mockReturnValue(entries); + const lstat = jest.spyOn(fs, 'lstatSync').mockReturnValue({ + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => false, + } as Stats); + + try { + expect(() => findCodePushResiduePaths(projectRoot)).toThrow( + 'more than 20000 filesystem entries', + ); + } finally { + readdir.mockRestore(); + lstat.mockRestore(); + } + }); + + it('fails closed when a directory cannot be inspected', () => { + const readdir = jest.spyOn(fs, 'readdirSync').mockImplementationOnce(() => { + throw new Error('unreadable'); + }); + + try { + expect(() => findCodePushResiduePaths(projectRoot)).toThrow('cannot inspect .'); + } finally { + readdir.mockRestore(); + } + }); + + it('fails closed when a candidate file disappears during inspection or reading', () => { + writeFixture('src/Updater.ts', 'CodePush.sync();\n'); + const realLstat = fs.lstatSync.bind(fs); + const lstat = jest.spyOn(fs, 'lstatSync').mockImplementation(targetPath => { + if (String(targetPath).endsWith(`${path.sep}Updater.ts`)) throw new Error('gone'); + return realLstat(targetPath); + }); + try { + expect(() => findCodePushResiduePaths(projectRoot)).toThrow( + 'cannot inspect src/Updater.ts', + ); + } finally { + lstat.mockRestore(); + } + + const readFile = jest.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { + throw new Error('unreadable'); + }); + try { + expect(() => findCodePushResiduePaths(projectRoot)).toThrow('cannot read src/Updater.ts'); + } finally { + readFile.mockRestore(); + } + }); +}); diff --git a/src/tests/CLI/scripts/aipowered/diff-preview.test.ts b/src/tests/CLI/scripts/aipowered/diff-preview.test.ts index a8e39cd..51a847c 100644 --- a/src/tests/CLI/scripts/aipowered/diff-preview.test.ts +++ b/src/tests/CLI/scripts/aipowered/diff-preview.test.ts @@ -54,4 +54,49 @@ describe('CLI/scripts/aipowered/diff-preview', () => { ' unchanged', ].join('\n')); }); + + it('redacts project API keys from both sides of a config preview', () => { + const diff = buildUnifiedDiff({ + projectRoot: '/project', + originals: new Map([ + [ + 'bundle.drop.config.js', + `module.exports = { project: { name: 'Old', apiKey: 'old-secret' } };\n`, + ], + ]), + changes: [{ + file: 'bundle.drop.config.js', + originalSha256: 'hash', + updated: 'module.exports = { project: { name: "New", apiKey: "new-secret" } };\n', + reason: 'Update config', + confidence: 'high', + decisionType: 'safe_auto_patch', + }], + }); + + expect(diff).not.toContain('old-secret'); + expect(diff).not.toContain('new-secret'); + expect(diff).toContain('apiKey: ""'); + }); + + it('escapes terminal controls and bidi overrides in provider-authored diffs', () => { + const diff = buildUnifiedDiff({ + projectRoot: '/project', + originals: new Map([['app.config.js', 'export default {};\n']]), + changes: [{ + file: 'app.config.js', + originalSha256: 'hash', + updated: 'export default {};\x1b[2J\rspoof\u202E\n', + reason: 'Configure', + confidence: 'high', + decisionType: 'review_only_patch', + }], + }); + + expect(diff).toContain('\\x1b[2J'); + expect(diff).toContain('\\rspoof'); + expect(diff).toContain('\\u202e'); + expect(diff).not.toContain('\x1b'); + expect(diff).not.toContain('\u202E'); + }); }); diff --git a/src/tests/CLI/scripts/aipowered/init-project-config.test.ts b/src/tests/CLI/scripts/aipowered/init-project-config.test.ts index cc11e1b..f2db806 100644 --- a/src/tests/CLI/scripts/aipowered/init-project-config.test.ts +++ b/src/tests/CLI/scripts/aipowered/init-project-config.test.ts @@ -12,6 +12,7 @@ const mockDetectProjectType = jest.fn(); const mockEvaluateExpoConfig = jest.fn(); const mockScanProjectForAiSetup = jest.fn(); const mockRequestAiSetupPlan = jest.fn(); +const mockFindCodePushResiduePaths = jest.fn(); const mockValidateSetupChangesBeforeApply = jest.fn(); const mockValidateAppliedSetupChanges = jest.fn(); const mockApplySetupPatchPlans = jest.fn(); @@ -27,7 +28,9 @@ const mockSetBundleDropProjectType = jest.fn((content: string, projectType: stri content.replace('module.exports = {', `module.exports = {\n projectType: '${projectType}',`), ); const mockDetectPackageManager = jest.fn(); +const mockCodePushRemovalCommand = jest.fn(); const mockExpoUpdatesRemovalCommand = jest.fn(); +const mockRemoveCodePushWithPackageManager = jest.fn(); const mockRemoveExpoUpdatesWithPackageManager = jest.fn(); const mockRestoreDependencyMigration = jest.fn(); const mockExecSync = jest.fn(); @@ -44,6 +47,13 @@ jest.mock('../../../../expo', () => ({ evaluateExpoConfig: (...args: unknown[]) => mockEvaluateExpoConfig(...args), })); jest.mock('../../../../CLI/scripts/aipowered/scanner', () => ({ + authoritativeDynamicExpoConfigFile: (_root: string, candidate: unknown) => { + if (typeof candidate !== 'string' || !candidate) return null; + if (candidate.startsWith('../') || candidate.startsWith('/outside')) { + throw new Error(`Expo reported an unsafe dynamic app config path: ${candidate}. No files changed.`); + } + return candidate.replace(/^\/project\//, ''); + }, findProjectRoot: (startDir: string) => startDir, isBundleDropHostedAiPlanningServer: (serverUrl: string) => serverUrl === 'https://api.bundledrop.app', @@ -52,6 +62,9 @@ jest.mock('../../../../CLI/scripts/aipowered/scanner', () => ({ jest.mock('../../../../CLI/scripts/aipowered/backend-client', () => ({ requestAiSetupPlan: (...args: unknown[]) => mockRequestAiSetupPlan(...args), })); +jest.mock('../../../../CLI/scripts/aipowered/code-push-residue', () => ({ + findCodePushResiduePaths: (...args: unknown[]) => mockFindCodePushResiduePaths(...args), +})); jest.mock('../../../../CLI/scripts/aipowered/validate-plan', () => ({ validateSetupChangesBeforeApply: (...args: unknown[]) => mockValidateSetupChangesBeforeApply(...args), @@ -80,8 +93,11 @@ jest.mock('../../../../CLI/scripts/expo/configure-expo', () => ({ mockSetBundleDropProjectType(...args), })); jest.mock('../../../../CLI/scripts/expo/package-manager', () => ({ + codePushRemovalCommand: (...args: unknown[]) => mockCodePushRemovalCommand(...args), detectPackageManager: (...args: unknown[]) => mockDetectPackageManager(...args), expoUpdatesRemovalCommand: (...args: unknown[]) => mockExpoUpdatesRemovalCommand(...args), + removeCodePushWithPackageManager: (...args: unknown[]) => + mockRemoveCodePushWithPackageManager(...args), removeExpoUpdatesWithPackageManager: (...args: unknown[]) => mockRemoveExpoUpdatesWithPackageManager(...args), restoreDependencyMigration: (...args: unknown[]) => mockRestoreDependencyMigration(...args), @@ -177,6 +193,7 @@ describe('initProjectConfigAi', () => { mockDetectProjectType.mockReturnValue('bare'); mockScanProjectForAiSetup.mockReturnValue(scanner('bare')); mockRequestAiSetupPlan.mockResolvedValue(plan()); + mockFindCodePushResiduePaths.mockReturnValue([]); mockApplySetupPatchPlans.mockReturnValue({ projectRoot, backupDir: '/project/.bundledrop-backup/native', @@ -201,7 +218,13 @@ describe('initProjectConfigAi', () => { exp: { plugins: ['@gfean/react-native-bundle-drop'], updates: { enabled: false } }, }); mockDetectPackageManager.mockReturnValue('yarn'); + mockCodePushRemovalCommand.mockReturnValue(['yarn', 'remove', 'react-native-code-push']); mockExpoUpdatesRemovalCommand.mockReturnValue(['yarn', 'remove', 'expo-updates']); + mockRemoveCodePushWithPackageManager.mockReturnValue({ + projectRoot, + backupDir: '/project/.bundledrop-backup/code-push', + files: ['package.json', 'yarn.lock'], + }); mockRemoveExpoUpdatesWithPackageManager.mockReturnValue({ projectRoot, backupDir: '/project/.bundledrop-backup/dependency', @@ -243,11 +266,52 @@ describe('initProjectConfigAi', () => { expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('https://api.example.com (external AI planning server)'), ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Provider context size:'), + ); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('MainApplication.kt (android_entrypoint, full content)'), ); }); + it('makes zero provider calls when scanner context exceeds the local prompt budget', async () => { + mockScanProjectForAiSetup.mockImplementation(() => { + throw new Error('including it would exceed the 131072-byte total context limit'); + }); + + await expect(initProjectConfigAi()).rejects.toThrow('131072-byte total context limit'); + expect(mockRequestAiSetupPlan).not.toHaveBeenCalled(); + }); + + it('rejects provider terminal controls before printing metadata or diffs', async () => { + queuePromptResponse({ send: true }); + mockRequestAiSetupPlan.mockResolvedValue(plan({ + summary: 'unsafe\x1b[2J summary', + warnings: ['overwrite\rwarning'], + actions: [{ + type: 'run_doctor', + reason: 'bidi\u202Ereason', + requiresConfirmation: false, + }], + })); + + await expect(initProjectConfigAi()).rejects.toThrow('unsafe terminal controls in summary'); + const displayed = consoleLogSpy.mock.calls.flat().map(String).join('\n'); + expect(displayed).not.toContain('\x1b[2J'); + expect(displayed).not.toContain('\u202E'); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + }); + + it('rejects a provider credential before printing or applying it', async () => { + const secret = 'bdp_pat_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'; + queuePromptResponse({ send: true }); + mockRequestAiSetupPlan.mockResolvedValue(plan({ summary: `Use ${secret}` })); + + await expect(initProjectConfigAi()).rejects.toThrow('private Bundle Drop credential'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain(secret); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + }); + it('runs doctor locally and skips consent and AI when setup is already healthy', async () => { mockDetectProjectType.mockReturnValue('expo'); mockScanProjectForAiSetup.mockReturnValue(scanner('expo', { @@ -271,6 +335,29 @@ describe('initProjectConfigAi', () => { expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('No AI planning is required'), ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'https://bundledrop.app/docs/installation#initialize-bundle-drop-in-javascript', + ), + ); + }); + + it('skips the AI provider when a migrated bare project rescans as configured', async () => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { + bundleDropStatus: 'configured', + codePushDetected: false, + })); + mockInspectProject.mockResolvedValue({ + projectRoot, + projectType: 'bare', + checks: [{ name: 'Native startup', status: 'pass', message: 'Configured.' }], + }); + + await initProjectConfigAi(); + + expect(mockInspectProject).toHaveBeenCalledWith({ cwd: projectRoot, projectType: 'bare' }); + expect(mockRequestAiSetupPlan).not.toHaveBeenCalled(); + expect(mockRunDoctor).toHaveBeenCalledWith({ projectType: 'bare', cwd: projectRoot }); }); it('continues through setup when doctor finds a blocking issue', async () => { @@ -396,6 +483,31 @@ describe('initProjectConfigAi', () => { ); }); + it('does not follow a virtual-config symlink on the low-confidence retention path', async () => { + const root = createTempProjectDir(); + const outsideRoot = createTempProjectDir(); + temporaryRoots.push(root, outsideRoot); + cwdSpy.mockReturnValue(root); + const outsideConfig = path.join(outsideRoot, 'outside-config.js'); + fs.writeFileSync(outsideConfig, 'outside-safe'); + fs.symlinkSync(outsideConfig, path.join(root, 'bundle.drop.config.js')); + mockRequestAiSetupPlan.mockResolvedValue(plan({ confidence: 'low', changes: [bareChange] })); + + await expect(initProjectConfigAi({ + yes: true, + virtualConfig: { + content: 'module.exports = { projectType: "bare" };\n', + serverUrl: 'https://api.example.com', + orgSlug: 'alpha-org', + projectSlug: 'demo-app', + authToken: 'pat-token', + }, + })).rejects.toThrow('symlinked or non-regular'); + + expect(fs.readFileSync(outsideConfig, 'utf8')).toBe('outside-safe'); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + }); + it('shows summarized context and stops when a required AI action is declined', async () => { const context = scanner('bare'); context.request.files[0].kind = 'package_manifest'; @@ -441,6 +553,71 @@ describe('initProjectConfigAi', () => { expect(mockValidateSetupChangesBeforeApply).toHaveBeenCalled(); }); + it('requires explicit diff-backed approval before applying a review-only change', async () => { + const reviewOnlyChange = { ...bareChange, decisionType: 'review_only_patch' as const }; + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [reviewOnlyChange] })); + queuePromptResponse({ send: true }); + queuePromptResponse({ approve: true }); + queuePromptResponse({ apply: true }); + + await initProjectConfigAi(); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(`Review-only proposed change: ${reviewOnlyChange.file}`), + ); + expect(consoleLogSpy).toHaveBeenCalledWith('colored diff'); + expect(mockApplySetupPatchPlans).toHaveBeenCalledWith({ + projectRoot, + projectType: 'bare', + changes: [reviewOnlyChange], + }); + }); + + it('leaves the whole transaction unchanged when a review-only change is declined', async () => { + const reviewOnlyChange = { ...bareChange, decisionType: 'review_only_patch' as const }; + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange, reviewOnlyChange] })); + queuePromptResponse({ send: true }); + queuePromptResponse({ approve: false }); + + await initProjectConfigAi(); + + expect(mockValidateSetupChangesBeforeApply).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + expect(mockApplyExpoConfigurationChanges).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Review-only AI change declined'), + ); + }); + + it('does not let --yes approve a review-only change', async () => { + const reviewOnlyChange = { ...bareChange, decisionType: 'review_only_patch' as const }; + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange, reviewOnlyChange] })); + + await initProjectConfigAi({ yes: true }); + + expect(mockValidateSetupChangesBeforeApply).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + expect(mockApplyExpoConfigurationChanges).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('--yes cannot approve them. No files changed.'), + ); + }); + + it('allows --yes to preview a review-only change during a dry run', async () => { + const reviewOnlyChange = { ...bareChange, decisionType: 'review_only_patch' as const }; + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [reviewOnlyChange] })); + + await initProjectConfigAi({ yes: true, dryRun: true }); + + expect(mockValidateSetupChangesBeforeApply).toHaveBeenCalledWith({ + projectType: 'bare', + originals: expect.any(Map), + changes: [reviewOnlyChange], + migrateExpoUpdates: false, + }); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + }); + it('blocks active expo-updates before previewing or applying changes', async () => { mockDetectProjectType.mockReturnValue('expo'); mockScanProjectForAiSetup.mockReturnValue(scanner('expo', { expoUpdatesStatus: 'active' })); @@ -475,6 +652,20 @@ describe('initProjectConfigAi', () => { expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); }); + it('does not POST or echo a private Bundle Drop credential rejected by the scanner', async () => { + const secret = 'bdp_proj_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'; + mockScanProjectForAiSetup.mockImplementation(() => { + throw new Error( + 'Refusing AI setup because app.config.js contains a Bundle Drop project key.', + ); + }); + + await expect(initProjectConfigAi({ yes: true })).rejects.toThrow('Bundle Drop project key'); + expect(mockRequestAiSetupPlan).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain(secret); + }); + it('offers explicit expo-updates migration and continues only when accepted', async () => { mockDetectProjectType.mockReturnValue('expo'); mockScanProjectForAiSetup.mockReturnValue(scanner('expo', { expoUpdatesStatus: 'active' })); @@ -503,6 +694,130 @@ describe('initProjectConfigAi', () => { expect(mockRemoveExpoUpdatesWithPackageManager).not.toHaveBeenCalled(); }); + it('offers one explicit CodePush migration confirmation and applies it transactionally', async () => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { codePushDetected: true })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ + actions: [{ + type: 'migrate_codepush', + reason: 'Move native startup ownership to Bundle Drop.', + requiresConfirmation: true, + }], + changes: [bareChange], + })); + queuePromptResponse({ send: true }); + queuePromptResponse({ migrate: true }); + queuePromptResponse({ apply: true }); + + await initProjectConfigAi(); + + expect(mockRemoveCodePushWithPackageManager).toHaveBeenCalledWith(projectRoot); + expect(mockApplySetupPatchPlans).toHaveBeenCalledWith({ + projectRoot, + projectType: 'bare', + changes: [bareChange], + }); + expect(mockRunDoctor).toHaveBeenCalledWith({ projectType: 'bare', cwd: projectRoot }); + }); + + it('keeps CodePush intact when interactive migration is declined', async () => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { codePushDetected: true })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); + queuePromptResponse({ send: true }); + queuePromptResponse({ migrate: false }); + + await expect(initProjectConfigAi()).rejects.toThrow('--migrate-code-push'); + + expect(mockRemoveCodePushWithPackageManager).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + }); + + it('does not let --yes silently authorize CodePush removal', async () => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { codePushDetected: true })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); + + await expect(initProjectConfigAi({ yes: true })).rejects.toThrow('--migrate-code-push'); + + expect(mockRemoveCodePushWithPackageManager).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + }); + + it.each([ + 'src/App.tsx', + 'android/app/build.gradle', + 'ios/Demo/Info.plist', + ])('blocks CodePush migration before writes when residue remains in %s', async residuePath => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { codePushDetected: true })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); + mockFindCodePushResiduePaths.mockReturnValue([residuePath]); + + await expect(initProjectConfigAi({ yes: true, migrateCodePush: true })).rejects.toThrow( + residuePath, + ); + + expect(mockValidateSetupChangesBeforeApply).not.toHaveBeenCalled(); + expect(mockRemoveCodePushWithPackageManager).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + expect(mockApplyExpoConfigurationChanges).not.toHaveBeenCalled(); + }); + + it('accepts the dedicated CodePush migration flag in noninteractive mode', async () => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { codePushDetected: true })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); + + await initProjectConfigAi({ yes: true, migrateCodePush: true }); + + expect(mockDetectPackageManager).toHaveBeenCalledWith(projectRoot); + expect(mockCodePushRemovalCommand).toHaveBeenCalledWith('yarn'); + expect(mockRemoveCodePushWithPackageManager).toHaveBeenCalledWith(projectRoot); + }); + + it('does not run CodePush removal when the dedicated flag is unnecessary', async () => { + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); + + await initProjectConfigAi({ yes: true, migrateCodePush: true }); + + expect(mockCodePushRemovalCommand).not.toHaveBeenCalled(); + expect(mockRemoveCodePushWithPackageManager).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).toHaveBeenCalled(); + }); + + it('previews the CodePush package command without removing the dependency', async () => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { codePushDetected: true })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); + + await initProjectConfigAi({ yes: true, migrateCodePush: true, dryRun: true }); + + expect(mockCodePushRemovalCommand).toHaveBeenCalledWith('yarn'); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('yarn remove react-native-code-push'), + ); + expect(mockRemoveCodePushWithPackageManager).not.toHaveBeenCalled(); + expect(mockApplySetupPatchPlans).not.toHaveBeenCalled(); + }); + + it('restores CodePush package files and native patches when validation fails', async () => { + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { codePushDetected: true })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); + mockValidateAppliedSetupChanges.mockImplementationOnce(() => { + throw new Error('CodePush migration validation failed'); + }); + + await expect(initProjectConfigAi({ yes: true, migrateCodePush: true })).rejects.toThrow( + 'CodePush migration validation failed', + ); + + expect(mockRestoreSetupBackups).toHaveBeenCalledWith({ + projectRoot, + backupDir: '/project/.bundledrop-backup/native', + changedFiles: [{ file: bareChange.file, existed: true }], + }); + expect(mockRestoreDependencyMigration).toHaveBeenCalledWith({ + projectRoot, + backupDir: '/project/.bundledrop-backup/code-push', + files: ['package.json', 'yarn.lock'], + }); + }); + it('previews bare changes in dry-run mode without mutation', async () => { mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); @@ -555,6 +870,69 @@ describe('initProjectConfigAi', () => { }); }); + it('applies the generated bootstrap and commit-safe ignore rules transactionally', async () => { + const root = createTempProjectDir(); + temporaryRoots.push(root); + cwdSpy.mockReturnValue(root); + fs.writeFileSync( + path.join(root, 'bundle.drop.config.js'), + "module.exports = { projectType: 'bare' };\n", + ); + fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules\n', 'utf8'); + + await initProjectConfigAi({ + yes: true, + runtimeDeliveryBootstrap: { content: '{"schemaVersion":1}\n' }, + }); + + expect(mockApplyExpoConfigurationChanges).toHaveBeenCalledWith({ + projectRoot: root, + changes: expect.arrayContaining([ + expect.objectContaining({ + file: '.bundle-drop/runtime-delivery.generated.json', + original: null, + updated: '{"schemaVersion":1}\n', + }), + expect.objectContaining({ + file: '.gitignore', + original: 'node_modules\n', + updated: expect.stringContaining('!.bundle-drop/runtime-delivery.generated.json'), + }), + ]), + }); + }); + + it('treats an unchanged bootstrap and ignore rule as already configured', async () => { + const root = createTempProjectDir(); + temporaryRoots.push(root); + cwdSpy.mockReturnValue(root); + fs.ensureDirSync(path.join(root, '.bundle-drop')); + fs.writeFileSync( + path.join(root, '.bundle-drop/runtime-delivery.generated.json'), + '{"schemaVersion":1}\n', + ); + fs.writeFileSync( + path.join(root, '.gitignore'), + '!.bundle-drop/runtime-delivery.generated.json\n', + ); + mockInspectProject.mockResolvedValue({ + projectRoot: root, + projectType: 'bare', + checks: [], + }); + mockScanProjectForAiSetup.mockReturnValue(scanner('bare', { + bundleDropStatus: 'configured', + })); + + await initProjectConfigAi({ + yes: true, + runtimeDeliveryBootstrap: { content: '{"schemaVersion":1}\n' }, + }); + + expect(mockRequestAiSetupPlan).not.toHaveBeenCalled(); + expect(mockRunDoctor).toHaveBeenCalledWith({ projectType: 'bare', cwd: root }); + }); + it('leaves bare files unchanged when final confirmation is cancelled', async () => { mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [bareChange] })); queuePromptResponse({ send: true }); @@ -592,12 +970,17 @@ describe('initProjectConfigAi', () => { projectRoot, projectType: 'bare', changes: [bareChange], + migrateExpoUpdates: false, + originals: expect.any(Map), }); expect(mockApplyExpoConfigurationChanges).toHaveBeenCalledWith({ projectRoot, changes: [metroChange], }); expect(mockRunDoctor).toHaveBeenCalledWith({ projectType: 'bare', cwd: projectRoot }); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('JavaScript entry point: call BundleDrop.init once'), + ); }); it('restores bare backups if post-apply validation fails', async () => { @@ -749,6 +1132,81 @@ describe('initProjectConfigAi', () => { expect(mockRunDoctor).not.toHaveBeenCalled(); }); + it('does not let --yes approve a provider-authored dynamic Expo config patch', async () => { + mockDetectProjectType.mockReturnValue('expo'); + mockScanProjectForAiSetup.mockReturnValue(scanner('expo')); + mockRequestAiSetupPlan.mockResolvedValue(plan({ + changes: [{ ...dynamicConfigChange, decisionType: 'safe_auto_patch' }], + })); + + await initProjectConfigAi({ yes: true }); + + expect(mockValidateSetupChangesBeforeApply).not.toHaveBeenCalled(); + expect(mockApplyExpoConfigurationChanges).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('--yes cannot approve them. No files changed.'), + ); + }); + + it('applies a dynamic Expo config only after explicit diff-backed approval', async () => { + mockDetectProjectType.mockReturnValue('expo'); + mockScanProjectForAiSetup.mockReturnValue(scanner('expo')); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [dynamicConfigChange] })); + mockHasDynamicExpoConfig.mockReturnValue(true); + mockEvaluateExpoConfig.mockReturnValue({ + exp: { plugins: ['@gfean/react-native-bundle-drop'] }, + dynamicConfigPath: '/project/app.config.ts', + }); + queuePromptResponse({ send: true }); + queuePromptResponse({ approve: true }); + queuePromptResponse({ apply: true }); + + await initProjectConfigAi(); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Review-only proposed change: app.config.ts'), + ); + expect(mockApplyExpoConfigurationChanges).toHaveBeenCalledWith({ + projectRoot, + changes: [{ + file: 'app.config.ts', + original: 'original dynamic config', + updated: dynamicConfigChange.updated, + reason: dynamicConfigChange.reason, + }], + }); + }); + + it('threads approved expo-updates migration through dynamic pre/post validation', async () => { + mockDetectProjectType.mockReturnValue('expo'); + mockScanProjectForAiSetup.mockReturnValue(scanner('expo', { expoUpdatesStatus: 'active' })); + mockRequestAiSetupPlan.mockResolvedValue(plan({ changes: [dynamicConfigChange] })); + mockHasDynamicExpoConfig.mockReturnValue(true); + mockEvaluateExpoConfig.mockReturnValue({ + exp: { plugins: ['@gfean/react-native-bundle-drop'] }, + dynamicConfigPath: '/project/app.config.ts', + }); + queuePromptResponse({ send: true }); + queuePromptResponse({ approve: true }); + queuePromptResponse({ apply: true }); + + await initProjectConfigAi({ migrateExpoUpdates: true }); + + expect(mockValidateSetupChangesBeforeApply).toHaveBeenCalledWith(expect.objectContaining({ + projectType: 'expo', + changes: [dynamicConfigChange], + migrateExpoUpdates: true, + })); + expect(mockValidateAppliedSetupChanges).toHaveBeenCalledWith({ + projectRoot, + projectType: 'expo', + changes: [dynamicConfigChange], + migrateExpoUpdates: true, + originals: expect.any(Map), + }); + expect(mockRemoveExpoUpdatesWithPackageManager).toHaveBeenCalledWith(projectRoot); + }); + it('rejects an AI patch aimed at a different dynamic config than Expo evaluated', async () => { mockDetectProjectType.mockReturnValue('expo'); mockScanProjectForAiSetup.mockReturnValue(scanner('expo')); @@ -759,7 +1217,7 @@ describe('initProjectConfigAi', () => { dynamicConfigPath: '/project/app.config.js', }); - await expect(initProjectConfigAi({ yes: true })).rejects.toThrow( + await expect(initProjectConfigAi({ yes: true, dryRun: true })).rejects.toThrow( 'evaluated root config (app.config.js)', ); expect(mockApplyExpoConfigurationChanges).not.toHaveBeenCalled(); @@ -772,7 +1230,7 @@ describe('initProjectConfigAi', () => { mockHasDynamicExpoConfig.mockReturnValue(true); mockEvaluateExpoConfig.mockReturnValue({ exp: { plugins: [] } }); - await expect(initProjectConfigAi({ yes: true })).rejects.toThrow( + await expect(initProjectConfigAi({ yes: true, dryRun: true })).rejects.toThrow( 'Expo did not identify the authoritative dynamic app config path', ); expect(mockApplyExpoConfigurationChanges).not.toHaveBeenCalled(); @@ -788,7 +1246,7 @@ describe('initProjectConfigAi', () => { dynamicConfigPath: '/outside/app.config.ts', }); - await expect(initProjectConfigAi({ yes: true })).rejects.toThrow( + await expect(initProjectConfigAi({ yes: true, dryRun: true })).rejects.toThrow( 'Expo reported an unsafe dynamic app config path', ); expect(mockApplyExpoConfigurationChanges).not.toHaveBeenCalled(); @@ -887,6 +1345,48 @@ describe('initProjectConfigAi', () => { expect(mockRunDoctor).not.toHaveBeenCalled(); }); + it.each(['ios', 'android'])('rejects a symlinked %s prebuild directory before execution', async directory => { + const root = createCommittedNativeProject(); + const outsideRoot = createTempProjectDir(); + temporaryRoots.push(outsideRoot); + const outsideNative = path.join(outsideRoot, directory); + fs.ensureDirSync(outsideNative); + const sentinel = path.join(outsideNative, 'sentinel.txt'); + fs.writeFileSync(sentinel, 'outside-safe'); + fs.removeSync(path.join(root, directory)); + fs.symlinkSync(outsideNative, path.join(root, directory)); + mockDetectProjectType.mockReturnValue('expo'); + mockScanProjectForAiSetup.mockReturnValue(scanner('expo', { hasNativeDirectories: true })); + + await expect(initProjectConfigAi({ yes: true, prebuild: true })) + .rejects.toThrow('symlinked or non-directory'); + + expect(mockExecFileSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('outside-safe'); + }); + + it('restores native directories without following a symlink created by failed prebuild', async () => { + const root = createCommittedNativeProject(); + const outsideRoot = createTempProjectDir(); + temporaryRoots.push(outsideRoot); + const sentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(sentinel, 'outside-safe'); + mockDetectProjectType.mockReturnValue('expo'); + mockScanProjectForAiSetup.mockReturnValue(scanner('expo', { hasNativeDirectories: true })); + mockExecFileSync.mockImplementation(() => { + fs.removeSync(path.join(root, 'ios')); + fs.symlinkSync(outsideRoot, path.join(root, 'ios')); + throw new Error('prebuild command failed'); + }); + + await expect(initProjectConfigAi({ yes: true, prebuild: true })) + .rejects.toThrow('Original native directories were restored'); + + expect(fs.lstatSync(path.join(root, 'ios')).isDirectory()).toBe(true); + expect(fs.readFileSync(path.join(root, 'ios/Podfile'), 'utf8')).toBe('platform :ios\n'); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('outside-safe'); + }); + it('applies managed Expo setup and validates the evaluated plugin', async () => { const metroChange = { file: 'metro.config.js', @@ -906,6 +1406,9 @@ describe('initProjectConfigAi', () => { }); expect(mockEvaluateExpoConfig).toHaveBeenCalledWith(projectRoot); expect(mockRunDoctor).toHaveBeenCalledWith({ projectType: 'expo', cwd: projectRoot }); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('JavaScript entry point: call BundleDrop.init once'), + ); }); it('accepts tuple-form Bundle Drop plugin registration in evaluated Expo config', async () => { diff --git a/src/tests/CLI/scripts/aipowered/scanner-setup.test.ts b/src/tests/CLI/scripts/aipowered/scanner-setup.test.ts index cd7e43c..a31a58b 100644 --- a/src/tests/CLI/scripts/aipowered/scanner-setup.test.ts +++ b/src/tests/CLI/scripts/aipowered/scanner-setup.test.ts @@ -9,13 +9,74 @@ import { isTrustedAiPlanningServer, scanProjectForAiSetup, } from '../../../../CLI/scripts/aipowered/scanner'; +import { findCodePushResiduePaths } from '../../../../CLI/scripts/aipowered/code-push-residue'; import { createTempProjectDir, removeTempDir } from '../../../utils/tempDir'; +import { + MODERN_KOTLIN_MAIN_APPLICATION, + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION, + RN71_JAVA_MAIN_APPLICATION, + RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_KOTLIN_MAIN_APPLICATION, + RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION, + RN71_OBJC_APP_DELEGATE, + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + RN85_SWIFT_APP_DELEGATE, +} from '../../../fixtures/rn85SwiftAppDelegate'; describe('CLI/scripts/aipowered/scanner unified setup', () => { let projectRoot = ''; let fakeHome = ''; let homedirSpy: jest.SpyInstance; + const modernAndroidReviewerProbe = (resolver: string, lazyPrefix = '') => [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication: Application(), ReactApplication {', + ` ${resolver}`, + ' override val reactHost: ReactHost by lazy {', + ` ${lazyPrefix}`, + ' getDefaultReactHost(', + ' context = applicationContext,', + ' packages = PackageList(this).packages,', + ' jsBundleFilePath = getJSBundleFile(),', + ' )', + ' }', + '}', + ].join('\n'); + + const writeNativeFixture = (relativePath: string, source: string) => { + let authoritativeSource = source; + if ( + relativePath.endsWith('AppDelegate.swift') && + !/@(?:main|UIApplicationMain)\b/.test(source) + ) { + authoritativeSource = source.replace(/\bclass\s+AppDelegate\b/, '@main class AppDelegate'); + } + const nativeFile = path.join(projectRoot, relativePath); + fs.mkdirSync(path.dirname(nativeFile), { recursive: true }); + fs.writeFileSync(nativeFile, authoritativeSource); + + if (relativePath.includes('/MainApplication.')) { + const packageName = authoritativeSource.match( + /(?:^|\n)\s*package\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/, + )?.[1] || relativePath.match(/\/(?:java|kotlin)\/(.+)\/MainApplication\./)?.[1] + ?.replace(/\//g, '.'); + const manifest = path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'); + fs.mkdirSync(path.dirname(manifest), { recursive: true }); + fs.writeFileSync( + manifest, + ``, + ); + } else if (/AppDelegate\.m{1,2}$/.test(relativePath)) { + const mainFile = path.join(path.dirname(nativeFile), 'main.m'); + fs.writeFileSync( + mainFile, + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"AppDelegate"); }', + ); + } + return authoritativeSource; + }; + const installEvaluatedExpoProject = (params: { expoVersion?: string; reactNativeVersion?: string; @@ -52,6 +113,9 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { exports.getConfig = root => ({ exp: JSON.parse(fs.readFileSync(path.join(root, 'evaluated-expo.json'), 'utf8')), pkg: JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')), + dynamicConfigPath: fs.existsSync(path.join(root, 'app.config.js')) + ? path.join(root, 'app.config.js') + : null, });`, ); }; @@ -119,6 +183,12 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { 'known access token format', ); expect(findCredentialLikeLiteral('const apiKey = process.env.PROJECT_API_KEY')).toBeNull(); + expect(findCredentialLikeLiteral( + 'const value = "bdp_proj_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"', + )).toBe('Bundle Drop project key'); + expect(findCredentialLikeLiteral( + 'export default "bdp_pat_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"', + )).toBe('Bundle Drop personal access token'); }); it('finds the project config from a nested directory', () => { @@ -257,12 +327,17 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { const bundleConfigFile = result.request.files.find( file => file.path === 'bundle.drop.config.js', ); + const appJsonFile = result.request.files.find(file => file.path === 'app.json'); + const metroFile = result.request.files.find(file => file.path === 'metro.config.js'); expect(packageFile?.content).toContain('BundleDrop context summary for package.json'); expect(packageFile?.content).not.toContain('pnpm@10.0.0'); expect(bundleConfigFile?.content).toContain( 'BundleDrop context summary for bundle.drop.config.js', ); + expect(bundleConfigFile?.content).toContain('runtimeVersionAuthority: expo_source'); expect(bundleConfigFile?.content).not.toContain("runtimeVersion: { source: 'expo' }"); + expect(appJsonFile?.content).toContain('BundleDrop context summary for app.json'); + expect(metroFile?.content).toContain('BundleDrop context summary for metro.config.js'); }); it.each([ @@ -338,6 +413,240 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { ); }); + it.each([ + ['bidirectional override', '\u202E'], + ['JavaScript line separator', '\u2028'], + ['JavaScript paragraph separator', '\u2029'], + ])('rejects a %s in local source before provider consent', (_label, unsafeCharacter) => { + installEvaluatedExpoProject(); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { expo: '57.0.0', 'react-native': '0.86.0' } }), + ); + fs.writeFileSync( + path.join(projectRoot, 'app.config.js'), + `module.exports = { expo: { name: "Demo${unsafeCharacter}" } };\n`, + ); + + expect(() => scanProjectForAiSetup('expo', projectRoot)).toThrow( + 'contains unsafe terminal or bidirectional control characters', + ); + }); + + it('shares only Expo evaluated authoritative dynamic config when stale configs coexist', () => { + installEvaluatedExpoProject(); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { expo: '57.0.0', 'react-native': '0.86.0' } }), + ); + fs.writeFileSync(path.join(projectRoot, 'app.config.js'), 'module.exports = { expo: {} };'); + fs.writeFileSync(path.join(projectRoot, 'app.config.ts'), 'export default { stale: true };'); + + const paths = scanProjectForAiSetup('expo', projectRoot).request.files.map(file => file.path); + + expect(paths).toContain('app.config.js'); + expect(paths).not.toContain('app.config.ts'); + expect(paths).not.toContain('app.json'); + }); + + it('fails closed when AndroidManifest starts a different application class', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '0.71.0' } }), + ); + const applicationFile = path.join( + projectRoot, + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + ); + fs.mkdirSync(path.dirname(applicationFile), { recursive: true }); + fs.writeFileSync(applicationFile, RN71_KOTLIN_MAIN_APPLICATION); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'), + '', + ); + + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'AndroidManifest.xml starts com.demo.CustomApplication, not com.demo.MainApplication', + ); + }); + + it('fails closed when Objective-C UIApplicationMain names a different delegate', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '0.71.0' } }), + ); + const delegateFile = path.join(projectRoot, 'ios/Demo/AppDelegate.m'); + fs.mkdirSync(path.dirname(delegateFile), { recursive: true }); + fs.writeFileSync(delegateFile, [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n')); + fs.writeFileSync( + path.join(projectRoot, 'ios/Demo/main.m'), + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"OtherDelegate"); }', + ); + + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'UIApplicationMain argument 4 does not select AppDelegate', + ); + }); + + it('binds Android authority across manifests without accepting spoofed attributes', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '0.86.0' } }), + ); + writeNativeFixture( + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + RN71_KOTLIN_MAIN_APPLICATION, + ); + const mainManifest = path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'); + fs.writeFileSync( + mainManifest, + '', + ); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'starts com.demo.OtherApplication, not com.demo.MainApplication', + ); + + fs.writeFileSync( + mainManifest, + '', + ); + const releaseManifest = path.join(projectRoot, 'android/app/src/release/AndroidManifest.xml'); + fs.mkdirSync(path.dirname(releaseManifest), { recursive: true }); + fs.writeFileSync( + releaseManifest, + '', + ); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'release/AndroidManifest.xml starts com.demo.ReleaseApplication', + ); + + fs.rmSync(path.dirname(releaseManifest), { recursive: true }); + const testManifest = path.join(projectRoot, 'android/app/src/androidTest/AndroidManifest.xml'); + fs.mkdirSync(path.dirname(testManifest), { recursive: true }); + fs.writeFileSync( + testManifest, + '', + ); + expect(() => scanProjectForAiSetup('bare', projectRoot)).not.toThrow(); + }); + + it('resolves a relative Android application through Gradle namespace, not source decoys', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '0.86.0' } }), + ); + const applicationFile = path.join( + projectRoot, + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + ); + fs.mkdirSync(path.dirname(applicationFile), { recursive: true }); + fs.writeFileSync(applicationFile, RN71_KOTLIN_MAIN_APPLICATION); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'), + '', + ); + fs.writeFileSync( + path.join(projectRoot, 'android/app/build.gradle.kts'), + 'android {\n namespace = "com.demo"\n}', + ); + expect(() => scanProjectForAiSetup('bare', projectRoot)).not.toThrow(); + + fs.writeFileSync( + applicationFile, + RN71_KOTLIN_MAIN_APPLICATION.replace('package com.demo', 'package dead.demo'), + ); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'starts com.demo.MainApplication, not dead.demo.MainApplication', + ); + }); + + it('requires explicit native principal sources before sharing setup context', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '0.86.0' } }), + ); + const androidFile = path.join( + projectRoot, + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + ); + fs.mkdirSync(path.dirname(androidFile), { recursive: true }); + fs.writeFileSync(androidFile, RN71_KOTLIN_MAIN_APPLICATION); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'main AndroidManifest.xml is missing', + ); + + fs.rmSync(path.join(projectRoot, 'android'), { recursive: true }); + const swiftFile = path.join(projectRoot, 'ios/Demo/AppDelegate.swift'); + fs.mkdirSync(path.dirname(swiftFile), { recursive: true }); + fs.writeFileSync(swiftFile, [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n')); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'Swift @main/UIApplicationMain principal is missing', + ); + + fs.rmSync(path.join(projectRoot, 'ios'), { recursive: true }); + const objcFile = path.join(projectRoot, 'ios/Demo/AppDelegate.m'); + fs.mkdirSync(path.dirname(objcFile), { recursive: true }); + fs.writeFileSync(objcFile, [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n')); + fs.writeFileSync( + path.join(projectRoot, 'ios/Demo/main.m'), + 'int main(void) { return CustomUIApplicationMain(0, nil, nil, @"AppDelegate"); }', + ); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'Exactly one UIApplicationMain call is required', + ); + }); + + it('does not accept Swift principal annotations hidden in strings or nested comments', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '0.86.0' } }), + ); + const appDelegate = path.join(projectRoot, 'ios/Demo/AppDelegate.swift'); + fs.mkdirSync(path.dirname(appDelegate), { recursive: true }); + fs.writeFileSync(appDelegate, [ + 'import BundleDrop', + 'let documentation = "@main class AppDelegate"', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n')); + fs.writeFileSync( + path.join(projectRoot, 'ios/Demo/RealApp.swift'), + '@main struct RealApp { static func main() {} }', + ); + + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'Swift principal annotation does not uniquely select AppDelegate', + ); + + fs.rmSync(path.join(projectRoot, 'ios/Demo/RealApp.swift')); + fs.writeFileSync(appDelegate, [ + 'import BundleDrop', + '/* outer /* nested */ @main class AppDelegate */', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n')); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'Swift @main/UIApplicationMain principal is missing', + ); + }); + it('reports absent Updates and partial Bundle Drop setup in a managed project', () => { installEvaluatedExpoProject({ expoVersion: '55.0.3', @@ -370,15 +679,15 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { path.join(projectRoot, 'package.json'), JSON.stringify({ dependencies: { 'react-native': '0.86.0' } }), ); - const androidFile = path.join( - projectRoot, + fs.writeFileSync( + path.join(projectRoot, 'app.json'), + JSON.stringify({ name: 'BareDemo', displayName: 'Bare Demo' }), + ); + writeNativeFixture( 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + 'package com.demo\nclass MainApplication {}', ); - const iosFile = path.join(projectRoot, 'ios/Demo/AppDelegate.mm'); - fs.mkdirSync(path.dirname(androidFile), { recursive: true }); - fs.mkdirSync(path.dirname(iosFile), { recursive: true }); - fs.writeFileSync(androidFile, 'class MainApplication {}'); - fs.writeFileSync(iosFile, '@implementation AppDelegate @end'); + writeNativeFixture('ios/Demo/AppDelegate.mm', '@implementation AppDelegate @end'); const bare = scanProjectForAiSetup('bare', projectRoot); const expo = scanProjectForAiSetup('expo', projectRoot); @@ -387,6 +696,11 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { ['android/app/src/main/kotlin/com/demo/MainApplication.kt', 'android_entrypoint'], ['ios/Demo/AppDelegate.mm', 'ios_entrypoint'], ])); + expect(bare.request.files.map(file => file.path)).not.toContain('app.json'); + expect(expo.request.files.map(file => [file.path, file.kind])).toContainEqual([ + 'app.json', + 'expo_app_config', + ]); expect(expo.request.files.map(file => file.kind)).not.toEqual(expect.arrayContaining([ 'android_entrypoint', 'ios_entrypoint', @@ -398,7 +712,650 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { ])); }); - it('skips generated and symlinked files during bare native discovery', () => { + it('does not detect CodePush after its dependency and native startup hook are migrated', () => { + const packagePath = path.join(projectRoot, 'package.json'); + const androidFile = path.join( + projectRoot, + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + ); + fs.mkdirSync(path.dirname(androidFile), { recursive: true }); + fs.writeFileSync( + packagePath, + JSON.stringify({ + dependencies: { + '@gfean/react-native-bundle-drop': '0.4.3', + 'react-native': '0.86.0', + 'react-native-code-push': '9.0.0', + }, + }), + ); + fs.writeFileSync( + androidFile, + 'class MainApplication { fun getJSBundleFile() = CodePush.getJSBundleFile() }', + ); + writeNativeFixture( + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + 'class MainApplication { fun getJSBundleFile() = CodePush.getJSBundleFile() }', + ); + + expect(scanProjectForAiSetup('bare', projectRoot).request.detected.codePushDetected).toBe(true); + + fs.writeFileSync( + packagePath, + JSON.stringify({ + dependencies: { + '@gfean/react-native-bundle-drop': '0.4.3', + 'react-native': '0.86.0', + }, + }), + ); + writeNativeFixture('android/app/src/main/kotlin/com/demo/MainApplication.kt', RN71_KOTLIN_MAIN_APPLICATION); + + const migrated = scanProjectForAiSetup('bare', projectRoot).request.detected; + expect(findCodePushResiduePaths(projectRoot)).toEqual([]); + expect(migrated.codePushDetected).toBe(false); + expect(migrated.signals).not.toContain('codePushDependency'); + expect(migrated.bundleDropStatus).toBe('configured'); + }); + + it.each([ + { + label: 'legacy Kotlin override', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION, + }, + { + label: 'legacy Java override', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: RN71_JAVA_MAIN_APPLICATION, + }, + { + label: 'legacy Java multiline local fallback', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION, + }, + { + label: 'legacy Java conditional local fallback', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + }, + { + label: 'legacy Kotlin conditional local fallback', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + }, + { + label: 'modern Kotlin ReactHost connection', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: MODERN_KOTLIN_MAIN_APPLICATION, + }, + { + label: 'archived RN85 Kotlin NativePaths host connection', + file: 'android/app/src/main/kotlin/app/bundledrop/harness/rn85/MainApplication.kt', + content: RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + }, + { + label: 'fully-qualified NativePaths host override', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION, + }, + { + label: 'Swift AppDelegate resolver', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' @objc override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + }, + { + label: 'RN85 Swift factory delegate resolver', + file: 'ios/BundleDropDemo/AppDelegate.swift', + content: RN85_SWIFT_APP_DELEGATE, + }, + { + label: 'Objective-C AppDelegate resolver', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' return self.bundleURL;', + '}', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'), + }, + { + label: 'RN71 Objective-C delegated source URL with DEBUG Metro fallback', + file: 'ios/Demo/AppDelegate.m', + content: RN71_OBJC_APP_DELEGATE, + }, + ])('reports configured for a strict $label', ({ file, content }) => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + dependencies: { + '@gfean/react-native-bundle-drop': '0.4.3', + 'react-native': '0.86.0', + }, + }), + ); + writeNativeFixture(file, content); + + expect(scanProjectForAiSetup('bare', projectRoot).request.detected.bundleDropStatus) + .toBe('configured'); + }); + + it.each([ + { + label: 'nested Kotlin comment resolver', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'package com.demo', + 'class MainApplication {', + ' /* outer /* nested */', + ' import com.bundledrop.BundleDropModule', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' */', + '}', + ].join('\n'), + }, + { + label: 'Kotlin raw multiline string resolver and host decoy', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val documentation = """ "', + ' private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ' override val reactHost: ReactHost by lazy {', + ' getDefaultReactHost(jsBundleFilePath = getJSBundleFile())', + ' }', + ' " """', + '}', + ].join('\n'), + }, + { + label: 'nested Swift comment resolver', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' /* outer /* nested */', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' */', + '}', + ].join('\n'), + }, + { + label: 'Swift multiline string resolver decoy', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' let documentation = """ "', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + ' " """', + '}', + ].join('\n'), + }, + { + label: 'conditional Android Release bypass', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: modernAndroidReviewerProbe( + 'private fun getJSBundleFile(): String? { return if (useOta) BundleDropModule.resolveJSBundleFile(this, null) else "/android_asset/index.android.bundle" }', + ), + }, + { + label: 'wrong Android resolver symbol', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: modernAndroidReviewerProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFileForTests(this, null)', + ), + }, + { + label: 'wrong Android resolver context', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: modernAndroidReviewerProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(42, null)', + ), + }, + { + label: 'transformed Java resolver return', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + ' );', + ' ).trim();', + ), + }, + { + label: 'Java fallback ternary with a statement branch', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION.replace( + '? selectEnterpriseBundle()', + '? return selectEnterpriseBundle()', + ), + }, + { + label: 'Java resolver with Kotlin non-null suffix', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + ' );', + ' )!!;', + ), + }, + { + label: 'Java anonymous host with the wrong this receiver', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + 'getApplicationContext(),', + 'this,', + ), + }, + { + label: 'Kotlin anonymous host with Java receiver syntax', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace( + 'this@MainApplication,', + 'MainApplication.this,', + ), + }, + { + label: 'non-null Kotlin resolver without an unwrap or fallback', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace(' )!!', ' )'), + }, + { + label: 'mismatched Android import alias', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: modernAndroidReviewerProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ).replace('import com.bundledrop.BundleDropModule', 'import com.bundledrop.BundleDropModule as BDM'), + }, + { + label: 'early modern Android host bypass', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: modernAndroidReviewerProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + 'if (useCustom) return@lazy customReactHost', + ), + }, + { + label: 'conditional Swift Release bypass', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + ' return useOta ? BundleDropLocator.bundleURL() : Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'), + }, + { + label: 'RN85 sourceURL bypassing a dead bundleURL', + file: 'ios/BundleDropDemo/AppDelegate.swift', + content: RN85_SWIFT_APP_DELEGATE.replace( + ' self.bundleURL()', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ), + }, + { + label: 'direct Swift sourceURL bypassing bundleURL', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' override func sourceURL(for bridge: RCTBridge) -> URL? {', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'), + }, + { + label: 'ignored Objective-C bundleURL delegation', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' [self bundleURL];', + ' return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];', + '}', + '@end', + ].join('\n'), + }, + { + label: 'near-match Android method', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile(): String? = null', + ' fun getJSBundleFileForTests() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'), + }, + { + label: 'nested Android helper owner', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' class Helper {', + ' fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' }', + '}', + ].join('\n'), + }, + { + label: 'renamed Android lifecycle decoy', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' override fun onCreate() {}', + ' fun onCreateForTests() { super.onCreate(); loadReactNative(this) }', + '}', + ].join('\n'), + }, + { + label: 'parameterized Android onCreate overload', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' fun onCreate(test: Boolean) { super.onCreate(); loadReactNative(this) }', + '}', + ].join('\n'), + }, + { + label: 'parameterized Kotlin resolver decoy', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile(test: Boolean) =', + ' BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'), + }, + { + label: 'parameterized Java resolver decoy', + file: 'android/app/src/main/java/com/demo/MainApplication.java', + content: [ + 'import com.bundledrop.BundleDropModule;', + 'public class MainApplication {', + ' public String getJSBundleFile(boolean test) {', + ' return BundleDropModule.resolveJSBundleFile(this, null);', + ' }', + '}', + ].join('\n'), + }, + { + label: 'dead Swift helper owner', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate { func bundleURL() -> URL? { nil } }', + 'class Helper { func bundleURL() -> URL? { BundleDropLocator.bundleURL() } }', + ].join('\n'), + }, + { + label: 'parameterized Swift resolver decoy', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' func bundleURL(test: Bool) -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + }, + { + label: 'unconnected Swift factory delegate', + file: 'ios/Demo/AppDelegate.swift', + content: RN85_SWIFT_APP_DELEGATE.replace( + 'RCTReactNativeFactory(delegate: delegate)', + 'RCTReactNativeFactory(delegate: ReactNativeDelegate())', + ), + }, + { + label: 'Objective-C AppDelegate category decoy', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate (BundleDrop)', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'), + }, + { + label: 'duplicate Objective-C AppDelegate implementation', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + '@implementation AppDelegate', + '@end', + ].join('\n'), + }, + { + label: 'dead modern Kotlin host connection', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: MODERN_KOTLIN_MAIN_APPLICATION + .replace('jsBundleFilePath = getJSBundleFile(),', 'isHermesEnabled = true,') + .replace( + '\n}', + '\n fun deadHost() = getDefaultReactHost(jsBundleFilePath = getJSBundleFile())\n}', + ), + }, + { + label: 'unused legacy Kotlin native host', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace( + 'override val reactNativeHost', + 'val unusedHost', + ), + }, + { + label: 'nested legacy Kotlin authority getter', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val actualHost: ReactNativeHost = object : DefaultReactNativeHost(this) {}', + ' override val reactNativeHost: ReactNativeHost get() = actualHost', + ' class Helper {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'), + }, + { + label: 'anonymous legacy Kotlin authority getter', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val deadApplication = object : ReactApplication {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'), + }, + { + label: 'DEBUG-only Kotlin resolver', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION + .replace( + '"/data/local/tmp/dev.jsbundle"', + 'BundleDropModule.resolveJSBundleFile(this@MainApplication, null)!!', + ) + .replace( + /BundleDropModule\.resolveJSBundleFile\(\n this@MainApplication,\n "\/android_asset\/index\.android\.bundle",\n \)!!/, + '"/android_asset/index.android.bundle"', + ), + }, + { + label: 'ignored Swift resolver result', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? { BundleDropLocator.bundleURL(); return nil }', + '}', + ].join('\n'), + }, + { + label: 'cross-statement Kotlin resolver result', + file: 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace( + /override fun getJSBundleFile\(\): String =[\s\S]*?\n }\n}/, + `override fun getJSBundleFile(): String? { + val embeddedPath: String? = null + return embeddedPath + BundleDropModule.resolveJSBundleFile(this@MainApplication, null) + } + } + }`, + ), + }, + { + label: 'DEBUG-only mixed Swift preprocessor branch', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + '#if FEATURE_PREVIEW', + ' return Bundle.main.url(forResource: "preview", withExtension: "jsbundle")', + '#elseif DEBUG', + ' return BundleDropLocator.bundleURL()', + '#else', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + '#endif', + ' }', + '}', + ].join('\n'), + }, + ])('does not report configured for a $label', ({ file, content }) => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + dependencies: { + '@gfean/react-native-bundle-drop': '0.4.3', + 'react-native': '0.86.0', + }, + }), + ); + writeNativeFixture(file, content); + + expect(scanProjectForAiSetup('bare', projectRoot).request.detected.bundleDropStatus) + .toBe('partial'); + }); + + it('fails closed when a dead Swift AppDelegate is not the application principal', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '0.86.0' } }), + ); + writeNativeFixture('ios/Demo/AppDelegate.swift', [ + 'import BundleDrop', + '@main class RealAppDelegate: UIResponder, UIApplicationDelegate {}', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n')); + + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'Swift principal annotation does not uniquely select AppDelegate', + ); + }); + + it('does not report configured when a present native platform has no entrypoint', () => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + dependencies: { + '@gfean/react-native-bundle-drop': '0.4.3', + 'react-native': '0.86.0', + }, + }), + ); + writeNativeFixture( + 'android/app/src/main/kotlin/com/demo/MainApplication.kt', + RN71_KOTLIN_MAIN_APPLICATION, + ); + fs.mkdirSync(path.join(projectRoot, 'ios/Demo'), { recursive: true }); + + expect(scanProjectForAiSetup('bare', projectRoot).request.detected.bundleDropStatus) + .toBe('partial'); + }); + + it.each([ + { + label: 'Android', + files: [ + 'android/app/src/main/kotlin/com/first/MainApplication.kt', + 'android/app/src/main/kotlin/com/second/MainApplication.kt', + ], + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'), + }, + { + label: 'iOS', + files: [ + 'ios/First/AppDelegate.swift', + 'ios/Second/AppDelegate.swift', + ], + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + }, + ])('does not report configured with duplicate integrated $label entrypoints', ({ files, content }) => { + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + dependencies: { + '@gfean/react-native-bundle-drop': '0.4.3', + 'react-native': '0.86.0', + }, + }), + ); + for (const file of files) { + const nativeFile = path.join(projectRoot, file); + fs.mkdirSync(path.dirname(nativeFile), { recursive: true }); + fs.writeFileSync(nativeFile, content); + } + + expect(scanProjectForAiSetup('bare', projectRoot).request.detected.bundleDropStatus) + .toBe('partial'); + }); + + it('skips generated files but fails closed on symlinked native source', () => { const podsDelegate = path.join(projectRoot, 'ios/Pods/Generated/AppDelegate.swift'); const linkedDelegate = path.join(projectRoot, 'ios/Linked/AppDelegate.swift'); const outsideDelegate = path.join(fakeHome, 'AppDelegate.swift'); @@ -408,12 +1365,9 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { fs.writeFileSync(outsideDelegate, 'class AppDelegate {}'); fs.symlinkSync(outsideDelegate, linkedDelegate); - const result = scanProjectForAiSetup('bare', projectRoot); - - expect(result.request.files.map(file => file.path)).not.toEqual(expect.arrayContaining([ - 'ios/Pods/Generated/AppDelegate.swift', - 'ios/Linked/AppDelegate.swift', - ])); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'symbolic-link source path Linked/AppDelegate.swift', + ); }); it('falls back to declared versions when installed manifests have non-string versions', () => { @@ -460,23 +1414,39 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow('Missing "serverUrl"'); }); - it('skips symlinked and oversized setup files', () => { + it('fails closed when an Expo setup file is oversized or symlinked', () => { installEvaluatedExpoProject(); fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ dependencies: {} })); - fs.writeFileSync(path.join(projectRoot, 'app.json'), 'x'.repeat(81 * 1024)); + fs.writeFileSync(path.join(projectRoot, 'app.config.js'), 'x'.repeat(81 * 1024)); + + expect(() => scanProjectForAiSetup('expo', projectRoot)).toThrow( + 'app.config.js: it exceeds the 81920-byte per-file limit', + ); + + fs.unlinkSync(path.join(projectRoot, 'app.config.js')); const outside = path.join(fakeHome, 'metro.config.js'); fs.writeFileSync(outside, 'module.exports = {};'); fs.symlinkSync(outside, path.join(projectRoot, 'metro.config.js')); - const result = scanProjectForAiSetup('expo', projectRoot); + expect(() => scanProjectForAiSetup('expo', projectRoot)).toThrow( + 'metro.config.js: the path is not a regular project file', + ); + }); - expect(result.request.files.map(file => file.path)).not.toEqual(expect.arrayContaining([ - 'app.json', - 'metro.config.js', - ])); + it.each([ + 'android/app/src/main/java/com/example/MainApplication.kt', + 'ios/Fixture/AppDelegate.swift', + ])('fails closed when required native entrypoint %s exceeds the file limit', relativePath => { + const entrypoint = path.join(projectRoot, relativePath); + fs.mkdirSync(path.dirname(entrypoint), { recursive: true }); + fs.writeFileSync(entrypoint, 'x'.repeat(81 * 1024)); + + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + `${relativePath}: it exceeds the 81920-byte per-file limit`, + ); }); - it('enforces the total AI setup context budget across many valid native entrypoints', () => { + it('fails closed instead of omitting an entrypoint at the total context limit', () => { fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); const payload = 'class MainApplication {\n' + 'x'.repeat(70 * 1024) + '\n}'; for (let index = 0; index < 6; index += 1) { @@ -488,15 +1458,27 @@ describe('CLI/scripts/aipowered/scanner unified setup', () => { fs.writeFileSync(file, payload); } - const result = scanProjectForAiSetup('bare', projectRoot); - const nativeFiles = result.request.files.filter(file => file.kind === 'android_entrypoint'); + expect(() => scanProjectForAiSetup('bare', projectRoot)).toThrow( + 'including it would exceed the 131072-byte total context limit', + ); + }); + + it('summarizes oversized CLI-owned app.json and Metro files within a bounded read limit', () => { + installEvaluatedExpoProject(); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { expo: '57.0.0', 'react-native': '0.86.0' } }), + ); + fs.writeFileSync(path.join(projectRoot, 'app.json'), `{"expo":{"padding":"${'x'.repeat(90 * 1024)}"}}`); + fs.writeFileSync( + path.join(projectRoot, 'metro.config.js'), + `module.exports = { padding: '${'y'.repeat(90 * 1024)}' };`, + ); + + const files = scanProjectForAiSetup('expo', projectRoot).request.files; - expect(nativeFiles.length).toBeGreaterThan(0); - expect(nativeFiles.length).toBeLessThan(6); - expect(result.request.files.reduce( - (total, file) => total + Buffer.byteLength(file.content, 'utf8'), - 0, - )).toBeLessThanOrEqual(350 * 1024); + expect(files.find(file => file.path === 'app.json')?.content.length).toBeLessThan(2000); + expect(files.find(file => file.path === 'metro.config.js')?.content.length).toBeLessThan(2000); }); it('fails before scanning when setup server configuration is incomplete or untrusted', () => { diff --git a/src/tests/CLI/scripts/aipowered/terminal-safety.test.ts b/src/tests/CLI/scripts/aipowered/terminal-safety.test.ts new file mode 100644 index 0000000..e7c608a --- /dev/null +++ b/src/tests/CLI/scripts/aipowered/terminal-safety.test.ts @@ -0,0 +1,96 @@ +import { + assertSafeProviderPlan, + escapeTerminalControls, + hasUnsafeTerminalControl, +} from '../../../../CLI/scripts/aipowered/terminal-safety'; +import type { AiSetupPlanResponse } from '../../../../CLI/scripts/aipowered/types'; + +const safePlan = (): AiSetupPlanResponse => ({ + confidence: 'high', + summary: 'Ready', + warnings: ['Review changes'], + actions: [{ type: 'run_doctor', reason: 'Validate', requiresConfirmation: false }], + changes: [{ + file: 'app.config.js', + originalSha256: 'hash', + updated: 'export default {};\n', + reason: 'Configure', + confidence: 'high', + decisionType: 'review_only_patch', + }], +}); + +describe('AI setup terminal safety', () => { + it('allows ordinary text, tabs, newlines, and CRLF without rewriting it', () => { + const value = 'summary\tline one\r\nline two\n'; + + expect(hasUnsafeTerminalControl(value)).toBe(false); + expect(escapeTerminalControls(value)).toBe('summary\tline one\\r\nline two\n'); + expect(() => assertSafeProviderPlan(safePlan())).not.toThrow(); + }); + + it.each([ + ['summary', (plan: AiSetupPlanResponse) => { plan.summary = 'clear\x1b[2J'; }], + ['warning', (plan: AiSetupPlanResponse) => { plan.warnings[0] = 'overwrite\rline'; }], + ['action reason', (plan: AiSetupPlanResponse) => { plan.actions[0].reason = 'bidi\u202E'; }], + ['change reason', (plan: AiSetupPlanResponse) => { plan.changes[0].reason = 'bell\x07'; }], + ['change content', (plan: AiSetupPlanResponse) => { plan.changes[0].updated = 'bad\x1b[2J'; }], + ['change content U+2028', (plan: AiSetupPlanResponse) => { + plan.changes[0].updated = 'comment\u2028module = { exports: {} }'; + }], + ['change content U+2029', (plan: AiSetupPlanResponse) => { + plan.changes[0].updated = 'comment\u2029module = { exports: {} }'; + }], + ])('rejects unsafe controls in provider %s', (_label, mutate) => { + const plan = safePlan(); + mutate(plan); + expect(() => assertSafeProviderPlan(plan)).toThrow('unsafe terminal controls'); + }); + + it('renders controls as inert visible escape markers', () => { + expect(escapeTerminalControls('a\x1b[2J\rb\u202Ec\u2028d\u2029')).toBe( + 'a\\x1b[2J\\rb\\u202ec\\u2028d\\u2029', + ); + }); + + it('rejects provider text containing a private Bundle Drop credential before output', () => { + const plan = safePlan(); + plan.summary = 'Use bdp_proj_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'; + expect(() => assertSafeProviderPlan(plan)).toThrow('private Bundle Drop credential'); + }); + + it.each([ + ['non-object plan', () => null], + ['missing typed arrays', () => ({ confidence: 'high', summary: 'Ready' })], + ['invalid action entry', () => { + const plan: any = safePlan(); + plan.actions = [null]; + return plan; + }], + ['invalid change entry', () => { + const plan: any = safePlan(); + plan.changes = [null]; + return plan; + }], + ['non-text summary', () => { + const plan: any = safePlan(); + plan.summary = 42; + return plan; + }], + ])('rejects malformed provider structure: %s', (_label, buildPlan) => { + expect(() => assertSafeProviderPlan(buildPlan())).toThrow('AI setup response'); + }); + + it.each([ + ['plan confidence', (plan: any) => { plan.confidence = 'certain'; }], + ['action type', (plan: any) => { plan.actions[0].type = 'delete_project'; }], + ['confirmation flag', (plan: any) => { plan.actions[0].requiresConfirmation = 'false'; }], + ['change confidence', (plan: any) => { plan.changes[0].confidence = true; }], + ['change decision', (plan: any) => { plan.changes[0].decisionType = 'auto'; }], + ['original hash', (plan: any) => { plan.changes[0].originalSha256 = false; }], + ])('rejects an invalid provider %s type before display or apply', (_label, mutate) => { + const plan: any = safePlan(); + mutate(plan); + expect(() => assertSafeProviderPlan(plan)).toThrow('AI setup response contains'); + }); +}); diff --git a/src/tests/CLI/scripts/aipowered/validate-setup-plan.test.ts b/src/tests/CLI/scripts/aipowered/validate-setup-plan.test.ts index d2286c0..659802f 100644 --- a/src/tests/CLI/scripts/aipowered/validate-setup-plan.test.ts +++ b/src/tests/CLI/scripts/aipowered/validate-setup-plan.test.ts @@ -9,60 +9,2021 @@ import { validateAppliedSetupChanges, validateSetupChangesBeforeApply, } from '../../../../CLI/scripts/aipowered/validate-plan'; +import { + MODERN_KOTLIN_MAIN_APPLICATION, + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION, + RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_KOTLIN_MAIN_APPLICATION, + RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION, + RN71_OBJC_APP_DELEGATE, + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + RN85_SWIFT_APP_DELEGATE, +} from '../../../fixtures/rn85SwiftAppDelegate'; import { createTempProjectDir, removeTempDir } from '../../../utils/tempDir'; -const hash = (content: string) => crypto.createHash('sha256').update(content).digest('hex'); +const hash = (content: string) => crypto.createHash('sha256').update(content).digest('hex'); + +const patchFor = (file: string, original: string, updated: string): AiPatchPlan => ({ + file, + originalSha256: hash(original), + updated, + reason: 'test', + confidence: 'high', + decisionType: isPatchableNativeEntrypoint(file) || file.startsWith('app.config.') + ? 'review_only_patch' + : 'safe_auto_patch', +}); + +const writeAuthoritativeNativeFile = ( + projectRoot: string, + relativePath: string, + source: string, +) => { + let authoritativeSource = source; + if ( + relativePath.endsWith('AppDelegate.swift') && + !/@(?:main|UIApplicationMain)\b/.test(source) + ) { + authoritativeSource = source.replace(/\bclass\s+AppDelegate\b/, '@main class AppDelegate'); + } + const filePath = path.join(projectRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, authoritativeSource); + + if (relativePath.includes('/MainApplication.')) { + const packageName = authoritativeSource.match( + /(?:^|\n)\s*package\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/, + )?.[1] || relativePath.match(/\/(?:java|kotlin)\/(.+)\/MainApplication\./)?.[1] + ?.replace(/\//g, '.'); + const manifest = path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'); + fs.mkdirSync(path.dirname(manifest), { recursive: true }); + fs.writeFileSync( + manifest, + ``, + ); + } else if (/AppDelegate\.m{1,2}$/.test(relativePath)) { + fs.writeFileSync( + path.join(path.dirname(filePath), 'main.m'), + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"AppDelegate"); }', + ); + } + return authoritativeSource; +}; + +const modernPostApplyProbe = (resolver: string, lazyPrefix = '') => [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication: Application(), ReactApplication {', + ` ${resolver}`, + ' override val reactHost: ReactHost by lazy {', + ` ${lazyPrefix}`, + ' getDefaultReactHost(', + ' context = applicationContext,', + ' packages = PackageList(this).packages,', + ' jsBundleFilePath = getJSBundleFile(),', + ' )', + ' }', + '}', +].join('\n'); + +describe('CLI/scripts/aipowered/validate-plan setup validation', () => { + const original = 'module.exports = {};\n'; + + it('recognizes only supported Expo config and native entrypoint names', () => { + for (const file of ['app.json', 'app.config.js', 'app.config.ts', 'app.config.cjs', 'app.config.mjs', 'metro.config.js', 'metro.config.ts', 'metro.config.cjs', 'metro.config.mjs']) { + expect(isPatchableExpoConfig(file)).toBe(true); + } + expect(isPatchableExpoConfig('config/app.json')).toBe(false); + expect(isPatchableExpoConfig('package.json')).toBe(false); + expect(isPatchableNativeEntrypoint('android/app/src/main/java/demo/MainApplication.java')).toBe(true); + expect(isPatchableNativeEntrypoint('ios/Demo/AppDelegate.mm')).toBe(true); + expect(isPatchableNativeEntrypoint('ios/Demo/SceneDelegate.swift')).toBe(false); + }); + + it('accepts only a valid provider-authored dynamic Expo config change', () => { + const dynamicOriginal = + 'export default ({ config }) => ({ ...config, name: "Demo" });\n'; + const changes = [patchFor( + 'app.config.ts', + dynamicOriginal, + 'export default ({ config }) => ({ ...config, ' + + 'plugins: ["@gfean/react-native-bundle-drop"], name: "Demo" });\n', + )]; + const originals = new Map([['app.config.ts', dynamicOriginal]]); + + expect(() => + validateSetupChangesBeforeApply({ projectType: 'expo', originals, changes }), + ).not.toThrow(); + }); + + it('requires review-only dynamic Expo patches and preserves unrelated config code', () => { + const file = 'app.config.ts'; + const dynamicOriginal = [ + 'import { withSentry } from "./sentry";', + 'export default ({ config }) => ({', + ' ...config,', + ' name: "Critical App",', + ' extra: { apiRegion: "eu-west-1" },', + '});', + ].join('\n'); + const validUpdate = dynamicOriginal.replace( + ' name: "Critical App",', + ' plugins: ["@gfean/react-native-bundle-drop"],\n name: "Critical App",', + ); + const validate = (change: AiPatchPlan) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, dynamicOriginal]]), + changes: [change], + }); + + expect(() => validate(patchFor(file, dynamicOriginal, validUpdate))).not.toThrow(); + expect(() => validate({ + ...patchFor(file, dynamicOriginal, validUpdate), + decisionType: 'safe_auto_patch', + })).toThrow('require explicit review-only approval'); + expect(() => validate(patchFor( + file, + dynamicOriginal, + validUpdate.replace(' extra: { apiRegion: "eu-west-1" },\n', ''), + ))).toThrow('changed code outside authorized setup fields'); + expect(() => validate(patchFor( + file, + dynamicOriginal, + validUpdate.replace( + ' name: "Critical App",\n extra: { apiRegion: "eu-west-1" },', + ' extra: { apiRegion: "eu-west-1" },\n name: "Critical App",', + ), + ))).toThrow('changed code outside authorized setup fields'); + }); + + it('allows only approved expo-updates fields to be removed from dynamic config', () => { + const file = 'app.config.ts'; + const migrationOriginal = [ + 'export default ({ config }) => ({', + ' ...config,', + ' name: "Critical App",', + ' plugins: ["expo-router", "expo-updates"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" },', + ' extra: { apiRegion: "eu-west-1" },', + '});', + ].join('\n'); + const migrationUpdate = [ + 'export default ({ config }) => ({', + ' ...config,', + ' name: "Critical App",', + ' plugins: ["expo-router", "@gfean/react-native-bundle-drop"],', + ' updates: { checkAutomatically: "ON_LOAD" },', + ' extra: { apiRegion: "eu-west-1" },', + '});', + ].join('\n'); + const change = patchFor(file, migrationOriginal, migrationUpdate); + const validate = (updatedChange: AiPatchPlan, migrateExpoUpdates: boolean) => + validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, migrationOriginal]]), + changes: [updatedChange], + migrateExpoUpdates, + }); + + expect(() => validate(change, false)).toThrow('changed code outside authorized setup fields'); + expect(() => validate(change, true)).not.toThrow(); + expect(() => validate({ + ...change, + updated: migrationUpdate.replace(' extra: { apiRegion: "eu-west-1" },\n', ''), + }, true)).toThrow('changed code outside authorized setup fields'); + + const projectRoot = createTempProjectDir(); + const filePath = path.join(projectRoot, file); + fs.writeFileSync(filePath, migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + migrateExpoUpdates: true, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('exempts the full nested expo-updates plugin tuple but preserves unrelated tuples', () => { + const file = 'app.config.ts'; + const migrationOriginal = [ + 'export default {', + ' plugins: [', + ' ["expo-router", { root: "app" }],', + ' ["expo-updates", { requestHeaders: { nested: { channel: "stable" } }, assets: ["one", { two: true }] }],', + ' ["unrelated-plugin", { keep: { deeply: [1, 2, 3] } }],', + ' ],', + '};', + ].join('\n'); + const migrationUpdate = [ + 'export default {', + ' plugins: [', + ' ["expo-router", { root: "app" }],', + ' ["unrelated-plugin", { keep: { deeply: [1, 2, 3] } }],', + ' "@gfean/react-native-bundle-drop",', + ' ],', + '};', + ].join('\n'); + const validate = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, migrationOriginal]]), + changes: [patchFor(file, migrationOriginal, updated)], + migrateExpoUpdates: true, + }); + + expect(() => validate(migrationUpdate)).not.toThrow(); + expect(() => validate(migrationUpdate.replace( + ' ["unrelated-plugin", { keep: { deeply: [1, 2, 3] } }],\n', + '', + ))).toThrow('changed code outside authorized setup fields'); + }); + + it('accepts the canonical E3 CommonJS Expo Updates migration pre- and post-apply', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: [', + ' ...(config.plugins ?? []),', + ' ["expo-updates", { requestHeaders: { "expo-channel-name": "production" } }],', + ' ["expo-build-properties", { ios: { deploymentTarget: "15.1" }, android: { kotlinVersion: "2.1.20" } }],', + ' ],', + ' updates: {', + ' enabled: true,', + ' url: "https://u.expo.dev/project-id",', + ' checkAutomatically: "ON_LOAD",', + ' },', + ' extra: { ...config.extra, keepMe: { nested: ["one", { two: true }] } },', + '});', + ].join('\n'); + const migrationUpdate = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: [', + ' ...(config.plugins ?? []),', + ' ["expo-build-properties", { ios: { deploymentTarget: "15.1" }, android: { kotlinVersion: "2.1.20" } }],', + ' "@gfean/react-native-bundle-drop",', + ' ],', + ' updates: {', + ' checkAutomatically: "ON_LOAD",', + ' },', + ' extra: { ...config.extra, keepMe: { nested: ["one", { two: true }] } },', + '});', + ].join('\n'); + const change = patchFor(file, migrationOriginal, migrationUpdate); + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).not.toThrow(); + removeTempDir(projectRoot); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor( + file, + migrationOriginal, + migrationUpdate.replace( + ' ["expo-build-properties", { ios: { deploymentTarget: "15.1" }, android: { kotlinVersion: "2.1.20" } }],\n', + '', + ), + )], + migrateExpoUpdates: true, + })).toThrow('changed code outside authorized setup fields'); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor( + file, + migrationOriginal, + migrationUpdate.replace(' checkAutomatically: "ON_LOAD",\n', ''), + )], + migrateExpoUpdates: true, + })).toThrow('changed code outside authorized setup fields'); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor( + file, + migrationOriginal, + migrationUpdate.replace('keepMe: { nested: ["one", { two: true }] }', 'keepMe: {}'), + )], + migrateExpoUpdates: true, + })).toThrow('changed code outside authorized setup fields'); + }); + + it('preserves the complete dynamic-config source outside exact migration spans', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'const audit = value => ({ value });', + 'module.exports = {', + ' plugins: ["expo-updates"],', + ' extra: audit("unchanged"),', + '};', + ].join('\n'); + const migrationUpdate = migrationOriginal.replace( + '"expo-updates"', + '"@gfean/react-native-bundle-drop"', + ); + const change = patchFor(file, migrationOriginal, migrationUpdate); + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('adds one literal Bundle Drop property when the dynamic config has no plugins property', () => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = { name: "Demo" };'; + const updatedConfig = 'module.exports = { name: "Demo", ' + + 'plugins: ["@gfean/react-native-bundle-drop"] };'; + const change = patchFor(file, originalConfig, updatedConfig); + const originals = new Map([[file, originalConfig]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), updatedConfig); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('adds one literal plugin to a unique direct exported expo object', () => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = { expo: { name: "Demo" }, outside: "keep" };'; + const updatedConfig = 'module.exports = { expo: { name: "Demo", ' + + 'plugins: ["@gfean/react-native-bundle-drop"] }, outside: "keep" };'; + const change = patchFor(file, originalConfig, updatedConfig); + const originals = new Map([[file, originalConfig]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), updatedConfig); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('migrates Expo Updates only inside a unique direct exported expo object', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = {', + ' expo: {', + ' plugins: ["expo-updates", "expo-build-properties"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" },', + ' extra: { keepMe: "yes" },', + ' },', + ' outside: { keepToo: true },', + '};', + ].join('\n'); + const migrationUpdate = [ + 'module.exports = {', + ' expo: {', + ' plugins: ["@gfean/react-native-bundle-drop", "expo-build-properties"],', + ' updates: { checkAutomatically: "ON_LOAD" },', + ' extra: { keepMe: "yes" },', + ' },', + ' outside: { keepToo: true },', + '};', + ].join('\n'); + const change = patchFor(file, migrationOriginal, migrationUpdate); + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('preserves leading outer and nested spreads before explicit Expo authority', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' expo: {', + ' ...config.expo,', + ' plugins: ["expo-updates"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project" },', + ' },', + '});', + ].join('\n'); + const migrationUpdate = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' expo: {', + ' ...config.expo,', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' },', + '});', + ].join('\n'); + const change = patchFor(file, migrationOriginal, migrationUpdate); + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('preserves an unrelated outer method beside direct nested Expo authority', () => { + const file = 'app.config.cjs'; + const migrationOriginal = 'module.exports = { helper() { return "keep"; }, ' + + '"quotedHelper"() { return "keep-too"; }, ' + + 'expo: { plugins: ["expo-updates"] } };'; + const migrationUpdate = migrationOriginal.replace( + '"expo-updates"', + '"@gfean/react-native-bundle-drop"', + ); + const originals = new Map([[file, migrationOriginal]]); + const change = patchFor(file, migrationOriginal, migrationUpdate); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it.each([ + [ + 'simultaneous root and nested plugin authority', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"], ' + + 'expo: { plugins: ["@gfean/react-native-bundle-drop"] } };', + ], + [ + 'duplicate direct expo objects', + 'module.exports = { expo: { plugins: ["@gfean/react-native-bundle-drop"] }, ' + + 'expo: { name: "decoy" } };', + ], + [ + 'a computed expo object', + 'module.exports = { ["expo"]: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"] } };', + ], + [ + 'an expo accessor beside direct nested authority', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"] }, ' + + 'get expo() { return { plugins: [] }; } };', + ], + [ + 'an expo method beside direct nested authority', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"] }, ' + + 'expo() { return { plugins: [] }; } };', + ], + [ + 'a quoted expo accessor beside direct nested authority', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"] }, ' + + 'get "expo"() { return { plugins: [] }; } };', + ], + [ + 'an escaped quoted expo method beside direct nested authority', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"] }, ' + + '"ex\\u0070o"() { return { plugins: [] }; } };', + ], + [ + 'a quoted nested plugins accessor', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"], ' + + 'get "plugins"() { return []; } } };', + ], + [ + 'an outer spread after nested authority', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"] }, ...config };', + ], + [ + 'a spread after nested plugin authority', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"], ...config.expo } };', + ], + [ + 'a dynamic expo authority value', + 'module.exports = { expo: (() => ({ ' + + 'plugins: ["@gfean/react-native-bundle-drop"] }))() };', + ], + [ + 'a newly executable value inside nested authority', + 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"], ' + + 'extra: (() => { module = { exports: {} }; return {}; })() } };', + ], + ])('rejects ambiguous or mutable nested Expo authority: %s', (_label, invalidUpdate) => { + const file = 'app.config.cjs'; + const migrationOriginal = 'module.exports = { expo: { ' + + 'plugins: ["expo-updates"], extra: {} } };'; + const originals = new Map([[file, migrationOriginal]]); + const change = patchFor(file, migrationOriginal, invalidUpdate); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).toThrow(); + removeTempDir(projectRoot); + }); + + it.each([ + ['fixed Unicode identifier', 'pl\\u0075gins'], + ['braced Unicode identifier', 'pl\\u{75}gins'], + ['quoted fixed Unicode', '"pl\\u0075gins"'], + ['quoted braced Unicode', '"pl\\u{75}gins"'], + ['quoted hex', '"pl\\x75gins"'], + ['quoted legacy octal', '"pl\\165gins"'], + ['quoted simple escape', '"\\plugins"'], + ['quoted line continuation', '"plu' + '\\' + '\n' + 'gins"'], + ])('rejects a duplicate runtime plugins key encoded as %s', (_label, runtimeKey) => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = { plugins: [] };'; + const invalidUpdate = 'module.exports = { ' + + 'plugins: ["@gfean/react-native-bundle-drop"], ' + + `${runtimeKey}: [] };`; + const originals = new Map([[file, originalConfig]]); + const change = patchFor(file, originalConfig, invalidUpdate); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).toThrow('must contain exactly one Bundle Drop plugin'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).toThrow('must contain exactly one Bundle Drop plugin'); + removeTempDir(projectRoot); + }); + + it('rejects escaped updates and expo definitions that override nested authority', () => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = { expo: { plugins: [] } };'; + const invalidUpdates = 'module.exports = { expo: { ' + + 'plugins: ["@gfean/react-native-bundle-drop"], updates: {}, ' + + '"upd\\u0061tes": { enabled: true } } };'; + const invalidExpoAccessor = 'module.exports = { ' + + 'expo: { plugins: ["@gfean/react-native-bundle-drop"] }, ' + + 'get \\u0065xpo() { return { plugins: [] }; } };'; + const originals = new Map([[file, originalConfig]]); + + for (const invalidUpdate of [invalidUpdates, invalidExpoAccessor]) { + const change = patchFor(file, originalConfig, invalidUpdate); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).toThrow(); + removeTempDir(projectRoot); + } + }); + + it('preserves unrelated escaped keys and exact computed authority literals', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = {', + ' ["expo"]: {', + ' ["plugins"]: ["expo-updates"],', + ' ["updates"]: { ["enabled"]: true, checkAutomatically: "ON_LOAD" },', + ' h\\u0065lper: "keep",', + ' },', + '};', + ].join('\n'); + const migrationUpdate = [ + 'module.exports = {', + ' ["expo"]: {', + ' ["plugins"]: ["@gfean/react-native-bundle-drop"],', + ' ["updates"]: { checkAutomatically: "ON_LOAD" },', + ' h\\u0065lper: "keep",', + ' },', + '};', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + const change = patchFor(file, migrationOriginal, migrationUpdate); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it.each([ + ['dynamic concatenation', '["plu" + "gins"]'], + ['a newly inserted computed literal property', '["plugins"]'], + ])('fails closed on computed authority from %s', (_label, propertyKey) => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = { name: "Demo" };'; + const invalidUpdate = 'module.exports = { ' + + `name: "Demo", ${propertyKey}: ` + + '["@gfean/react-native-bundle-drop"] };'; + const originals = new Map([[file, originalConfig]]); + const change = patchFor(file, originalConfig, invalidUpdate); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).toThrow(); + removeTempDir(projectRoot); + }); + + it.each([ + [ + 'module rebinding in a new prelude', + 'module.exports = { plugins: ["expo-updates"] };', + [ + 'const ignored = (module = { exports: { plugins: [] } });', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'), + ], + [ + 'eval in a new prelude', + 'module.exports = { plugins: ["expo-updates"] };', + [ + 'eval("module = { exports: {} }");', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'), + ], + [ + 'an invented pre-export call', + 'module.exports = { plugins: ["expo-updates"] };', + [ + 'inventedSetupCall();', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'), + ], + [ + 'a root-value side effect', + 'module.exports = { plugins: ["expo-updates"], extra: {} };', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"], ' + + 'extra: (() => { module = { exports: {} }; return {}; })() };', + ], + [ + 'an executable Bundle Drop tuple option', + 'module.exports = { plugins: ["expo-updates"] };', + 'module.exports = { plugins: [["@gfean/react-native-bundle-drop", ' + + '(() => { module = { exports: {} }; return {}; })()]] };', + ], + [ + 'an operator flip that activates a preserved side effect', + [ + 'const changeModule = () => { module = { exports: {} }; };', + 'module.exports = { plugins: ["expo-updates"], extra: false && changeModule() };', + ].join('\n'), + [ + 'const changeModule = () => { module = { exports: {} }; };', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"], ' + + 'extra: false || changeModule() };', + ].join('\n'), + ], + [ + 'a removed return line terminator', + [ + 'module.exports = {', + ' plugins: ["expo-updates"],', + ' extra: (() => { return', + ' (() => { module = { exports: {} }; return {}; })(); })(),', + '};', + ].join('\n'), + [ + 'module.exports = {', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' extra: (() => { return (() => { module = { exports: {} }; return {}; })(); })(),', + '};', + ].join('\n'), + ], + [ + 'a removed return terminator after a block comment', + [ + 'module.exports = {', + ' plugins: ["expo-updates"],', + ' extra: (() => { return /* preserve ASI */', + ' (() => { module = { exports: {} }; return {}; })(); })(),', + '};', + ].join('\n'), + [ + 'module.exports = {', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' extra: (() => { return /* preserve ASI */ (() => { ' + + 'module = { exports: {} }; return {}; })(); })(),', + '};', + ].join('\n'), + ], + ])('rejects an unauthorized dynamic-config change: %s', ( + _label, + migrationOriginal, + invalidMigration, + ) => { + const file = 'app.config.cjs'; + const originals = new Map([[file, migrationOriginal]]); + const change = patchFor(file, migrationOriginal, invalidMigration); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).toThrow(); + removeTempDir(projectRoot); + }); + + it.each(['\u2028', '\u2029'])( + 'rejects JavaScript line separator %p before and after dynamic config apply', + separator => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = { plugins: [] };'; + const updatedConfig = '// documentation' + separator + + 'module = { exports: {} };\n' + + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };'; + const originals = new Map([[file, originalConfig]]); + const change = patchFor(file, originalConfig, updatedConfig); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).toThrow('unsafe control or JavaScript line-separator characters'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), updatedConfig); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).toThrow('unsafe control or JavaScript line-separator characters'); + removeTempDir(projectRoot); + }, + ); + + it('requires an approved Expo Updates migration to be complete pre- and post-apply', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["expo-updates", "expo-build-properties"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" },', + ' extra: {', + ' plugins: ["expo-updates", "keep-plugin"],', + ' updates: { enabled: true, url: "https://nested.example", keepNested: "yes" },', + ' },', + '});', + ].join('\n'); + const completeMigration = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["@gfean/react-native-bundle-drop", "expo-build-properties"],', + ' updates: { checkAutomatically: "ON_LOAD" },', + ' extra: {', + ' plugins: ["expo-updates", "keep-plugin"],', + ' updates: { enabled: true, url: "https://nested.example", keepNested: "yes" },', + ' },', + '});', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + const validateBefore = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, updated)], + migrateExpoUpdates: true, + }); + + expect(() => validateBefore(completeMigration)).not.toThrow(); + + const incompleteMigrations = [ + completeMigration.replace( + 'plugins: ["@gfean/react-native-bundle-drop", "expo-build-properties"]', + 'plugins: ["@gfean/react-native-bundle-drop", "expo-updates", "expo-build-properties"]', + ), + completeMigration.replace( + 'updates: { checkAutomatically: "ON_LOAD" }', + 'updates: { enabled: true, checkAutomatically: "ON_LOAD" }', + ), + completeMigration.replace( + 'updates: { checkAutomatically: "ON_LOAD" }', + 'updates: { url: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" }', + ), + ]; + for (const incompleteMigration of incompleteMigrations) { + expect(() => validateBefore(incompleteMigration)) + .toThrow('did not fully remove active Expo Updates configuration'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), incompleteMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [patchFor(file, migrationOriginal, completeMigration)], + originals, + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + removeTempDir(projectRoot); + } + }); + + it('removes shorthand and computed root Expo Updates fields without accepting partial migration', () => { + const file = 'app.config.cjs'; + const originalVariants = [ + [ + 'const enabled = true;', + 'const url = "https://u.expo.dev/project";', + 'module.exports = {', + ' plugins: ["expo-updates"],', + ' updates: { enabled, url, checkAutomatically: "ON_LOAD" },', + '};', + ].join('\n'), + [ + 'module.exports = {', + ' plugins: ["expo-updates"],', + ' updates: { ["enabled"]: true, ["url"]: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" },', + '};', + ].join('\n'), + ]; + + for (const migrationOriginal of originalVariants) { + const incompleteMigration = migrationOriginal.replace( + 'plugins: ["expo-updates"]', + 'plugins: ["@gfean/react-native-bundle-drop"]', + ); + const completeMigration = incompleteMigration + .replace('enabled, url, ', '') + .replace('["enabled"]: true, ["url"]: "https://u.expo.dev/project", ', ''); + const originals = new Map([[file, migrationOriginal]]); + const validate = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, updated)], + migrateExpoUpdates: true, + }); + + expect(() => validate(completeMigration)).not.toThrow(); + expect(() => validate(incompleteMigration)) + .toThrow('did not fully remove active Expo Updates configuration'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), incompleteMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [patchFor(file, migrationOriginal, completeMigration)], + originals, + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + removeTempDir(projectRoot); + } + }); + + it('requires exactly one real Bundle Drop plugin in the exported root config', () => { + const file = 'app.config.js'; + const originalConfig = 'export default { plugins: [] };'; + const validate = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor(file, originalConfig, updated)], + }); + + expect(() => validate([ + 'export default {', + ' plugins: [],', + ' documentation: "@gfean/react-native-bundle-drop",', + '};', + ].join('\n'))).toThrow('must contain exactly one Bundle Drop plugin'); + expect(() => validate([ + 'export default {', + ' // @gfean/react-native-bundle-drop', + ' plugins: [],', + '};', + ].join('\n'))).toThrow('must contain exactly one Bundle Drop plugin'); + expect(() => validate([ + 'export default {', + ' plugins: [', + ' "@gfean/react-native-bundle-drop",', + ' ["@gfean/react-native-bundle-drop", {}],', + ' ],', + '};', + ].join('\n'))).toThrow('must contain exactly one Bundle Drop plugin'); + expect(() => validate([ + 'export default {', + ' plugins: [["@gfean/react-native-bundle-drop"] && "not-a-plugin"],', + '};', + ].join('\n'))).toThrow('must contain exactly one Bundle Drop plugin'); + }); + + it('decodes constant plugin expressions and rejects hidden Expo Updates authority', () => { + const file = 'app.config.cjs'; + const expoPluginExpressions = [ + '"expo\\u002dupdates"', + '`expo-updates`', + '"expo-" + "updates"', + 'EXPO_UPDATES', + ]; + for (const expoPlugin of expoPluginExpressions) { + const declaration = expoPlugin === 'EXPO_UPDATES' + ? 'const EXPO_UPDATES = "expo-" + "updates";\n' + : ''; + const migrationOriginal = `${declaration}module.exports = { plugins: [${expoPlugin}] };`; + const incompleteMigration = `${declaration}module.exports = { plugins: [` + + `${expoPlugin}, "@gfean/react-native-bundle-drop"] };`; + const completeMigration = `${declaration}module.exports = { ` + + 'plugins: ["@gfean/react-native-bundle-drop"] };'; + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, completeMigration)], + migrateExpoUpdates: true, + })).not.toThrow(); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, incompleteMigration)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), incompleteMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [patchFor(file, migrationOriginal, completeMigration)], + originals, + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + removeTempDir(projectRoot); + } + }); + + it('resolves only static multiline leading-plus constants before migration completion', () => { + const file = 'app.config.cjs'; + const declaration = [ + 'const ACTIVE = "expo-"', + ' + "updates";', + ].join('\n'); + const migrationOriginal = [ + declaration, + 'module.exports = { plugins: [ACTIVE] };', + ].join('\n'); + const incompleteMigration = [ + declaration, + 'module.exports = { plugins: [ACTIVE, "@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const completeMigration = [ + declaration, + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, completeMigration)], + migrateExpoUpdates: true, + })).not.toThrow(); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, incompleteMigration)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), incompleteMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [patchFor(file, migrationOriginal, completeMigration)], + originals, + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + removeTempDir(projectRoot); + + const dynamicDeclaration = [ + 'const ACTIVE = "expo-"', + ' + getPluginName();', + ].join('\n'); + const dynamicOriginal = [ + dynamicDeclaration, + 'module.exports = { plugins: [ACTIVE] };', + ].join('\n'); + const dynamicUpdate = [ + dynamicDeclaration, + 'module.exports = { plugins: [ACTIVE, "@gfean/react-native-bundle-drop"] };', + ].join('\n'); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, dynamicOriginal]]), + changes: [patchFor(file, dynamicOriginal, dynamicUpdate)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + }); + + it.each([ + [ + 'block comment', + 'const ACTIVE = "expo-"\n /* kept comment */ + "updates";', + ], + [ + 'line comment', + 'const ACTIVE = "expo-" // kept comment\n + "updates";', + ], + ])('resolves a leading-plus continuation after a %s', (_label, declaration) => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + declaration, + 'module.exports = { plugins: [ACTIVE] };', + ].join('\n'); + const completeMigration = [ + declaration, + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const incompleteMigration = [ + declaration, + 'module.exports = { plugins: [ACTIVE, "@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + const completeChange = patchFor(file, migrationOriginal, completeMigration); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [completeChange], + migrateExpoUpdates: true, + })).not.toThrow(); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, incompleteMigration)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), incompleteMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [completeChange], + originals, + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + removeTempDir(projectRoot); + }); + + it.each([ + ['logical AND', '"@gfean/react-native-bundle-drop"\n && "customer-plugin"'], + ['logical OR', '""\n || "expo-updates"'], + ['nullish coalescing', 'null\n ?? "expo-updates"'], + ['subtraction', '"@gfean/react-native-bundle-drop"\n - 1'], + ['multiplication', '"@gfean/react-native-bundle-drop"\n * 1'], + ['division', '"@gfean/react-native-bundle-drop"\n / 1'], + ['remainder', '"@gfean/react-native-bundle-drop"\n % 1'], + ['member access', '"@gfean/react-native-bundle-drop"\n .trim()'], + ['optional member access', '"@gfean/react-native-bundle-drop"\n ?.trim()'], + ['index access', '"@gfean/react-native-bundle-drop"\n [0]'], + ['call', '"@gfean/react-native-bundle-drop"\n ()'], + ['in operator', '"@gfean/react-native-bundle-drop"\n in registry'], + ['instanceof operator', '"@gfean/react-native-bundle-drop"\n instanceof String'], + ])('does not truncate a multiline %s expression into a trusted plugin alias', ( + _label, + expression, + ) => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = { plugins: [] };'; + const updatedConfig = [ + `const PLUGIN = ${expression};`, + 'module.exports = { plugins: [PLUGIN] };', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor(file, originalConfig, updatedConfig)], + })).toThrow('must contain exactly one Bundle Drop plugin'); + }); + + it('rejects a retained Expo Updates alias continued with logical OR', () => { + const file = 'app.config.cjs'; + const declaration = 'const ACTIVE = ""\n || "expo-updates";'; + const migrationOriginal = [ + declaration, + 'module.exports = { plugins: [ACTIVE] };', + ].join('\n'); + const incompleteMigration = [ + declaration, + 'module.exports = { plugins: [ACTIVE, "@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + const change = patchFor(file, migrationOriginal, incompleteMigration); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), incompleteMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).toThrow(); + removeTempDir(projectRoot); + }); + + it('preserves a semicolonless static prelude while inserting the literal plugin', () => { + const file = 'app.config.cjs'; + const originalConfig = [ + 'const BUNDLE_DROP = "@gfean/react-native-bundle-drop"', + 'module.exports = { plugins: [] };', + ].join('\n'); + const updatedConfig = [ + 'const BUNDLE_DROP = "@gfean/react-native-bundle-drop"', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor(file, originalConfig, updatedConfig)], + })).not.toThrow(); + }); + + it('supports bounded TypeScript string aliases and const assertions', () => { + const file = 'app.config.ts'; + const originalConfig = [ + 'const EXPO_UPDATES = "expo-updates" as const;', + 'const BUNDLE_DROP: string = "@gfean/react-native-bundle-drop";', + 'export default { plugins: [EXPO_UPDATES] };', + ].join('\n'); + const completeMigration = [ + 'const EXPO_UPDATES = "expo-updates" as const;', + 'const BUNDLE_DROP: string = "@gfean/react-native-bundle-drop";', + 'export default { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const incompleteMigration = completeMigration.replace( + 'plugins: ["@gfean/react-native-bundle-drop"]', + 'plugins: [EXPO_UPDATES, "@gfean/react-native-bundle-drop"]', + ); + const originals = new Map([[file, originalConfig]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, originalConfig, completeMigration)], + migrateExpoUpdates: true, + })).not.toThrow(); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, originalConfig, incompleteMigration)], + migrateExpoUpdates: true, + })).toThrow(); + + const dynamicOriginal = [ + 'const EXPO_UPDATES = getPluginName() as const;', + 'export default { plugins: [EXPO_UPDATES] };', + ].join('\n'); + const dynamicUpdate = dynamicOriginal.replace( + 'plugins: [EXPO_UPDATES]', + 'plugins: [EXPO_UPDATES, "@gfean/react-native-bundle-drop"]', + ); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, dynamicOriginal]]), + changes: [patchFor(file, dynamicOriginal, dynamicUpdate)], + migrateExpoUpdates: true, + })).toThrow(); + + for (const assertion of ['as string', 'satisfies string']) { + const assertedOriginal = [ + `const EXPO_UPDATES = "expo-updates" ${assertion};`, + `const BUNDLE_DROP = "@gfean/react-native-bundle-drop" ${assertion};`, + 'export default { plugins: [EXPO_UPDATES] };', + ].join('\n'); + const assertedComplete = [ + `const EXPO_UPDATES = "expo-updates" ${assertion};`, + `const BUNDLE_DROP = "@gfean/react-native-bundle-drop" ${assertion};`, + 'export default { plugins: ["@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const assertedIncomplete = assertedComplete.replace( + 'plugins: ["@gfean/react-native-bundle-drop"]', + 'plugins: [EXPO_UPDATES, "@gfean/react-native-bundle-drop"]', + ); + const assertedOriginals = new Map([[file, assertedOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: assertedOriginals, + changes: [patchFor(file, assertedOriginal, assertedComplete)], + migrateExpoUpdates: true, + })).not.toThrow(); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: assertedOriginals, + changes: [patchFor(file, assertedOriginal, assertedIncomplete)], + migrateExpoUpdates: true, + })).toThrow(); + + const dynamicAssertedOriginal = [ + `const EXPO_UPDATES = getPluginName() ${assertion};`, + 'export default { plugins: [EXPO_UPDATES] };', + ].join('\n'); + const dynamicAssertedUpdate = dynamicAssertedOriginal.replace( + 'plugins: [EXPO_UPDATES]', + 'plugins: [EXPO_UPDATES, "@gfean/react-native-bundle-drop"]', + ); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, dynamicAssertedOriginal]]), + changes: [patchFor(file, dynamicAssertedOriginal, dynamicAssertedUpdate)], + migrateExpoUpdates: true, + })).toThrow(); + } + + const complexAssertionOriginal = [ + 'const EXPO_UPDATES = "expo-updates" as string | null;', + 'export default { plugins: [EXPO_UPDATES] };', + ].join('\n'); + const complexAssertionUpdate = complexAssertionOriginal.replace( + 'plugins: [EXPO_UPDATES]', + 'plugins: [EXPO_UPDATES, "@gfean/react-native-bundle-drop"]', + ); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, complexAssertionOriginal]]), + changes: [patchFor(file, complexAssertionOriginal, complexAssertionUpdate)], + migrateExpoUpdates: true, + })).toThrow(); + }); + + it('recognizes decoded Bundle Drop authority but authorizes only a literal insertion', () => { + const file = 'app.config.js'; + const originalConfig = 'export default { plugins: [] };'; + const decodedBundleDropExpressions = [ + '"@gfean/react-native-bundle\\u002ddrop"', + '`@gfean/react-native-bundle-drop`', + '"@gfean/react-native-" + "bundle-drop"', + ]; + for (const pluginExpression of decodedBundleDropExpressions) { + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor( + file, + originalConfig, + `export default { plugins: [${pluginExpression}] };`, + )], + })).toThrow('changed code outside authorized setup fields'); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor( + file, + originalConfig, + `export default { plugins: [${pluginExpression}, "@gfean/react-native-bundle-drop"] };`, + )], + })).toThrow('must contain exactly one Bundle Drop plugin'); + } + }); + + it('rejects ambiguous or statically authoritative plugin spreads but keeps the live spread', () => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = ({ config }) => ({ ...config, plugins: [' + + '...(config.plugins || [])] });'; + const validate = (plugins: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor( + file, + originalConfig, + `module.exports = ({ config }) => ({ ...config, plugins: [${plugins}] });`, + )], + migrateExpoUpdates: true, + }); + + expect(() => validate( + '...(config.plugins || []), "@gfean/react-native-bundle-drop"', + )).not.toThrow(); + expect(() => validate( + '...["expo-updates"], "@gfean/react-native-bundle-drop"', + )).toThrow(); + expect(() => validate( + '...["expo\\u002dupdates"], "@gfean/react-native-bundle-drop"', + )).toThrow(); + expect(() => validate( + '`expo-${updates}`, "@gfean/react-native-bundle-drop"', + )).toThrow(); + expect(() => validate( + '...ACTIVE_PLUGINS, "@gfean/react-native-bundle-drop"', + )).toThrow(); + }); + + it.each([ + [ + 'mutable alias', + 'let ACTIVE = "expo-updates";', + 'ACTIVE', + ], + [ + 'function result', + 'const ACTIVE = getPluginName();', + 'ACTIVE', + ], + [ + 'spread', + 'const ACTIVE_PLUGINS = getPluginNames();', + '...ACTIVE_PLUGINS', + ], + ])('fails closed on an unresolved %s plugin during migration', ( + _label, + declaration, + pluginExpression, + ) => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + declaration, + `module.exports = { plugins: [${pluginExpression}] };`, + ].join('\n'); + const incompleteMigration = [ + declaration, + `module.exports = { plugins: [${pluginExpression}, ` + + '"@gfean/react-native-bundle-drop"] };', + ].join('\n'); + const change = patchFor(file, migrationOriginal, incompleteMigration); + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), incompleteMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + removeTempDir(projectRoot); + }); + + it('does not allow a provider to introduce the existing-config plugin spread', () => { + const file = 'app.config.cjs'; + const originalConfig = 'module.exports = ({ config }) => ({ ...config, plugins: [] });'; + const updatedConfig = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: [', + ' ...(config.plugins || []),', + ' "@gfean/react-native-bundle-drop",', + ' ],', + '});', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor(file, originalConfig, updatedConfig)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + }); + + it('rejects every spread inside authoritative updates during migration', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = {', + ' plugins: ["expo-updates"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" },', + '};', + ].join('\n'); + const invalidMigration = [ + 'module.exports = {', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' updates: { ...{ enabled: true, url: "https://u.expo.dev/project" }, checkAutomatically: "ON_LOAD" },', + '};', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, migrationOriginal]]), + changes: [patchFor(file, migrationOriginal, invalidMigration)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + }); + + it('does not authorize deletion of an original authoritative updates spread', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["expo-updates"],', + ' updates: { ...config.updates, enabled: true, url: "https://u.expo.dev/project" },', + '});', + ].join('\n'); + const invalidMigration = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' updates: {},', + '});', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, migrationOriginal]]), + changes: [patchFor(file, migrationOriginal, invalidMigration)], + migrateExpoUpdates: true, + })).toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidMigration); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [patchFor(file, migrationOriginal, invalidMigration)], + originals: new Map([[file, migrationOriginal]]), + migrateExpoUpdates: true, + })).toThrow('changed code outside authorized setup fields'); + removeTempDir(projectRoot); + }); + + it('migrates escaped direct Expo Updates keys but preserves unrelated escaped keys', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = {', + ' plugins: ["expo-updates"],', + ' updates: {', + ' en\\u0061bled: true,', + ' ["u\\u0072l"]: "https://u.expo.dev/project",', + ' "check\\u0041utomatically": "ON_LOAD",', + ' },', + '};', + ].join('\n'); + const migrationUpdate = [ + 'module.exports = {', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' updates: {', + ' "check\\u0041utomatically": "ON_LOAD",', + ' },', + '};', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, migrationUpdate)], + migrateExpoUpdates: true, + })).not.toThrow(); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor( + file, + migrationOriginal, + migrationUpdate.replace(' "check\\u0041utomatically": "ON_LOAD",\n', ''), + )], + migrateExpoUpdates: true, + })).toThrow('changed code outside authorized setup fields'); + + const retainedAuthority = migrationUpdate.replace( + ' updates: {', + ' updates: { en\\u0061bled: true,', + ); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, retainedAuthority)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); -const patchFor = (file: string, original: string, updated: string): AiPatchPlan => ({ - file, - originalSha256: hash(original), - updated, - reason: 'test', - confidence: 'high', - decisionType: 'safe_auto_patch', -}); + const escapedMethodAuthority = migrationUpdate.replace( + ' updates: {', + ' updates: { get en\\u0061bled() { return true; }, ' + + 'u\\u0072l() { return "https://u.expo.dev/project"; },', + ); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, escapedMethodAuthority)], + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); -describe('CLI/scripts/aipowered/validate-plan setup validation', () => { - const original = 'module.exports = {};\n'; + const projectRoot = createTempProjectDir(); + const completedChange = patchFor(file, migrationOriginal, migrationUpdate); + fs.writeFileSync(path.join(projectRoot, file), migrationUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [completedChange], + originals, + migrateExpoUpdates: true, + })).not.toThrow(); + fs.writeFileSync(path.join(projectRoot, file), retainedAuthority); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [completedChange], + originals, + migrateExpoUpdates: true, + })).toThrow('did not fully remove active Expo Updates configuration'); + removeTempDir(projectRoot); + }); - it('recognizes only supported Expo config and native entrypoint names', () => { - for (const file of ['app.json', 'app.config.js', 'app.config.ts', 'app.config.cjs', 'app.config.mjs', 'metro.config.js', 'metro.config.ts', 'metro.config.cjs', 'metro.config.mjs']) { - expect(isPatchableExpoConfig(file)).toBe(true); + it('accepts a canonical block-body dynamic config export pre- and post-apply', () => { + const file = 'app.config.cjs'; + const originalConfig = [ + 'module.exports = ({ config }) => {', + ' return { ...config, plugins: [] };', + '};', + ].join('\n'); + const updatedConfig = originalConfig.replace( + 'plugins: []', + 'plugins: ["@gfean/react-native-bundle-drop"]', + ); + const change = patchFor(file, originalConfig, updatedConfig); + const originals = new Map([[file, originalConfig]]); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).not.toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), updatedConfig); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('accepts a canonical typed TypeScript dynamic config export', () => { + const file = 'app.config.ts'; + const originalConfig = [ + 'import type { ConfigContext, ExpoConfig } from "expo/config";', + 'export default ({ config }: ConfigContext): ExpoConfig => ({', + ' ...config,', + ' plugins: [],', + '});', + ].join('\n'); + const updatedConfig = originalConfig.replace( + 'plugins: []', + 'plugins: ["@gfean/react-native-bundle-drop"]', + ); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor(file, originalConfig, updatedConfig)], + })).not.toThrow(); + }); + + it('accepts a semicolonless declaration before the authoritative export', () => { + const file = 'app.config.cjs'; + const originalConfig = [ + 'const helper = 1', + 'module.exports = { plugins: [], extra: { helper } }; // terminal comment is allowed', + ].join('\n'); + const updatedConfig = originalConfig.replace( + 'plugins: []', + 'plugins: ["@gfean/react-native-bundle-drop"]', + ); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor(file, originalConfig, updatedConfig)], + })).not.toThrow(); + }); + + it.each([ + [ + 'post-export arrow decoy', + [ + 'const actual = { plugins: [] };', + 'module.exports = actual;', + 'const decoy = () => ({ plugins: ["@gfean/react-native-bundle-drop"] });', + ].join('\n'), + ], + [ + 'conditional export', + 'if (false) module.exports = () => ({ plugins: ["@gfean/react-native-bundle-drop"] });', + ], + [ + 'nested dead export', + 'function dead() { module.exports = () => ({ plugins: ["@gfean/react-native-bundle-drop"] }); }', + ], + [ + 'template interpolation override', + [ + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] };', + 'const override = `${module.exports = { plugins: [] }}`;', + ].join('\n'), + ], + [ + 'direct object value bypass', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] } && { plugins: [] };', + ], + [ + 'concise arrow value bypass', + 'module.exports = ({ config }) => ({ plugins: ["@gfean/react-native-bundle-drop"] }) && ({ plugins: [] });', + ], + [ + 'trailing root spread override', + 'module.exports = ({ config }) => ({ plugins: ["@gfean/react-native-bundle-drop"], updates: {}, ...config });', + ], + [ + 'computed root overrides', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"], ["plugins"]: [], updates: {}, ["updates"]: { enabled: true } };', + ], + [ + 'accessor root overrides', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"], get plugins() { return []; }, updates: {}, get updates() { return { enabled: true }; } };', + ], + [ + 'post-export plugin mutation', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] }; module.exports.plugins = [];', + ], + [ + 'post-export Object.assign mutation', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] }; Object.assign(module.exports, { plugins: [] });', + ], + [ + 'post-export Expo Updates mutation', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"], updates: {} }; module.exports.updates = { enabled: true };', + ], + [ + 'post-export plugin push', + 'module.exports = { plugins: ["@gfean/react-native-bundle-drop"] }; module.exports.plugins.push("expo-updates");', + ], + ])('rejects a non-authoritative dynamic Expo root: %s', (_label, updatedConfig) => { + const file = 'app.config.cjs'; + const originals = new Map([[file, updatedConfig]]); + const change = patchFor(file, updatedConfig, updatedConfig); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + })).toThrow('must contain exactly one Bundle Drop plugin'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), updatedConfig); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + })).toThrow('must contain exactly one Bundle Drop plugin'); + removeTempDir(projectRoot); + }); + + it('exempts Expo Updates migration fields only from the exported root config', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["expo-updates"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" },', + ' extra: {', + ' plugins: ["expo-updates", "keep-plugin"],', + ' updates: { enabled: true, url: "https://unrelated.example", keepNested: "yes" },', + ' keepMe: "yes",', + ' },', + '});', + ].join('\n'); + const migrationUpdate = [ + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' updates: { checkAutomatically: "ON_LOAD" },', + ' extra: {', + ' plugins: ["expo-updates", "keep-plugin"],', + ' updates: { enabled: true, url: "https://unrelated.example", keepNested: "yes" },', + ' keepMe: "yes",', + ' },', + '});', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + const validateBefore = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, updated)], + migrateExpoUpdates: true, + }); + + expect(() => validateBefore(migrationUpdate)).not.toThrow(); + + const invalidUpdates = migrationUpdate.replace( + ' updates: { enabled: true, url: "https://unrelated.example", keepNested: "yes" },', + ' updates: { keepNested: "yes" },', + ); + const invalidPlugins = migrationUpdate.replace( + ' plugins: ["expo-updates", "keep-plugin"],', + ' plugins: ["keep-plugin"],', + ); + for (const invalidUpdate of [invalidUpdates, invalidPlugins]) { + expect(() => validateBefore(invalidUpdate)).toThrow('changed code outside authorized setup fields'); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [patchFor(file, migrationOriginal, migrationUpdate)], + originals, + migrateExpoUpdates: true, + })).toThrow('changed code outside authorized setup fields'); + removeTempDir(projectRoot); } - expect(isPatchableExpoConfig('config/app.json')).toBe(false); - expect(isPatchableExpoConfig('package.json')).toBe(false); - expect(isPatchableNativeEntrypoint('android/app/src/main/java/demo/MainApplication.java')).toBe(true); - expect(isPatchableNativeEntrypoint('ios/Demo/AppDelegate.mm')).toBe(true); - expect(isPatchableNativeEntrypoint('ios/Demo/SceneDelegate.swift')).toBe(false); }); - it('accepts valid Expo app-config and Metro changes', () => { - const changes = [ - patchFor( - 'app.config.ts', - original, - "export default { plugins: ['@gfean/react-native-bundle-drop'] };\n", - ), - patchFor( - 'metro.config.cjs', - original, - "module.exports = withBundleDropExpo(require('expo/metro-config'));\n", - ), - ]; - const originals = new Map(changes.map(change => [change.file, original])); + it('binds Expo Updates migration exemptions to the actual export expression', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'const helper = () => ({ updates: { enabled: true, url: "https://unrelated", keep: "yes" } });', + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["expo-updates"],', + ' extra: { keepMe: "yes" },', + '});', + ].join('\n'); + const migrationUpdate = migrationOriginal + .replace('plugins: ["expo-updates"]', 'plugins: ["@gfean/react-native-bundle-drop"]'); + const originals = new Map([[file, migrationOriginal]]); + const validate = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [patchFor(file, migrationOriginal, updated)], + migrateExpoUpdates: true, + }); - expect(() => - validateSetupChangesBeforeApply({ projectType: 'expo', originals, changes }), - ).not.toThrow(); + expect(() => validate(migrationUpdate)).not.toThrow(); + expect(() => validate(migrationUpdate.replace( + 'updates: { enabled: true, url: "https://unrelated", keep: "yes" }', + 'updates: { keep: "yes" }', + ))).toThrow('changed code outside authorized setup fields'); + }); + + it('rejects Expo Updates migration exemptions when exports are ambiguous', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'if (false) module.exports = () => ({', + ' updates: { enabled: true, url: "https://unrelated", keep: "yes" },', + '});', + 'module.exports = ({ config }) => ({ ...config, extra: { keepMe: "yes" } });', + ].join('\n'); + const invalidUpdate = [ + 'if (false) module.exports = () => ({', + ' updates: { keep: "yes" },', + '});', + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' extra: { keepMe: "yes" },', + '});', + ].join('\n'); + const originals = new Map([[file, migrationOriginal]]); + const change = patchFor(file, migrationOriginal, invalidUpdate); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals, + changes: [change], + migrateExpoUpdates: true, + })).toThrow(); + + const projectRoot = createTempProjectDir(); + fs.writeFileSync(path.join(projectRoot, file), invalidUpdate); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'expo', + changes: [change], + originals, + migrateExpoUpdates: true, + })).toThrow(); + removeTempDir(projectRoot); + }); + + it('ignores export examples inside dynamic config string and regex literals', () => { + const file = 'app.config.cjs'; + const migrationOriginal = [ + 'const matcher = /module.exports = example|export default example/;', + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["expo-updates"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project", checkAutomatically: "ON_LOAD" },', + ' extra: { matcher: /}/, documentation: "module.exports = example; export default example", keepMe: "yes" },', + '});', + ].join('\n'); + const migrationUpdate = [ + 'const matcher = /module.exports = example|export default example/;', + 'module.exports = ({ config }) => ({', + ' ...config,', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' updates: { checkAutomatically: "ON_LOAD" },', + ' extra: { matcher: /}/, documentation: "module.exports = example; export default example", keepMe: "yes" },', + '});', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, migrationOriginal]]), + changes: [patchFor(file, migrationOriginal, migrationUpdate)], + migrateExpoUpdates: true, + })).not.toThrow(); + }); + + it('exempts only the authoritative top-level updates field during migration', () => { + const file = 'app.config.ts'; + const originalConfig = [ + 'export default {', + ' plugins: ["expo-updates"],', + ' updates: { enabled: true, url: "https://u.expo.dev/project" },', + ' extra: { updates: { enabled: true, url: "keep-private" } },', + '};', + ].join('\n'); + const validUpdate = [ + 'export default {', + ' plugins: ["@gfean/react-native-bundle-drop"],', + ' extra: { updates: { enabled: true, url: "keep-private" } },', + '};', + ].join('\n'); + const validate = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'expo', + originals: new Map([[file, originalConfig]]), + changes: [patchFor(file, originalConfig, updated)], + migrateExpoUpdates: true, + }); + + expect(() => validate(validUpdate)).not.toThrow(); + expect(() => validate(validUpdate.replace( + ' extra: { updates: { enabled: true, url: "keep-private" } },', + ' extra: { updates: {} },', + ))).toThrow('changed code outside authorized setup fields'); }); it('rejects common unsafe, stale, duplicate, empty, placeholder, and malformed changes', () => { + const dynamicOriginal = 'export default {};'; const valid = patchFor( - 'app.json', - original, - '{"expo":{"plugins":["@gfean/react-native-bundle-drop"]}}', + 'app.config.js', + dynamicOriginal, + 'export default { plugins: ["@gfean/react-native-bundle-drop"] };', ); - const originals = new Map([[valid.file, original]]); + const originals = new Map([[valid.file, dynamicOriginal]]); expect(() => validateSetupChangesBeforeApply({ projectType: 'expo', @@ -77,40 +2038,47 @@ describe('CLI/scripts/aipowered/validate-plan setup validation', () => { expect(() => validateSetupChangesBeforeApply({ projectType: 'expo', originals, changes: [{ ...valid, updated: '{]' }] })).toThrow('unbalanced'); }); - it('rejects Expo changes outside the allowlist or missing required integration markers', () => { + it('allows provider changes only for dynamic Expo config', () => { expect(() => validateSetupChangesBeforeApply({ projectType: 'expo', originals: new Map([['package.json', original]]), changes: [patchFor('package.json', original, '{"name":"demo"}')], - })).toThrow('may not modify'); + })).toThrow('dynamic root app.config'); expect(() => validateSetupChangesBeforeApply({ projectType: 'expo', originals: new Map([['metro.config.js', original]]), changes: [patchFor('metro.config.js', original, "module.exports = require('expo/metro-config');")], - })).toThrow('missing the Bundle Drop wrapper'); + })).toThrow('dynamic root app.config'); expect(() => validateSetupChangesBeforeApply({ projectType: 'expo', originals: new Map([['app.json', original]]), changes: [patchFor('app.json', original, '{"expo":{"plugins":[]}}')], - })).toThrow('missing the Bundle Drop plugin'); + })).toThrow('dynamic root app.config'); }); it('accepts valid Android and iOS bare changes and rejects missing resolver calls', () => { const androidFile = 'android/app/src/main/java/demo/MainApplication.kt'; const iosFile = 'ios/Demo/AppDelegate.swift'; + const androidOriginal = 'class MainApplication {}'; + const iosOriginal = 'class AppDelegate {}'; const validAndroid = patchFor( androidFile, - original, - 'import com.bundledrop.BundleDropModule\nclass MainApplication { fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null) }', + androidOriginal, + RN71_KOTLIN_MAIN_APPLICATION, ); const validIos = patchFor( iosFile, - original, - 'import BundleDrop\nclass AppDelegate { func bundleURL() -> URL? { BundleDropLocator.bundleURL() } }', + iosOriginal, + [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), ); expect(() => validateSetupChangesBeforeApply({ projectType: 'bare', - originals: new Map([[androidFile, original], [iosFile, original]]), + originals: new Map([[androidFile, androidOriginal], [iosFile, iosOriginal]]), changes: [validAndroid, validIos], })).not.toThrow(); @@ -121,16 +2089,263 @@ describe('CLI/scripts/aipowered/validate-plan setup validation', () => { })).toThrow('may not modify'); expect(() => validateSetupChangesBeforeApply({ projectType: 'bare', - originals: new Map([[androidFile, original]]), - changes: [patchFor(androidFile, original, 'class MainApplication { fun getJSBundleFile() = null }')], + originals: new Map([[androidFile, androidOriginal]]), + changes: [patchFor(androidFile, androidOriginal, 'class MainApplication { fun getJSBundleFile() = null }')], })).toThrow('Android update'); expect(() => validateSetupChangesBeforeApply({ projectType: 'bare', - originals: new Map([[iosFile, original]]), - changes: [patchFor(iosFile, original, 'class AppDelegate { func bundleURL() -> URL? { nil } }')], + originals: new Map([[iosFile, iosOriginal]]), + changes: [patchFor(iosFile, iosOriginal, 'class AppDelegate { func bundleURL() -> URL? { nil } }')], })).toThrow('iOS update'); }); + it('requires every native AI patch to use explicit review-only approval', () => { + const androidFile = 'android/app/src/main/java/demo/MainApplication.kt'; + const androidOriginal = 'class MainApplication {}'; + const validUpdate = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[androidFile, androidOriginal]]), + changes: [{ + ...patchFor(androidFile, androidOriginal, validUpdate), + decisionType: 'safe_auto_patch', + }], + })).toThrow('require explicit review-only approval'); + }); + + it('rejects deletion or reordering of custom packages, analytics, and startup fallbacks', () => { + const androidFile = 'android/app/src/main/java/demo/MainApplication.kt'; + const androidOriginal = [ + 'import com.example.analytics.Analytics', + 'import com.example.packages.CustomPackage', + 'class MainApplication {', + ' override val reactNativeHost: ReactNativeHost =', + ' object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile(): String? =', + ' CustomBundleProvider.shared.path("embedded-main")', + ' }', + ' override fun onCreate() {', + ' super.onCreate()', + ' Analytics.start(this)', + ' }', + ' override fun getPackages() = PackageList(this).packages.apply {', + ' add(CustomPackage())', + ' }', + '}', + ].join('\n'); + const completeUpdate = [ + 'import com.bundledrop.BundleDropModule', + 'import com.example.analytics.Analytics', + 'import com.example.packages.CustomPackage', + 'class MainApplication {', + ' override val reactNativeHost: ReactNativeHost =', + ' object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile(): String? =', + ' BundleDropModule.resolveJSBundleFile(', + ' this@MainApplication,', + ' CustomBundleProvider.shared.path("embedded-main"),', + ' )', + ' }', + ' override fun onCreate() {', + ' super.onCreate()', + ' Analytics.start(this)', + ' }', + ' override fun getPackages() = PackageList(this).packages.apply {', + ' add(CustomPackage())', + ' }', + '}', + ].join('\n'); + const validate = (updated: string) => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[androidFile, androidOriginal]]), + changes: [patchFor(androidFile, androidOriginal, updated)], + }); + + expect(() => validate(completeUpdate)).not.toThrow(); + expect(() => validate(completeUpdate.replace( + ' override fun getPackages() = PackageList(this).packages.apply {\n' + + ' add(CustomPackage())\n }\n', + '', + ))).toThrow('substantive native code or ordering'); + expect(() => validate(completeUpdate.replace(' Analytics.start(this)\n', ''))) + .toThrow('substantive native code or ordering'); + expect(() => validate(completeUpdate.replace( + ' CustomBundleProvider.shared.path("embedded-main"),', + ' null,', + ))).toThrow('substantive native code or ordering'); + expect(() => validate(completeUpdate.replace( + 'import com.example.analytics.Analytics\nimport com.example.packages.CustomPackage', + 'import com.example.packages.CustomPackage\nimport com.example.analytics.Analytics', + ))).toThrow('substantive native code or ordering'); + }); + + it('allows the authorized CodePush resolver replacement while preserving the startup declaration', () => { + const androidFile = 'android/app/src/main/java/demo/MainApplication.kt'; + const codePushOriginal = [ + 'import com.microsoft.codepush.react.CodePush', + 'class MainApplication {', + ' override val reactNativeHost: ReactNativeHost =', + ' object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile(): String? = CodePush.getJSBundleFile()', + ' }', + '}', + ].join('\n'); + const bundleDropUpdate = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override val reactNativeHost: ReactNativeHost =', + ' object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile(): String? =', + ' BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + '}', + ].join('\n'); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[androidFile, codePushOriginal]]), + changes: [patchFor(androidFile, codePushOriginal, bundleDropUpdate)], + })).not.toThrow(); + + const aliasedCodePushOriginal = codePushOriginal + .replace('import com.microsoft.codepush.react.CodePush', + 'import com.microsoft.codepush.react.CodePush as LegacyCodePush') + .replace('CodePush.getJSBundleFile()', 'LegacyCodePush.getJSBundleFile()'); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[androidFile, aliasedCodePushOriginal]]), + changes: [patchFor(androidFile, aliasedCodePushOriginal, bundleDropUpdate)], + })).not.toThrow(); + + const customAliasUse = aliasedCodePushOriginal.replace( + 'class MainApplication {', + 'class MainApplication {\n val registeredProvider = LegacyCodePush::class.java', + ); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[androidFile, customAliasUse]]), + changes: [patchFor(androidFile, customAliasUse, bundleDropUpdate)], + })).toThrow('substantive native code or ordering'); + + const iosFile = 'ios/Demo/AppDelegate.mm'; + const macroCodePushOriginal = [ + '#define LegacyCodePush CodePush', + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' return [LegacyCodePush bundleURL];', + '}', + '@end', + ].join('\n'); + const iosBundleDropUpdate = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' return [BundleDropLocator bundleURL];', + '}', + '@end', + ].join('\n'); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[iosFile, macroCodePushOriginal]]), + changes: [patchFor(iosFile, macroCodePushOriginal, iosBundleDropUpdate)], + })).not.toThrow(); + }); + + it('rejects retained CodePush native co-authority before and after apply', () => { + const projectRoot = createTempProjectDir(); + const file = 'android/app/src/main/java/demo/MainApplication.kt'; + const originalContent = 'class MainApplication {}'; + const retainedCodePush = [ + 'import com.bundledrop.BundleDropModule', + 'import com.microsoft.codepush.react.CodePush as LegacyCodePush', + 'class MainApplication {', + ' override val reactNativeHost: ReactNativeHost =', + ' object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(', + ' this@MainApplication, LegacyCodePush.getJSBundleFile(),', + ' )', + ' }', + '}', + ].join('\n'); + const change = patchFor(file, originalContent, retainedCodePush); + + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[file, originalContent]]), + changes: [change], + })).toThrow('Android update'); + + const filePath = path.join(projectRoot, file); + writeAuthoritativeNativeFile(projectRoot, file, retainedCodePush); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [change], + })).toThrow('Android update'); + + const aliasedOriginal = [ + 'import com.microsoft.codepush.react.CodePush as LegacyCodePush', + 'class MainApplication {', + ' override val reactNativeHost: ReactNativeHost =', + ' object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = LegacyCodePush.getJSBundleFile()', + ' }', + '}', + ].join('\n'); + const aliasRetainedWithoutImport = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override val reactNativeHost: ReactNativeHost =', + ' object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(', + ' this@MainApplication, LegacyCodePush.getJSBundleFile(),', + ' )', + ' }', + '}', + ].join('\n'); + const aliasChange = patchFor(file, aliasedOriginal, aliasRetainedWithoutImport); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[file, aliasedOriginal]]), + changes: [aliasChange], + })).toThrow('CodePush alias residue'); + fs.writeFileSync(filePath, aliasRetainedWithoutImport); + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [aliasChange], + originals: new Map([[file, aliasedOriginal]]), + })).toThrow('CodePush alias residue'); + + const iosFile = 'ios/Demo/AppDelegate.mm'; + const macroOriginal = [ + '#define LegacyCodePush CodePush', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [LegacyCodePush bundleURL]; }', + '@end', + ].join('\n'); + const macroAliasRetained = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL {', + ' return [BundleDropLocator bundleURL] ?: [LegacyCodePush bundleURL];', + '}', + '@end', + ].join('\n'); + expect(() => validateSetupChangesBeforeApply({ + projectType: 'bare', + originals: new Map([[iosFile, macroOriginal]]), + changes: [patchFor(iosFile, macroOriginal, macroAliasRetained)], + })).toThrow('CodePush alias residue'); + removeTempDir(projectRoot); + }); + it('rejects invented bare native module identifiers even when resolver calls are present', () => { const androidFile = 'android/app/src/main/java/demo/MainApplication.kt'; const iosFile = 'ios/Demo/AppDelegate.swift'; @@ -158,14 +2373,618 @@ describe('CLI/scripts/aipowered/validate-plan setup validation', () => { it('validates files again after application using their current content', () => { const projectRoot = createTempProjectDir(); - const file = 'app.json'; + const file = 'app.config.js'; const filePath = path.join(projectRoot, file); - fs.writeFileSync(filePath, '{"expo":{"plugins":["@gfean/react-native-bundle-drop"]}}'); + fs.writeFileSync( + filePath, + 'export default { plugins: ["@gfean/react-native-bundle-drop"] };', + ); const change = patchFor(file, original, fs.readFileSync(filePath, 'utf8')); expect(() => validateAppliedSetupChanges({ projectRoot, projectType: 'expo', changes: [change] })).not.toThrow(); - fs.writeFileSync(filePath, '{"expo":{"plugins":[]}}'); - expect(() => validateAppliedSetupChanges({ projectRoot, projectType: 'expo', changes: [change] })).toThrow('missing the Bundle Drop plugin'); + fs.writeFileSync(filePath, 'export default { plugins: [] };'); + expect(() => validateAppliedSetupChanges({ projectRoot, projectType: 'expo', changes: [change] })) + .toThrow('must contain exactly one Bundle Drop plugin'); + removeTempDir(projectRoot); + }); + + it('rejects a post-apply file swapped to a symlink without reading its target', () => { + const projectRoot = createTempProjectDir(); + const outsideRoot = createTempProjectDir(); + const file = 'app.config.js'; + const outsideFile = path.join(outsideRoot, 'secret.js'); + fs.writeFileSync(outsideFile, 'outside-secret-sentinel'); + fs.symlinkSync(outsideFile, path.join(projectRoot, file)); + const change = patchFor(file, original, 'export default { plugins: [] };'); + + expect(() => validateAppliedSetupChanges({ projectRoot, projectType: 'expo', changes: [change] })) + .toThrow('symlinked or non-regular transaction target'); + expect(fs.readFileSync(outsideFile, 'utf8')).toBe('outside-secret-sentinel'); + removeTempDir(projectRoot); + removeTempDir(outsideRoot); + }); + + it('rejects native post-apply entrypoints not selected by platform principals', () => { + const projectRoot = createTempProjectDir(); + const androidFile = 'android/app/src/main/kotlin/com/demo/MainApplication.kt'; + const iosFile = 'ios/Demo/AppDelegate.m'; + for (const [file, content] of [ + [androidFile, RN71_KOTLIN_MAIN_APPLICATION], + [iosFile, [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n')], + ] as const) { + const filePath = path.join(projectRoot, file); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + } + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'), + '', + ); + fs.writeFileSync( + path.join(projectRoot, 'ios/Demo/main.m'), + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"OtherDelegate"); }', + ); + + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [ + patchFor(androidFile, RN71_KOTLIN_MAIN_APPLICATION, RN71_KOTLIN_MAIN_APPLICATION), + patchFor(iosFile, 'class Placeholder {}', fs.readFileSync(path.join(projectRoot, iosFile), 'utf8')), + ], + })).toThrow('entrypoint authority is invalid'); + removeTempDir(projectRoot); + }); + + it('rejects a post-apply Swift principal hidden in a string beside the real principal', () => { + const projectRoot = createTempProjectDir(); + const file = 'ios/Demo/AppDelegate.swift'; + const content = [ + 'import BundleDrop', + 'let documentation = "@main class AppDelegate"', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'); + writeAuthoritativeNativeFile(projectRoot, file, content); + fs.writeFileSync( + path.join(projectRoot, 'ios/Demo/RealApp.swift'), + '@main struct RealApp { static func main() {} }', + ); + + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [patchFor(file, 'class AppDelegate {}', content)], + })).toThrow('entrypoint authority'); + removeTempDir(projectRoot); + }); + + it('rejects a parameterized Android onCreate overload during post-apply validation', () => { + const projectRoot = createTempProjectDir(); + const file = 'android/app/src/main/java/demo/MainApplication.kt'; + const invalidAppliedContent = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' fun onCreate(test: Boolean) { super.onCreate(); loadReactNative(this) }', + '}', + ].join('\n'); + const authoritativeContent = writeAuthoritativeNativeFile( + projectRoot, + file, + invalidAppliedContent, + ); + const change = patchFor(file, 'class MainApplication {}', authoritativeContent); + + expect(() => validateAppliedSetupChanges({ projectRoot, projectType: 'bare', changes: [change] })) + .toThrow('Android update'); + removeTempDir(projectRoot); + }); + + it('accepts archived RN85 NativePaths and connected Swift delegate post-apply', () => { + const projectRoot = createTempProjectDir(); + const changes = [ + patchFor( + 'android/app/src/main/java/demo/MainApplication.kt', + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + ), + patchFor( + 'ios/Demo/AppDelegate.swift', + RN85_SWIFT_APP_DELEGATE, + RN85_SWIFT_APP_DELEGATE, + ), + ]; + for (const change of changes) { + change.updated = writeAuthoritativeNativeFile(projectRoot, change.file, change.updated); + } + + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes, + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('accepts the RN71 Java multiline local fallback post-apply', () => { + const projectRoot = createTempProjectDir(); + const file = 'android/app/src/main/java/com/demo/MainApplication.java'; + const updated = writeAuthoritativeNativeFile( + projectRoot, + file, + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION, + ); + + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [patchFor(file, 'public class MainApplication {}', updated)], + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('accepts the preserved Kotlin conditional local fallback post-apply', () => { + const projectRoot = createTempProjectDir(); + const file = 'android/app/src/main/kotlin/com/demo/MainApplication.kt'; + const updated = writeAuthoritativeNativeFile( + projectRoot, + file, + RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + ); + + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [patchFor(file, 'class MainApplication {}', updated)], + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('accepts the preserved Java conditional local fallback post-apply', () => { + const projectRoot = createTempProjectDir(); + const file = 'android/app/src/main/java/com/demo/MainApplication.java'; + const updated = writeAuthoritativeNativeFile( + projectRoot, + file, + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + ); + + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [patchFor(file, 'public class MainApplication {}', updated)], + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it('accepts RN71 Objective-C delegated startup and preserves DEBUG Metro fallback post-apply', () => { + const projectRoot = createTempProjectDir(); + const file = 'ios/Demo/AppDelegate.m'; + const updated = writeAuthoritativeNativeFile( + projectRoot, + file, + RN71_OBJC_APP_DELEGATE, + ); + + expect(() => validateAppliedSetupChanges({ + projectRoot, + projectType: 'bare', + changes: [patchFor(file, RN71_OBJC_APP_DELEGATE, updated)], + originals: new Map([[file, RN71_OBJC_APP_DELEGATE]]), + })).not.toThrow(); + removeTempDir(projectRoot); + }); + + it.each([ + { + label: 'nested Kotlin comment resolver', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: [ + 'package com.demo', + 'class MainApplication {', + ' /* outer /* nested */', + ' import com.bundledrop.BundleDropModule', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' */', + '}', + ].join('\n'), + error: 'Android update', + }, + { + label: 'Kotlin raw multiline string resolver and host decoy', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val documentation = """ "', + ' private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ' override val reactHost: ReactHost by lazy {', + ' getDefaultReactHost(jsBundleFilePath = getJSBundleFile())', + ' }', + ' " """', + '}', + ].join('\n'), + error: 'Android update', + }, + { + label: 'nested Swift comment resolver', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' /* outer /* nested */', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' */', + '}', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'Swift multiline string resolver decoy', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' let documentation = """ "', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + ' " """', + '}', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'conditional Android Release bypass', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: modernPostApplyProbe( + 'private fun getJSBundleFile(): String? { return if (useOta) BundleDropModule.resolveJSBundleFile(this, null) else "/android_asset/index.android.bundle" }', + ), + error: 'Android update', + }, + { + label: 'near-match Android resolver', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: modernPostApplyProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFileForTests(this, null)', + ), + error: 'Android update', + }, + { + label: 'wrong Android resolver context', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: modernPostApplyProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(42, null)', + ), + error: 'Android update', + }, + { + label: 'transformed Java resolver return', + file: 'android/app/src/main/java/demo/MainApplication.java', + content: RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + ' );', + ' ).trim();', + ), + error: 'Android update', + }, + { + label: 'Java fallback ternary with a statement branch', + file: 'android/app/src/main/java/demo/MainApplication.java', + content: RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION.replace( + '? selectEnterpriseBundle()', + '? return selectEnterpriseBundle()', + ), + error: 'Android update', + }, + { + label: 'Java resolver with Kotlin non-null suffix', + file: 'android/app/src/main/java/demo/MainApplication.java', + content: RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace(' );', ' )!!;'), + error: 'Android update', + }, + { + label: 'Java anonymous host with wrong this receiver', + file: 'android/app/src/main/java/demo/MainApplication.java', + content: RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + 'getApplicationContext(),', + 'this,', + ), + error: 'Android update', + }, + { + label: 'Kotlin anonymous host with Java receiver syntax', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace( + 'this@MainApplication,', + 'MainApplication.this,', + ), + error: 'Android update', + }, + { + label: 'non-null Kotlin resolver without unwrap', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace(' )!!', ' )'), + error: 'Android update', + }, + { + label: 'mismatched Android import alias', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: modernPostApplyProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ).replace('import com.bundledrop.BundleDropModule', 'import com.bundledrop.BundleDropModule as BDM'), + error: 'Android update', + }, + { + label: 'early modern Android host bypass', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: modernPostApplyProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + 'if (useCustom) return@lazy customReactHost', + ), + error: 'Android update', + }, + { + label: 'conditional Swift Release bypass', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + ' return useOta ? BundleDropLocator.bundleURL() : Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'RN85 sourceURL bypass', + file: 'ios/Demo/AppDelegate.swift', + content: RN85_SWIFT_APP_DELEGATE.replace( + ' self.bundleURL()', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ), + error: 'iOS update', + }, + { + label: 'direct Swift sourceURL bypass', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' override func sourceURL(for bridge: RCTBridge) -> URL? {', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'ignored Objective-C delegation', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' [self bundleURL];', + ' return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];', + '}', + '@end', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'parameterized Kotlin resolver', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile(test: Boolean) =', + ' BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'), + error: 'Android update', + }, + { + label: 'parameterized Java resolver', + file: 'android/app/src/main/java/demo/MainApplication.java', + content: [ + 'import com.bundledrop.BundleDropModule;', + 'public class MainApplication {', + ' public String getJSBundleFile(boolean test) {', + ' return BundleDropModule.resolveJSBundleFile(this, null);', + ' }', + '}', + ].join('\n'), + error: 'Android update', + }, + { + label: 'parameterized Swift resolver', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL(test: Bool) -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'unconnected Swift factory delegate', + file: 'ios/Demo/AppDelegate.swift', + content: RN85_SWIFT_APP_DELEGATE.replace( + 'RCTReactNativeFactory(delegate: delegate)', + 'RCTReactNativeFactory(delegate: ReactNativeDelegate())', + ), + error: 'iOS update', + }, + { + label: 'Objective-C AppDelegate category', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate (BundleDrop)', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'duplicate Objective-C AppDelegate implementation', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + '@implementation AppDelegate', + '@end', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'dead modern Kotlin host connection', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: MODERN_KOTLIN_MAIN_APPLICATION + .replace('jsBundleFilePath = getJSBundleFile(),', 'isHermesEnabled = true,') + .replace( + '\n}', + '\n fun deadHost() = getDefaultReactHost(jsBundleFilePath = getJSBundleFile())\n}', + ), + error: 'Android update', + }, + { + label: 'unused legacy Kotlin host', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace( + 'override val reactNativeHost', + 'val unusedHost', + ), + error: 'Android update', + }, + { + label: 'nested legacy Kotlin authority getter', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val actualHost: ReactNativeHost = object : DefaultReactNativeHost(this) {}', + ' override val reactNativeHost: ReactNativeHost get() = actualHost', + ' class Helper {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'), + error: 'Android update', + }, + { + label: 'anonymous legacy Kotlin authority getter', + file: 'android/app/src/main/java/demo/MainApplication.kt', + content: [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val deadApplication = object : ReactApplication {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'), + error: 'Android update', + }, + { + label: 'dead Swift AppDelegate beside the real principal', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + '@main class RealAppDelegate: UIResponder, UIApplicationDelegate {}', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + error: 'entrypoint authority', + }, + { + label: 'DEBUG-only Swift resolver', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + '#if DEBUG', + ' return BundleDropLocator.bundleURL()', + '#else', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + '#endif', + ' }', + '}', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'ignored Objective-C resolver result', + file: 'ios/Demo/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { [BundleDropLocator bundleURL]; return nil; }', + '@end', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'cross-statement Swift resolver result', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + ' let otaURL = Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' BundleDropLocator.bundleURL()', + ' return otaURL', + ' }', + '}', + ].join('\n'), + error: 'iOS update', + }, + { + label: 'DEBUG-only mixed Swift preprocessor branch', + file: 'ios/Demo/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + '#if FEATURE_PREVIEW', + ' return Bundle.main.url(forResource: "preview", withExtension: "jsbundle")', + '#elseif DEBUG', + ' return BundleDropLocator.bundleURL()', + '#else', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + '#endif', + ' }', + '}', + ].join('\n'), + error: 'iOS update', + }, + ])('rejects a $label during post-apply validation', ({ file, content, error }) => { + const projectRoot = createTempProjectDir(); + const authoritativeContent = writeAuthoritativeNativeFile(projectRoot, file, content); + const change = patchFor(file, 'class Placeholder {}', authoritativeContent); + + expect(() => validateAppliedSetupChanges({ projectRoot, projectType: 'bare', changes: [change] })) + .toThrow(error); removeTempDir(projectRoot); }); }); diff --git a/src/tests/CLI/scripts/bare-metro-config.test.ts b/src/tests/CLI/scripts/bare-metro-config.test.ts index afcfd1a..770cdef 100644 --- a/src/tests/CLI/scripts/bare-metro-config.test.ts +++ b/src/tests/CLI/scripts/bare-metro-config.test.ts @@ -22,9 +22,8 @@ describe('CLI/scripts/bare-metro-config', () => { file: 'metro.config.js', original: null, })); - expect(change?.updated).toContain( - "'bundle-drop-config': path.resolve(__dirname, 'bundle.drop.config.js')", - ); + expect(change?.updated).toContain("require('@gfean/react-native-bundle-drop/metro')"); + expect(change?.updated).toContain('withBundleDrop(config, { projectRoot: __dirname })'); expect(change?.updated).toContain('getDefaultConfig'); }); @@ -37,17 +36,94 @@ describe('CLI/scripts/bare-metro-config', () => { expect(change?.original).toBe(original); expect(change?.updated).toContain("sourceExts: ['js', 'ts']"); - expect(change?.updated).toContain( - "'bundle-drop-config': path.resolve(__dirname, 'bundle.drop.config.js')", - ); + expect(change?.updated).toContain('withBundleDrop(module.exports || {}'); }); - it('does not plan a duplicate alias', () => { + it('migrates a legacy direct alias', () => { fs.writeFileSync( path.join(projectRoot, 'metro.config.js'), "module.exports = { resolver: { extraNodeModules: { 'bundle-drop-config': true } } };\n", ); + const change = planBareMetroConfig(projectRoot); + expect(change?.reason).toContain('Migrate the legacy'); + expect(change?.updated).toContain('withBundleDrop(module.exports || {}'); + }); + + it('does not plan a duplicate package wrapper', () => { + fs.writeFileSync( + path.join(projectRoot, 'metro.config.js'), + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\nmodule.exports = withBundleDrop({});\n", + 'utf8', + ); + expect(planBareMetroConfig(projectRoot)).toBeNull(); }); + + it('uses the single existing CommonJS variant and rejects competing configs', () => { + fs.writeFileSync(path.join(projectRoot, 'metro.config.cjs'), 'module.exports = {};\n'); + + expect(planBareMetroConfig(projectRoot)).toEqual(expect.objectContaining({ + file: 'metro.config.cjs', + })); + + fs.writeFileSync(path.join(projectRoot, 'metro.config.js'), 'module.exports = {};\n'); + expect(() => planBareMetroConfig(projectRoot)).toThrow('Multiple Metro config files'); + }); + + it('ignores wrapper text in comments and strings', () => { + const metroPath = path.join(projectRoot, 'metro.config.js'); + for (const decoy of [ + '// withBundleDrop(config)\nmodule.exports = config;\n', + 'const note = "withBundleDrop(config)";\nmodule.exports = config;\n', + ]) { + fs.writeFileSync(metroPath, decoy); + const change = planBareMetroConfig(projectRoot); + expect(change).not.toBeNull(); + expect(change?.updated).toContain('module.exports = withBundleDrop(module.exports || {}'); + } + }); + + it.each([ + [ + 'aliased package export', + "const { withBundleDrop: other } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nmodule.exports = withBundleDrop(config);\n', + ], + [ + 'unrelated package export renamed to the wrapper', + "const { other: withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nmodule.exports = withBundleDrop(config);\n', + ], + [ + 'nested dead export', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nfunction dead() { module.exports = withBundleDrop(config); }\n' + + 'module.exports = config;\n', + ], + [ + 'zero-argument wrapper', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop();\n', + ], + [ + 'unsupported base value', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = undefined;\nmodule.exports = withBundleDrop(config);\n', + ], + ])('fails closed on a non-authoritative %s', (_label, content) => { + fs.writeFileSync(path.join(projectRoot, 'metro.config.js'), content); + + expect(() => planBareMetroConfig(projectRoot)).toThrow( + 'contains a non-authoritative withBundleDrop reference', + ); + }); + + it('creates a CommonJS config safely for ESM packages and fails closed on ESM edits', () => { + fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ type: 'module' })); + expect(planBareMetroConfig(projectRoot)?.file).toBe('metro.config.cjs'); + + fs.writeFileSync(path.join(projectRoot, 'metro.config.js'), 'export default {};\n'); + expect(() => planBareMetroConfig(projectRoot)).toThrow('will not append CommonJS'); + }); }); diff --git a/src/tests/CLI/scripts/doctor.test.ts b/src/tests/CLI/scripts/doctor.test.ts index 70c31c2..3457573 100644 --- a/src/tests/CLI/scripts/doctor.test.ts +++ b/src/tests/CLI/scripts/doctor.test.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; +import { execFileSync } from 'child_process'; const mockResolveOfficialEasBuildIdentity = jest.fn( async ({ receiptIdentity }: { receiptIdentity: ExpoBuildIdentity }) => receiptIdentity, @@ -17,6 +18,17 @@ import { inspectProject, runDoctor } from '../../../CLI/scripts/doctor'; import type { ExpoBuildIdentityReceipt } from '../../../metro'; import { resolveExpoIntegrationGeneration } from '../../../expo/buildReceipt'; import { createExpoFixture, removeFixture } from '../../expo/fixture'; +import { + MODERN_KOTLIN_MAIN_APPLICATION, + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION, + RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_KOTLIN_MAIN_APPLICATION, + RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION, + RN71_OBJC_APP_DELEGATE, + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + RN85_SWIFT_APP_DELEGATE, +} from '../../fixtures/rn85SwiftAppDelegate'; import { createTempProjectDir, removeTempDir } from '../../utils/tempDir'; const PACKAGE_NAME = '@gfean/react-native-bundle-drop'; @@ -52,9 +64,24 @@ describe('CLI/scripts/doctor', () => { bundleDropRuntimeVersion: runtimeVersion, }); projects.push({ root: projectRoot, expo: true }); + const bundleConfigPath = path.join(projectRoot, 'bundle.drop.config.js'); + const bundleConfig = JSON.parse( + fs.readFileSync(bundleConfigPath, 'utf8').match(/module\.exports = (.*);/)![1], + ); + fs.writeFileSync( + bundleConfigPath, + `module.exports = ${JSON.stringify({ + ...bundleConfig, + serverUrl: 'https://api.example.com', + org: { slug: 'org' }, + project: { name: 'Fixture', slug: 'fixture' }, + })};\n`, + ); fs.writeFileSync( path.join(projectRoot, 'metro.config.js'), - "const { getDefaultConfig } = require('expo/metro-config');\nmodule.exports = withBundleDropExpo(getDefaultConfig(__dirname));\n", + "const { getDefaultConfig } = require('expo/metro-config');\n" + + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDropExpo(getDefaultConfig(__dirname));\n', ); return projectRoot; }; @@ -99,11 +126,11 @@ describe('CLI/scripts/doctor', () => { ); fs.writeFileSync( path.join(projectRoot, 'bundle.drop.config.js'), - "module.exports = { projectType: 'bare', runtimeVersion: { ios: 'ios-runtime', android: 'android-runtime' } };", + "module.exports = { projectType: 'bare', serverUrl: 'https://api.example.com', org: { slug: 'org' }, project: { name: 'Fixture', slug: 'fixture' }, runtimeVersion: { ios: 'ios-runtime', android: 'android-runtime' } };", ); fs.writeFileSync( path.join(projectRoot, 'metro.config.cjs'), - "module.exports = { resolver: { extraNodeModules: { 'bundle-drop-config': 'config' } } };", + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro'); module.exports = withBundleDrop({});", ); const packageRoot = path.join(projectRoot, 'node_modules', '@gfean', 'react-native-bundle-drop'); @@ -125,7 +152,11 @@ describe('CLI/scripts/doctor', () => { fs.mkdirSync(path.dirname(androidEntrypoint), { recursive: true }); fs.writeFileSync( androidEntrypoint, - 'import com.bundledrop.BundleDropModule\nclass MainApplication { fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null) }', + RN71_KOTLIN_MAIN_APPLICATION, + ); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'), + '', ); const androidAutolinking = path.join( projectRoot, @@ -141,12 +172,70 @@ describe('CLI/scripts/doctor', () => { fs.mkdirSync(path.dirname(iosEntrypoint), { recursive: true }); fs.writeFileSync( iosEntrypoint, - 'import BundleDrop\nclass AppDelegate { func bundleURL() -> URL? { BundleDropLocator.bundleURL() } }', + [ + 'import BundleDrop', + '@main class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), ); fs.writeFileSync(path.join(projectRoot, 'ios/Podfile.lock'), 'PODS:\n - BundleDrop (0.4.3)\n'); return projectRoot; }; + const writeDoctorNativeProbe = ( + projectRoot: string, + relativePath: string, + source: string, + ) => { + let authoritativeSource = source; + if (relativePath.includes('/MainApplication.')) { + if (!/(?:^|\n)\s*package\s+/.test(authoritativeSource)) { + authoritativeSource = relativePath.endsWith('.java') + ? `package com.demo;\n${authoritativeSource}` + : `package com.demo\n${authoritativeSource}`; + } + if (relativePath.endsWith('.java')) { + fs.rmSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.kt'), + { force: true }, + ); + } + } else if (relativePath.endsWith('AppDelegate.swift')) { + if (!/@(?:main|UIApplicationMain)\b/.test(authoritativeSource)) { + authoritativeSource = authoritativeSource.replace( + /\bclass\s+AppDelegate\b/, + '@main class AppDelegate', + ); + } + } else if (/AppDelegate\.m{1,2}$/.test(relativePath)) { + fs.rmSync(path.join(projectRoot, 'ios/Fixture/AppDelegate.swift'), { force: true }); + fs.writeFileSync( + path.join(projectRoot, 'ios/Fixture/main.m'), + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"AppDelegate"); }', + ); + } + const entrypoint = path.join(projectRoot, relativePath); + fs.mkdirSync(path.dirname(entrypoint), { recursive: true }); + fs.writeFileSync(entrypoint, authoritativeSource); + }; + + const modernDoctorProbe = (resolver: string, lazyPrefix = '') => [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication: Application(), ReactApplication {', + ` ${resolver}`, + ' override val reactHost: ReactHost by lazy {', + ` ${lazyPrefix}`, + ' getDefaultReactHost(', + ' context = applicationContext,', + ' packages = PackageList(this).packages,', + ' jsBundleFilePath = getJSBundleFile(),', + ' )', + ' }', + '}', + ].join('\n'); + const writeReceipt = (projectRoot: string, receipt: ExpoBuildIdentityReceipt) => { const receiptPath = path.join(projectRoot, '.bundle-drop', 'build-identity.json'); fs.mkdirSync(path.dirname(receiptPath), { recursive: true }); @@ -554,6 +643,241 @@ describe('CLI/scripts/doctor', () => { ])); }); + it('rejects dead or competing Metro wrapper authority for bare and Expo projects', async () => { + const bareRoot = createBareProject(); + fs.writeFileSync( + path.join(bareRoot, 'metro.config.cjs'), + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const ignored = withBundleDrop(config);\nmodule.exports = config;\n', + ); + let result = await inspectProject({ cwd: bareRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'Bare Metro alias', status: 'error' }), + ])); + + fs.writeFileSync(path.join(bareRoot, 'metro.config.js'), 'module.exports = {};\n'); + result = await inspectProject({ cwd: bareRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'Bare Metro alias', + status: 'error', + message: expect.stringContaining('Multiple Metro config files'), + }), + ])); + + const expoRoot = createExpoProject({ + name: 'Fixture', + slug: 'fixture', + plugins: [PACKAGE_NAME], + }, { ios: 'ios-runtime', android: 'android-runtime' }); + fs.writeFileSync( + path.join(expoRoot, 'metro.config.js'), + "const { getDefaultConfig } = require('expo/metro-config');\n" + + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const ignored = withBundleDropExpo(getDefaultConfig(__dirname));\n' + + 'module.exports = getDefaultConfig(__dirname);\n', + ); + const expoResult = await inspectProject({ cwd: expoRoot, projectType: 'expo' }); + expect(expoResult.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'Expo Metro wrapper', status: 'error' }), + ])); + }); + + it.each([ + [ + 'aliased package export', + "const { withBundleDrop: other } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nmodule.exports = withBundleDrop(config);\n', + ], + [ + 'renamed unrelated package export', + "const { other: withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nmodule.exports = withBundleDrop(config);\n', + ], + [ + 'nested dead export', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nfunction dead() { module.exports = withBundleDrop(config); }\n' + + 'module.exports = config;\n', + ], + [ + 'zero-argument wrapper', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop();\n', + ], + [ + 'unsupported base value', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = undefined;\nmodule.exports = withBundleDrop(config);\n', + ], + ])('rejects a bare Metro %s', async (_label, content) => { + const projectRoot = createBareProject(); + fs.writeFileSync(path.join(projectRoot, 'metro.config.cjs'), content); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Bare Metro alias', + status: 'error', + })); + }); + + it.each([ + [ + 'aliased package export', + "const { withBundleDropExpo: other } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nmodule.exports = withBundleDropExpo(config);\n', + ], + [ + 'nested dead export', + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nfunction dead() { module.exports = withBundleDropExpo(config); }\n' + + 'module.exports = config;\n', + ], + [ + 'zero-argument wrapper', + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDropExpo();\n', + ], + [ + 'unsupported base value', + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = undefined;\nmodule.exports = withBundleDropExpo(config);\n', + ], + ])('rejects an Expo Metro %s', async (_label, content) => { + const projectRoot = createExpoProject({ + name: 'Fixture', + slug: 'fixture', + plugins: [PACKAGE_NAME], + }, { ios: 'ios-runtime', android: 'android-runtime' }); + fs.writeFileSync(path.join(projectRoot, 'metro.config.js'), content); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'expo' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Expo Metro wrapper', + status: 'error', + })); + }); + + it('reports generated v2 bootstrap and ignored inline migration states', async () => { + const projectRoot = createBareProject(); + fs.mkdirSync(path.join(projectRoot, '.bundle-drop'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'), + JSON.stringify({ + schemaVersion: 1, + project: { + serverUrl: 'https://api.example.com', + orgSlug: 'org', + projectSlug: 'fixture', + projectId: 'project-id-1', + orgId: 'org-id-1', + }, + runtimeDelivery: { + manifestBaseUrl: 'https://manifests.example.com', + manifestAccessId: `mft_${'A'.repeat(43)}`, + publicKeys: { + key: { + kty: 'EC', + crv: 'P-256', + x: 'd-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM', + y: '_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI', + }, + }, + }, + }), + ); + + let result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'pass', + })); + + const malformedBootstrapPath = path.join( + projectRoot, + '.bundle-drop/runtime-delivery.generated.json', + ); + const malformedBootstrap = JSON.parse(fs.readFileSync(malformedBootstrapPath, 'utf8')); + delete malformedBootstrap.project.orgId; + fs.writeFileSync(malformedBootstrapPath, JSON.stringify(malformedBootstrap)); + result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'error', + message: expect.stringContaining('invalid stable project identity'), + })); + + const legacyRoot = createBareProject(); + fs.writeFileSync( + path.join(legacyRoot, 'bundle.drop.config.js'), + "module.exports = { projectType: 'bare', serverUrl: 'https://api.example.com', org: { slug: 'org' }, project: { name: 'Fixture', slug: 'fixture' }, runtimeVersion: { ios: 'ios-runtime', android: 'android-runtime' }, runtimeDelivery: { mode: 'v1' } };", + ); + result = await inspectProject({ cwd: legacyRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'warning', + message: expect.stringContaining('Stale inline runtime delivery config is ignored'), + })); + }); + + it('rejects ignored bootstraps and warns until a valid bootstrap is committed', async () => { + const projectRoot = createBareProject(); + const bootstrapPath = path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync( + bootstrapPath, + JSON.stringify({ + schemaVersion: 1, + project: { + serverUrl: 'https://api.example.com', + orgSlug: 'org', + projectSlug: 'fixture', + }, + runtimeDelivery: { + manifestBaseUrl: 'https://manifests.example.com', + manifestAccessId: `mft_${'A'.repeat(43)}`, + publicKeys: { + key: { + kty: 'EC', + crv: 'P-256', + x: 'd-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM', + y: '_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI', + }, + }, + }, + }), + ); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + fs.writeFileSync(path.join(projectRoot, '.gitignore'), '.bundle-drop/\n'); + + let result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'error', + message: expect.stringContaining('ignored by Git'), + })); + + fs.writeFileSync( + path.join(projectRoot, '.gitignore'), + '.bundle-drop/*\n!.bundle-drop/runtime-delivery.generated.json\n', + ); + result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'warning', + message: expect.stringContaining('not committed yet'), + })); + + execFileSync('git', ['add', '.gitignore', '.bundle-drop/runtime-delivery.generated.json'], { + cwd: projectRoot, + }); + result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'pass', + })); + }); + it('reports bare runtime authority, startup wiring, package metadata, and stale native linking', async () => { const projectRoot = createBareProject(); const packageRoot = path.join(projectRoot, 'node_modules', '@gfean', 'react-native-bundle-drop'); @@ -594,13 +918,17 @@ describe('CLI/scripts/doctor', () => { fs.unlinkSync(path.join(projectRoot, 'ios/Podfile.lock')); fs.writeFileSync( path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.kt'), - 'import com.bundledrop.BundleDropNativePaths\nclass MainApplication { val path = BundleDropNativePaths.getDownloadedBundlePath(this) }', + MODERN_KOTLIN_MAIN_APPLICATION, ); fs.unlinkSync(path.join(projectRoot, 'ios/Fixture/AppDelegate.swift')); fs.writeFileSync( path.join(projectRoot, 'ios/Fixture/AppDelegate.mm'), '#import \n@implementation AppDelegate\n- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }\n@end', ); + fs.writeFileSync( + path.join(projectRoot, 'ios/Fixture/main.m'), + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"AppDelegate"); }', + ); const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); expect(result.checks).toEqual(expect.arrayContaining([ @@ -611,6 +939,148 @@ describe('CLI/scripts/doctor', () => { ])); }); + it('accepts the archived RN85 NativePaths host and connected Swift factory delegate', async () => { + const projectRoot = createBareProject(); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.kt'), + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + ); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'), + '', + ); + fs.writeFileSync( + path.join(projectRoot, 'ios/Fixture/AppDelegate.swift'), + RN85_SWIFT_APP_DELEGATE, + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'android OTA startup ownership', status: 'pass' }), + expect.objectContaining({ name: 'ios OTA startup ownership', status: 'pass' }), + ])); + + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.kt'), + RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION, + ); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'), + '', + ); + const overrideResult = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(overrideResult.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'android OTA startup ownership', status: 'pass' }), + ])); + }); + + it('accepts RN71 Objective-C delegation with DEBUG Metro and Release embedded fallbacks', async () => { + const projectRoot = createBareProject(); + fs.unlinkSync(path.join(projectRoot, 'ios/Fixture/AppDelegate.swift')); + fs.writeFileSync(path.join(projectRoot, 'ios/Fixture/AppDelegate.m'), RN71_OBJC_APP_DELEGATE); + fs.writeFileSync( + path.join(projectRoot, 'ios/Fixture/main.m'), + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"AppDelegate"); }', + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'ios OTA startup ownership', + status: 'pass', + })); + }); + + it('accepts the documented RN71 Java multiline local fallback', async () => { + const projectRoot = createBareProject(); + const kotlinEntrypoint = path.join( + projectRoot, + 'android/app/src/main/java/com/fixture/MainApplication.kt', + ); + fs.unlinkSync(kotlinEntrypoint); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.java'), + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION, + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'android OTA startup ownership', + status: 'pass', + })); + }); + + it('accepts the preserved RN71 Kotlin conditional local fallback', async () => { + const projectRoot = createBareProject(); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.kt'), + RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'android OTA startup ownership', + status: 'pass', + })); + }); + + it('accepts the preserved RN71 Java conditional local fallback', async () => { + const projectRoot = createBareProject(); + const kotlinEntrypoint = path.join( + projectRoot, + 'android/app/src/main/java/com/fixture/MainApplication.kt', + ); + fs.unlinkSync(kotlinEntrypoint); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.java'), + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'android OTA startup ownership', + status: 'pass', + })); + }); + + it.each([ + { + label: 'Android', + file: 'android/app/src/main/java/com/duplicate/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'), + check: 'android OTA startup ownership', + }, + { + label: 'iOS', + file: 'ios/Duplicate/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + check: 'ios OTA startup ownership', + }, + ])('rejects duplicate integrated $label entrypoints', async ({ file, content, check }) => { + const projectRoot = createBareProject(); + const duplicatePath = path.join(projectRoot, file); + fs.mkdirSync(path.dirname(duplicatePath), { recursive: true }); + fs.writeFileSync(duplicatePath, content); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: check, + status: 'error', + message: expect.stringContaining('Multiple'), + }), + ])); + }); + it('rejects bare startup calls that import nonexistent native modules', async () => { const projectRoot = createBareProject(); fs.writeFileSync( @@ -629,6 +1099,437 @@ describe('CLI/scripts/doctor', () => { ])); }); + it('rejects native entrypoints not selected by the platform principal', async () => { + const projectRoot = createBareProject(); + const manifestPath = path.join(projectRoot, 'android/app/src/main/AndroidManifest.xml'); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync( + manifestPath, + '', + ); + fs.unlinkSync(path.join(projectRoot, 'ios/Fixture/AppDelegate.swift')); + fs.writeFileSync(path.join(projectRoot, 'ios/Fixture/AppDelegate.m'), [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n')); + fs.writeFileSync( + path.join(projectRoot, 'ios/Fixture/main.m'), + 'int main(int argc, char **argv) { return UIApplicationMain(argc, argv, nil, @"OtherDelegate"); }', + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'android OTA startup ownership', status: 'error' }), + expect.objectContaining({ name: 'ios OTA startup ownership', status: 'error' }), + ])); + }); + + it('rejects a Swift principal decoy in a string beside the real app principal', async () => { + const projectRoot = createBareProject(); + fs.writeFileSync(path.join(projectRoot, 'ios/Fixture/AppDelegate.swift'), [ + 'import BundleDrop', + 'let documentation = "@main class AppDelegate"', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n')); + fs.writeFileSync( + path.join(projectRoot, 'ios/Fixture/RealApp.swift'), + '@main struct RealApp { static func main() {} }', + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'ios OTA startup ownership', + status: 'error', + message: expect.stringContaining('principal annotation'), + })); + }); + + it('rejects resolver calls in dead Android and iOS helper methods', async () => { + const projectRoot = createBareProject(); + const androidEntrypoint = path.join( + projectRoot, + 'android/app/src/main/java/com/fixture/MainApplication.kt', + ); + fs.writeFileSync(androidEntrypoint, [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile(): String? = null', + ' fun getJSBundleFileForTests() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n')); + + let result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'android OTA startup ownership', status: 'error' }), + ])); + + fs.writeFileSync(androidEntrypoint, [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n')); + fs.writeFileSync( + path.join(projectRoot, 'ios/Fixture/AppDelegate.swift'), + [ + 'import BundleDrop', + 'class AppDelegate { func bundleURL() -> URL? { nil } }', + 'class Helper { func bundleURL() -> URL? { BundleDropLocator.bundleURL() } }', + ].join('\n'), + ); + + result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'ios OTA startup ownership', status: 'error' }), + ])); + }); + + it.each([ + { + label: 'dead modern Android host connection', + file: 'android/app/src/main/java/com/fixture/MainApplication.kt', + content: MODERN_KOTLIN_MAIN_APPLICATION + .replace('jsBundleFilePath = getJSBundleFile(),', 'isHermesEnabled = true,') + .replace( + '\n}', + '\n fun deadHost() = getDefaultReactHost(jsBundleFilePath = getJSBundleFile())\n}', + ), + check: 'android OTA startup ownership', + }, + { + label: 'unused legacy Android host', + file: 'android/app/src/main/java/com/fixture/MainApplication.kt', + content: RN71_KOTLIN_MAIN_APPLICATION.replace( + 'override val reactNativeHost', + 'val unusedHost', + ), + check: 'android OTA startup ownership', + }, + { + label: 'nested legacy Android authority getter', + file: 'android/app/src/main/java/com/fixture/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val actualHost: ReactNativeHost = object : DefaultReactNativeHost(this) {}', + ' override val reactNativeHost: ReactNativeHost get() = actualHost', + ' class Helper {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'), + check: 'android OTA startup ownership', + }, + { + label: 'anonymous legacy Android authority getter', + file: 'android/app/src/main/java/com/fixture/MainApplication.kt', + content: [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val deadApplication = object : ReactApplication {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'), + check: 'android OTA startup ownership', + }, + { + label: 'dead Swift AppDelegate beside the real principal', + file: 'ios/Fixture/AppDelegate.swift', + content: [ + 'import BundleDrop', + '@main class RealAppDelegate: UIResponder, UIApplicationDelegate {}', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + check: 'ios OTA startup ownership', + }, + { + label: 'DEBUG-only Swift resolver', + file: 'ios/Fixture/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + '#if DEBUG', + ' return BundleDropLocator.bundleURL()', + '#else', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + '#endif', + ' }', + '}', + ].join('\n'), + check: 'ios OTA startup ownership', + }, + { + label: 'ignored Objective-C resolver result', + file: 'ios/Fixture/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { [BundleDropLocator bundleURL]; return nil; }', + '@end', + ].join('\n'), + check: 'ios OTA startup ownership', + }, + ])('rejects $label', async ({ file, content, check }) => { + const projectRoot = createBareProject(); + writeDoctorNativeProbe(projectRoot, file, content); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: check, + status: 'error', + })); + }); + + it.each([ + ['nested Kotlin comment resolver', 'android/app/src/main/java/com/fixture/MainApplication.kt', [ + 'package com.demo', + 'class MainApplication {', + ' /* outer /* nested */', + ' import com.bundledrop.BundleDropModule', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' */', + '}', + ].join('\n')], + ['Kotlin raw multiline string resolver and host decoy', 'android/app/src/main/java/com/fixture/MainApplication.kt', [ + 'package com.demo', + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val documentation = """ "', + ' private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ' override val reactHost: ReactHost by lazy {', + ' getDefaultReactHost(jsBundleFilePath = getJSBundleFile())', + ' }', + ' " """', + '}', + ].join('\n')], + ['nested Swift comment resolver', 'ios/Fixture/AppDelegate.swift', [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' /* outer /* nested */', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' */', + '}', + ].join('\n')], + ['Swift multiline string resolver decoy', 'ios/Fixture/AppDelegate.swift', [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' let documentation = """ "', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + ' " """', + '}', + ].join('\n')], + ['conditional Android Release bypass', 'android/app/src/main/java/com/fixture/MainApplication.kt', modernDoctorProbe( + 'private fun getJSBundleFile(): String? { return if (useOta) BundleDropModule.resolveJSBundleFile(this, null) else "/android_asset/index.android.bundle" }', + )], + ['near-match Android resolver', 'android/app/src/main/java/com/fixture/MainApplication.kt', modernDoctorProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFileForTests(this, null)', + )], + ['wrong Android resolver context', 'android/app/src/main/java/com/fixture/MainApplication.kt', modernDoctorProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(42, null)', + )], + ['transformed Java resolver return', 'android/app/src/main/java/com/fixture/MainApplication.java', + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + ' );', + ' ).trim();', + )], + ['Java fallback ternary with a statement branch', 'android/app/src/main/java/com/fixture/MainApplication.java', + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION.replace( + '? selectEnterpriseBundle()', + '? return selectEnterpriseBundle()', + )], + ['Java resolver with Kotlin non-null suffix', 'android/app/src/main/java/com/fixture/MainApplication.java', + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace(' );', ' )!!;')], + ['Java anonymous host with wrong this receiver', 'android/app/src/main/java/com/fixture/MainApplication.java', + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace('getApplicationContext(),', 'this,')], + ['Kotlin anonymous host with Java receiver syntax', 'android/app/src/main/java/com/fixture/MainApplication.kt', + RN71_KOTLIN_MAIN_APPLICATION.replace('this@MainApplication,', 'MainApplication.this,')], + ['non-null Kotlin resolver without unwrap', 'android/app/src/main/java/com/fixture/MainApplication.kt', + RN71_KOTLIN_MAIN_APPLICATION.replace(' )!!', ' )')], + ['mismatched Android import alias', 'android/app/src/main/java/com/fixture/MainApplication.kt', modernDoctorProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ).replace('import com.bundledrop.BundleDropModule', 'import com.bundledrop.BundleDropModule as BDM')], + ['early modern Android host bypass', 'android/app/src/main/java/com/fixture/MainApplication.kt', modernDoctorProbe( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + 'if (useCustom) return@lazy customReactHost', + )], + ['conditional Swift Release bypass', 'ios/Fixture/AppDelegate.swift', [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + ' return useOta ? BundleDropLocator.bundleURL() : Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n')], + ['RN85 sourceURL bypass', 'ios/Fixture/AppDelegate.swift', RN85_SWIFT_APP_DELEGATE.replace( + ' self.bundleURL()', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + )], + ['direct Swift sourceURL bypass', 'ios/Fixture/AppDelegate.swift', [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' override func sourceURL(for bridge: RCTBridge) -> URL? {', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n')], + ['ignored Objective-C delegation', 'ios/Fixture/AppDelegate.mm', [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' [self bundleURL];', + ' return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];', + '}', + '@end', + ].join('\n')], + ])('rejects reviewer probe: %s', async (_label, file, content) => { + const projectRoot = createBareProject(); + writeDoctorNativeProbe(projectRoot, file, content); + + const platform = file.startsWith('android/') ? 'android' : 'ios'; + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: `${platform} OTA startup ownership`, + status: 'error', + })); + }); + + it('rejects a parameterized Android onCreate overload as the startup lifecycle', async () => { + const projectRoot = createBareProject(); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.kt'), + [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' fun onCreate(test: Boolean) { super.onCreate(); loadReactNative(this) }', + '}', + ].join('\n'), + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'android OTA startup ownership', status: 'error' }), + ])); + }); + + it('rejects Bundle Drop and CodePush native co-authority', async () => { + const projectRoot = createBareProject(); + fs.writeFileSync( + path.join(projectRoot, 'android/app/src/main/java/com/fixture/MainApplication.kt'), + [ + 'import com.bundledrop.BundleDropModule', + 'import com.microsoft.codepush.react.CodePush as LegacyCodePush', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(', + ' this, LegacyCodePush.getJSBundleFile(),', + ' )', + '}', + ].join('\n'), + ); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'android OTA startup ownership', status: 'error' }), + ])); + }); + + it.each([ + { + label: 'parameterized Kotlin resolver', + file: 'android/app/src/main/java/com/fixture/MainApplication.kt', + content: [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile(test: Boolean) =', + ' BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'), + check: 'android OTA startup ownership', + }, + { + label: 'parameterized Java resolver', + file: 'android/app/src/main/java/com/fixture/MainApplication.java', + content: [ + 'import com.bundledrop.BundleDropModule;', + 'public class MainApplication {', + ' public String getJSBundleFile(boolean test) {', + ' return BundleDropModule.resolveJSBundleFile(this, null);', + ' }', + '}', + ].join('\n'), + check: 'android OTA startup ownership', + }, + { + label: 'parameterized Swift resolver', + file: 'ios/Fixture/AppDelegate.swift', + content: [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL(test: Bool) -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), + check: 'ios OTA startup ownership', + }, + { + label: 'unconnected Swift factory delegate', + file: 'ios/Fixture/AppDelegate.swift', + content: RN85_SWIFT_APP_DELEGATE.replace( + 'RCTReactNativeFactory(delegate: delegate)', + 'RCTReactNativeFactory(delegate: ReactNativeDelegate())', + ), + check: 'ios OTA startup ownership', + }, + { + label: 'Objective-C AppDelegate category', + file: 'ios/Fixture/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate (BundleDrop)', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'), + check: 'ios OTA startup ownership', + }, + { + label: 'duplicate Objective-C AppDelegate implementation', + file: 'ios/Fixture/AppDelegate.mm', + content: [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + '@implementation AppDelegate', + '@end', + ].join('\n'), + check: 'ios OTA startup ownership', + }, + ])('rejects a $label decoy', async ({ file, content, check }) => { + const projectRoot = createBareProject(); + writeDoctorNativeProbe(projectRoot, file, content); + + const result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: check, status: 'error' }), + ])); + }); + it('reports a missing installed package and malformed bare runtime config', async () => { const projectRoot = createBareProject(); fs.rmSync( diff --git a/src/tests/CLI/scripts/expo/configure-expo.test.ts b/src/tests/CLI/scripts/expo/configure-expo.test.ts index 49709f9..eb34531 100644 --- a/src/tests/CLI/scripts/expo/configure-expo.test.ts +++ b/src/tests/CLI/scripts/expo/configure-expo.test.ts @@ -111,12 +111,87 @@ describe('CLI/scripts/expo/configure-expo', () => { "projectType: 'expo'", ); expect(fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8')).toBe( - 'node_modules\n.bundle-drop/\n', + 'node_modules\n\n' + + '# Bundle Drop: commit the public trust bootstrap; ignore generated runtime artifacts.\n' + + '!.bundle-drop/\n.bundle-drop/*\n' + + '!.bundle-drop/runtime-delivery.generated.json\n', ); expect(fs.existsSync(path.join(projectRoot, '.fingerprintignore'))).toBe(false); expect(planExpoProjectConfiguration({ projectRoot, migrateExpoUpdates: true })).toEqual([]); }); + it('requires one authoritative exported Expo Metro wrapper', () => { + writeStandardBundleConfig(); + write( + 'metro.config.js', + "const { getDefaultConfig } = require('expo/metro-config');\n" + + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const ignored = withBundleDropExpo(getDefaultConfig(__dirname));\n' + + 'module.exports = getDefaultConfig(__dirname);\n', + ); + + expect(() => planExpoProjectConfiguration({ + projectRoot, + migrateExpoUpdates: false, + })).toThrow('contains a non-authoritative withBundleDropExpo reference'); + + write('metro.config.cjs', 'module.exports = {};\n'); + expect(() => planExpoProjectConfiguration({ + projectRoot, + migrateExpoUpdates: false, + })).toThrow('Multiple Metro config files'); + }); + + it.each([ + [ + 'aliased package export', + "const { withBundleDropExpo: other } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nmodule.exports = withBundleDropExpo(config);\n', + ], + [ + 'nested dead export', + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = {};\nfunction dead() { module.exports = withBundleDropExpo(config); }\n' + + 'module.exports = config;\n', + ], + [ + 'zero-argument wrapper', + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDropExpo();\n', + ], + [ + 'unsupported base value', + "const { withBundleDropExpo } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = undefined;\nmodule.exports = withBundleDropExpo(config);\n', + ], + ])('fails closed on a non-authoritative Expo %s', (_label, content) => { + writeStandardBundleConfig(); + write('metro.config.js', content); + + expect(() => planExpoProjectConfiguration({ + projectRoot, + migrateExpoUpdates: false, + })).toThrow('contains a non-authoritative withBundleDropExpo reference'); + }); + + it('uses a CommonJS Metro file for ESM packages and does not append across syntax modes', () => { + writeStandardBundleConfig(); + write('package.json', JSON.stringify({ type: 'module' })); + + expect(planExpoProjectConfiguration({ + projectRoot, + migrateExpoUpdates: false, + })).toEqual(expect.arrayContaining([ + expect.objectContaining({ file: 'metro.config.cjs', original: null }), + ])); + + write('metro.config.mjs', 'export default {};\n'); + expect(() => planExpoProjectConfiguration({ + projectRoot, + migrateExpoUpdates: false, + })).toThrow('will not append CommonJS'); + }); + it('registers the plugin once without removing Expo Updates when migration is declined', () => { const originalPackage = `${JSON.stringify({ dependencies: { expo: '56.0.0', 'expo-updates': '1.0.0' }, @@ -186,7 +261,10 @@ describe('CLI/scripts/expo/configure-expo', () => { original: null, updated: '**/*-gradle-plugin/.kotlin/**/*\n', })); - expect(gitignore).toEqual(expect.objectContaining({ original: null, updated: '.bundle-drop/\n' })); + expect(gitignore).toEqual(expect.objectContaining({ + original: null, + updated: expect.stringContaining('!.bundle-drop/runtime-delivery.generated.json'), + })); }); it('preserves an existing Expo runtime policy', () => { @@ -445,6 +523,130 @@ describe('CLI/scripts/expo/configure-expo', () => { const changes = planExpoProjectConfiguration({ projectRoot, migrateExpoUpdates: false }); expect(() => applyExpoConfigurationChanges({ projectRoot, changes })).not.toThrow(); - expect(fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8')).toBe('.bundle-drop/\n'); + expect(fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8')).toContain( + '!.bundle-drop/runtime-delivery.generated.json', + ); + }); + + it('rejects Expo target, parent, and backup symlink escapes without partial writes', () => { + const outsideRoot = createTempProjectDir(); + const outsideSentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(outsideSentinel, 'outside-safe'); + const originalApp = '{"expo":{}}\n'; + const appPath = write('app.json', originalApp); + try { + fs.symlinkSync(outsideSentinel, `${appPath}.bundledrop-tmp`); + applyExpoConfigurationChanges({ + projectRoot, + changes: [{ + file: 'app.json', + original: originalApp, + updated: '{"expo":{"plugins":[]}}\n', + reason: 'test random temp', + }], + }); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + + fs.rmSync(path.join(projectRoot, '.bundledrop-backup'), { recursive: true }); + fs.symlinkSync(outsideRoot, path.join(projectRoot, '.bundledrop-backup')); + expect(() => applyExpoConfigurationChanges({ projectRoot, changes: [] })) + .toThrow('symlinked or non-directory'); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + + fs.unlinkSync(path.join(projectRoot, '.bundledrop-backup')); + fs.unlinkSync(appPath); + fs.symlinkSync(outsideSentinel, appPath); + expect(() => applyExpoConfigurationChanges({ + projectRoot, + changes: [{ + file: 'app.json', + original: 'outside-safe', + updated: originalApp, + reason: 'reject target symlink', + }], + })).toThrow('symlinked or non-regular'); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + + fs.unlinkSync(appPath); + fs.symlinkSync(outsideRoot, path.join(projectRoot, '.bundle-drop')); + expect(() => applyExpoConfigurationChanges({ projectRoot, changes: [] })) + .toThrow('symlinked or non-directory'); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + } finally { + removeTempDir(outsideRoot); + } + }); + + it('rolls back an earlier Expo write when a later target is a symlink', () => { + const outsideRoot = createTempProjectDir(); + const outsideSentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(outsideSentinel, 'outside-safe'); + const originalApp = '{"expo":{}}\n'; + const appPath = write('app.json', originalApp); + fs.symlinkSync(outsideSentinel, path.join(projectRoot, 'metro.config.js')); + try { + expect(() => applyExpoConfigurationChanges({ + projectRoot, + changes: [ + { + file: 'app.json', + original: originalApp, + updated: '{"expo":{"plugins":[]}}\n', + reason: 'first change', + }, + { + file: 'metro.config.js', + original: 'outside-safe', + updated: 'module.exports = {};\n', + reason: 'symlink escape', + }, + ], + })).toThrow('symlinked or non-regular'); + expect(fs.readFileSync(appPath, 'utf8')).toBe(originalApp); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('outside-safe'); + } finally { + removeTempDir(outsideRoot); + } + }); + + it.each([ + 'app.json', + 'metro.config.js', + 'bundle.drop.config.js', + 'package.json', + '.fingerprintignore', + '.gitignore', + ])('refuses to read a symlinked %s while planning', targetFile => { + const root = createTempProjectDir(); + const outsideRoot = createTempProjectDir(); + const outsideSecret = path.join(outsideRoot, 'secret.txt'); + fs.writeFileSync(outsideSecret, 'planning-secret-sentinel'); + const regularFiles: Record = { + 'app.json': '{"expo":{}}\n', + 'metro.config.js': "module.exports = require('expo/metro-config');\n", + 'bundle.drop.config.js': "module.exports = { runtimeVersion: { source: 'expo' } };\n", + 'package.json': '{"dependencies":{"expo-updates":"1.0.0"}}\n', + '.fingerprintignore': '', + '.gitignore': '', + }; + try { + for (const [file, content] of Object.entries(regularFiles)) { + const filePath = path.join(root, file); + if (file === targetFile) { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + fs.symlinkSync(outsideSecret, filePath); + } + else fs.writeFileSync(filePath, content); + } + + expect(() => planExpoProjectConfiguration({ + projectRoot: root, + migrateExpoUpdates: true, + })).toThrow('symlinked or non-regular transaction target'); + expect(fs.readFileSync(outsideSecret, 'utf8')).toBe('planning-secret-sentinel'); + } finally { + removeTempDir(root); + removeTempDir(outsideRoot); + } }); }); diff --git a/src/tests/CLI/scripts/expo/package-manager.test.ts b/src/tests/CLI/scripts/expo/package-manager.test.ts index 3a1117d..91701a9 100644 --- a/src/tests/CLI/scripts/expo/package-manager.test.ts +++ b/src/tests/CLI/scripts/expo/package-manager.test.ts @@ -8,11 +8,14 @@ jest.mock('child_process', () => ({ })); import { + codePushRemovalCommand, detectPackageManager, expoUpdatesRemovalCommand, + removeCodePushWithPackageManager, removeExpoUpdatesWithPackageManager, restoreDependencyMigration, } from '../../../../CLI/scripts/expo/package-manager'; +import * as safeFileTransaction from '../../../../CLI/scripts/safe-file-transaction'; import { createTempProjectDir, removeTempDir } from '../../../utils/tempDir'; describe('CLI/scripts/expo/package-manager', () => { @@ -24,6 +27,7 @@ describe('CLI/scripts/expo/package-manager', () => { }); afterEach(() => { + jest.restoreAllMocks(); removeTempDir(projectRoot); }); @@ -77,6 +81,15 @@ describe('CLI/scripts/expo/package-manager', () => { expect(expoUpdatesRemovalCommand(manager)).toEqual(expected); }); + it.each([ + ['npm', ['npm', 'uninstall', 'react-native-code-push', '--legacy-peer-deps']], + ['yarn', ['yarn', 'remove', 'react-native-code-push']], + ['pnpm', ['pnpm', 'remove', 'react-native-code-push']], + ['bun', ['bun', 'remove', 'react-native-code-push']], + ] as const)('builds shell-free CodePush removal argv for %s', (manager, expected) => { + expect(codePushRemovalCommand(manager)).toEqual(expected); + }); + it('backs up package files, executes exact shell-free argv, and verifies removal', () => { const originalPackage = { packageManager: 'pnpm@10.0.0', @@ -114,6 +127,40 @@ describe('CLI/scripts/expo/package-manager', () => { expect(fs.readFileSync(path.join(projectRoot, 'pnpm-lock.yaml'), 'utf8')).toBe('original lock'); }); + it('removes CodePush with the detected package manager and leaves it absent for rescans', () => { + const originalPackage = { + packageManager: 'yarn@4.0.0', + dependencies: { + 'react-native': '0.86.0', + 'react-native-code-push': '9.0.0', + }, + }; + writePackage(originalPackage); + fs.writeFileSync(path.join(projectRoot, 'yarn.lock'), 'original lock'); + mockSpawnSync.mockImplementation((_command, _args, options) => { + const packagePath = path.join(options.cwd, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + delete pkg.dependencies['react-native-code-push']; + fs.writeFileSync(packagePath, JSON.stringify(pkg)); + fs.writeFileSync(path.join(options.cwd, 'yarn.lock'), 'updated lock'); + return { status: 0 }; + }); + + const backup = removeCodePushWithPackageManager(projectRoot); + + expect(mockSpawnSync).toHaveBeenCalledWith( + 'yarn', + ['remove', 'react-native-code-push'], + { cwd: projectRoot, stdio: 'inherit', shell: false }, + ); + expect(backup.backupDir).toContain('code-push-'); + const migratedPackage = JSON.parse( + fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'), + ); + expect(migratedPackage.dependencies).toEqual({ 'react-native': '0.86.0' }); + expect(JSON.stringify(migratedPackage)).not.toContain('react-native-code-push'); + }); + it.each([ [{ error: new Error('not found'), status: null }, 'error'], [{ status: 1 }, 'nonzero status'], @@ -161,6 +208,37 @@ describe('CLI/scripts/expo/package-manager', () => { expect(fs.existsSync(path.join(projectRoot, 'package-lock.json'))).toBe(false); }); + it('fails safely if package.json disappears before its backup is written', () => { + writePackage({ dependencies: { 'expo-updates': '1.0.0' } }); + const realInspect = safeFileTransaction.inspectProjectFile; + let packageInspections = 0; + jest.spyOn(safeFileTransaction, 'inspectProjectFile').mockImplementation((root, relativePath) => { + if (relativePath === 'package.json' && ++packageInspections === 3) { + return { exists: false, content: '', mode: 0o666 }; + } + return realInspect(root, relativePath); + }); + + expect(() => removeExpoUpdatesWithPackageManager(projectRoot)).toThrow( + 'Package file disappeared before migration: package.json', + ); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it('restores the backup if a successful command removes package.json', () => { + const original = '{"dependencies":{"expo-updates":"1.0.0"}}\n'; + fs.writeFileSync(path.join(projectRoot, 'package.json'), original); + mockSpawnSync.mockImplementation((_command, _args, options) => { + fs.unlinkSync(path.join(options.cwd, 'package.json')); + return { status: 0 }; + }); + + expect(() => removeExpoUpdatesWithPackageManager(projectRoot)).toThrow( + 'completed but package.json is missing', + ); + expect(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).toBe(original); + }); + it('checks optional and peer dependency declarations after command completion', () => { for (const dependencyGroup of ['optionalDependencies', 'peerDependencies']) { writePackage({ [dependencyGroup]: { 'expo-updates': '1.0.0' } }); @@ -170,4 +248,65 @@ describe('CLI/scripts/expo/package-manager', () => { ); } }); + + it('rejects a symlinked dependency-migration backup root without spawning', () => { + const outsideRoot = createTempProjectDir(); + const sentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(sentinel, 'outside-safe'); + writePackage({ dependencies: { 'expo-updates': '1.0.0' } }); + fs.symlinkSync(outsideRoot, path.join(projectRoot, '.bundledrop-backup')); + try { + expect(() => removeExpoUpdatesWithPackageManager(projectRoot)) + .toThrow('symlinked or non-directory'); + expect(mockSpawnSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('outside-safe'); + } finally { + removeTempDir(outsideRoot); + } + }); + + it('rejects symlinked package and lock files without changing external sentinels', () => { + const outsideRoot = createTempProjectDir(); + const outsidePackage = path.join(outsideRoot, 'package.json'); + const outsideLock = path.join(outsideRoot, 'yarn.lock'); + const packageContent = JSON.stringify({ + packageManager: 'yarn@4.0.0', + dependencies: { 'expo-updates': '1.0.0' }, + }); + fs.writeFileSync(outsidePackage, packageContent); + fs.writeFileSync(outsideLock, 'outside-lock'); + fs.unlinkSync(path.join(projectRoot, 'package.json')); + fs.symlinkSync(outsidePackage, path.join(projectRoot, 'package.json')); + try { + expect(() => removeExpoUpdatesWithPackageManager(projectRoot)) + .toThrow('symlinked or non-regular'); + expect(mockSpawnSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(outsidePackage, 'utf8')).toBe(packageContent); + + fs.unlinkSync(path.join(projectRoot, 'package.json')); + writePackage(JSON.parse(packageContent)); + fs.symlinkSync(outsideLock, path.join(projectRoot, 'yarn.lock')); + expect(() => removeExpoUpdatesWithPackageManager(projectRoot)) + .toThrow('symlinked or non-regular'); + expect(mockSpawnSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(outsideLock, 'utf8')).toBe('outside-lock'); + } finally { + removeTempDir(outsideRoot); + } + }); + + it('rejects a package symlink before parsing its external contents', () => { + const outsideRoot = createTempProjectDir(); + const outsidePackage = path.join(outsideRoot, 'package.json'); + fs.writeFileSync(outsidePackage, '{malformed external json'); + fs.unlinkSync(path.join(projectRoot, 'package.json')); + fs.symlinkSync(outsidePackage, path.join(projectRoot, 'package.json')); + try { + expect(() => detectPackageManager(projectRoot)).toThrow('symlinked or non-regular'); + expect(mockSpawnSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(outsidePackage, 'utf8')).toBe('{malformed external json'); + } finally { + removeTempDir(outsideRoot); + } + }); }); diff --git a/src/tests/CLI/scripts/init-config.test.ts b/src/tests/CLI/scripts/init-config.test.ts index 7763040..1652790 100644 --- a/src/tests/CLI/scripts/init-config.test.ts +++ b/src/tests/CLI/scripts/init-config.test.ts @@ -12,8 +12,32 @@ import { getBundleDropConfigPath, hasExistingBundleDropConfig, initConfig, + normalizeRuntimeDeliveryBootstrap, } from '../../../CLI/scripts/init-config'; +const runtimeDeliveryBootstrap = (mode: 'v1' | 'shadow' | 'v2' = 'v2') => ({ + mode, + manifestBaseUrl: 'https://manifests.example.com/root/', + manifestAccessId: `mft_${'A'.repeat(43)}`, + publicKeys: { + 'test-key': { + kty: 'EC', + crv: 'P-256', + x: 'd-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM', + y: '_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI', + }, + }, +}); + +const projectCredentials = (overrides: Record = {}) => ({ + projectId: 'project-1', + projectSlug: 'demo-app', + orgId: 'org-1', + orgSlug: 'alpha-org', + runtimeDeliveryMode: 'v1', + ...overrides, +}); + describe('CLI/scripts/init-config', () => { const originalCwd = process.cwd(); let consoleSpy: jest.SpyInstance; @@ -37,6 +61,7 @@ describe('CLI/scripts/init-config', () => { expect(getBundleDropConfigPath(nested)).toBe(path.join(tempDir, 'bundle.drop.config.js')); expect(hasExistingBundleDropConfig(nested)).toBe(false); + expect(hasExistingBundleDropConfig()).toBe(false); fs.writeFileSync(path.join(tempDir, 'bundle.drop.config.js'), 'module.exports = {};', 'utf8'); expect(hasExistingBundleDropConfig(nested)).toBe(true); @@ -66,11 +91,386 @@ describe('CLI/scripts/init-config', () => { expect(consoleSpy).toHaveBeenCalled(); }); + it('preserves an existing config that cannot be evaluated', async () => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync(configPath, 'module.exports = { broken:', 'utf8'); + + const result = await initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + expect(result?.content).toBe('module.exports = { broken:'); + expect(mockAxiosNodeGet).not.toHaveBeenCalled(); + }); + + it('rejects an existing or dangling config symlink without touching its external target', async () => { + const outsideRoot = createTempProjectDir(); + const outsideConfig = path.join(outsideRoot, 'outside-config.js'); + fs.writeFileSync(outsideConfig, 'outside-safe'); + fs.symlinkSync(outsideConfig, path.join(tempDir, 'bundle.drop.config.js')); + try { + await expect(initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + })).rejects.toThrow('symlinked or non-regular'); + expect(fs.readFileSync(outsideConfig, 'utf8')).toBe('outside-safe'); + expect(mockAxiosNodeGet).not.toHaveBeenCalled(); + + fs.unlinkSync(path.join(tempDir, 'bundle.drop.config.js')); + fs.unlinkSync(outsideConfig); + fs.symlinkSync(outsideConfig, path.join(tempDir, 'bundle.drop.config.js')); + await expect(initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + })).rejects.toThrow('symlinked or non-regular'); + expect(fs.existsSync(outsideConfig)).toBe(false); + } finally { + removeTempDir(outsideRoot); + } + }); + + it('syncs a bootstrap for an existing config without rewriting it', async () => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + const original = `module.exports = { + serverUrl: 'https://api.example.com', + org: { slug: 'alpha-org' }, + project: { name: 'Demo', slug: 'demo-app', apiKey: 'existing-key' }, +};\n`; + fs.writeFileSync(configPath, original, 'utf8'); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap('v2'), + }), + }); + + const result = await initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + expect(fs.readFileSync(configPath, 'utf8')).toBe(original); + expect(result?.bootstrapPath).toBe( + path.join(fs.realpathSync(tempDir), '.bundle-drop/runtime-delivery.generated.json'), + ); + expect(fs.readFileSync(path.join(tempDir, '.gitignore'), 'utf8')).toContain( + '!.bundle-drop/runtime-delivery.generated.json', + ); + expect(mockAxiosNodeGet).toHaveBeenCalledWith( + 'https://api.example.com/projects/demo-app/credentials?orgSlug=alpha-org', + expect.any(Object), + ); + }); + + it('accepts the neutral credentials response and recreates deleted generated state', async () => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + 'utf8', + ); + fs.rmSync(path.join(tempDir, '.bundle-drop'), { recursive: true, force: true }); + fs.writeFileSync(path.join(tempDir, '.gitignore'), '.bundle-drop/\n', 'utf8'); + const neutralRuntimeDelivery = runtimeDeliveryBootstrap('v2'); + delete (neutralRuntimeDelivery as { mode?: string }).mode; + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + runtimeDeliveryMode: undefined, + runtimeDelivery: neutralRuntimeDelivery, + }), + }); + + const result = await initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + expect(result).toEqual(expect.objectContaining({ runtimeDeliveryAvailable: true })); + expect(fs.existsSync( + path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'), + )).toBe(true); + expect(fs.readFileSync(path.join(tempDir, '.gitignore'), 'utf8')).toContain( + '!.bundle-drop/runtime-delivery.generated.json', + ); + }); + + it('treats a null runtime delivery response as explicit retirement', async () => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + 'utf8', + ); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ runtimeDeliveryMode: undefined, runtimeDelivery: null }), + }); + + const result = await initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + expect(result).toEqual(expect.objectContaining({ + runtimeDeliveryAvailable: false, + bootstrapRetired: true, + })); + expect(fs.existsSync(bootstrapPath)).toBe(false); + }); + + it('preserves the last good bootstrap when a refresh response is malformed', async () => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { name: 'Demo', slug: 'demo-app' } };\n", + 'utf8', + ); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + runtimeDeliveryMode: 'v2', + runtimeDelivery: { ...runtimeDeliveryBootstrap('v2'), publicKeys: {} }, + }), + }); + + await initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + expect(fs.readFileSync(bootstrapPath, 'utf8')).toBe('{"lastGood":true}\n'); + }); + + it.each(['v1', 'shadow'] as const)( + 'atomically retires a stale bootstrap for the deprecated %s response', + async runtimeDeliveryMode => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { name: 'Demo', slug: 'demo-app' } };\n", + 'utf8', + ); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ runtimeDeliveryMode }), + }); + + const result = await initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + expect(result).toEqual(expect.objectContaining({ + runtimeDeliveryAvailable: false, + bootstrapRetired: true, + })); + expect(fs.existsSync(bootstrapPath)).toBe(false); + expect(fs.readFileSync(configPath, 'utf8')).toContain("slug: 'demo-app'"); + }, + ); + + it('previews legacy-mode convergence without deleting the current bootstrap', async () => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + 'utf8', + ); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); + mockAxiosNodeGet.mockResolvedValue({ data: projectCredentials() }); + + const result = await initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + dryRun: true, + }); + + expect(result?.bootstrapRetired).toBe(true); + expect(fs.readFileSync(bootstrapPath, 'utf8')).toBe('{"lastGood":true}\n'); + }); + + it('preserves the last-good bootstrap on transport failure or a missing delivery field', async () => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + 'utf8', + ); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); + mockAxiosNodeGet.mockRejectedValueOnce(new Error('network down')); + + await initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + expect(fs.readFileSync(bootstrapPath, 'utf8')).toBe('{"lastGood":true}\n'); + + mockAxiosNodeGet.mockResolvedValueOnce({ + data: projectCredentials({ runtimeDeliveryMode: undefined }), + }); + await expect(initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + })).resolves.toEqual(expect.not.objectContaining({ bootstrapRetired: true })); + expect(fs.readFileSync(bootstrapPath, 'utf8')).toBe('{"lastGood":true}\n'); + }); + + it.each([ + { + label: 'non-object payload', + response: null, + message: 'response is malformed', + }, + { + label: 'invalid project ID', + response: projectCredentials({ projectId: 7 }), + message: 'missing its authoritative project identity', + }, + { + label: 'invalid project slug', + response: projectCredentials({ projectSlug: 7 }), + message: 'missing its authoritative project identity', + }, + { + label: 'invalid organization ID', + response: projectCredentials({ orgId: 7 }), + message: 'missing its authoritative project identity', + }, + { + label: 'invalid organization slug', + response: projectCredentials({ orgSlug: 7 }), + message: 'missing its authoritative project identity', + }, + { + label: 'invalid deprecated runtime delivery mode', + response: projectCredentials({ runtimeDeliveryMode: 'preview' }), + message: 'invalid legacy runtime delivery mode', + }, + { + label: 'invalid download key', + response: projectCredentials({ downloadApiKey: 7 }), + message: 'invalid download key', + }, + { + label: 'invalid download key hint', + response: projectCredentials({ downloadKeyHint: 7 }), + message: 'invalid download key hint', + }, + ])('rejects a malformed credentials response and preserves local state: $label', async ({ + response, + message, + }) => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + 'utf8', + ); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); + mockAxiosNodeGet.mockResolvedValue({ data: response }); + + await expect(initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + })).rejects.toThrow(message); + expect(fs.readFileSync(bootstrapPath, 'utf8')).toBe('{"lastGood":true}\n'); + }); + + it.each([ + ['string', 'KEY123'], + ['null', null], + ] as const)('accepts a %s download key hint in an authoritative response', async (_, hint) => { + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ downloadKeyHint: hint }), + }); + + const result = await initConfig({ + serverUrl: 'https://api.example.com/', + organizations: [{ orgId: 'org-1', slug: 'alpha-org', name: 'Alpha Org' }], + projects: [{ orgId: 'org-1', slug: 'demo-app', name: 'Demo App' }], + authToken: 'jwt-token', + }); + + expect(result).toEqual(expect.objectContaining({ + runtimeDeliveryAvailable: false, + bootstrapRetired: true, + })); + }); + + it.each([ + { orgSlug: 'other-org' }, + { projectSlug: 'other-app' }, + ])('rejects a credentials identity mismatch without replacing local state: %p', async mismatch => { + const configPath = path.join(tempDir, 'bundle.drop.config.js'); + fs.writeFileSync( + configPath, + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + 'utf8', + ); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); + fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + ...mismatch, + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap(), + }), + }); + + await expect(initConfig({ + serverUrl: 'https://ignored.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + })).rejects.toThrow('Project credentials identity mismatch'); + expect(fs.readFileSync(bootstrapPath, 'utf8')).toBe('{"lastGood":true}\n'); + }); + it('creates a config using selected org/project values and fetched project credentials', async () => { mockAxiosNodeGet.mockResolvedValue({ - data: { + data: projectCredentials({ + projectId: 'project-owners', + projectSlug: 'owners-app', + orgId: 'org-2', + orgSlug: 'beta-org', downloadApiKey: "download-key'value", - }, + }), }); queuePromptResponse({ chosenOrg: 'beta-org' }); queuePromptResponse({ projectSlug: 'owners-app' }); @@ -91,7 +491,7 @@ describe('CLI/scripts/init-config', () => { const content = fs.readFileSync(path.join(tempDir, 'bundle.drop.config.js'), 'utf8'); expect(mockAxiosNodeGet).toHaveBeenCalledWith( - 'https://api.example.com/projects/owners-app/credentials', + 'https://api.example.com/projects/owners-app/credentials?orgSlug=beta-org', { headers: { Accept: 'application/json', @@ -107,6 +507,135 @@ describe('CLI/scripts/init-config', () => { expect(content).toContain('apiKey: "download-key\'value"'); }); + it('preserves the existing generated config exactly when runtime delivery is omitted', async () => { + await initConfig({ + serverUrl: 'https://api.example.com/', + organizations: [{ orgId: 'org-1', slug: 'alpha-org', name: 'Alpha Org' }], + projects: [{ orgId: 'org-1', slug: 'demo-app', name: 'Demo App' }], + downloadApiKey: 'download-key', + }); + + expect(fs.readFileSync(path.join(tempDir, 'bundle.drop.config.js'), 'utf8')).toBe(`module.exports = { + serverUrl: "https://api.example.com", + defaultChannel: 'develop', + runtimeVersion: { + ios: '1.0.0', + android: '1.0.0', + }, + org: { + slug: "alpha-org", + }, + project: { + name: "Demo App", + slug: "demo-app", + apiKey: "download-key", + }, +}; +`); + }); + + it('writes validated credentials to a version-neutral bootstrap, not the public config', async () => { + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + downloadApiKey: 'download-key', + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap('v2'), + }), + }); + await initConfig({ + serverUrl: 'https://api.example.com/', + organizations: [{ orgId: 'org-1', slug: 'alpha-org', name: 'Alpha Org' }], + projects: [{ orgId: 'org-1', slug: 'demo-app', name: 'Demo App' }], + authToken: 'jwt-token', + }); + + const content = fs.readFileSync(path.join(tempDir, 'bundle.drop.config.js'), 'utf8'); + expect(content).not.toContain('runtimeDelivery'); + const bootstrap = fs.readFileSync( + path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'), + 'utf8', + ); + expect(bootstrap).not.toContain('"mode"'); + expect(bootstrap).toContain('"projectId": "project-1"'); + expect(bootstrap).toContain('"orgId": "org-1"'); + expect(bootstrap).toContain('"manifestBaseUrl": "https://manifests.example.com/root"'); + expect(bootstrap).toContain(`"manifestAccessId": "mft_${'A'.repeat(43)}"`); + expect(bootstrap).toContain('"test-key": {'); + expect(bootstrap).not.toContain('"d":'); + + expect(normalizeRuntimeDeliveryBootstrap(runtimeDeliveryBootstrap('v2'))).toEqual( + expect.objectContaining({ manifestBaseUrl: 'https://manifests.example.com/root' }), + ); + }); + + it('does not promote v1, shadow, or malformed backend bootstrap values', async () => { + expect(normalizeRuntimeDeliveryBootstrap(runtimeDeliveryBootstrap('v1'))).toBeUndefined(); + expect(normalizeRuntimeDeliveryBootstrap(runtimeDeliveryBootstrap('shadow'))).toBeUndefined(); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + downloadApiKey: 'download-key', + runtimeDeliveryMode: 'v2', + runtimeDelivery: { + ...runtimeDeliveryBootstrap('v2'), + publicKeys: { + 'test-key': { + ...runtimeDeliveryBootstrap('v2').publicKeys['test-key'], + d: 'private-material-must-never-be-written', + }, + }, + }, + }), + }); + await initConfig({ + serverUrl: 'https://api.example.com/', + organizations: [{ orgId: 'org-1', slug: 'alpha-org', name: 'Alpha Org' }], + projects: [{ orgId: 'org-1', slug: 'demo-app', name: 'Demo App' }], + authToken: 'jwt-token', + }); + const content = fs.readFileSync(path.join(tempDir, 'bundle.drop.config.js'), 'utf8'); + expect(content).not.toContain('runtimeDelivery'); + expect(content).not.toContain('private-material'); + expect(content).toContain('apiKey: "download-key"'); + expect(fs.existsSync(path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'))).toBe(false); + }); + + it('fails closed for malformed runtime-delivery bootstrap subshapes', () => { + const valid = runtimeDeliveryBootstrap('v2'); + const invalidValues: unknown[] = [ + null, + [], + 'shadow', + { ...valid, mode: 'preview' }, + { ...valid, manifestBaseUrl: '' }, + { ...valid, manifestBaseUrl: 7 }, + { ...valid, manifestBaseUrl: 'not a URL' }, + { ...valid, manifestBaseUrl: 'file:///tmp/manifests' }, + { ...valid, manifestAccessId: 7 }, + { ...valid, manifestAccessId: 'too-short' }, + { ...valid, publicKeys: null }, + { ...valid, publicKeys: [] }, + { ...valid, publicKeys: {} }, + { ...valid, publicKeys: { '': valid.publicKeys['test-key'] } }, + { ...valid, publicKeys: { key: null } }, + { ...valid, publicKeys: { key: { ...valid.publicKeys['test-key'], extra: true } } }, + { ...valid, publicKeys: { key: { ...valid.publicKeys['test-key'], kty: 'RSA' } } }, + { ...valid, publicKeys: { key: { ...valid.publicKeys['test-key'], crv: 'P-384' } } }, + { ...valid, publicKeys: { key: { ...valid.publicKeys['test-key'], x: 7 } } }, + { ...valid, publicKeys: { key: { ...valid.publicKeys['test-key'], x: 'bad' } } }, + { ...valid, publicKeys: { key: { ...valid.publicKeys['test-key'], y: '*' } } }, + ]; + + for (const value of invalidValues) { + expect(normalizeRuntimeDeliveryBootstrap(value)).toBeUndefined(); + } + expect(normalizeRuntimeDeliveryBootstrap({ + ...valid, + manifestBaseUrl: 'http://localhost:8787/', + })).toEqual(expect.objectContaining({ + manifestBaseUrl: 'http://localhost:8787', + })); + }); + it('creates an unambiguous Expo config when the project type is known', async () => { await initConfig({ serverUrl: 'https://api.example.com/', @@ -125,9 +654,13 @@ describe('CLI/scripts/init-config', () => { it('filters project choices to the selected organization', async () => { mockAxiosNodeGet.mockResolvedValue({ - data: { + data: projectCredentials({ + projectId: 'project-beta', + projectSlug: 'beta-app', + orgId: 'org-2', + orgSlug: 'beta-org', downloadApiKey: 'beta-key', - }, + }), }); queuePromptResponse({ chosenOrg: 'beta-org' }); @@ -149,11 +682,47 @@ describe('CLI/scripts/init-config', () => { expect(content).toContain('slug: "beta-org"'); expect(content).toContain('slug: "beta-app"'); expect(mockAxiosNodeGet).toHaveBeenCalledWith( - 'https://api.example.com/projects/beta-app/credentials', + 'https://api.example.com/projects/beta-app/credentials?orgSlug=beta-org', expect.any(Object) ); }); + it('binds the same project slug to the selected organization identity', async () => { + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + projectId: 'project-beta-shared', + projectSlug: 'shared-app', + orgId: 'org-2', + orgSlug: 'beta-org', + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap(), + }), + }); + queuePromptResponse({ chosenOrg: 'beta-org' }); + + await initConfig({ + serverUrl: 'https://api.example.com/', + organizations: [ + { orgId: 'org-1', slug: 'alpha-org', name: 'Alpha Org' }, + { orgId: 'org-2', slug: 'beta-org', name: 'Beta Org' }, + ], + projects: [ + { orgId: 'org-1', slug: 'shared-app', name: 'Alpha App' }, + { orgId: 'org-2', slug: 'shared-app', name: 'Beta App' }, + ], + authToken: 'jwt-token', + }); + + expect(mockAxiosNodeGet).toHaveBeenCalledWith( + 'https://api.example.com/projects/shared-app/credentials?orgSlug=beta-org', + expect.any(Object), + ); + expect(fs.readFileSync( + path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'), + 'utf8', + )).toContain('"projectId": "project-beta-shared"'); + }); + it('does not select a project from another organization', async () => { await initConfig({ serverUrl: 'https://api.example.com/', @@ -273,7 +842,7 @@ describe('CLI/scripts/init-config', () => { it('warns when credentials cannot be fetched or do not include a project API key', async () => { mockAxiosNodeGet.mockResolvedValueOnce({ - data: {}, + data: projectCredentials(), }); await initConfig({ @@ -298,11 +867,18 @@ describe('CLI/scripts/init-config', () => { serverUrl: 'https://api.example.com/', organizations: [{ orgId: 'org-1', slug: 'alpha-org', name: 'Alpha Org' }], projects: [{ orgId: 'org-1', slug: 'demo-app', name: 'Demo App' }], + downloadApiKey: 'stale-auth-file-key', authToken: 'jwt-token', }); + const failedFetchConfig = fs.readFileSync( + path.join(tempDir, 'bundle.drop.config.js'), + 'utf8', + ); + expect(failedFetchConfig).toContain('apiKey: ""'); + expect(failedFetchConfig).not.toContain('stale-auth-file-key'); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to fetch project credentials from https://api.example.com/projects/demo-app/credentials') + expect.stringContaining('Failed to fetch project credentials from https://api.example.com/projects/demo-app/credentials?orgSlug=alpha-org') ); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('No project API key returned from /projects/:projectSlug/credentials') diff --git a/src/tests/CLI/scripts/login-cli.test.ts b/src/tests/CLI/scripts/login-cli.test.ts index b378332..a2924a1 100644 --- a/src/tests/CLI/scripts/login-cli.test.ts +++ b/src/tests/CLI/scripts/login-cli.test.ts @@ -741,6 +741,7 @@ describe('CLI/scripts/login-cli', () => { organizations: [{ name: 'Alpha', slug: 'alpha-org', orgId: 'org-1' }], downloadApiKey: 'download-key', authToken: 'jwt-token', + dryRun: true, projectType: 'expo', }); expect(mockRunPostInitPrompts).toHaveBeenCalledWith({ projectType: 'expo' }); @@ -824,7 +825,7 @@ describe('CLI/scripts/login-cli', () => { expect(process.exitCode).toBe(1); }); - it('falls back to a printed URL when the browser cannot be opened and skips setup for existing configs', async () => { + it('falls back to a printed URL and refreshes bootstrap without rerunning existing setup', async () => { queuePromptResponse({ shouldOpenBrowser: true }); mockHasExistingBundleDropConfig.mockReturnValue(true); mockSpawn.mockImplementation(() => createSpawnChild('error')); @@ -875,17 +876,16 @@ describe('CLI/scripts/login-cli', () => { await loginPromise; - expect(mockInitConfig).not.toHaveBeenCalled(); + expect(mockInitConfig).toHaveBeenCalledWith(expect.objectContaining({ + authToken: 'jwt-token', + dryRun: false, + })); + expect(mockDetectProjectType).not.toHaveBeenCalled(); expect(mockRunPostInitPrompts).not.toHaveBeenCalled(); expect( consoleLogSpy.mock.calls.some(call => call.join(' ').includes('Could not open the browser automatically. Open this URL manually:') ), ).toBe(true); - expect( - consoleLogSpy.mock.calls.some(call => - call.join(' ').includes('Skipping setup prompts.') - ), - ).toBe(true); }); }); diff --git a/src/tests/CLI/scripts/metro-config-authority.test.ts b/src/tests/CLI/scripts/metro-config-authority.test.ts new file mode 100644 index 0000000..2249232 --- /dev/null +++ b/src/tests/CLI/scripts/metro-config-authority.test.ts @@ -0,0 +1,198 @@ +import fs from 'fs'; +import path from 'path'; + +import { + assertCommonJsMetroConfig, + findSingleMetroConfig, + hasAuthoritativeMetroWrapper, + hasExecutableMetroModuleReference, + hasExecutableMetroWrapperReference, + newCommonJsMetroConfigFile, +} from '../../../CLI/scripts/metro-config-authority'; +import { createTempProjectDir, removeTempDir } from '../../utils/tempDir'; + +describe('CLI/scripts/metro-config-authority', () => { + let projectRoot = ''; + + beforeEach(() => { + projectRoot = createTempProjectDir(); + }); + + afterEach(() => { + removeTempDir(projectRoot); + }); + + it('distinguishes executable wrapper and package references from comments and strings', () => { + expect(hasExecutableMetroWrapperReference('withBundleDrop(config)', 'withBundleDrop')).toBe(true); + expect(hasExecutableMetroWrapperReference( + '// withBundleDrop(config)\nconst note = "withBundleDrop(config)";', + 'withBundleDrop', + )).toBe(false); + expect(hasExecutableMetroWrapperReference( + '/* withBundleDrop(config)\n * remains documentation only\n */', + 'withBundleDrop', + )).toBe(false); + expect(hasExecutableMetroModuleReference( + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');", + '@gfean/react-native-bundle-drop/metro', + )).toBe(true); + expect(hasExecutableMetroModuleReference( + "if (false) { require('@gfean/react-native-bundle-drop/metro'); }", + '@gfean/react-native-bundle-drop/metro', + )).toBe(false); + expect(hasExecutableMetroModuleReference( + "const note = \"require('@gfean/react-native-bundle-drop/metro')\";", + '@gfean/react-native-bundle-drop/metro', + )).toBe(false); + expect(hasExecutableMetroModuleReference( + "/* require('@gfean/react-native-bundle-drop/metro')\n * documentation only\n */", + '@gfean/react-native-bundle-drop/metro', + )).toBe(false); + }); + + it.each([ + [ + 'direct object', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop({ resolver: {} });\n', + 'withBundleDrop', + ], + [ + 'Expo default config', + "import { withBundleDropExpo } from '@gfean/react-native-bundle-drop/metro';\n" + + "import { getDefaultConfig } from 'expo/metro-config';\n" + + 'export default withBundleDropExpo(getDefaultConfig(__dirname));\n', + 'withBundleDropExpo', + ], + [ + 'React Native merge config', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + "const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');\n" + + 'module.exports = withBundleDrop(mergeConfig(getDefaultConfig(__dirname), {}));\n', + 'withBundleDrop', + ], + [ + 'React Native aliased base config', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + "const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');\n" + + 'const base = getDefaultConfig(__dirname);\n' + + 'const config = mergeConfig(base, { resolver: {} });\n' + + 'module.exports = withBundleDrop(config);\n', + 'withBundleDrop', + ], + [ + 'earlier CommonJS export', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = {};\n' + + 'module.exports = withBundleDrop(module.exports);\n', + 'withBundleDrop', + ], + [ + 'top-level initializer chain', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const base = {};\nconst config = base;\n' + + 'module.exports = withBundleDrop(config, { projectRoot: __dirname });\n', + 'withBundleDrop', + ], + [ + 'package-managed appended wrapper', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop(module.exports || {}, { projectRoot: __dirname });\n', + 'withBundleDrop', + ], + ] as const)('accepts an authoritative %s export', (_label, source, wrapper) => { + expect(hasAuthoritativeMetroWrapper(source, wrapper)).toBe(true); + }); + + it.each([ + [ + 'aliased binding', + "const { withBundleDrop: other } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop({});\n', + ], + [ + 'duplicate binding', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + "import { withBundleDrop } from '@gfean/react-native-bundle-drop/metro';\n" + + 'module.exports = withBundleDrop({});\n', + ], + [ + 'nested export', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'function dead() { module.exports = withBundleDrop({}); }\nmodule.exports = {};\n', + ], + [ + 'wrapper is not final', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop({});\nmodule.exports = {};\n', + ], + [ + 'missing base argument', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop();\n', + ], + [ + 'unsupported base', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = undefined;\nmodule.exports = withBundleDrop(config);\n', + ], + [ + 'unsupported initializer call', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = createConfig();\nmodule.exports = withBundleDrop(config);\n', + ], + [ + 'merge config without an imported binding', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop(mergeConfig({}, {}));\n', + ], + [ + 'unterminated initializer before the final export', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const config = createConfig()module.exports = withBundleDrop(config)', + ], + [ + 'cyclic initializer', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'const first = second;\nconst second = first;\nmodule.exports = withBundleDrop(first);\n', + ], + [ + 'trailing executable code', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop({});\nstartServer();\n', + ], + [ + 'unbalanced wrapper call', + "const { withBundleDrop } = require('@gfean/react-native-bundle-drop/metro');\n" + + 'module.exports = withBundleDrop({};\n', + ], + ])('rejects %s', (_label, source) => { + expect(hasAuthoritativeMetroWrapper(source, 'withBundleDrop')).toBe(false); + }); + + it('finds one Metro authority and rejects competing configs', () => { + expect(findSingleMetroConfig(projectRoot)).toBeUndefined(); + fs.writeFileSync(path.join(projectRoot, 'metro.config.cjs'), 'module.exports = {};\n'); + expect(findSingleMetroConfig(projectRoot)).toBe('metro.config.cjs'); + fs.writeFileSync(path.join(projectRoot, 'metro.config.js'), 'module.exports = {};\n'); + expect(() => findSingleMetroConfig(projectRoot)).toThrow('Multiple Metro config files'); + }); + + it('selects and validates CommonJS config filenames from package type', () => { + expect(newCommonJsMetroConfigFile(projectRoot)).toBe('metro.config.js'); + expect(() => assertCommonJsMetroConfig(projectRoot, 'metro.config.js')).not.toThrow(); + + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"type":"module"}\n'); + expect(newCommonJsMetroConfigFile(projectRoot)).toBe('metro.config.cjs'); + expect(() => assertCommonJsMetroConfig(projectRoot, 'metro.config.cjs')).not.toThrow(); + expect(() => assertCommonJsMetroConfig(projectRoot, 'metro.config.js')).toThrow( + 'uses ESM or TypeScript syntax', + ); + expect(() => assertCommonJsMetroConfig(projectRoot, 'metro.config.mjs')).toThrow( + 'uses ESM or TypeScript syntax', + ); + expect(() => assertCommonJsMetroConfig(projectRoot, 'metro.config.ts')).toThrow( + 'uses ESM or TypeScript syntax', + ); + }); +}); diff --git a/src/tests/CLI/scripts/native-entrypoint-authority.test.ts b/src/tests/CLI/scripts/native-entrypoint-authority.test.ts new file mode 100644 index 0000000..de8b095 --- /dev/null +++ b/src/tests/CLI/scripts/native-entrypoint-authority.test.ts @@ -0,0 +1,266 @@ +import fs from 'fs'; +import path from 'path'; + +import { findNativeEntrypointAuthorityIssue } from '../../../CLI/scripts/native-entrypoint-authority'; +import { createTempProjectDir, removeTempDir } from '../../utils/tempDir'; + +describe('CLI/scripts/native-entrypoint-authority', () => { + let projectRoot = ''; + + const write = (relativePath: string, content: string) => { + const filePath = path.join(projectRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + }; + + beforeEach(() => { + projectRoot = createTempProjectDir(); + }); + + afterEach(() => { + removeTempDir(projectRoot); + }); + + it('treats an absent platform as out of scope and duplicate entrypoints as ambiguous', () => { + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'android', [])).toBeNull(); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [ + 'ios/One/AppDelegate.swift', + 'ios/Two/AppDelegate.swift', + ])).toContain('Multiple ios application entrypoints'); + }); + + it('binds the Android entrypoint through a Gradle namespace and ignores test sources', () => { + const entrypoint = 'android/app/src/main/java/com/example/MainApplication.kt'; + write(entrypoint, 'class MainApplication\n'); + write('android/app/build.gradle.kts', 'android {\n namespace = "com.example"\n}\n'); + write( + 'android/app/src/main/AndroidManifest.xml', + '', + ); + write( + 'android/app/src/release/AndroidManifest.xml', + '', + ); + write( + 'android/app/src/androidTest/AndroidManifest.xml', + '', + ); + write('android/app/src/debug', 'not a source-set directory'); + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'android', [entrypoint])).toBeNull(); + }); + + it('uses the declared Android package before the source path package', () => { + const entrypoint = 'android/app/src/main/java/wrong/path/MainApplication.java'; + write(entrypoint, 'package com.example; public class MainApplication {}\n'); + write( + 'android/app/src/main/AndroidManifest.xml', + '', + ); + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'android', [entrypoint])).toBeNull(); + }); + + it('ignores commented Android application declarations without joining surrounding markup', () => { + const entrypoint = 'android/app/src/main/java/com/example/MainApplication.kt'; + write(entrypoint, 'package com.example\nclass MainApplication\n'); + write( + 'android/app/src/main/AndroidManifest.xml', + '' + + '', + ); + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'android', [entrypoint])).toBeNull(); + }); + + it.each([ + ['missing entrypoint', null, 'Android application entrypoint is missing'], + ['missing main manifest', undefined, 'main AndroidManifest.xml is missing'], + [ + 'missing application', + '', + 'has no application declaration', + ], + [ + 'multiple applications', + '' + + '', + 'multiple application declarations', + ], + [ + 'missing application name', + '', + 'does not explicitly name the application class', + ], + [ + 'duplicate application name', + '', + 'multiple android:name application authorities', + ], + [ + 'dynamic application name', + '', + 'application class is not statically resolvable', + ], + [ + 'wrong application', + '', + 'not com.example.MainApplication', + ], + ])('rejects Android authority with %s', (_label, manifest, expected) => { + const entrypoint = 'android/app/src/main/java/com/example/MainApplication.kt'; + if (manifest !== null) write(entrypoint, 'package com.example\nclass MainApplication\n'); + if (manifest !== null && manifest !== undefined) { + write('android/app/src/main/AndroidManifest.xml', manifest); + } + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'android', [entrypoint])).toContain( + expected, + ); + }); + + it('rejects a relative Android application without package or namespace authority', () => { + const entrypoint = 'android/app/src/main/java/MainApplication.kt'; + write(entrypoint, 'class MainApplication\n'); + write( + 'android/app/src/main/AndroidManifest.xml', + '', + ); + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'android', [entrypoint])).toContain( + 'without a manifest package or Gradle namespace', + ); + }); + + it('rejects a symlinked Android source set', () => { + const entrypoint = 'android/app/src/main/java/com/example/MainApplication.kt'; + write(entrypoint, 'package com.example\nclass MainApplication\n'); + write( + 'android/app/src/main/AndroidManifest.xml', + '', + ); + const outside = createTempProjectDir(); + fs.symlinkSync(outside, path.join(projectRoot, 'android/app/src/release')); + + try { + expect(() => findNativeEntrypointAuthorityIssue(projectRoot, 'android', [entrypoint])) + .toThrow('Android source-set path is a symbolic link'); + } finally { + removeTempDir(outside); + } + }); + + it('accepts a unique annotated Swift AppDelegate', () => { + const entrypoint = 'ios/Demo/AppDelegate.swift'; + write( + entrypoint, + '@main\npublic final class AppDelegate: UIResponder, UIApplicationDelegate {}\n', + ); + write('ios/Demo/Notes.swift', 'let text = "@main class OtherDelegate"\n'); + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toBeNull(); + }); + + it('rejects competing, conflicting, and missing Swift principals', () => { + const entrypoint = 'ios/Demo/AppDelegate.swift'; + write(entrypoint, 'class AppDelegate: UIResponder, UIApplicationDelegate {}\n'); + write('ios/Demo/Other.swift', '@main struct MyApp {}\n'); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'does not uniquely select AppDelegate', + ); + + fs.rmSync(path.join(projectRoot, 'ios/Demo/Other.swift')); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'Swift @main/UIApplicationMain principal is missing', + ); + + write(entrypoint, '@main class AppDelegate {}\n'); + write( + 'ios/main.swift', + 'UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, "AppDelegate")\n', + ); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'conflicts with an external main source', + ); + }); + + it('accepts an explicit Swift main source with balanced nested arguments', () => { + const entrypoint = 'ios/Demo/AppDelegate.swift'; + write(entrypoint, 'class AppDelegate: UIResponder, UIApplicationDelegate {}\n'); + write( + 'ios/main.swift', + 'UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, helper(nil, "x"), ' + + 'NSStringFromClass(AppDelegate.self))\n', + ); + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toBeNull(); + }); + + it('rejects an invalid or ambiguous Swift main source', () => { + const entrypoint = 'ios/Demo/AppDelegate.swift'; + write(entrypoint, 'class AppDelegate {}\n'); + write( + 'ios/main.swift', + 'UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, "OtherDelegate")\n', + ); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'argument 4 does not select AppDelegate', + ); + + write('ios/Second/main.swift', 'UIApplicationMain(1, nil, nil, "AppDelegate")\n'); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'Multiple or conflicting iOS application principal sources', + ); + }); + + it('accepts Objective-C UIApplicationMain and ignores commented decoys', () => { + const entrypoint = 'ios/Demo/AppDelegate.m'; + write(entrypoint, '@implementation AppDelegate\n@end\n'); + write( + 'ios/Demo/main.m', + '/* outer /* UIApplicationMain(0, nil, nil, @"Wrong") */ still comment */\n' + + 'int main(int argc, char **argv) {\n' + + ' return UIApplicationMain(argc, argv, helper(nil, @"x"), ' + + 'NSStringFromClass([AppDelegate class]));\n}\n', + ); + + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toBeNull(); + }); + + it('rejects missing, duplicate, and wrong Objective-C principals', () => { + const entrypoint = 'ios/Demo/AppDelegate.mm'; + write(entrypoint, '@implementation AppDelegate\n@end\n'); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'principal source is missing', + ); + + write('ios/Demo/main.mm', 'UIApplicationMain(argc, argv, nil, @"OtherDelegate");\n'); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'argument 4 does not select AppDelegate', + ); + + write( + 'ios/Demo/main.mm', + 'UIApplicationMain(argc, argv, nil, @"AppDelegate");\n' + + 'UIApplicationMain(argc, argv, nil, @"AppDelegate");\n', + ); + expect(findNativeEntrypointAuthorityIssue(projectRoot, 'ios', [entrypoint])).toContain( + 'Exactly one UIApplicationMain call is required', + ); + }); + + it('rejects a symlinked iOS source root', () => { + const outside = createTempProjectDir(); + fs.symlinkSync(outside, path.join(projectRoot, 'ios')); + try { + expect(() => findNativeEntrypointAuthorityIssue( + projectRoot, + 'ios', + ['ios/Demo/AppDelegate.swift'], + )).toThrow('iOS source root is not a regular project directory'); + } finally { + removeTempDir(outside); + } + }); +}); diff --git a/src/tests/CLI/scripts/native-setup-contract.test.ts b/src/tests/CLI/scripts/native-setup-contract.test.ts index 08947f3..b000107 100644 --- a/src/tests/CLI/scripts/native-setup-contract.test.ts +++ b/src/tests/CLI/scripts/native-setup-contract.test.ts @@ -1,70 +1,877 @@ import { + findMissingBareNativeStartupStructure, hasBareAndroidStartupIntegration, hasBareIosStartupIntegration, stripCommentsAndStrings, } from '../../../CLI/scripts/native-setup-contract'; +import { + MODERN_KOTLIN_MAIN_APPLICATION, + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION, + RN71_JAVA_MAIN_APPLICATION, + RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION, + RN71_KOTLIN_MAIN_APPLICATION, + RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION, + RN71_OBJC_APP_DELEGATE, + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION, + RN85_SWIFT_APP_DELEGATE, +} from '../../fixtures/rn85SwiftAppDelegate'; describe('bare native setup contract', () => { - it('accepts the shipped Android module import and fully qualified references', () => { - expect(hasBareAndroidStartupIntegration([ + it('accepts legacy Kotlin and Java native host overrides', () => { + expect(hasBareAndroidStartupIntegration(RN71_KOTLIN_MAIN_APPLICATION)).toBe(true); + expect(hasBareAndroidStartupIntegration(RN71_JAVA_MAIN_APPLICATION)).toBe(true); + expect(hasBareAndroidStartupIntegration(RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION)) + .toBe(true); + expect(hasBareAndroidStartupIntegration(RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION)) + .toBe(true); + expect(hasBareAndroidStartupIntegration(RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION)) + .toBe(true); + expect(hasBareAndroidStartupIntegration( + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION.replace( + ' fallback\n );', + ' otherFallback\n );', + ), + )).toBe(false); + for (const invalidSelection of [ + ['? selectEnterpriseBundle()', '? return selectEnterpriseBundle()'], + ['enterprisePolicy.enabled', 'if enterprisePolicy.enabled'], + ['? selectEnterpriseBundle()', '? audit(), selectEnterpriseBundle()'], + ]) { + expect(hasBareAndroidStartupIntegration( + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION.replace( + invalidSelection[0], + invalidSelection[1], + ), + )).toBe(false); + } + expect(hasBareAndroidStartupIntegration( + RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION.replace( + 'selectEnterpriseBundle()', + 'selectEnterpriseBundle(region, channel)', + ), + )).toBe(true); + }); + + it.each([ + ['method chaining', '.trim()'], + ['string concatenation', ' + "/bad"'], + ['comparison', ' != null'], + ['logical transform', ' || false'], + ])('rejects a Java resolver result with %s', (_label, suffix) => { + const transformed = RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + ' );', + ` )${suffix};`, + ); + expect(hasBareAndroidStartupIntegration(transformed)).toBe(false); + }); + + it('binds resolver receiver syntax and Kotlin nullability to the parsed language', () => { + for (const invalidJavaContext of [ + 'this', + 'this@MainApplication', + 'applicationContext', + ]) { + expect(hasBareAndroidStartupIntegration( + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + 'getApplicationContext(),', + `${invalidJavaContext},`, + ), + )).toBe(false); + } + for (const invalidJavaSuffix of ['!!', ' ?: super.getJSBundleFile()']) { + expect(hasBareAndroidStartupIntegration( + RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION.replace( + ' );', + ` )${invalidJavaSuffix};`, + ), + )).toBe(false); + } + + expect(hasBareAndroidStartupIntegration( + RN71_KOTLIN_MAIN_APPLICATION.replace( + 'this@MainApplication,', + 'MainApplication.this,', + ), + )).toBe(false); + expect(hasBareAndroidStartupIntegration( + RN71_KOTLIN_MAIN_APPLICATION.replace('this@MainApplication,', 'this,'), + )).toBe(false); + + const nullableDirect = RN71_KOTLIN_MAIN_APPLICATION + .replace('override fun getJSBundleFile(): String =', 'override fun getJSBundleFile(): String? =') + .replace(' )!!', ' )'); + expect(hasBareAndroidStartupIntegration(nullableDirect)).toBe(true); + expect(hasBareAndroidStartupIntegration( + nullableDirect.replace('getJSBundleFile(): String?', 'getJSBundleFile(): String'), + )).toBe(false); + }); + + it('accepts a modern Kotlin helper only when the ReactHost consumes it', () => { + const modernHost = MODERN_KOTLIN_MAIN_APPLICATION; + expect(hasBareAndroidStartupIntegration(modernHost)).toBe(true); + expect(hasBareAndroidStartupIntegration( + modernHost.replace('jsBundleFilePath = getJSBundleFile(),', 'isHermesEnabled = true,'), + )).toBe(false); + + const deadHostConnection = modernHost + .replace('jsBundleFilePath = getJSBundleFile(),', 'isHermesEnabled = true,') + .replace( + '\n}', + ` + fun deadHost() = getDefaultReactHost( + context = applicationContext, + packageList = emptyList(), + jsBundleFilePath = getJSBundleFile(), + ) +}`, + ); + expect(hasBareAndroidStartupIntegration(deadHostConnection)).toBe(false); + + const deadAssignedHost = modernHost.replace( + 'getDefaultReactHost(\n context = applicationContext,', + `val deadHost = getDefaultReactHost( + context = applicationContext,`, + ).replace( + ' }\n}', + ` deadHost + getDefaultReactHost( + context = applicationContext, + packageList = emptyList(), + ) + } +}`, + ); + expect(hasBareAndroidStartupIntegration(deadAssignedHost)).toBe(false); + }); + + it('binds legacy resolvers to the authoritative ReactNativeHost', () => { + expect(hasBareAndroidStartupIntegration( + RN71_KOTLIN_MAIN_APPLICATION.replace( + 'override val reactNativeHost', + 'val unusedHost', + ), + )).toBe(false); + expect(hasBareAndroidStartupIntegration( + RN71_JAVA_MAIN_APPLICATION.replace( + 'return mReactNativeHost;', + 'return otherHost;', + ), + )).toBe(false); + + const nestedGetterDecoy = [ 'import com.bundledrop.BundleDropModule', - 'fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', - ].join('\n'))).toBe(true); + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val actualHost: ReactNativeHost = object : DefaultReactNativeHost(this) {}', + ' override val reactNativeHost: ReactNativeHost get() = actualHost', + ' class Helper {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'); + expect(hasBareAndroidStartupIntegration(nestedGetterDecoy)).toBe(false); + + const branchedJavaGetter = RN71_JAVA_MAIN_APPLICATION.replace( + 'return mReactNativeHost;', + 'if (false) return mReactNativeHost; return actualHost;', + ); + expect(hasBareAndroidStartupIntegration(branchedJavaGetter)).toBe(false); + + const anonymousGetterDecoy = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val deadHost: ReactNativeHost = object : DefaultReactNativeHost(this) {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this@MainApplication, null)', + ' }', + ' val deadApplication = object : ReactApplication {', + ' override val reactNativeHost: ReactNativeHost get() = deadHost', + ' }', + '}', + ].join('\n'); + expect(hasBareAndroidStartupIntegration(anonymousGetterDecoy)).toBe(false); + + const anonymousModernHostDecoy = MODERN_KOTLIN_MAIN_APPLICATION + .replace( + ' override val reactHost: ReactHost by lazy {', + ' val deadApplication = object : ReactApplication {\n' + + ' override val reactHost: ReactHost by lazy {', + ) + .replace('\n }\n}', '\n }\n }\n}'); + expect(hasBareAndroidStartupIntegration(anonymousModernHostDecoy)).toBe(false); + }); + + it('accepts only authoritative RN85 NativePaths and Swift factory connections', () => { + expect(hasBareAndroidStartupIntegration(RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION)) + .toBe(true); expect(hasBareAndroidStartupIntegration( - 'fun getJSBundleFile() = com.bundledrop.BundleDropModule.resolveJSBundleFile(this, null)', + RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION.replace( + 'jsBundleFilePath = BundleDropNativePaths.getDownloadedBundlePath(applicationContext),', + 'isHermesEnabled = true,', + ), + )).toBe(false); + expect(hasBareAndroidStartupIntegration(RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION)) + .toBe(true); + expect(hasBareIosStartupIntegration( + 'ios/BundleDropDemo/AppDelegate.swift', + RN85_SWIFT_APP_DELEGATE, )).toBe(true); - expect(hasBareAndroidStartupIntegration([ - 'import com.bundledrop.BundleDropNativePaths;', - 'val path = BundleDropNativePaths.getDownloadedBundlePath(this)', - ].join('\n'))).toBe(true); + expect(hasBareIosStartupIntegration( + 'ios/BundleDropDemo/AppDelegate.swift', + RN85_SWIFT_APP_DELEGATE.replace( + 'RCTReactNativeFactory(delegate: delegate)', + 'RCTReactNativeFactory(delegate: ReactNativeDelegate())', + ), + )).toBe(false); + + const deadFactory = RN85_SWIFT_APP_DELEGATE + .replace(' factory.startReactNative(', ' if false {\n factory.startReactNative(') + .replace( + ' launchOptions: launchOptions\n )', + ' launchOptions: launchOptions\n )\n }\n let actualFactory = RCTReactNativeFactory(delegate: OtherDelegate())\n actualFactory.startReactNative(withModuleName: "Demo", in: window)', + ); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', deadFactory)).toBe(false); }); - it('rejects invented Android imports and markers in comments or strings', () => { - expect(hasBareAndroidStartupIntegration([ - 'import com.gfean.reactnativebundledrop.BundleDropModule', - 'fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', - ].join('\n'))).toBe(false); - expect(hasBareAndroidStartupIntegration([ - '// import com.bundledrop.BundleDropModule', - 'val marker = "getJSBundleFile BundleDropModule.resolveJSBundleFile"', - ].join('\n'))).toBe(false); + it('rejects Android dead methods, nested owners, duplicates, and renamed lifecycle decoys', () => { + const deadMethod = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile(): String? = null', + ' fun getJSBundleFileForTests() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'); + const nestedOwner = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' class Helper {', + ' fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' }', + '}', + ].join('\n'); + const duplicate = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'); + const lifecycleDecoy = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' override fun onCreate() {}', + ' fun onCreateForTests() { super.onCreate(); loadReactNative(this) }', + '}', + ].join('\n'); + const parameterizedKotlin = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile(test: Boolean) =', + ' BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'); + const parameterizedJava = [ + 'import com.bundledrop.BundleDropModule;', + 'public class MainApplication {', + ' public String getJSBundleFile(boolean test) {', + ' return BundleDropModule.resolveJSBundleFile(this, null);', + ' }', + '}', + ].join('\n'); + + expect(hasBareAndroidStartupIntegration(deadMethod)).toBe(false); + expect(hasBareAndroidStartupIntegration(nestedOwner)).toBe(false); + expect(hasBareAndroidStartupIntegration(duplicate)).toBe(false); + expect(hasBareAndroidStartupIntegration(lifecycleDecoy)).toBe(false); + expect(hasBareAndroidStartupIntegration(parameterizedKotlin)).toBe(false); + expect(hasBareAndroidStartupIntegration(parameterizedJava)).toBe(false); + }); + + it('does not parse native startup declarations from multiline string literals', () => { + const kotlinRawStringDecoy = [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication {', + ' val documentation = """ "', + ' private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ' override val reactHost: ReactHost by lazy {', + ' getDefaultReactHost(jsBundleFilePath = getJSBundleFile())', + ' }', + ' " """', + '}', + ].join('\n'); + expect(hasBareAndroidStartupIntegration(kotlinRawStringDecoy)).toBe(false); + + const swiftMultilineStringDecoy = [ + 'import BundleDrop', + '@main class AppDelegate: RCTAppDelegate {', + ' let documentation = #""" "', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + ' " """#', + '}', + ].join('\n'); + expect(hasBareIosStartupIntegration( + 'ios/Demo/AppDelegate.swift', + swiftMultilineStringDecoy, + )).toBe(false); }); - it('accepts the shipped Swift module and Objective-C header', () => { + it('accepts Swift and Objective-C startup methods owned by AppDelegate', () => { + expect(hasBareIosStartupIntegration( + 'ios/Demo/AppDelegate.m', + RN71_OBJC_APP_DELEGATE, + )).toBe(true); expect(hasBareIosStartupIntegration( 'ios/Demo/AppDelegate.swift', - 'import BundleDrop\nfunc bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' @objc override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'), )).toBe(true); expect(hasBareIosStartupIntegration( 'ios/Demo/AppDelegate.mm', - '#import \n- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { return [BundleDropLocator bundleURL]; }', + [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { return self.bundleURL; }', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'), )).toBe(true); expect(hasBareIosStartupIntegration( 'ios/Demo/AppDelegate.m', - '#import \n- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' return [BundleDropLocator bundleURL];', + '}', + '@end', + ].join('\n'), )).toBe(true); }); - it('rejects invented iOS modules and markers in comments or strings', () => { + it('requires the resolver result on the Release return path', () => { + const debugOnlyKotlin = RN71_KOTLIN_MAIN_APPLICATION + .replace('"/data/local/tmp/dev.jsbundle"', 'BundleDropModule.resolveJSBundleFile(this@MainApplication, null)!!') + .replace( + /BundleDropModule\.resolveJSBundleFile\(\n this@MainApplication,\n "\/android_asset\/index\.android\.bundle",\n \)!!/, + '"/android_asset/index.android.bundle"', + ); + const debugOnlyJava = RN71_JAVA_MAIN_APPLICATION + .replace('return null;', 'return BundleDropModule.resolveJSBundleFile(MainApplication.this, null);') + .replace( + /return BundleDropModule\.resolveJSBundleFile\([\s\S]*?\n \);/, + 'return "/android_asset/index.android.bundle";', + ); + const debugOnlySwift = [ + 'import BundleDrop', + 'class AppDelegate {', + ' override func bundleURL() -> URL? {', + '#if DEBUG', + ' return BundleDropLocator.bundleURL()', + '#else', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + '#endif', + ' }', + '}', + ].join('\n'); + const debugOnlyObjc = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL {', + '#if DEBUG', + ' return [BundleDropLocator bundleURL];', + '#else', + ' return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];', + '#endif', + '}', + '@end', + ].join('\n'); + + expect(hasBareAndroidStartupIntegration(debugOnlyKotlin)).toBe(false); + expect(hasBareAndroidStartupIntegration(debugOnlyJava)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', debugOnlySwift)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', debugOnlyObjc)).toBe(false); + }); + + it('rejects ignored resolver results and accepts returned local values', () => { + const ignoredKotlin = RN71_KOTLIN_MAIN_APPLICATION.replace( + /override fun getJSBundleFile\(\): String =[\s\S]*?\n }\n}/, + `override fun getJSBundleFile(): String? { + BundleDropModule.resolveJSBundleFile(this@MainApplication, null) + return null + } + } +}`, + ); + const ignoredJava = RN71_JAVA_MAIN_APPLICATION.replace( + /protected String getJSBundleFile\(\) \{[\s\S]*?\n }\n };/, + `protected String getJSBundleFile() { + BundleDropModule.resolveJSBundleFile(MainApplication.this, null); + return null; + } + };`, + ); + const ignoredSwift = [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL(); return nil }', + '}', + ].join('\n'); + const ignoredObjc = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { [BundleDropLocator bundleURL]; return nil; }', + '@end', + ].join('\n'); + const returnedSwift = ignoredSwift.replace( + 'BundleDropLocator.bundleURL(); return nil', + 'let otaURL = BundleDropLocator.bundleURL(); return otaURL', + ); + const returnedObjc = ignoredObjc.replace( + '[BundleDropLocator bundleURL]; return nil;', + 'NSURL *otaURL = [BundleDropLocator bundleURL]; return otaURL;', + ); + + expect(hasBareAndroidStartupIntegration(ignoredKotlin)).toBe(false); + expect(hasBareAndroidStartupIntegration(ignoredJava)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', ignoredSwift)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', ignoredObjc)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', returnedSwift)).toBe(true); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', returnedObjc)).toBe(true); + }); + + it('does not link resolver calls across completed return or assignment statements', () => { + const kotlinCrossStatement = RN71_KOTLIN_MAIN_APPLICATION.replace( + /override fun getJSBundleFile\(\): String =[\s\S]*?\n }\n}/, + `override fun getJSBundleFile(): String? { + val embeddedPath: String? = null + return embeddedPath + BundleDropModule.resolveJSBundleFile(this@MainApplication, null) + } + } +}`, + ); + const swiftReturnCrossStatement = [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' BundleDropLocator.bundleURL()', + ' }', + '}', + ].join('\n'); + const swiftAssignmentCrossStatement = [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + ' let otaURL = Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' BundleDropLocator.bundleURL()', + ' return otaURL', + ' }', + '}', + ].join('\n'); + const objcCrossStatement = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return nil; [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'); + + expect(hasBareAndroidStartupIntegration(kotlinCrossStatement)).toBe(false); + expect(hasBareIosStartupIntegration( + 'ios/Demo/AppDelegate.swift', + swiftReturnCrossStatement, + )).toBe(false); + expect(hasBareIosStartupIntegration( + 'ios/Demo/AppDelegate.swift', + swiftAssignmentCrossStatement, + )).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', objcCrossStatement)) + .toBe(false); + }); + + it('accepts only anchored canonical optional-locator fallback branches', () => { + const swift = [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + ' if let otaURL = BundleDropLocator.bundleURL() { return otaURL }', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'); + const objc = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL {', + ' NSURL *otaURL = [BundleDropLocator bundleURL];', + ' if (otaURL != nil) { return otaURL; }', + ' return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];', + '}', + '@end', + ].join('\n'); + const deadSwift = swift.replace( + ' if let otaURL', + ' if false {\n if let otaURL', + ).replace( + ' return Bundle.main', + ' return Bundle.main', + ).replace('\n }\n}', '\n }\n }\n}'); + + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', swift)).toBe(true); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', objc)).toBe(true); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', deadSwift)).toBe(false); + }); + + it('rejects the exact reviewer native authority bypass probes', () => { + const modernWrapper = (resolver: string, lazyPrefix = '') => [ + 'import com.bundledrop.BundleDropModule', + 'class MainApplication: Application(), ReactApplication {', + ` ${resolver}`, + ' override val reactHost: ReactHost by lazy {', + ` ${lazyPrefix}`, + ' getDefaultReactHost(', + ' context = applicationContext,', + ' packages = PackageList(this).packages,', + ' jsBundleFilePath = getJSBundleFile(),', + ' )', + ' }', + '}', + ].join('\n'); + const conditionalAndroid = modernWrapper( + 'private fun getJSBundleFile(): String? { return if (useOta) BundleDropModule.resolveJSBundleFile(this, null) else "/android_asset/index.android.bundle" }', + ); + const nearMatchAndroid = modernWrapper( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFileForTests(this, null)', + ); + const wrongContextAndroid = modernWrapper( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(42, null)', + ); + const aliasMismatchAndroid = modernWrapper( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + ).replace( + 'import com.bundledrop.BundleDropModule', + 'import com.bundledrop.BundleDropModule as BDM', + ); + const earlyLazyBypass = modernWrapper( + 'private fun getJSBundleFile(): String? = BundleDropModule.resolveJSBundleFile(this, null)', + 'if (useCustom) return@lazy customReactHost', + ); + const swiftTernary = [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + ' return useOta ? BundleDropLocator.bundleURL() : Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'); + const deadFactoryBundle = RN85_SWIFT_APP_DELEGATE.replace( + ' self.bundleURL()', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ); + const bypassedAppDelegateBundle = [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' override func sourceURL(for bridge: RCTBridge) -> URL? {', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'); + const deadObjcDelegation = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {', + ' [self bundleURL];', + ' return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];', + '}', + '@end', + ].join('\n'); + + for (const source of [ + conditionalAndroid, + nearMatchAndroid, + wrongContextAndroid, + aliasMismatchAndroid, + earlyLazyBypass, + ]) { + expect(hasBareAndroidStartupIntegration(source)).toBe(false); + } + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', swiftTernary)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', deadFactoryBundle)).toBe(false); expect(hasBareIosStartupIntegration( 'ios/Demo/AppDelegate.swift', - 'import ReactNativeBundleDrop\nfunc bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + bypassedAppDelegateBundle, )).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', deadObjcDelegation)).toBe(false); + }); + + it('removes DEBUG-only elseif branches from mixed preprocessor chains', () => { + const mixedSwift = [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? {', + '#if FEATURE_PREVIEW', + ' return Bundle.main.url(forResource: "preview", withExtension: "jsbundle")', + '#elseif DEBUG', + ' return BundleDropLocator.bundleURL()', + '#else', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + '#endif', + ' }', + '}', + ].join('\n'); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', mixedSwift)).toBe(false); + }); + + it('rejects Objective-C class methods and compound Swift DEBUG projections', () => { + const objcClassMethods = [ + '#import ', + '@implementation AppDelegate', + '+ (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '+ (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { return [self bundleURL]; }', + '@end', + ].join('\n'); + const swiftCompoundDebug = (condition: string, debugBranchFirst: boolean) => [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + `#if ${condition}`, + debugBranchFirst + ? ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")' + : ' return BundleDropLocator.bundleURL()', + '#else', + debugBranchFirst + ? ' return BundleDropLocator.bundleURL()' + : ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + '#endif', + ' }', + '}', + ].join('\n'); + + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', objcClassMethods)).toBe(false); expect(hasBareIosStartupIntegration( 'ios/Demo/AppDelegate.swift', - '// import BundleDrop\nlet marker = "BundleDropLocator.bundleURL()"', + swiftCompoundDebug('DEBUG || FEATURE_OFFLINE', true), )).toBe(false); expect(hasBareIosStartupIntegration( + 'ios/Demo/AppDelegate.swift', + swiftCompoundDebug('!DEBUG && USE_OTA', false), + )).toBe(false); + }); + + it('accepts safe NativePaths context references and rejects computed arguments', () => { + for (const context of ['this', 'this@MainApplication', 'appContext', 'holder.appContext']) { + expect(hasBareAndroidStartupIntegration( + RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION.replace('this@MainApplication', context), + )).toBe(true); + } + expect(hasBareAndroidStartupIntegration( + RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION.replace( + 'this@MainApplication', + 'resolveContext()', + ), + )).toBe(false); + }); + + it('rejects Swift and Objective-C dead owners and near-match lifecycle methods', () => { + const swiftHelper = [ + 'import BundleDrop', + 'class AppDelegate { func bundleURL() -> URL? { nil } }', + 'class Helper { func bundleURL() -> URL? { BundleDropLocator.bundleURL() } }', + ].join('\n'); + const swiftNearMatch = [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL() -> URL? { nil }', + ' func bundleURLForTests() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'); + const swiftParameterized = [ + 'import BundleDrop', + 'class AppDelegate {', + ' func bundleURL(test: Bool) -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'); + const objcHelper = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { return nil; }', + '@end', + '@implementation Helper', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'); + const objcNearMatch = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { return nil; }', + '- (NSURL *)sourceURLForBridgeForTests:(RCTBridge *)bridge {', + ' return [BundleDropLocator bundleURL];', + '}', + '@end', + ].join('\n'); + const objcCategory = [ + '#import ', + '@implementation AppDelegate (BundleDrop)', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + ].join('\n'); + const objcDuplicate = [ + '#import ', + '@implementation AppDelegate', + '- (NSURL *)bundleURL { return [BundleDropLocator bundleURL]; }', + '@end', + '@implementation AppDelegate', + '@end', + ].join('\n'); + const explicitOtherPrincipal = [ + 'import BundleDrop', + '@main class RealAppDelegate: UIResponder, UIApplicationDelegate {}', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + '}', + ].join('\n'); + + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', swiftHelper)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', swiftNearMatch)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', swiftParameterized)) + .toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', objcHelper)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', objcNearMatch)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', objcCategory)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.mm', objcDuplicate)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', explicitOtherPrincipal)) + .toBe(false); + }); + + it('rejects unresolved DEBUG conditions and competing Release returns', () => { + const compoundDebugAndroid = RN71_KOTLIN_MAIN_APPLICATION.replace( + 'if (BuildConfig.DEBUG) {', + 'if (BuildConfig.DEBUG && featureFlag) {', + ).replace( + '"/data/local/tmp/dev.jsbundle"', + 'BundleDropModule.resolveJSBundleFile(this@MainApplication, null)!!', + ).replace( + /BundleDropModule\.resolveJSBundleFile\(\n this@MainApplication,\n "\/android_asset\/index\.android\.bundle",\n \)!!/, + '"/android_asset/index.android.bundle"', + ); + const competingAndroid = RN71_KOTLIN_MAIN_APPLICATION.replace( + /override fun getJSBundleFile\(\): String =[\s\S]*?\n }\n}/, + `override fun getJSBundleFile(): String? { + if (false) return BundleDropModule.resolveJSBundleFile(this@MainApplication, null) + return null + } + } +}`, + ); + const competingSwift = [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + ' if false { return BundleDropLocator.bundleURL() }', + ' return Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'); + + expect(hasBareAndroidStartupIntegration(compoundDebugAndroid)).toBe(false); + expect(hasBareAndroidStartupIntegration(competingAndroid)).toBe(false); + expect(hasBareIosStartupIntegration('ios/Demo/AppDelegate.swift', competingSwift)).toBe(false); + }); + + it('reports lifecycle, debug-provider, fallback, and delegation removal from startup bodies', () => { + const originalAndroid = [ + 'class MainApplication {', + ' override fun onCreate() { super.onCreate(); loadReactNative(this) }', + '}', + ].join('\n'); + const updatedAndroid = originalAndroid.replace( + 'override fun onCreate() { super.onCreate(); loadReactNative(this) }', + 'fun onCreateForTests() { super.onCreate(); loadReactNative(this) }', + ); + expect(findMissingBareNativeStartupStructure( + 'android/app/src/main/kotlin/demo/MainApplication.kt', + originalAndroid, + updatedAndroid, + )).toContain('onCreate'); + + const originalSwift = [ + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? {', + ' RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")', + ' Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'); + const updatedSwift = [ + 'class AppDelegate: RCTAppDelegate {', + ' override func bundleURL() -> URL? { BundleDropLocator.bundleURL() }', + ' func fallbackForTests() {', + ' RCTBundleURLProvider.sharedSettings()', + ' Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ' }', + '}', + ].join('\n'); + expect(findMissingBareNativeStartupStructure( + 'ios/Demo/AppDelegate.swift', + originalSwift, + updatedSwift, + )).toEqual(expect.arrayContaining([ + 'bundleURL/RCTBundleURLProvider', + 'bundleURL/Bundle.main.url', + ])); + + const originalObjc = [ + '@implementation AppDelegate', + '- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { return [self bundleURL]; }', + '- (NSURL *)bundleURL { return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; }', + '@end', + ].join('\n'); + const updatedObjc = originalObjc.replace( + 'return [self bundleURL];', + 'return [BundleDropLocator bundleURL];', + ); + expect(findMissingBareNativeStartupStructure( 'ios/Demo/AppDelegate.mm', - '@implementation AppDelegate\n@end', + originalObjc, + updatedObjc, + )).toContain('sourceURLForBridge/bundleURL delegation'); + }); + + it('rejects invented imports, NativePaths-only references, comments, and strings', () => { + expect(hasBareAndroidStartupIntegration([ + 'import com.gfean.reactnativebundledrop.BundleDropModule', + 'class MainApplication {', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + '}', + ].join('\n'))).toBe(false); + expect(hasBareAndroidStartupIntegration([ + 'import com.bundledrop.BundleDropNativePaths', + 'class MainApplication { val path = BundleDropNativePaths.getDownloadedBundlePath(this) }', + ].join('\n'))).toBe(false); + expect(hasBareIosStartupIntegration( + 'ios/Demo/AppDelegate.swift', + 'import ReactNativeBundleDrop\nclass AppDelegate { func bundleURL() -> URL? { BundleDropLocator.bundleURL() } }', + )).toBe(false); + expect(hasBareIosStartupIntegration( + 'ios/Demo/AppDelegate.swift', + '// import BundleDrop\nlet marker = "BundleDropLocator.bundleURL()"', )).toBe(false); }); - it('strips every supported comment and string form while preserving executable code', () => { + it('strips supported comment and string forms while preserving executable code', () => { const source = [ '// line comment', - 'const first = "double \\\" quoted";', - "const second = 'single \\\' quoted';", + 'const first = "double \\" quoted";', + "const second = 'single \\' quoted';", 'const third = `template \\` quoted`;', '/* block comment */', 'fun resolver() = BundleDropModule.resolveJSBundleFile(this, null)', @@ -80,20 +887,43 @@ describe('bare native setup contract', () => { expect(code).toContain('BundleDropModule.resolveJSBundleFile'); }); - it('rejects partial native references that do not own cold-start resolution', () => { - expect(hasBareAndroidStartupIntegration( - 'import com.bundledrop.BundleDropModule\nclass MainApplication {}', - )).toBe(false); - expect(hasBareAndroidStartupIntegration( - 'fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', - )).toBe(false); + it('does not expose resolver code hidden by nested Kotlin or Swift block comments', () => { + const nestedKotlinComment = [ + 'class MainApplication {', + ' /* outer comment', + ' /* nested comment */', + ' import com.bundledrop.BundleDropModule', + ' override fun getJSBundleFile() = BundleDropModule.resolveJSBundleFile(this, null)', + ' */', + '}', + ].join('\n'); + const nestedSwiftComment = [ + 'import BundleDrop', + 'class AppDelegate: RCTAppDelegate {', + ' /* outer comment', + ' /* nested comment */', + ' override func bundleURL() -> URL? { return BundleDropLocator.bundleURL() }', + ' */', + '}', + ].join('\n'); + + expect(hasBareAndroidStartupIntegration(nestedKotlinComment)).toBe(false); expect(hasBareIosStartupIntegration( 'ios/Demo/AppDelegate.swift', - 'import BundleDrop\nclass AppDelegate {}', - )).toBe(false); - expect(hasBareIosStartupIntegration( - 'ios/Demo/AppDelegate.mm', - '#import \n@implementation AppDelegate\n@end', + nestedSwiftComment, )).toBe(false); }); + + it('rejects Bundle Drop and CodePush co-authority but ignores comments and strings', () => { + const configured = RN71_KOTLIN_MAIN_APPLICATION; + expect(hasBareAndroidStartupIntegration(configured.replace( + '"/android_asset/index.android.bundle",', + 'CodePush.getJSBundleFile(),', + ))).toBe(false); + expect(hasBareAndroidStartupIntegration([ + '// CodePush.getJSBundleFile() is intentionally not used.', + configured, + 'val migrationNote = "CodePush"', + ].join('\n'))).toBe(true); + }); }); diff --git a/src/tests/CLI/scripts/post-init.test.ts b/src/tests/CLI/scripts/post-init.test.ts index 870df39..37aa2d2 100644 --- a/src/tests/CLI/scripts/post-init.test.ts +++ b/src/tests/CLI/scripts/post-init.test.ts @@ -22,6 +22,7 @@ describe('CLI/scripts/post-init', () => { const options = { projectType: 'expo' as const, dryRun: true, + migrateCodePush: true, migrateExpoUpdates: true, prebuild: false, yes: true, diff --git a/src/tests/CLI/scripts/safe-file-transaction.test.ts b/src/tests/CLI/scripts/safe-file-transaction.test.ts new file mode 100644 index 0000000..b8bcd3a --- /dev/null +++ b/src/tests/CLI/scripts/safe-file-transaction.test.ts @@ -0,0 +1,154 @@ +import fs from 'fs'; +import type { Stats } from 'fs'; +import path from 'path'; + +import { + createSafeBackupDirectory, + inspectProjectDirectory, + inspectProjectFile, + removeProjectFile, + restoreProjectFile, + writeBackupFile, + writeProjectFileAtomically, +} from '../../../CLI/scripts/safe-file-transaction'; +import { createTempProjectDir, removeTempDir } from '../../utils/tempDir'; + +describe('CLI/scripts/safe-file-transaction', () => { + let projectRoot = ''; + + beforeEach(() => { + projectRoot = createTempProjectDir(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + removeTempDir(projectRoot); + }); + + it('inspects missing and regular project paths without creating anything', () => { + expect(inspectProjectFile(projectRoot, 'missing/file.txt')).toEqual({ + exists: false, + content: '', + mode: 0o666, + }); + expect(inspectProjectDirectory(projectRoot, 'missing/directory')).toBe(false); + + fs.mkdirSync(path.join(projectRoot, 'config')); + fs.writeFileSync(path.join(projectRoot, 'config/value.txt'), 'value'); + fs.chmodSync(path.join(projectRoot, 'config/value.txt'), 0o640); + + expect(inspectProjectDirectory(projectRoot, 'config')).toBe(true); + expect(inspectProjectFile(projectRoot, 'config/value.txt')).toEqual({ + exists: true, + content: 'value', + mode: 0o640, + }); + }); + + it.each(['', '/absolute.txt', '../outside.txt', 'nested\\file.txt'])( + 'rejects unsafe relative path %p', + relativePath => { + expect(() => inspectProjectFile(projectRoot, relativePath)).toThrow( + 'Refusing unsafe transaction path', + ); + }, + ); + + it('rejects non-directory roots, symlinked parents, and non-regular targets', () => { + const rootFile = path.join(projectRoot, 'root-file'); + fs.writeFileSync(rootFile, 'not a directory'); + expect(() => inspectProjectFile(rootFile, 'child.txt')).toThrow( + 'Refusing symlinked or non-directory transaction path', + ); + + const realDirectory = path.join(projectRoot, 'real'); + fs.mkdirSync(realDirectory); + fs.symlinkSync(realDirectory, path.join(projectRoot, 'linked')); + expect(() => inspectProjectFile(projectRoot, 'linked/file.txt')).toThrow( + 'Refusing symlinked or non-directory transaction path', + ); + + fs.mkdirSync(path.join(projectRoot, 'directory-target')); + expect(() => inspectProjectFile(projectRoot, 'directory-target')).toThrow( + 'Refusing symlinked or non-regular transaction target', + ); + expect(() => inspectProjectDirectory(projectRoot, 'root-file')).toThrow( + 'Refusing symlinked or non-directory transaction target', + ); + }); + + it('propagates unexpected filesystem inspection failures', () => { + const realLstat = fs.lstatSync.bind(fs); + const lstat = jest.spyOn(fs, 'lstatSync').mockImplementation(targetPath => { + if (String(targetPath).endsWith(`${path.sep}blocked.txt`)) { + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + } + return realLstat(targetPath); + }); + + expect(() => inspectProjectFile(projectRoot, 'blocked.txt')).toThrow('permission denied'); + lstat.mockRestore(); + }); + + it('rejects a target that changes away from a regular file after opening', () => { + fs.writeFileSync(path.join(projectRoot, 'value.txt'), 'value'); + jest.spyOn(fs, 'fstatSync').mockReturnValueOnce({ + isFile: () => false, + } as Stats); + + expect(() => inspectProjectFile(projectRoot, 'value.txt')).toThrow( + 'Refusing symlinked or non-regular transaction target', + ); + }); + + it('creates and replaces files atomically while preserving the existing mode', () => { + writeProjectFileAtomically(projectRoot, 'nested/value.txt', 'first', 0o600); + expect(fs.readFileSync(path.join(projectRoot, 'nested/value.txt'), 'utf8')).toBe('first'); + expect(fs.statSync(path.join(projectRoot, 'nested/value.txt')).mode & 0o777).toBe(0o600); + + writeProjectFileAtomically(projectRoot, 'nested/value.txt', 'second', 0o666); + expect(fs.readFileSync(path.join(projectRoot, 'nested/value.txt'), 'utf8')).toBe('second'); + expect(fs.statSync(path.join(projectRoot, 'nested/value.txt')).mode & 0o777).toBe(0o600); + expect(fs.readdirSync(path.join(projectRoot, 'nested'))).toEqual(['value.txt']); + }); + + it('cleans the exclusive temporary file when the final rename fails', () => { + const rename = jest.spyOn(fs, 'renameSync').mockImplementationOnce(() => { + throw new Error('rename failed'); + }); + + expect(() => writeProjectFileAtomically(projectRoot, 'value.txt', 'content')).toThrow( + 'rename failed', + ); + expect(rename).toHaveBeenCalledTimes(1); + expect(fs.readdirSync(projectRoot).filter(file => file.includes('.bundledrop-'))).toEqual([]); + }); + + it('backs up, restores, and removes project files with exact content and mode', () => { + fs.writeFileSync(path.join(projectRoot, 'package.json'), 'original'); + fs.chmodSync(path.join(projectRoot, 'package.json'), 0o640); + const backupRoot = createSafeBackupDirectory(projectRoot, 'dependency'); + + writeBackupFile(backupRoot, 'package.json', 'original', 0o640); + writeProjectFileAtomically(projectRoot, 'package.json', 'changed'); + restoreProjectFile(projectRoot, backupRoot, 'package.json'); + + expect(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).toBe('original'); + expect(fs.statSync(path.join(projectRoot, 'package.json')).mode & 0o777).toBe(0o640); + expect(() => writeBackupFile(backupRoot, 'package.json', 'duplicate', 0o600)).toThrow(); + + removeProjectFile(projectRoot, 'package.json'); + removeProjectFile(projectRoot, 'package.json'); + expect(fs.existsSync(path.join(projectRoot, 'package.json'))).toBe(false); + }); + + it('refuses restore when the requested backup is absent', () => { + const backupRoot = createSafeBackupDirectory(projectRoot, 'missing'); + + expect(() => restoreProjectFile(projectRoot, backupRoot, 'package.json')).toThrow( + 'Missing transaction backup: package.json', + ); + }); +}); diff --git a/src/tests/api/clientApi.test.ts b/src/tests/api/clientApi.test.ts index b9c9fec..b06d497 100644 --- a/src/tests/api/clientApi.test.ts +++ b/src/tests/api/clientApi.test.ts @@ -59,6 +59,50 @@ describe('api/clientApi', () => { ); }); + it('uses the shared artifact-authorization and throttled heartbeat contracts exactly', async () => { + const { clientApi, apiClient } = loadClientApiModule(); + const post = jest.spyOn(apiClient, 'post').mockResolvedValue(apiResponse({ action: 'NOOP' })); + const authorization = { + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + generation: 7, + targetReleaseRef: 'release-7', + targetHash: 'a'.repeat(64), + mode: 'patch' as const, + patchArtifactRef: 'patch-6-7', + currentHash: 'b'.repeat(64), + rejectedHashes: ['c'.repeat(64)], + installId: 'install-7', + transport: { + manifestVersion: 1 as const, + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: true, + }, + }; + await clientApi.postOtaArtifactAuthorization('team/app', authorization); + expect(post).toHaveBeenCalledWith( + '/projects/team%2Fapp/ota/artifacts/authorize', + authorization, + { headers: { Accept: 'application/json' }, timeout: 15000 }, + ); + + const heartbeat = { + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + installId: 'install-7', + currentHash: 'b'.repeat(64), + environment: 'production', + }; + await clientApi.postOtaActiveInstallHeartbeat('team/app', heartbeat); + expect(post).toHaveBeenCalledWith( + '/projects/team%2Fapp/ota/active-install', + heartbeat, + { headers: { Accept: 'application/json' }, timeout: 3000 }, + ); + }); + it('calls the public channel and installed-report endpoints with encoded params', async () => { const { clientApi, apiClient } = loadClientApiModule(); const get = jest.spyOn(apiClient, 'get').mockResolvedValue(apiResponse(['General'])); diff --git a/src/tests/fixtures/rn85SwiftAppDelegate.ts b/src/tests/fixtures/rn85SwiftAppDelegate.ts new file mode 100644 index 0000000..254fb1f --- /dev/null +++ b/src/tests/fixtures/rn85SwiftAppDelegate.ts @@ -0,0 +1,261 @@ +export const RN85_SWIFT_APP_DELEGATE = `import UIKit +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import BundleDrop + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + var reactNativeDelegate: ReactNativeDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = RCTReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + window = UIWindow(frame: UIScreen.main.bounds) + + factory.startReactNative( + withModuleName: "BundleDropDemo", + in: window, + launchOptions: launchOptions + ) + + return true + } +} + +class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + self.bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + BundleDropLocator.bundleURL() ?? RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") +#else + BundleDropLocator.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} +`; + +export const RN85_ANDROID_NATIVE_PATHS_MAIN_APPLICATION = `package app.bundledrop.harness.rn85 + +import android.app.Application +import com.bundledrop.BundleDropNativePaths +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + }, + jsBundleFilePath = BundleDropNativePaths.getDownloadedBundlePath(applicationContext), + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} +`; + +export const RN71_KOTLIN_MAIN_APPLICATION = `package com.demo + +import com.bundledrop.BundleDropModule +import com.facebook.react.ReactNativeHost +import com.facebook.react.defaults.DefaultReactNativeHost + +class MainApplication { + override val reactNativeHost: ReactNativeHost = + object : DefaultReactNativeHost(this) { + override fun getJSBundleFile(): String = + if (BuildConfig.DEBUG) { + "/data/local/tmp/dev.jsbundle" + } else { + BundleDropModule.resolveJSBundleFile( + this@MainApplication, + "/android_asset/index.android.bundle", + )!! + } + } +} +`; + +export const RN71_KOTLIN_CONDITIONAL_FALLBACK_MAIN_APPLICATION = `package com.demo + +import com.bundledrop.BundleDropModule +import com.facebook.react.ReactNativeHost +import com.facebook.react.defaults.DefaultReactNativeHost + +class MainApplication { + override val reactNativeHost: ReactNativeHost = + object : DefaultReactNativeHost(this) { + override fun getJSBundleFile(): String? { + val fallback = if (enterprisePolicy.enabled) { + selectEnterpriseBundle() + } else { + embeddedBundlePath() + } + return BundleDropModule.resolveJSBundleFile(this@MainApplication, fallback) ?: fallback + } + } +} +`; + +export const RN71_JAVA_MAIN_APPLICATION = `package com.demo; + +import com.bundledrop.BundleDropModule; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.defaults.DefaultReactNativeHost; + +public class MainApplication { + private final ReactNativeHost mReactNativeHost = new DefaultReactNativeHost(this) { + @Override + protected String getJSBundleFile() { + if (BuildConfig.DEBUG) { + return null; + } + return BundleDropModule.resolveJSBundleFile( + MainApplication.this, + "/android_asset/index.android.bundle" + ); + } + }; + + @Override + public ReactNativeHost getReactNativeHost() { + return mReactNativeHost; + } +} +`; + +export const RN71_JAVA_LOCAL_FALLBACK_MAIN_APPLICATION = `package com.demo; + +import com.bundledrop.BundleDropModule; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.defaults.DefaultReactNativeHost; + +public class MainApplication { + private final ReactNativeHost mReactNativeHost = new DefaultReactNativeHost(this) { + @Override + protected String getJSBundleFile() { + return BundleDropModule.resolveJSBundleFile( + getApplicationContext(), + super.getJSBundleFile() + ); + } + }; + + @Override + public ReactNativeHost getReactNativeHost() { + return mReactNativeHost; + } +} +`; + +export const RN71_JAVA_CONDITIONAL_FALLBACK_MAIN_APPLICATION = `package com.demo; + +import com.bundledrop.BundleDropModule; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.defaults.DefaultReactNativeHost; + +public class MainApplication { + private final ReactNativeHost mReactNativeHost = new DefaultReactNativeHost(this) { + @Override + protected String getJSBundleFile() { + String fallback = enterprisePolicy.enabled + ? selectEnterpriseBundle() + : embeddedBundlePath(); + return BundleDropModule.resolveJSBundleFile( + getApplicationContext(), + fallback + ); + } + }; + + @Override + public ReactNativeHost getReactNativeHost() { + return mReactNativeHost; + } +} +`; + +export const RN71_OBJC_APP_DELEGATE = `#import "AppDelegate.h" +#import +#import + +@implementation AppDelegate + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge +{ + return [self bundleURL]; +} + +- (NSURL *)bundleURL +{ +#if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; +#else + NSURL *otaURL = [BundleDropLocator bundleURL]; + if (otaURL != nil) { return otaURL; } + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif +} + +@end +`; + +export const MODERN_KOTLIN_MAIN_APPLICATION = `package com.demo + +import com.bundledrop.BundleDropModule +import com.facebook.react.ReactHost + +class MainApplication { + private fun getJSBundleFile(): String? = + if (BuildConfig.DEBUG) null + else BundleDropModule.resolveJSBundleFile(this, null) + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = emptyList(), + jsBundleFilePath = getJSBundleFile(), + ) + } +} +`; + +export const RN71_KOTLIN_NATIVE_PATHS_MAIN_APPLICATION = `package com.demo + +import com.facebook.react.ReactNativeHost +import com.facebook.react.defaults.DefaultReactNativeHost + +class MainApplication { + override val reactNativeHost: ReactNativeHost = + object : DefaultReactNativeHost(this) { + override fun getJSBundleFile(): String? = + com.bundledrop.BundleDropNativePaths.getDownloadedBundlePath(this@MainApplication) + } +} +`; diff --git a/src/tests/install/installFromZip.test.ts b/src/tests/install/installFromZip.test.ts index 7f7def1..080f497 100644 --- a/src/tests/install/installFromZip.test.ts +++ b/src/tests/install/installFromZip.test.ts @@ -105,6 +105,41 @@ describe('install/installFromZip', () => { ).rejects.toThrow('Bundle manifest is missing'); }); + it('binds v2 full installs to the signed archive, manifest, and JavaScript hashes', async () => { + setMockPlatform('android'); + const manifest = makeManifest({ + 'main.jsbundle': { content: 'signed-bundle', role: 'jsbundle' as const }, + }); + configureUnzipEntries({ + 'main.jsbundle': 'signed-bundle', + [BUNDLE_MANIFEST]: JSON.stringify(manifest), + }); + + await expect(installFromZip({ + downloadUrl: DOWNLOAD_URL, + hash: manifest.bundleHash, + platform: 'android', + expectedArchiveHash: '0'.repeat(64), + })).rejects.toThrow('Full bundle archive hash mismatch'); + + await expect(installFromZip({ + downloadUrl: DOWNLOAD_URL, + hash: manifest.bundleHash, + platform: 'android', + expectedArchiveHash: DOWNLOADED_ZIP_HASH, + expectedManifestHash: '0'.repeat(64), + })).rejects.toThrow('Signed manifest hash does not match'); + + await expect(installFromZip({ + downloadUrl: DOWNLOAD_URL, + hash: manifest.bundleHash, + platform: 'android', + expectedArchiveHash: DOWNLOADED_ZIP_HASH, + expectedManifestHash: manifest.manifestHash, + expectedJsBundleHash: '0'.repeat(64), + })).rejects.toThrow('Signed JavaScript bundle hash does not match'); + }); + it('rejects invalid canonical hashes for patch installs', async () => { setMockPlatform('android'); setMockFile('/mock/doc/bundle-drop/current.json', JSON.stringify({ @@ -850,6 +885,29 @@ describe('install/installFromZip', () => { }); const statusSpy = jest.fn(); + await expect( + installFromPatchSet({ + patchesUrl: 'https://cdn.example.com/patch.zip', + patchSetHash: DOWNLOADED_ZIP_HASH, + baseHash: baseManifest.bundleHash, + targetHash: targetManifest.bundleHash, + algorithm: XDELTA_PATCH_ALGORITHM, + expectedManifestHash: '0'.repeat(64), + }), + ).rejects.toThrow('Signed manifest hash does not match patch target manifest'); + + await expect( + installFromPatchSet({ + patchesUrl: 'https://cdn.example.com/patch.zip', + patchSetHash: DOWNLOADED_ZIP_HASH, + baseHash: baseManifest.bundleHash, + targetHash: targetManifest.bundleHash, + algorithm: XDELTA_PATCH_ALGORITHM, + expectedManifestHash: targetManifest.manifestHash, + expectedJsBundleHash: '0'.repeat(64), + }), + ).rejects.toThrow('Signed JavaScript bundle hash does not match patch target manifest'); + await expect( installFromPatchSet({ patchesUrl: 'https://cdn.example.com/patch.zip', @@ -1387,4 +1445,42 @@ describe('install/installFromZip', () => { expect(installErr).toBeInstanceOf(InstallPhaseError); expect((installErr as InstallPhaseError).phase).toBe('install'); }); + + it('preserves a rejected missing-assets capability as a download failure', async () => { + setMockPlatform('android'); + const baseManifest = makeManifest({ + 'main.jsbundle': { content: 'base', role: 'jsbundle' as const }, + }); + const targetManifest = makeManifest({ + 'main.jsbundle': { content: 'target', role: 'jsbundle' as const }, + }); + setMockFile('/mock/doc/bundle-drop/current.json', JSON.stringify({ + hash: baseManifest.bundleHash, + bundlePath: `/mock/doc/bundle-drop/bundles/${baseManifest.bundleHash}/main.jsbundle`, + })); + configureUnzipEntries({ + [BUNDLE_MANIFEST]: JSON.stringify(targetManifest), + 'files/full/main.jsbundle': 'target', + }); + mockDownloadFile + .mockImplementationOnce(async (_url: string, destination: string) => { + setMockFile(destination, '__downloaded_zip__'); + }) + .mockRejectedValueOnce(Object.assign(new Error('HTTP 403: expired'), { status: 403 })); + + const rejection = await installFromPatchSet({ + patchesUrl: 'https://cdn.example.com/patch.zip', + patchSetHash: DOWNLOADED_ZIP_HASH, + missingAssetsUrl: 'https://cdn.example.com/missing-assets.zip', + missingAssetsHash: 'f'.repeat(64), + baseHash: baseManifest.bundleHash, + targetHash: targetManifest.bundleHash, + algorithm: XDELTA_PATCH_ALGORITHM, + platform: 'android', + }).catch((error: unknown) => error); + + expect(rejection).toBeInstanceOf(InstallPhaseError); + expect((rejection as InstallPhaseError).phase).toBe('download'); + expect((rejection as InstallPhaseError).originalCause).toMatchObject({ status: 403 }); + }); }); diff --git a/src/tests/integration/runtimeDeliveryManifestHost.integration.test.ts b/src/tests/integration/runtimeDeliveryManifestHost.integration.test.ts new file mode 100644 index 0000000..8933acd --- /dev/null +++ b/src/tests/integration/runtimeDeliveryManifestHost.integration.test.ts @@ -0,0 +1,179 @@ +jest.mock('../../context', () => require('../mocks/context')); +jest.mock('../../native/fs', () => require('../mocks/native/fs')); +jest.mock('../../native/bundleDropNative', () => require('../mocks/native/bundleDropNative')); +jest.mock('../../api/clientApi', () => require('../mocks/api/clientApi')); + +import { checkForUpdate } from '../../manager/updateCheck'; +import { + getRuntimeDeliveryDiagnosticCounters, + resetRuntimeDeliveryDiagnosticsForTests, + type RuntimeDeliveryDiagnosticName, +} from '../../runtime-delivery/diagnostics'; +import { + mockPostOtaActiveInstallHeartbeat, + mockPostOtaResolve, +} from '../mocks/api/clientApi'; +import { resetBundleDropNativeMocks } from '../mocks/native/bundleDropNative'; +import { + resetContextMocks, + setMockConfig, + setMockPlatform, +} from '../mocks/context'; +import { + resetNativeFsMocks, + setMockFile, +} from '../mocks/native/fs'; +import { mockGetDownloadedBundlePathNative } from '../mocks/native/bundleDropNative'; + +const INSTALL_ID_PATH = '/mock/doc/bundle-drop/install-id.txt'; +const validationWorker = + 'https://bundledrop-manifest-v2-nonprod-validation.george-fean.workers.dev'; + +type HostScenario = { + name: string; + manifestBaseUrl: string; + expectedDiagnostic: RuntimeDeliveryDiagnosticName; + minimumMs: number; + maximumMs: number; +}; + +const scenarios: HostScenario[] = [ + { + name: 'HTTP 503', + manifestBaseUrl: `${validationWorker}/manifest-host/http-503`, + expectedDiagnostic: 'manifest_http_error', + minimumMs: 0, + maximumMs: 3_000, + }, + { + name: 'chunked body over 1 MiB', + manifestBaseUrl: `${validationWorker}/manifest-host/oversized`, + expectedDiagnostic: 'manifest_too_large', + minimumMs: 0, + maximumMs: 5_000, + }, + { + name: 'request over five seconds', + manifestBaseUrl: `${validationWorker}/manifest-host/slow`, + expectedDiagnostic: 'manifest_timeout', + minimumMs: 4_900, + maximumMs: 7_000, + }, + { + name: 'DNS/network failure', + manifestBaseUrl: 'https://does-not-exist-runtime-delivery.bundledrop.app', + expectedDiagnostic: 'manifest_network_error', + minimumMs: 0, + maximumMs: 5_000, + }, +]; + +const describeLive = process.env.BUNDLE_DROP_RUNTIME_DELIVERY_LIVE_HOST_VALIDATION === 'true' + ? describe + : describe.skip; + +describeLive('runtime-delivery non-production manifest-host failures', () => { + jest.setTimeout(30_000); + + beforeEach(() => { + resetContextMocks(); + resetNativeFsMocks(); + resetBundleDropNativeMocks(); + resetRuntimeDeliveryDiagnosticsForTests(); + setMockFile(INSTALL_ID_PATH, 'nonprod-validation-install'); + mockPostOtaActiveInstallHeartbeat.mockReset().mockResolvedValue({ data: undefined } as never); + mockPostOtaResolve.mockReset().mockResolvedValue({ + data: { action: 'NOOP', reason: 'UP_TO_DATE' }, + } as never); + }); + + it.each(scenarios)( + 'falls back safely for $name', + async ({ name, manifestBaseUrl, expectedDiagnostic, minimumMs, maximumMs }) => { + setMockConfig({ + runtimeDelivery: { + mode: 'v2', + manifestBaseUrl, + manifestAccessId: 'NonProdValidationAccess', + publicKeys: { + unused: { + kty: 'EC', + crv: 'P-256', + x: 'A'.repeat(43), + y: 'A'.repeat(43), + }, + }, + }, + }); + + const startedAt = Date.now(); + await expect(checkForUpdate('NonProd')).resolves.toEqual(expect.objectContaining({ + action: 'NOOP', + reason: 'UP_TO_DATE', + })); + const elapsedMs = Date.now() - startedAt; + const counters = getRuntimeDeliveryDiagnosticCounters(); + + expect(elapsedMs).toBeGreaterThanOrEqual(minimumMs); + expect(elapsedMs).toBeLessThan(maximumMs); + expect(counters[expectedDiagnostic]).toBe(1); + expect(counters.origin_fallback).toBe(1); + expect(mockPostOtaResolve).toHaveBeenCalledTimes(1); + console.log('[BundleDrop nonprod host validation]', JSON.stringify({ + name, + elapsedMs, + expectedDiagnostic, + originFallbacks: counters.origin_fallback, + })); + }, + ); + + it('verifies a real backend-signed manifest and applies its revocation locally', async () => { + const publicKeysJson = process.env.BUNDLE_DROP_RUNTIME_DELIVERY_LIVE_PUBLIC_KEYS_JSON; + if (!publicKeysJson) { + throw new Error('BUNDLE_DROP_RUNTIME_DELIVERY_LIVE_PUBLIC_KEYS_JSON is required'); + } + const revokedHash = 'f'.repeat(64); + setMockPlatform('ios'); + setMockConfig({ + project: { + name: 'Non-production validation', + slug: 'nonprod-validation', + apiKey: 'nonprod-validation-key', + }, + runtimeDelivery: { + mode: 'v2', + manifestBaseUrl: 'https://manifests-v2-nonprod.bundledrop.app', + manifestAccessId: 'NonProdValidation_c817c0ffee123456', + publicKeys: JSON.parse(publicKeysJson), + }, + }); + setMockFile('/mock/lib/bundle-drop/install-id.txt', 'nonprod-revocation-install'); + // bundlePointer.ts captures the test root when the module is loaded (Android by default). + setMockFile('/mock/doc/bundle-drop/current.json', JSON.stringify({ hash: revokedHash })); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/lib/bundle-drop/bundles/${revokedHash}/main.jsbundle`, + ); + + const startedAt = Date.now(); + await expect(checkForUpdate('NonProd')).resolves.toEqual(expect.objectContaining({ + action: 'ROLLBACK', + reason: 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET', + })); + const elapsedMs = Date.now() - startedAt; + const counters = getRuntimeDeliveryDiagnosticCounters(); + + expect(counters.manifest_hit).toBe(1); + expect(counters.origin_fallback).toBe(0); + expect(counters.invalid_signature).toBe(0); + expect(counters.unknown_key).toBe(0); + expect(mockPostOtaResolve).not.toHaveBeenCalled(); + console.log('[BundleDrop nonprod signed revocation validation]', JSON.stringify({ + elapsedMs, + action: 'ROLLBACK', + reason: 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET', + manifestHits: counters.manifest_hit, + originFallbacks: counters.origin_fallback, + })); + }); +}); diff --git a/src/tests/manager/downloadAndInstall.test.ts b/src/tests/manager/downloadAndInstall.test.ts index 05bc481..5e6c8d3 100644 --- a/src/tests/manager/downloadAndInstall.test.ts +++ b/src/tests/manager/downloadAndInstall.test.ts @@ -3,7 +3,10 @@ import * as bundleInfoModule from '../../bundleInfo'; import { downloadUpdate, installBundle } from '../../manager/downloadAndInstall'; import { resetContextMocks } from '../mocks/context'; import { mockInstallFromPatchSet, mockInstallFromZip } from '../mocks/install/installFromZip'; -import { mockCheckForUpdate } from '../mocks/manager/updateCheck'; +import { + mockAuthorizeRuntimeDeliveryUpdate, + mockCheckForUpdate, +} from '../mocks/manager/updateCheck'; import { getMockFile, readMockJson, resetNativeFsMocks, setMockFile } from '../mocks/native/fs'; import { mockReportPatchApplyFailure } from '../mocks/api/clientApi'; import { mockGetDownloadedBundlePathNative, resetBundleDropNativeMocks } from '../mocks/native/bundleDropNative'; @@ -180,6 +183,217 @@ describe('manager/downloadAndInstall', () => { ); }); + it('forwards signed v2 archive, manifest, and JavaScript hashes into installation', async () => { + const hash = '3'.repeat(64); + mockCheckForUpdate.mockResolvedValue({ + action: 'INSTALL', + channelName: 'General', + hash, + runtimeVersion: '1.0.0', + mode: 'full', + runtimeDelivery: { + generation: 7, + targetReleaseRef: 'release-7', + selectedMode: 'full', + manifestHash: 'a'.repeat(64), + jsBundleHash: 'b'.repeat(64), + fullBundleHash: 'c'.repeat(64), + }, + }); + mockAuthorizeRuntimeDeliveryUpdate.mockResolvedValue({ + action: 'INSTALL', + channelName: 'General', + hash, + runtimeVersion: '1.0.0', + mode: 'full', + downloadUrl: 'https://cdn.example.com/v2-full.zip', + manifestUrl: 'https://cdn.example.com/v2-manifest.json', + runtimeDelivery: { + generation: 7, + targetReleaseRef: 'release-7', + selectedMode: 'full', + manifestHash: 'a'.repeat(64), + jsBundleHash: 'b'.repeat(64), + fullBundleHash: 'c'.repeat(64), + }, + }); + mockInstallFromZip.mockResolvedValue({ + bundlePath: `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`, + metadataFromZip: { runtimeVersion: '1.0.0' }, + }); + + await expect(downloadUpdate()).resolves.toEqual(expect.objectContaining({ status: 'staged', hash })); + expect(mockInstallFromZip).toHaveBeenCalledWith(expect.objectContaining({ + expectedArchiveHash: 'c'.repeat(64), + expectedManifestHash: 'a'.repeat(64), + expectedJsBundleHash: 'b'.repeat(64), + })); + }); + + it('refreshes a rejected v2 artifact capability exactly once and retries the same target', async () => { + const hash = '4'.repeat(64); + const selection = { + action: 'INSTALL' as const, + channelName: 'General', + hash, + runtimeVersion: '1.0.0', + mode: 'full' as const, + runtimeDelivery: { + generation: 8, + targetReleaseRef: 'release-8', + selectedMode: 'full' as const, + manifestHash: 'a'.repeat(64), + jsBundleHash: 'b'.repeat(64), + fullBundleHash: 'c'.repeat(64), + }, + }; + mockCheckForUpdate.mockResolvedValue(selection); + mockAuthorizeRuntimeDeliveryUpdate + .mockResolvedValueOnce({ ...selection, downloadUrl: 'https://cdn.example.com/expired.zip' }) + .mockResolvedValueOnce({ ...selection, downloadUrl: 'https://cdn.example.com/refreshed.zip' }); + mockInstallFromZip + .mockRejectedValueOnce(new InstallPhaseError('download', new Error('HTTP 403: expired'))) + .mockResolvedValueOnce({ + bundlePath: `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`, + metadataFromZip: { runtimeVersion: '1.0.0' }, + }); + const statusSpy = jest.fn(); + + await expect(downloadUpdate(undefined, statusSpy)).resolves.toMatchObject({ + status: 'staged', hash, + }); + expect(mockAuthorizeRuntimeDeliveryUpdate).toHaveBeenCalledTimes(2); + expect(mockInstallFromZip).toHaveBeenNthCalledWith(1, expect.objectContaining({ + downloadUrl: 'https://cdn.example.com/expired.zip', + })); + expect(mockInstallFromZip).toHaveBeenNthCalledWith(2, expect.objectContaining({ + downloadUrl: 'https://cdn.example.com/refreshed.zip', + })); + expect(statusSpy).toHaveBeenCalledWith( + '🔐 Download authorization expired; refreshing once...', + ); + }); + + it('does not retry when reauthorization changes the signed target identity', async () => { + const hash = '5'.repeat(64); + const selection = { + action: 'INSTALL' as const, + channelName: 'General', + hash, + runtimeVersion: '1.0.0', + mode: 'full' as const, + runtimeDelivery: { + generation: 9, + targetReleaseRef: 'release-9', + selectedMode: 'full' as const, + }, + }; + mockCheckForUpdate.mockResolvedValue(selection); + mockAuthorizeRuntimeDeliveryUpdate + .mockResolvedValueOnce({ ...selection, downloadUrl: 'https://cdn.example.com/expired.zip' }) + .mockResolvedValueOnce({ + ...selection, + hash: '6'.repeat(64), + downloadUrl: 'https://cdn.example.com/different.zip', + }); + mockInstallFromZip.mockRejectedValueOnce( + new InstallPhaseError('download', { statusCode: 401 }), + ); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(downloadUpdate()).rejects.toMatchObject({ + code: 'DOWNLOAD_FAILED', step: 'download', + }); + expect(mockInstallFromZip).toHaveBeenCalledTimes(1); + expect(mockAuthorizeRuntimeDeliveryUpdate).toHaveBeenCalledTimes(2); + } finally { + consoleSpy.mockRestore(); + } + }); + + it('refreshes a full fallback without retrying a patch that already failed locally', async () => { + const baseHash = '7'.repeat(64); + const hash = '8'.repeat(64); + const selection = { + action: 'INSTALL' as const, + channelName: 'General', + hash, + runtimeVersion: '1.0.0', + mode: 'patch' as const, + baseHash, + patchSet: { + algorithm: 'xdelta3-vcdiff' as const, + patchSetHash: 'patch-set-hash', + patchesUrl: 'https://cdn.example.com/patch.zip', + }, + fallback: { + mode: 'full' as const, + downloadUrl: 'https://cdn.example.com/expired-full.zip', + }, + runtimeDelivery: { + generation: 10, + targetReleaseRef: 'release-10', + selectedMode: 'patch' as const, + baseHash, + patchAlgorithm: 'xdelta3-vcdiff', + patchSetHash: 'patch-set-hash', + patchArtifactRef: 'patch-ref', + missingAssetsHash: undefined, + }, + }; + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ + hash: baseHash, + bundlePath: `/mock/doc/bundle-drop/bundles/${baseHash}/main.jsbundle`, + })); + mockCheckForUpdate.mockResolvedValue(selection); + mockAuthorizeRuntimeDeliveryUpdate + .mockResolvedValueOnce(selection) + .mockResolvedValueOnce({ + ...selection, + fallback: { + mode: 'full', + downloadUrl: 'https://cdn.example.com/refreshed-full.zip', + }, + }); + mockInstallFromPatchSet.mockRejectedValueOnce(new Error('local patch apply failed')); + mockInstallFromZip + .mockRejectedValueOnce(new InstallPhaseError('download', { status: 403 })) + .mockResolvedValueOnce({ + bundlePath: `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`, + metadataFromZip: { runtimeVersion: '1.0.0' }, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await expect(downloadUpdate()).resolves.toMatchObject({ status: 'staged', hash }); + expect(mockInstallFromPatchSet).toHaveBeenCalledTimes(1); + expect(mockInstallFromZip).toHaveBeenCalledTimes(2); + expect(mockInstallFromZip).toHaveBeenLastCalledWith(expect.objectContaining({ + downloadUrl: 'https://cdn.example.com/refreshed-full.zip', + })); + expect(mockAuthorizeRuntimeDeliveryUpdate).toHaveBeenCalledTimes(2); + } finally { + warnSpy.mockRestore(); + } + }); + + it('does not reauthorize v1 downloads or non-capability failures', async () => { + mockCheckForUpdate.mockResolvedValue({ + action: 'INSTALL', channelName: 'General', hash: 'hash-v1', + downloadUrl: 'https://cdn.example.com/v1.zip', mode: 'full', + }); + mockInstallFromZip.mockRejectedValueOnce( + new InstallPhaseError('download', new Error('HTTP 403: denied')), + ); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(downloadUpdate()).rejects.toMatchObject({ code: 'DOWNLOAD_FAILED' }); + expect(mockAuthorizeRuntimeDeliveryUpdate).toHaveBeenCalledTimes(1); + expect(mockInstallFromZip).toHaveBeenCalledTimes(1); + } finally { + consoleSpy.mockRestore(); + } + }); + it('clears the candidate pointer before throwing when native rejects without a previous pointer', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/src/tests/manager/rollbackState.test.ts b/src/tests/manager/rollbackState.test.ts index 2e88437..134b369 100644 --- a/src/tests/manager/rollbackState.test.ts +++ b/src/tests/manager/rollbackState.test.ts @@ -862,6 +862,37 @@ describe('manager/rollbackState', () => { nowSpy.mockRestore(); }); + it('forces native rollback instead of activating a previous OTA bundle', async () => { + setMockFile( + CURRENT_POINTER_PATH, + JSON.stringify({ + hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + bundlePath: '/mock/doc/bundle-drop/bundles/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/main.jsbundle', + updatedAt: '2026-03-01T00:00:00.000Z', + }), + ); + setMockFile( + PREVIOUS_POINTER_PATH, + JSON.stringify({ + hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + bundlePath: '/mock/doc/bundle-drop/bundles/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/main.jsbundle', + updatedAt: '2026-02-01T00:00:00.000Z', + }), + ); + + await expect( + rollbackToPreviousOrNative({ forceNative: true }), + ).resolves.toEqual({ rolledBack: true, toNative: true }); + + expect(readMockJson(CURRENT_POINTER_PATH)).toBeNull(); + expect(readMockJson(PREVIOUS_POINTER_PATH)).toBeNull(); + expect(readMockJson(STATE_PATH)).toEqual( + expect.objectContaining({ candidateCommitted: true, crashCount: 0 }), + ); + expect(readMockJson(STATE_PATH)).not.toHaveProperty('activeHash'); + expect(readMockJson(STATE_PATH)).not.toHaveProperty('candidateHash'); + }); + it('falls back to native when the previous pointer matches the active bundle', async () => { const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(32_000_000); diff --git a/src/tests/manager/updateCheck.test.ts b/src/tests/manager/updateCheck.test.ts index ba463bb..5515f65 100644 --- a/src/tests/manager/updateCheck.test.ts +++ b/src/tests/manager/updateCheck.test.ts @@ -3,13 +3,31 @@ import { getAvailableChannels, checkForUpdate, getInstalledBundleInfo, + authorizeRuntimeDeliveryUpdate, } from '../../manager/updateCheck'; import type { OtaResolveResponse } from '../../api/types'; -import { mockGetBundleList, mockGetPublicChannels, mockPostOtaResolve } from '../mocks/api/clientApi'; -import { resetContextMocks, setMockConfig, setMockPlatform } from '../mocks/context'; -import { mockSupportsXdelta, resetNativeFsMocks, setMockFile } from '../mocks/native/fs'; +import { + mockGetBundleList, + mockGetPublicChannels, + mockPostOtaActiveInstallHeartbeat, + mockPostOtaArtifactAuthorization, + mockPostOtaResolve, +} from '../mocks/api/clientApi'; +import { + resetContextMocks, + setMockConfig, + setMockPlatform, + setMockRuntimeVersion, +} from '../mocks/context'; +import { + mockSupportsXdelta, + mockVerifyEs256Signature, + resetNativeFsMocks, + setMockFile, +} from '../mocks/native/fs'; import { mockGetDownloadedBundlePathNative, resetBundleDropNativeMocks } from '../mocks/native/bundleDropNative'; import { initializeBundleDropRuntime, resetBundleDropRuntimeForTests } from '../../runtime/initState'; +import * as manifestStateModule from '../../runtime-delivery/manifestState'; jest.mock('../../context', () => require('../mocks/context')); jest.mock('../../native/fs', () => require('../mocks/native/fs')); @@ -22,6 +40,109 @@ const PREVIOUS_POINTER_PATH = '/mock/doc/bundle-drop/previous.json'; const STATE_PATH = '/mock/doc/bundle-drop/state.json'; const USER_PROPERTIES_PATH = '/mock/doc/bundle-drop/user-properties.json'; const INSTALL_ID_PATH = '/mock/doc/bundle-drop/install-id.txt'; +const RUNTIME_DELIVERY_STATE_PATH = '/mock/doc/bundle-drop/runtime-delivery-state.json'; +const v2Hash = (character: string) => character.repeat(64); + +const makeV2Envelope = (overrides: Record = {}) => { + const protectedHeader = Buffer.from(JSON.stringify({ + alg: 'ES256', + kid: 'v2-key', + typ: 'bundledrop-manifest+jws', + })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ + schemaVersion: 3, + type: 'lane', + projectSlug: 'bundle-drop-app', + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + generation: 4, + generatedAt: '2026-08-17T00:00:00.000Z', + resolutionMode: 'local', + publishingMode: 'automatic', + rolloutAlgorithm: 'sha256-install-id-uint32be-mod100-v1', + revokedHashes: [], + releases: [{ + releaseRef: 'release-next', + bundleHash: v2Hash('a'), + bundleVersion: 4, + version: '1.0.4', + runtimeVersion: '1.0.0', + manifestHash: v2Hash('b'), + jsBundleHash: v2Hash('c'), + fullBundleHash: v2Hash('d'), + fullBundleSizeBytes: 1000, + available: true, + expiresAt: null, + }], + publishedRollouts: [], + patchPolicy: { enabled: true, maxPatchToFullRatio: 0.7 }, + patchEdges: [], + candidateSetComplete: true, + ...overrides, + })).toString('base64url'); + return JSON.stringify({ + protected: protectedHeader, + payload, + signature: 'A'.repeat(86), + }); +}; + +const manifestResponse = (body = makeV2Envelope()): Response => new Response(body, { + status: 200, + headers: { 'content-type': 'application/jose+json' }, +}); + +const makeAuthorityLeaseEnvelope = () => { + const now = Date.now(); + return JSON.stringify({ + protected: Buffer.from(JSON.stringify({ + alg: 'ES256', + kid: 'v2-key', + typ: 'bundledrop-authority-lease+jws', + })).toString('base64url'), + payload: Buffer.from(JSON.stringify({ + schemaVersion: 1, + type: 'publisher-lease', + manifestOrigin: 'https://manifests.example.com', + generatedAt: new Date(now - 1_000).toISOString(), + expiresAt: new Date(now + 10_000).toISOString(), + })).toString('base64url'), + signature: 'A'.repeat(86), + }); +}; + +const authorityLeaseResponse = (): Response => new Response(makeAuthorityLeaseEnvelope(), { + status: 200, + headers: { 'content-type': 'application/jose+json' }, +}); + +const mockV2Fetch = (...manifestBodies: string[]): jest.SpyInstance => { + let manifestIndex = 0; + return jest.spyOn(global, 'fetch').mockImplementation(input => { + if (String(input).includes('/v2/_authority/publisher-lease.json')) { + return Promise.resolve(authorityLeaseResponse()) as never; + } + const lastManifestBody = manifestBodies.length > 0 + ? manifestBodies[manifestBodies.length - 1] + : undefined; + const body = manifestBodies[manifestIndex] ?? lastManifestBody ?? makeV2Envelope(); + manifestIndex += 1; + return Promise.resolve(manifestResponse(body)) as never; + }); +}; + +const enableRuntimeDelivery = () => { + setMockConfig({ + runtimeDelivery: { + manifestBaseUrl: 'https://manifests.example.com', + manifestAccessId: 'access-id', + publicKeys: { + 'v2-key': { kty: 'EC', crv: 'P-256', x: 'A'.repeat(43), y: 'A'.repeat(43) }, + }, + }, + }); +}; describe('manager/updateCheck', () => { beforeEach(() => { @@ -32,6 +153,489 @@ describe('manager/updateCheck', () => { mockGetBundleList.mockReset(); mockGetPublicChannels.mockReset(); mockPostOtaResolve.mockReset(); + mockPostOtaArtifactAuthorization.mockReset(); + mockPostOtaActiveInstallHeartbeat.mockReset().mockResolvedValue({ data: undefined } as never); + }); + + it('uses a verified complete v2 manifest without calling /resolve', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + mockVerifyEs256Signature.mockResolvedValue(true); + const fetchSpy = mockV2Fetch(); + try { + await expect(checkForUpdate()).resolves.toEqual({ + action: 'INSTALL', + upToDate: false, + channelName: 'General', + hash: v2Hash('a'), + bundleHash: v2Hash('a'), + bundleVersion: 4, + version: '1.0.4', + runtimeVersion: '1.0.0', + mode: 'full', + baseHash: undefined, + runtimeDelivery: { + generation: 4, + targetReleaseRef: 'release-next', + selectedMode: 'full', + baseHash: undefined, + patchAlgorithm: undefined, + patchSetHash: undefined, + patchArtifactRef: undefined, + missingAssetsHash: undefined, + manifestHash: v2Hash('b'), + jsBundleHash: v2Hash('c'), + fullBundleHash: v2Hash('d'), + }, + }); + expect(mockPostOtaResolve).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://manifests.example.com/v2/access-id/lanes/R2VuZXJhbA/android/MS4wLjA/current.json', + expect.objectContaining({ method: 'GET' }), + ); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('emits local rollback and no-op statuses from verified v2 manifests', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: v2Hash('9') })); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${v2Hash('9')}/main.jsbundle`, + ); + mockVerifyEs256Signature.mockResolvedValue(true); + const fetchSpy = mockV2Fetch( + makeV2Envelope({ + revokedHashes: [v2Hash('9')], + releases: [], + }), + makeV2Envelope({ generation: 5 }), + ); + const status = jest.fn(); + try { + await expect(checkForUpdate('General', status)).resolves.toEqual(expect.objectContaining({ + action: 'ROLLBACK', + reason: 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET', + })); + expect(status).toHaveBeenLastCalledWith('↩️ Rollback requested'); + + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: v2Hash('a') })); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${v2Hash('a')}/main.jsbundle`, + ); + await expect(checkForUpdate('General', status)).resolves.toEqual(expect.objectContaining({ + action: 'NOOP', + reason: 'UP_TO_DATE', + })); + expect(status).toHaveBeenLastCalledWith('✅ You have the latest version'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('keeps targeted/dynamic lanes and invalid manifests authoritative on /resolve', async () => { + enableRuntimeDelivery(); + mockVerifyEs256Signature.mockResolvedValue(true); + mockPostOtaResolve.mockResolvedValue({ data: { action: 'NOOP', reason: 'TARGETING_NOT_MATCHED' } } as never); + const fetchSpy = mockV2Fetch( + makeV2Envelope({ + resolutionMode: 'dynamic', + dynamicReason: 'private-targeting', + candidateSetComplete: false, + releases: [], + publishedRollouts: [], + patchEdges: [], + }), + JSON.stringify({ protected: 'bad', payload: 'bad', signature: 'bad' }), + ); + try { + await expect(checkForUpdate()).resolves.toEqual(expect.objectContaining({ + action: 'NOOP', + reason: 'TARGETING_NOT_MATCHED', + })); + expect(mockPostOtaResolve).toHaveBeenCalledTimes(1); + expect(mockPostOtaResolve).toHaveBeenLastCalledWith( + 'bundle-drop-app', + expect.objectContaining({ + transport: expect.objectContaining({ activeInstallHeartbeatVersion: 1 }), + }), + ); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(1); + + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: v2Hash('8') })); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${v2Hash('8')}/main.jsbundle`, + ); + await checkForUpdate(); + expect(mockPostOtaResolve).toHaveBeenCalledTimes(2); + expect(mockPostOtaResolve).toHaveBeenLastCalledWith( + 'bundle-drop-app', + expect.objectContaining({ + transport: expect.objectContaining({ activeInstallHeartbeatVersion: 1 }), + }), + ); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(2); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('falls back to /resolve when existing anti-rollback state is malformed', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + setMockFile(RUNTIME_DELIVERY_STATE_PATH, '{'); + mockVerifyEs256Signature.mockResolvedValue(true); + mockPostOtaResolve.mockResolvedValue({ + data: { action: 'NOOP', reason: 'UP_TO_DATE' }, + } as never); + const fetchSpy = mockV2Fetch(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await expect(checkForUpdate()).resolves.toEqual(expect.objectContaining({ + action: 'NOOP', + reason: 'UP_TO_DATE', + })); + expect(mockPostOtaResolve).toHaveBeenCalledTimes(1); + } finally { + fetchSpy.mockRestore(); + warn.mockRestore(); + } + }); + + it('uses persisted revocations when both the v2 manifest and origin resolver are unavailable', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: v2Hash('9') })); + setMockFile(RUNTIME_DELIVERY_STATE_PATH, JSON.stringify({ + schemaVersion: 1, + lanes: { + 'bundle-drop-app/General/android/1.0.0': { + highestGeneration: 4, + payloadSha256: v2Hash('e'), + revokedHashes: [v2Hash('9')], + verifiedAt: '2026-08-17T00:00:00.000Z', + }, + }, + })); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${v2Hash('9')}/main.jsbundle`, + ); + mockPostOtaResolve.mockRejectedValue(new Error('origin offline')); + const fetchSpy = jest.spyOn(global, 'fetch').mockRejectedValue(new Error('manifest offline')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await expect(checkForUpdate()).resolves.toEqual({ + action: 'ROLLBACK', + channelName: 'General', + reason: 'CURRENT_REVOKED_ORIGIN_UNAVAILABLE', + }); + } finally { + fetchSpy.mockRestore(); + warn.mockRestore(); + } + }); + + it('authorizes v2 artifacts only at install time and validates the target identity', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + mockPostOtaArtifactAuthorization.mockResolvedValue({ + data: { + action: 'INSTALL', + mode: 'full', + target: { + bundleHash: v2Hash('a'), + bundleVersion: 4, + version: '1.0.4', + runtimeVersion: '1.0.0', + downloadUrl: 'https://cdn.example.com/bundle.zip', + manifestUrl: 'https://cdn.example.com/bundle-manifest.json', + }, + }, + } as never); + const decision = await authorizeRuntimeDeliveryUpdate({ + action: 'INSTALL', + channelName: 'General', + hash: v2Hash('a'), + runtimeVersion: '1.0.0', + mode: 'full', + runtimeDelivery: { generation: 4, targetReleaseRef: 'release-next', selectedMode: 'full' }, + }); + expect(decision).toEqual(expect.objectContaining({ + action: 'INSTALL', + hash: v2Hash('a'), + downloadUrl: 'https://cdn.example.com/bundle.zip', + })); + expect(mockPostOtaArtifactAuthorization).toHaveBeenCalledWith( + 'bundle-drop-app', + expect.objectContaining({ + generation: 4, + targetReleaseRef: 'release-next', + targetHash: v2Hash('a'), + mode: 'full', + patchArtifactRef: null, + installId: 'install-1', + }), + ); + }); + + it('accepts an authorized full fallback for a selected patch and rejects unsafe patch changes', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + const selectedPatch = { + action: 'INSTALL' as const, + channelName: 'General', + hash: v2Hash('a'), + runtimeVersion: '1.0.0', + mode: 'patch' as const, + baseHash: v2Hash('9'), + runtimeDelivery: { + generation: 5, + targetReleaseRef: 'release-next', + selectedMode: 'patch' as const, + baseHash: v2Hash('9'), + patchAlgorithm: 'xdelta3-vcdiff', + patchSetHash: v2Hash('8'), + patchArtifactRef: 'patch-next', + }, + }; + mockPostOtaArtifactAuthorization.mockResolvedValueOnce({ + data: { + action: 'INSTALL', + mode: 'full', + target: { + bundleHash: v2Hash('a'), + runtimeVersion: '1.0.0', + downloadUrl: 'https://cdn.example.com/full.zip', + manifestUrl: 'https://cdn.example.com/manifest.json', + }, + }, + } as never); + await expect(authorizeRuntimeDeliveryUpdate(selectedPatch)).resolves.toEqual( + expect.objectContaining({ action: 'INSTALL', mode: 'full', hash: v2Hash('a') }), + ); + + mockPostOtaArtifactAuthorization.mockResolvedValueOnce({ + data: { + action: 'INSTALL', + mode: 'patch', + baseHash: v2Hash('7'), + target: { + bundleHash: v2Hash('a'), + runtimeVersion: '1.0.0', + manifestUrl: 'https://cdn.example.com/manifest.json', + }, + patchSet: { + algorithm: 'xdelta3-vcdiff', + patchSetHash: v2Hash('8'), + patchesUrl: 'https://cdn.example.com/patch.zip', + }, + fallback: { mode: 'full', downloadUrl: 'https://cdn.example.com/full.zip' }, + }, + } as never); + mockPostOtaResolve.mockResolvedValueOnce({ data: { action: 'NOOP', reason: 'UP_TO_DATE' } } as never); + await expect(authorizeRuntimeDeliveryUpdate(selectedPatch)).resolves.toEqual( + expect.objectContaining({ action: 'NOOP', reason: 'UP_TO_DATE' }), + ); + expect(mockPostOtaResolve).toHaveBeenCalledTimes(1); + + const authorizedPatch = (overrides: Record = {}) => ({ + action: 'INSTALL' as const, + mode: 'patch' as const, + baseHash: v2Hash('9'), + target: { + bundleHash: v2Hash('a'), + runtimeVersion: '1.0.0', + manifestUrl: 'https://cdn.example.com/manifest.json', + }, + patchSet: { + algorithm: 'xdelta3-vcdiff', + patchSetHash: v2Hash('8'), + patchesUrl: 'https://cdn.example.com/patch.zip', + ...overrides, + }, + fallback: { mode: 'full' as const, downloadUrl: 'https://cdn.example.com/full.zip' }, + }); + const unsafePatchResponses = [ + authorizedPatch({ algorithm: 'asset-only-v1' }), + authorizedPatch({ patchSetHash: v2Hash('7') }), + authorizedPatch({ assets: { missingAssetsHash: v2Hash('6') } }), + ]; + mockPostOtaResolve.mockResolvedValue({ data: { action: 'NOOP', reason: 'UP_TO_DATE' } } as never); + for (const response of unsafePatchResponses) { + mockPostOtaArtifactAuthorization.mockResolvedValueOnce({ data: response } as never); + await expect(authorizeRuntimeDeliveryUpdate(selectedPatch)).resolves.toEqual( + expect.objectContaining({ action: 'NOOP', reason: 'UP_TO_DATE' }), + ); + } + + mockPostOtaArtifactAuthorization.mockResolvedValueOnce({ data: authorizedPatch() } as never); + await expect(authorizeRuntimeDeliveryUpdate(selectedPatch)).resolves.toEqual( + expect.objectContaining({ + action: 'INSTALL', + mode: 'patch', + baseHash: v2Hash('9'), + runtimeDelivery: selectedPatch.runtimeDelivery, + }), + ); + }); + + it('falls back to /resolve when artifact authorization reports a stale generation', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + mockPostOtaArtifactAuthorization.mockRejectedValueOnce(new Error('HTTP 409 stale generation')); + mockPostOtaResolve.mockResolvedValueOnce({ data: { action: 'NOOP', reason: 'UP_TO_DATE' } } as never); + await expect(authorizeRuntimeDeliveryUpdate({ + action: 'INSTALL', + channelName: 'General', + hash: v2Hash('a'), + runtimeVersion: '1.0.0', + mode: 'full', + runtimeDelivery: { + generation: 3, + targetReleaseRef: 'release-next', + selectedMode: 'full', + }, + })).resolves.toEqual(expect.objectContaining({ action: 'NOOP', reason: 'UP_TO_DATE' })); + expect(mockPostOtaResolve).toHaveBeenCalledTimes(1); + }); + + it('passes through non-v2 decisions and accepts authoritative non-install authorization results', async () => { + const noop = { action: 'NOOP' as const, channelName: 'General', reason: 'UP_TO_DATE' }; + await expect(authorizeRuntimeDeliveryUpdate(noop)).resolves.toBe(noop); + const v1Install = { action: 'INSTALL' as const, channelName: 'General', hash: v2Hash('a') }; + await expect(authorizeRuntimeDeliveryUpdate(v1Install)).resolves.toBe(v1Install); + + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + mockPostOtaArtifactAuthorization.mockResolvedValueOnce({ + data: { action: 'ROLLBACK', reason: 'CURRENT_REVOKED' }, + } as never); + await expect(authorizeRuntimeDeliveryUpdate({ + action: 'INSTALL', + channelName: 'General', + hash: v2Hash('a'), + runtimeDelivery: { generation: 4, targetReleaseRef: 'release-next', selectedMode: 'full' }, + })).resolves.toEqual({ + action: 'ROLLBACK', + channelName: 'General', + reason: 'CURRENT_REVOKED', + }); + }); + + it('rejects changed authorization targets and missing runtime identity through safe /resolve fallback', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + const local = { + action: 'INSTALL' as const, + channelName: 'General', + hash: v2Hash('a'), + runtimeDelivery: { generation: 4, targetReleaseRef: 'release-next', selectedMode: 'full' as const }, + }; + mockPostOtaArtifactAuthorization.mockResolvedValueOnce({ + data: { + action: 'INSTALL', + mode: 'full', + target: { + bundleHash: v2Hash('b'), + runtimeVersion: '1.0.0', + downloadUrl: 'https://cdn.example.com/other.zip', + manifestUrl: 'https://cdn.example.com/other-manifest.json', + }, + }, + } as never); + mockPostOtaResolve.mockResolvedValueOnce({ data: { action: 'NOOP', reason: 'UP_TO_DATE' } } as never); + await expect(authorizeRuntimeDeliveryUpdate(local)).resolves.toEqual(expect.objectContaining({ + action: 'NOOP', + })); + expect(mockPostOtaResolve).toHaveBeenLastCalledWith( + 'bundle-drop-app', + expect.objectContaining({ + transport: expect.objectContaining({ activeInstallHeartbeatVersion: 1 }), + }), + ); + + setMockRuntimeVersion(undefined); + mockPostOtaResolve.mockResolvedValueOnce({ data: { action: 'NOOP', reason: 'UP_TO_DATE' } } as never); + await expect(authorizeRuntimeDeliveryUpdate(local)).resolves.toEqual(expect.objectContaining({ + action: 'NOOP', + })); + }); + + it('returns rollback or null when authorization and origin fallback both fail', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: v2Hash('9') })); + setMockFile(RUNTIME_DELIVERY_STATE_PATH, JSON.stringify({ + schemaVersion: 1, + lanes: { + 'bundle-drop-app/General/android/1.0.0': { + highestGeneration: 4, + payloadSha256: v2Hash('e'), + revokedHashes: [v2Hash('9')], + verifiedAt: '2026-08-17T00:00:00.000Z', + }, + }, + })); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${v2Hash('9')}/main.jsbundle`, + ); + const local = { + action: 'INSTALL' as const, + channelName: 'General', + hash: v2Hash('a'), + runtimeDelivery: { generation: 4, targetReleaseRef: 'release-next', selectedMode: 'full' as const }, + }; + mockPostOtaArtifactAuthorization.mockRejectedValue(new Error('authorization offline')); + mockPostOtaResolve.mockRejectedValue(new Error('origin offline')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await expect(authorizeRuntimeDeliveryUpdate(local)).resolves.toEqual({ + action: 'ROLLBACK', + channelName: 'General', + reason: 'CURRENT_REVOKED_ORIGIN_UNAVAILABLE', + }); + + setMockFile(RUNTIME_DELIVERY_STATE_PATH, JSON.stringify({ schemaVersion: 1, lanes: {} })); + await expect(authorizeRuntimeDeliveryUpdate(local)).resolves.toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('fails safely when last-known revocation state itself cannot be read', async () => { + enableRuntimeDelivery(); + setMockFile(INSTALL_ID_PATH, 'install-1'); + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: v2Hash('9') })); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${v2Hash('9')}/main.jsbundle`, + ); + const stateSpy = jest.spyOn(manifestStateModule, 'readVerifiedLaneState') + .mockRejectedValue(new Error('state unavailable')); + const fetchSpy = jest.spyOn(global, 'fetch').mockRejectedValue(new Error('manifest offline')); + mockPostOtaResolve.mockRejectedValue(new Error('origin offline')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await expect(checkForUpdate()).resolves.toBeNull(); + mockPostOtaArtifactAuthorization.mockRejectedValue(new Error('authorization offline')); + await expect(authorizeRuntimeDeliveryUpdate({ + action: 'INSTALL', + channelName: 'General', + hash: v2Hash('a'), + runtimeDelivery: { + generation: 4, + targetReleaseRef: 'release-next', + selectedMode: 'full', + }, + })).resolves.toBeNull(); + } finally { + stateSpy.mockRestore(); + fetchSpy.mockRestore(); + warn.mockRestore(); + } }); it('fetches the available public channels for the configured project', async () => { diff --git a/src/tests/metro.test.ts b/src/tests/metro.test.ts index 2d58f5e..1bbb3ab 100644 --- a/src/tests/metro.test.ts +++ b/src/tests/metro.test.ts @@ -10,7 +10,7 @@ jest.mock('../expo', () => ({ resolveExpoMetroRuntimeVersion: (...args: unknown[]) => mockResolveExpoMetroRuntimeVersion(...args), })); -import { withBundleDropExpo } from '../metro'; +import { withBundleDrop, withBundleDropExpo } from '../metro'; import type { ExpoBuildIdentity } from '../expo'; const identity = (platform: 'ios' | 'android'): ExpoBuildIdentity => { @@ -33,13 +33,40 @@ const identity = (platform: 'ios' | 'android'): ExpoBuildIdentity => { describe('withBundleDropExpo', () => { const roots: string[] = []; + const readGeneratedConfig = (root: string): Record => { + const generatedPath = path.join(root, '.bundle-drop/generated/bundle.drop.config.js'); + delete require.cache[require.resolve(generatedPath)]; + return require(generatedPath) as Record; + }; + const fixture = () => { const root = createTempProjectDir(); roots.push(root); fs.writeFileSync( path.join(root, 'bundle.drop.config.js'), - "module.exports = { serverUrl: 'https://api.example.com' };\n", + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'org' }, project: { name: 'App', slug: 'app' } };\n", ); + fs.ensureDirSync(path.join(root, '.bundle-drop')); + fs.writeJsonSync(path.join(root, '.bundle-drop/runtime-delivery.generated.json'), { + schemaVersion: 1, + project: { + serverUrl: 'https://api.example.com', + orgSlug: 'org', + projectSlug: 'app', + }, + runtimeDelivery: { + manifestBaseUrl: 'https://manifests.example.com', + manifestAccessId: `mft_${'A'.repeat(43)}`, + publicKeys: { + key: { + kty: 'EC', + crv: 'P-256', + x: 'd-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM', + y: '_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI', + }, + }, + }, + }); return root; }; @@ -96,6 +123,14 @@ describe('withBundleDropExpo', () => { expect(fs.readFileSync(generatedPath, 'utf8')).toContain( 'runtimeVersion: {"ios":"ios-runtime","android":"android-runtime"}', ); + expect(fs.readFileSync(generatedPath, 'utf8')).toContain( + 'runtimeDelivery: {"manifestBaseUrl":"https://manifests.example.com"', + ); + expect(readGeneratedConfig(root)).toEqual(expect.objectContaining({ + runtimeDelivery: expect.objectContaining({ + manifestBaseUrl: 'https://manifests.example.com', + }), + })); expect(fs.existsSync(path.join(root, '.bundle-drop/build-identity.json'))).toBe(false); }); @@ -139,4 +174,86 @@ describe('withBundleDropExpo', () => { cwdSpy.mockRestore(); } }); + + it('wraps bare Metro config with the same generated trust bootstrap', () => { + const root = fixture(); + const result = withBundleDrop( + { resolver: { sourceExts: ['js'], extraNodeModules: { existing: '/existing' } } }, + { projectRoot: root }, + ); + const generatedPath = path.join(root, '.bundle-drop/generated/bundle.drop.config.js'); + expect(result.resolver?.extraNodeModules).toEqual({ + existing: '/existing', + 'bundle-drop-config': generatedPath, + }); + const generated = fs.readFileSync(generatedPath, 'utf8'); + expect(generated).toContain( + 'runtimeDelivery: {"manifestBaseUrl":"https://manifests.example.com"', + ); + expect(readGeneratedConfig(root)).toEqual(expect.objectContaining({ + runtimeDelivery: expect.objectContaining({ + manifestBaseUrl: 'https://manifests.example.com', + }), + })); + expect(generated).not.toContain('runtimeVersion:'); + }); + + it('uses the current project as the default bare root', () => { + const root = fixture(); + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(root); + try { + const result = withBundleDrop<{ + resolver?: { extraNodeModules?: Record }; + }>({}); + expect(result.resolver?.extraNodeModules?.['bundle-drop-config']).toBe( + path.join(root, '.bundle-drop/generated/bundle.drop.config.js'), + ); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('fails closed when generated trust belongs to another project', () => { + const root = fixture(); + const bootstrapPath = path.join(root, '.bundle-drop/runtime-delivery.generated.json'); + const bootstrap = fs.readJsonSync(bootstrapPath); + bootstrap.project.projectSlug = 'other-app'; + fs.writeJsonSync(bootstrapPath, bootstrap); + expect(() => withBundleDrop({}, { projectRoot: root })).toThrow('belongs to a different'); + }); + + it('ignores retired inline delivery authority when no generated bootstrap exists', () => { + const root = fixture(); + fs.removeSync(path.join(root, '.bundle-drop/runtime-delivery.generated.json')); + fs.writeFileSync( + path.join(root, 'bundle.drop.config.js'), + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'org' }, project: { name: 'App', slug: 'app' }, runtimeDelivery: { mode: 'v2', manifestBaseUrl: 'https://stale.example.com' } };\n", + ); + withBundleDrop({}, { projectRoot: root }); + const resolvedConfig = readGeneratedConfig(root) as { + runtimeDelivery?: { mode?: string }; + serverUrl?: string; + org?: { slug?: string }; + project?: { slug?: string }; + }; + expect(resolvedConfig).toEqual(expect.objectContaining({ + serverUrl: 'https://api.example.com', + org: { slug: 'org' }, + project: expect.objectContaining({ slug: 'app' }), + })); + expect(resolvedConfig).not.toHaveProperty('runtimeDelivery'); + expect(resolvedConfig.runtimeDelivery?.mode ?? 'v1').toBe('v1'); + }); + + it('rejects an incomplete base config even without a generated bootstrap', () => { + const invalidRoot = fixture(); + fs.removeSync(path.join(invalidRoot, '.bundle-drop/runtime-delivery.generated.json')); + fs.writeFileSync( + path.join(invalidRoot, 'bundle.drop.config.js'), + "module.exports = { serverUrl: 'https://api.example.com' };\n", + ); + expect(() => withBundleDrop({}, { projectRoot: invalidRoot })).toThrow( + 'must define serverUrl, org.slug, and project.slug', + ); + }); }); diff --git a/src/tests/mocks/api/clientApi.ts b/src/tests/mocks/api/clientApi.ts index a877bd3..87b9ad7 100644 --- a/src/tests/mocks/api/clientApi.ts +++ b/src/tests/mocks/api/clientApi.ts @@ -9,6 +9,8 @@ import type { ReportPatchApplyFailurePayload, ReportLocalRollbackPayload, BundleListParams, + OtaArtifactAuthorizationRequest, + OtaActiveInstallHeartbeat, } from '../../../api/types'; export const mockPostOtaResolve = jest.fn< @@ -16,6 +18,16 @@ export const mockPostOtaResolve = jest.fn< [string, OtaResolveRequest] >(); +export const mockPostOtaArtifactAuthorization = jest.fn< + Promise>, + [string, OtaArtifactAuthorizationRequest] +>(); + +export const mockPostOtaActiveInstallHeartbeat = jest.fn< + Promise>, + [string, OtaActiveInstallHeartbeat] +>(); + export const mockGetPublicChannels = jest.fn< Promise>, [PublicChannelsParams] @@ -43,6 +55,8 @@ export const mockGetBundleList = jest.fn< export const resetClientApiMocks = () => { mockPostOtaResolve.mockReset(); + mockPostOtaArtifactAuthorization.mockReset(); + mockPostOtaActiveInstallHeartbeat.mockReset(); mockGetPublicChannels.mockReset(); mockReportInstalled.mockReset(); mockReportLocalRollback.mockReset(); @@ -52,6 +66,12 @@ export const resetClientApiMocks = () => { export const postOtaResolve = (...args: [string, OtaResolveRequest]) => mockPostOtaResolve(...args); +export const postOtaArtifactAuthorization = (...args: [string, OtaArtifactAuthorizationRequest]) => + mockPostOtaArtifactAuthorization(...args); + +export const postOtaActiveInstallHeartbeat = (...args: [string, OtaActiveInstallHeartbeat]) => + mockPostOtaActiveInstallHeartbeat(...args); + export const getPublicChannels = (...args: [PublicChannelsParams]) => mockGetPublicChannels(...args); export const reportInstalled = (...args: [string, string, ReportInstalledPayload]) => diff --git a/src/tests/mocks/context.ts b/src/tests/mocks/context.ts index bfaaadd..9a0c8ff 100644 --- a/src/tests/mocks/context.ts +++ b/src/tests/mocks/context.ts @@ -16,6 +16,12 @@ type MockConfig = { runtimeVersion?: RuntimeVersionConfig; defaultChannel?: string; rollback?: RollbackConfig; + runtimeDelivery?: { + mode?: 'v1' | 'shadow' | 'v2'; + manifestBaseUrl: string; + manifestAccessId: string; + publicKeys: Record; + }; }; type MockBundleDropContext = { @@ -30,6 +36,7 @@ type MockBundleDropContext = { healthCheckMode: 'auto' | 'manual'; healthyAfterSec: number; }; + runtimeDelivery?: MockConfig['runtimeDelivery']; }; const createConfig = (): MockConfig => ({ @@ -94,6 +101,7 @@ const syncDerivedValues = () => { healthCheckMode: config.rollback?.healthCheckMode === 'manual' ? 'manual' : 'auto', healthyAfterSec: config.rollback?.healthyAfterSec ?? 0, }, + runtimeDelivery: config.runtimeDelivery, }; }; @@ -116,6 +124,7 @@ export let bundleDropConfig: MockBundleDropContext = { healthCheckMode: config.rollback?.healthCheckMode === 'manual' ? 'manual' : 'auto', healthyAfterSec: config.rollback?.healthyAfterSec ?? 0, }, + runtimeDelivery: config.runtimeDelivery, }; export const setMockConfig = (partial: Partial) => { @@ -128,6 +137,13 @@ export const setMockPlatform = (nextPlatform: 'ios' | 'android') => { syncDerivedValues(); }; +export const setMockRuntimeVersion = (nextRuntimeVersion: string | undefined) => { + config.runtimeVersion = nextRuntimeVersion + ? { ios: nextRuntimeVersion, android: nextRuntimeVersion } + : undefined; + syncDerivedValues(); +}; + export const resetContextMocks = () => { config = createConfig(); platform = 'android'; diff --git a/src/tests/mocks/manager/updateCheck.ts b/src/tests/mocks/manager/updateCheck.ts index 316d06a..7f72669 100644 --- a/src/tests/mocks/manager/updateCheck.ts +++ b/src/tests/mocks/manager/updateCheck.ts @@ -7,11 +7,23 @@ export const mockCheckForUpdate = jest.fn< [string | undefined, ((status: string) => void) | undefined] >(); +export const mockAuthorizeRuntimeDeliveryUpdate = jest.fn< + Promise, + [UpdateCheckResponse] +>(); + export const resetUpdateCheckMocks = () => { mockCheckForUpdate.mockReset(); + mockAuthorizeRuntimeDeliveryUpdate.mockReset(); + mockAuthorizeRuntimeDeliveryUpdate.mockImplementation(async decision => decision); }; export const checkForUpdate = ( channelName?: string, onStatusUpdate?: (status: string) => void ) => mockCheckForUpdate(channelName, onStatusUpdate); + +export const authorizeRuntimeDeliveryUpdate = (decision: UpdateCheckResponse) => + mockAuthorizeRuntimeDeliveryUpdate(decision); + +resetUpdateCheckMocks(); diff --git a/src/tests/mocks/modules/react-native.ts b/src/tests/mocks/modules/react-native.ts index ecd6c01..ce3e6ae 100644 --- a/src/tests/mocks/modules/react-native.ts +++ b/src/tests/mocks/modules/react-native.ts @@ -33,12 +33,25 @@ export const NativeModules = { fsMoveFile: jest.fn(async (_src: string, _dest: string) => undefined), fsCopyFile: jest.fn(async (_src: string, _dest: string) => undefined), fsSha256File: jest.fn(async (_path: string) => 'hash'), + fsSha256String: jest.fn(async (_value: string) => '0'.repeat(64)), + fsVerifyEs256Signature: jest.fn(async ( + _input: string, + _signature: string, + _x: string, + _y: string, + ) => true), fsFileSize: jest.fn(async (_path: string) => 0), fsApplyXdelta: jest.fn(async (_base: string, _patch: string, _output: string) => undefined), fsVerifyBundleFiles: jest.fn(async (_bundleDir: string, _manifestPath: string) => ({ verified: true })), fsSupportsXdelta: jest.fn(async () => true), fsUnzip: jest.fn(async (_zipPath: string, _destPath: string) => [] as string[]), fsDownloadFile: jest.fn(async (_url: string, _destPath: string) => undefined), + fsDownloadFileBounded: jest.fn(async ( + _url: string, + _destPath: string, + _maxBytes: number, + _timeoutMs: number, + ) => undefined), getDownloadedBundlePath: jest.fn(async () => null), getImageManifestSync: jest.fn(() => null), getImageManifest: jest.fn(async () => null), diff --git a/src/tests/mocks/native/fs.ts b/src/tests/mocks/native/fs.ts index 0257aa5..76a49c5 100644 --- a/src/tests/mocks/native/fs.ts +++ b/src/tests/mocks/native/fs.ts @@ -152,6 +152,26 @@ export const mockSha256File = jest.fn(async (path: string) => { return require('crypto').createHash('sha256').update(Buffer.from(content)).digest('hex'); }); +export const mockSha256String = jest.fn(async (value: string) => + require('crypto').createHash('sha256').update(value, 'utf8').digest('hex') +); + +export const mockVerifyEs256Signature = jest.fn(async ( + signingInput: string, + signatureBase64Url: string, + xBase64Url: string, + yBase64Url: string, +) => require('crypto').verify( + 'sha256', + Buffer.from(signingInput, 'utf8'), + { + key: { kty: 'EC', crv: 'P-256', x: xBase64Url, y: yBase64Url }, + format: 'jwk', + dsaEncoding: 'ieee-p1363', + }, + Buffer.from(signatureBase64Url, 'base64url'), +)); + export const mockFileSize = jest.fn(async (path: string) => { const normalized = normalizePath(path); const content = files.get(normalized); @@ -333,6 +353,13 @@ export const mockDownloadFile = jest.fn(async (url: string, destPath: string) => } }); +export const mockDownloadFileBounded = jest.fn(async ( + url: string, + destPath: string, + _maxBytes: number, + _timeoutMs: number, +) => mockDownloadFile(url, destPath)); + export const resetNativeFsMocks = () => { mockExists.mockClear(); mockReadFile.mockClear(); @@ -343,6 +370,8 @@ export const resetNativeFsMocks = () => { mockMoveFile.mockClear(); mockCopyFile.mockClear(); mockSha256File.mockClear(); + mockSha256String.mockClear(); + mockVerifyEs256Signature.mockClear(); mockFileSize.mockClear(); mockApplyXdelta.mockClear(); mockVerifyBundleFiles.mockClear(); @@ -350,6 +379,7 @@ export const resetNativeFsMocks = () => { mockSupportsXdelta.mockResolvedValue(true); mockUnzip.mockClear(); mockDownloadFile.mockClear(); + mockDownloadFileBounded.mockClear(); unzipEntries.clear(); downloadContentsByUrl.clear(); downloadFailuresByUrl.clear(); @@ -401,12 +431,15 @@ const BundleDropFS = { moveFile: mockMoveFile, copyFile: mockCopyFile, sha256File: mockSha256File, + sha256String: mockSha256String, + verifyEs256Signature: mockVerifyEs256Signature, fileSize: mockFileSize, applyXdelta: mockApplyXdelta, verifyBundleFiles: mockVerifyBundleFiles, supportsXdelta: mockSupportsXdelta, unzip: mockUnzip, downloadFile: mockDownloadFile, + downloadFileBounded: mockDownloadFileBounded, }; export default BundleDropFS; diff --git a/src/tests/native/bundleDropNative.test.ts b/src/tests/native/bundleDropNative.test.ts index fc94f3c..eb5e51c 100644 --- a/src/tests/native/bundleDropNative.test.ts +++ b/src/tests/native/bundleDropNative.test.ts @@ -27,14 +27,37 @@ describe('native/bundleDropNative', () => { const enabled = loadBundleDropNativeModule(); expect(enabled.isExpoOtaStartupEnabledNative()).toBe(true); + const enabledByIosNumericBoolean = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDropExpoIdentity.otaStartupEnabled = 1; + }); + expect(enabledByIosNumericBoolean.isExpoOtaStartupEnabledNative()).toBe(true); + const disabled = loadBundleDropNativeModule(({ NativeModules }) => { NativeModules.BundleDropExpoIdentity.otaStartupEnabled = false; }); - expect(disabled.isExpoOtaStartupEnabledNative()).toBe(false); + const disabledByIosNumericBoolean = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDropExpoIdentity.otaStartupEnabled = 0; + }); + expect(disabledByIosNumericBoolean.isExpoOtaStartupEnabledNative()).toBe(false); + + const invalidStringValue = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDropExpoIdentity.otaStartupEnabled = '1'; + }); + expect(invalidStringValue.isExpoOtaStartupEnabledNative()).toBe(false); + + const missingIdentity = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDropExpoIdentity = undefined; + }); + expect(missingIdentity.isExpoOtaStartupEnabledNative()).toBe(false); + const reactNative = require('react-native') as typeof import('react-native'); - reactNative.NativeModules.BundleDropExpoIdentity.otaStartupEnabled = true; + reactNative.NativeModules.BundleDropExpoIdentity = { + appVersion: '1.2.3', + appBuildVersion: '45', + otaStartupEnabled: true, + }; }); it('returns null and warns when the native getter is unavailable', async () => { diff --git a/src/tests/native/expoNativeIsolation.test.ts b/src/tests/native/expoNativeIsolation.test.ts index d9b511d..0e1206b 100644 --- a/src/tests/native/expoNativeIsolation.test.ts +++ b/src/tests/native/expoNativeIsolation.test.ts @@ -13,7 +13,7 @@ describe('Expo native target isolation', () => { const expoPodspec = readPackageFile('BundleDropExpo.podspec'); const expoAndroidBuild = readPackageFile('expo/android/build.gradle'); - expect(packageManifest.nativeVersion).toBe('0.4.5'); + expect(packageManifest.nativeVersion).toBe('0.5.0'); expect(barePodspec).toContain('native_version = package["nativeVersion"] || package["version"]'); expect(expoPodspec).toContain('native_version = package["nativeVersion"] || package["version"]'); expect(expoAndroidBuild).toContain( diff --git a/src/tests/native/fs.test.ts b/src/tests/native/fs.test.ts index 88d0d47..c2f2a94 100644 --- a/src/tests/native/fs.test.ts +++ b/src/tests/native/fs.test.ts @@ -31,6 +31,8 @@ describe('native/fs', () => { await module.moveFile('/tmp/a', '/tmp/b'); await module.copyFile('/tmp/c', '/tmp/d'); await expect(module.sha256File('/tmp/file')).resolves.toBe('hash'); + await expect(module.sha256String('install-id')).resolves.toBe('0'.repeat(64)); + await expect(module.verifyEs256Signature('input', 'signature', 'x', 'y')).resolves.toBe(true); await expect(module.fileSize('/tmp/file')).resolves.toBe(0); await module.applyXdelta('/tmp/base', '/tmp/patch', '/tmp/out'); await expect(module.verifyBundleFiles('/tmp/bundle', '/tmp/bundle/bundle-manifest.json')).resolves.toEqual({ verified: true }); @@ -46,6 +48,10 @@ describe('native/fs', () => { expect(reactNative.NativeModules.BundleDrop.fsMoveFile).toHaveBeenCalledWith('/tmp/a', '/tmp/b'); expect(reactNative.NativeModules.BundleDrop.fsCopyFile).toHaveBeenCalledWith('/tmp/c', '/tmp/d'); expect(reactNative.NativeModules.BundleDrop.fsSha256File).toHaveBeenCalledWith('/tmp/file'); + expect(reactNative.NativeModules.BundleDrop.fsSha256String).toHaveBeenCalledWith('install-id'); + expect(reactNative.NativeModules.BundleDrop.fsVerifyEs256Signature).toHaveBeenCalledWith( + 'input', 'signature', 'x', 'y', + ); expect(reactNative.NativeModules.BundleDrop.fsFileSize).toHaveBeenCalledWith('/tmp/file'); expect(reactNative.NativeModules.BundleDrop.fsApplyXdelta).toHaveBeenCalledWith('/tmp/base', '/tmp/patch', '/tmp/out'); expect(reactNative.NativeModules.BundleDrop.fsVerifyBundleFiles).toHaveBeenCalledWith( @@ -63,6 +69,19 @@ describe('native/fs', () => { 'https://example.com/file.zip', '/tmp/file.zip', ); + reactNative.NativeModules.BundleDrop.fsDownloadFileBounded.mockResolvedValue(undefined); + await expect(module.downloadFileBounded( + 'https://example.com/manifest', + '/tmp/manifest', + 1024 * 1024, + 5000, + )).resolves.toBeUndefined(); + expect(reactNative.NativeModules.BundleDrop.fsDownloadFileBounded).toHaveBeenCalledWith( + 'https://example.com/manifest', + '/tmp/manifest', + 1024 * 1024, + 5000, + ); reactNative.NativeModules.BundleDrop.fsReadDir.mockResolvedValue(['a.txt', 'b']); await expect(module.readDir('/tmp/dir')).resolves.toEqual(['a.txt', 'b']); @@ -109,6 +128,31 @@ describe('native/fs', () => { expect(reactNative.NativeModules.BundleDrop.fsVerifyBundleFiles).toBeUndefined(); }); + it('throws clear outdated-native errors when v2 crypto methods are unavailable', async () => { + const { reactNative, module } = loadNativeFsModule(({ NativeModules }) => { + delete (NativeModules.BundleDrop as any).fsSha256String; + delete (NativeModules.BundleDrop as any).fsVerifyEs256Signature; + }); + await expect(module.sha256String('install-id')).rejects.toThrow('native module is outdated'); + await expect(module.verifyEs256Signature('input', 'signature', 'x', 'y')) + .rejects.toThrow('native module is outdated'); + expect(reactNative.NativeModules.BundleDrop.fsSha256String).toBeUndefined(); + expect(reactNative.NativeModules.BundleDrop.fsVerifyEs256Signature).toBeUndefined(); + }); + + it('throws a clear outdated-native error when bounded downloads are unavailable', async () => { + const { module } = loadNativeFsModule(({ NativeModules }) => { + delete (NativeModules.BundleDrop as any).fsDownloadFileBounded; + }); + + await expect(module.downloadFileBounded( + 'https://example.com/manifest', + '/tmp/manifest', + 1024 * 1024, + 5000, + )).rejects.toThrow('bounded manifest downloads'); + }); + it('throws a clear error when the native module is not linked', async () => { const { module } = loadNativeFsModule(({ NativeModules }) => { NativeModules.BundleDrop = undefined as any; @@ -123,11 +167,15 @@ describe('native/fs', () => { await expect(module.moveFile('/a', '/b')).rejects.toThrow('native module is not linked'); await expect(module.copyFile('/a', '/b')).rejects.toThrow('native module is not linked'); await expect(module.sha256File('/a')).rejects.toThrow('native module is not linked'); + await expect(module.sha256String('a')).rejects.toThrow('native module is not linked'); + await expect(module.verifyEs256Signature('a', 'b', 'c', 'd')).rejects.toThrow('native module is not linked'); await expect(module.fileSize('/a')).rejects.toThrow('native module is not linked'); await expect(module.applyXdelta('/a', '/b', '/c')).rejects.toThrow('native module is not linked'); await expect(module.verifyBundleFiles('/a', '/a/bundle-manifest.json')).rejects.toThrow('native module is not linked'); await expect(module.supportsXdelta()).rejects.toThrow('native module is not linked'); await expect(module.unzip('/a.zip', '/b')).rejects.toThrow('native module is not linked'); await expect(module.downloadFile('https://x.com/f', '/b')).rejects.toThrow('native module is not linked'); + await expect(module.downloadFileBounded('https://x.com/f', '/b', 10, 10)) + .rejects.toThrow('native module is not linked'); }); }); diff --git a/src/tests/patch-engine/patchTransport.test.ts b/src/tests/patch-engine/patchTransport.test.ts index 219beb8..c7776fb 100644 --- a/src/tests/patch-engine/patchTransport.test.ts +++ b/src/tests/patch-engine/patchTransport.test.ts @@ -2,6 +2,7 @@ import { tryInstallPatchTransport } from '../../patch-engine/patchTransport'; import { mockReportPatchApplyFailure } from '../mocks/api/clientApi'; import { mockInstallFromPatchSet } from '../mocks/install/installFromZip'; import { resetNativeFsMocks } from '../mocks/native/fs'; +import { InstallPhaseError } from '../../errors'; jest.mock('../../context', () => ({ runtimeVersion: undefined, @@ -54,6 +55,33 @@ describe('patch-engine/patchTransport', () => { ); }); + it('forwards signed target hashes to patch reconstruction', async () => { + mockInstallFromPatchSet.mockResolvedValueOnce({ + bundlePath: '/bundles/target/main.jsbundle', + metadataFromZip: { hash: 'target-hash' }, + }); + await tryInstallPatchTransport({ + target: { + mode: 'patch', + hash: 'target-hash', + baseHash: 'base-hash', + expectedManifestHash: 'manifest-hash', + expectedJsBundleHash: 'js-hash', + patchSet: { + algorithm: 'xdelta3-vcdiff', + patchesUrl: 'https://cdn.example.com/patch.zip', + patchSetHash: 'patch-hash', + }, + }, + projectSlug: 'bundle-drop-app', + platform: 'android', + }); + expect(mockInstallFromPatchSet).toHaveBeenCalledWith(expect.objectContaining({ + expectedManifestHash: 'manifest-hash', + expectedJsBundleHash: 'js-hash', + })); + }); + it('reports an empty runtime version when neither resolve nor config provides one', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -126,4 +154,23 @@ describe('patch-engine/patchTransport', () => { warnSpy.mockRestore(); } }); + + it('surfaces capability rejection without telemetry or full fallback conversion', async () => { + const rejected = new InstallPhaseError('download', new Error('HTTP 401: expired')); + mockInstallFromPatchSet.mockRejectedValueOnce(rejected); + + await expect(tryInstallPatchTransport({ + target: { + mode: 'patch', hash: 'target-hash', baseHash: 'base-hash', + patchSet: { + algorithm: 'xdelta3-vcdiff', + patchesUrl: 'https://cdn.example.com/expired-patch.zip', + patchSetHash: 'patch-hash', + }, + }, + projectSlug: 'bundle-drop-app', + platform: 'android', + })).rejects.toBe(rejected); + expect(mockReportPatchApplyFailure).not.toHaveBeenCalled(); + }); }); diff --git a/src/tests/runtime-delivery/artifactCapability.test.ts b/src/tests/runtime-delivery/artifactCapability.test.ts new file mode 100644 index 0000000..9a578bd --- /dev/null +++ b/src/tests/runtime-delivery/artifactCapability.test.ts @@ -0,0 +1,42 @@ +import { InstallPhaseError } from '../../errors'; +import { isArtifactCapabilityRejected } from '../../runtime-delivery/artifactCapability'; + +describe('runtime-delivery/artifactCapability', () => { + it.each([ + [{ status: 401 }], + [{ statusCode: 403 }], + [{ httpStatus: 401 }], + ['HTTP 403: expired'], + [{ message: 'HTTP 401 capability rejected' }], + [{ cause: { status: 403 } }], + [{ error: { userInfo: { message: 'HTTP 401' } } }], + ])('recognizes download-phase capability rejection from %p', cause => { + expect(isArtifactCapabilityRejected(new InstallPhaseError('download', cause))).toBe(true); + }); + + it('recognizes a nested InstallPhaseError cause', () => { + const nested = new InstallPhaseError('download', { status: 403 }); + expect(isArtifactCapabilityRejected(new InstallPhaseError('download', nested))).toBe(true); + }); + + it.each([ + null, + 403, + 'HTTP 500', + { status: 500 }, + { message: 'permission denied' }, + ])('rejects non-capability values from %p', cause => { + expect(isArtifactCapabilityRejected(new InstallPhaseError('download', cause))).toBe(false); + }); + + it('rejects install-phase and untagged failures', () => { + expect(isArtifactCapabilityRejected(new InstallPhaseError('install', { status: 403 }))).toBe(false); + expect(isArtifactCapabilityRejected({ status: 403 })).toBe(false); + }); + + it('bounds recursive inspection of malformed cyclic causes', () => { + const cyclic: Record = {}; + cyclic.cause = cyclic; + expect(isArtifactCapabilityRejected(new InstallPhaseError('download', cyclic))).toBe(false); + }); +}); diff --git a/src/tests/runtime-delivery/authorityLeaseVerifier.test.ts b/src/tests/runtime-delivery/authorityLeaseVerifier.test.ts new file mode 100644 index 0000000..e3867c4 --- /dev/null +++ b/src/tests/runtime-delivery/authorityLeaseVerifier.test.ts @@ -0,0 +1,261 @@ +jest.mock('../../native/fs', () => require('../mocks/native/fs')); + +import { + MAX_RUNTIME_DELIVERY_AUTHORITY_LEASE_MS, + verifyRuntimeDeliveryAuthorityLease, +} from '../../runtime-delivery/authorityLeaseVerifier'; +import type { RuntimeDeliveryJws } from '../../runtime-delivery/types'; +import type { RuntimeDeliveryPublicKey } from '../../runtime-delivery/types'; +import { + mockVerifyEs256Signature, + resetNativeFsMocks, +} from '../mocks/native/fs'; + +const PUBLIC_KEY = { + kty: 'EC' as const, + crv: 'P-256' as const, + x: 'a'.repeat(43), + y: 'b'.repeat(43), +}; +const NOW = Date.parse('2026-08-19T00:00:10.000Z'); + +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); +const serialize = ( + payload: Record, + header: Record = { + alg: 'ES256', + kid: 'lease-key', + typ: 'bundledrop-authority-lease+jws', + }, + overrides: Partial = {}, +) => JSON.stringify({ + protected: encode(header), + payload: encode(payload), + signature: Buffer.alloc(64, 1).toString('base64url'), + ...overrides, +}); + +const validPayload = (): Record => ({ + schemaVersion: 1, + type: 'publisher-lease', + manifestOrigin: 'https://manifests.example.com', + generatedAt: '2026-08-19T00:00:00.000Z', + expiresAt: '2026-08-19T00:00:15.000Z', +}); +const validV2Payload = (clientAuthority: 'enabled' | 'disabled'): Record => ({ + ...validPayload(), + schemaVersion: 2, + clientAuthority, +}); + +describe('runtime-delivery/authorityLeaseVerifier', () => { + beforeEach(() => { + resetNativeFsMocks(); + mockVerifyEs256Signature.mockResolvedValue(true); + }); + + it('accepts a valid signed lease for the configured manifest origin', async () => { + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize(validPayload()), + 'HTTPS://MANIFESTS.EXAMPLE.COM:443/', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).resolves.toEqual(validPayload()); + }); + + it('accepts enabled schema v2 leases and rejects a signed disabled lease', async () => { + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize(validV2Payload('enabled')), + 'https://manifests.example.com', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).resolves.toEqual(validV2Payload('enabled')); + + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize(validV2Payload('disabled')), + 'https://manifests.example.com', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).rejects.toMatchObject({ code: 'authority_disabled' }); + }); + + it('rejects malformed schema v2 authority states and schema mixing', async () => { + for (const payload of [ + { ...validV2Payload('enabled'), clientAuthority: 'shadow' }, + { ...validPayload(), clientAuthority: 'enabled' }, + { ...validV2Payload('enabled'), extra: true }, + ]) { + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize(payload), + 'https://manifests.example.com', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).rejects.toMatchObject({ code: 'authority_invalid' }); + } + }); + + it('does not depend on a global URL implementation', async () => { + const runtime = globalThis as unknown as { URL?: typeof URL }; + const originalUrl = runtime.URL; + delete runtime.URL; + + try { + expect(runtime.URL).toBeUndefined(); + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize(validPayload()), + 'https://manifests.example.com', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).resolves.toEqual(validPayload()); + + for (const manifestOrigin of [ + 'https://manifests.example.com/runtime', + 'https://manifests.example.com%2fruntime', + 'https://user@manifests.example.com', + ]) { + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize({ ...validPayload(), manifestOrigin }), + 'https://manifests.example.com', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).rejects.toMatchObject({ code: 'authority_origin_mismatch' }); + } + } finally { + runtime.URL = originalUrl; + } + }); + + it.each([ + 'http://manifests.example.com', + 'https://user@manifests.example.com', + 'https://user:password@manifests.example.com', + 'https://manifests.example.com/runtime', + 'https://manifests.example.com//', + 'https://manifests.example.com?environment=staging', + 'https://manifests.example.com#authority', + 'https://manifests.example.com\\evil.example.com', + 'https://manifests.example.com%2fruntime', + 'https://manifests.example.com%3fenvironment=staging', + 'https://manifests.example.com\u0000', + 'https://manifests.example.com:0', + 'https://manifests.example.com:65536', + 'https://manifests.example.com:abc', + 'https://:443', + `https://${'a'.repeat(254)}`, + 'https://bad_host.example.com', + 'https://bad..example.com', + `https://${'a'.repeat(64)}.example.com`, + 'https://-bad.example.com', + 'https://bad-.example.com', + 'https://127.0.0', + 'https://256.0.0.1', + ' https://manifests.example.com', + 'https://manifests.example.com ', + ])('rejects unsafe manifest origin %s', async manifestOrigin => { + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize({ ...validPayload(), manifestOrigin }), + 'https://manifests.example.com', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).rejects.toMatchObject({ code: 'authority_origin_mismatch' }); + }); + + it('requires an exact host and non-default port while normalizing origin syntax', async () => { + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize({ + ...validPayload(), + manifestOrigin: 'HTTPS://MANIFESTS.EXAMPLE.COM:8443/', + }), + 'https://manifests.example.com:8443', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).resolves.toMatchObject({ + manifestOrigin: 'HTTPS://MANIFESTS.EXAMPLE.COM:8443/', + }); + + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize(validPayload()), + 'https://manifests.example.com.evil.example', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).rejects.toMatchObject({ code: 'authority_origin_mismatch' }); + + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize({ + ...validPayload(), + manifestOrigin: 'https://manifests.example.com:8443', + }), + 'https://manifests.example.com:9443', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).rejects.toMatchObject({ code: 'authority_origin_mismatch' }); + }); + + it('rejects expired, overlong, future-issued, and cross-origin leases', async () => { + const verify = (payload: Record, now = NOW) => + verifyRuntimeDeliveryAuthorityLease( + serialize(payload), + 'https://manifests.example.com', + { 'lease-key': PUBLIC_KEY }, + now, + ); + + await expect(verify(validPayload(), Date.parse('2026-08-19T00:00:15.000Z'))) + .rejects.toMatchObject({ code: 'authority_expired' }); + await expect(verify({ + ...validPayload(), + expiresAt: new Date( + Date.parse(String(validPayload().generatedAt)) + + MAX_RUNTIME_DELIVERY_AUTHORITY_LEASE_MS + 1, + ).toISOString(), + })).rejects.toMatchObject({ code: 'authority_invalid' }); + await expect(verify({ + ...validPayload(), + generatedAt: '2026-08-19T00:00:15.001Z', + expiresAt: '2026-08-19T00:00:20.000Z', + })).rejects.toMatchObject({ code: 'authority_invalid' }); + await expect(verify({ + ...validPayload(), + manifestOrigin: 'https://other.example.com', + })).rejects.toMatchObject({ code: 'authority_origin_mismatch' }); + }); + + it('rejects malformed payloads, wrong JWS types, unknown keys, and bad signatures', async () => { + const verify = ( + serialized: string, + keys: Record = { 'lease-key': PUBLIC_KEY }, + ) => verifyRuntimeDeliveryAuthorityLease( + serialized, + 'https://manifests.example.com', + keys, + NOW, + ); + + await expect(verify(serialize({ ...validPayload(), extra: true }))) + .rejects.toMatchObject({ code: 'authority_invalid' }); + const malformedPayloadEnvelope = JSON.parse(serialize(validPayload())) as RuntimeDeliveryJws; + malformedPayloadEnvelope.payload = Buffer.from('{', 'utf8').toString('base64url'); + await expect(verify(JSON.stringify(malformedPayloadEnvelope))) + .rejects.toMatchObject({ code: 'authority_invalid' }); + await expect(verify(serialize({ + ...validPayload(), + manifestOrigin: 'ftp://manifests.example.com/root', + }))).rejects.toMatchObject({ code: 'authority_origin_mismatch' }); + await expect(verifyRuntimeDeliveryAuthorityLease( + serialize(validPayload()), + 'not a URL', + { 'lease-key': PUBLIC_KEY }, + NOW, + )).rejects.toMatchObject({ code: 'authority_origin_mismatch' }); + await expect(verify(serialize(validPayload(), { + alg: 'ES256', + kid: 'lease-key', + typ: 'bundledrop-manifest+jws', + }))).rejects.toMatchObject({ code: 'authority_invalid' }); + await expect(verify(serialize(validPayload()), {})) + .rejects.toMatchObject({ code: 'authority_unknown_key' }); + mockVerifyEs256Signature.mockResolvedValueOnce(false); + await expect(verify(serialize(validPayload()))) + .rejects.toMatchObject({ code: 'authority_invalid_signature' }); + }); +}); diff --git a/src/tests/runtime-delivery/bootstrapConfig.test.ts b/src/tests/runtime-delivery/bootstrapConfig.test.ts new file mode 100644 index 0000000..2f62f5b --- /dev/null +++ b/src/tests/runtime-delivery/bootstrapConfig.test.ts @@ -0,0 +1,219 @@ +import fs from 'fs-extra'; +import path from 'path'; + +import { + addRuntimeDeliveryBootstrapGitignoreRules, + createGeneratedRuntimeDeliveryBootstrap, + ensureRuntimeDeliveryBootstrapGitignore, + parseGeneratedRuntimeDeliveryBootstrap, + readGeneratedRuntimeDeliveryBootstrap, + removeGeneratedRuntimeDeliveryBootstrap, + writeGeneratedRuntimeDeliveryBootstrap, +} from '../../runtime-delivery/bootstrapConfig'; +import { createTempProjectDir, removeTempDir } from '../utils/tempDir'; + +const runtimeDelivery = { + mode: 'v2', + manifestBaseUrl: 'https://manifests.example.com/root/', + manifestAccessId: `mft_${'A'.repeat(43)}`, + publicKeys: { + key: { + kty: 'EC', + crv: 'P-256', + x: 'd-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM', + y: '_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI', + }, + }, +}; + +const identity = { + serverUrl: 'https://api.example.com/', + orgSlug: 'org', + projectSlug: 'app', +}; + +describe('runtime-delivery/bootstrapConfig', () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) removeTempDir(root); + }); + + it('creates, atomically writes, and identity-validates a neutral bootstrap', async () => { + const projectRoot = createTempProjectDir(); + roots.push(projectRoot); + const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery }); + expect(bootstrap).toEqual(expect.objectContaining({ + schemaVersion: 1, + project: { + serverUrl: 'https://api.example.com', + orgSlug: 'org', + projectSlug: 'app', + }, + runtimeDelivery: expect.objectContaining({ + manifestBaseUrl: 'https://manifests.example.com/root', + }), + })); + expect(bootstrap?.runtimeDelivery).not.toHaveProperty('mode'); + + await writeGeneratedRuntimeDeliveryBootstrap({ projectRoot, bootstrap: bootstrap! }); + expect(readGeneratedRuntimeDeliveryBootstrap({ + projectRoot, + expectedIdentity: identity, + })).toEqual(bootstrap); + expect(fs.readdirSync(path.join(projectRoot, '.bundle-drop'))).toEqual([ + 'runtime-delivery.generated.json', + ]); + }); + + it('keeps the bootstrap committed while ignoring the rest of its generated directory', async () => { + const projectRoot = createTempProjectDir(); + roots.push(projectRoot); + fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.bundle-drop/\n'); + + await ensureRuntimeDeliveryBootstrapGitignore(projectRoot); + const updated = fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8'); + expect(updated).toBe( + 'node_modules\n.bundle-drop/\n\n' + + '# Bundle Drop: commit the public trust bootstrap; ignore generated runtime artifacts.\n' + + '!.bundle-drop/\n.bundle-drop/*\n' + + '!.bundle-drop/runtime-delivery.generated.json\n', + ); + expect(addRuntimeDeliveryBootstrapGitignoreRules(updated)).toBe(updated); + }); + + it('rejects shadow promotion, private key material, and copied project identity', () => { + expect(createGeneratedRuntimeDeliveryBootstrap({ + identity, + runtimeDelivery: { ...runtimeDelivery, mode: 'shadow' }, + })).toBeUndefined(); + expect(createGeneratedRuntimeDeliveryBootstrap({ + identity, + runtimeDelivery: { + ...runtimeDelivery, + publicKeys: { key: { ...runtimeDelivery.publicKeys.key, d: 'private' } }, + }, + })).toBeUndefined(); + + const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery })!; + expect(() => parseGeneratedRuntimeDeliveryBootstrap(bootstrap, { + ...identity, + projectSlug: 'other-app', + })).toThrow('belongs to a different'); + }); + + it('persists stable backend IDs while remaining compatible with legacy schema-v1 identity', () => { + const stableIdentity = { + ...identity, + projectId: 'project-id-1', + orgId: 'org-id-1', + }; + const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ + identity: stableIdentity, + runtimeDelivery, + })!; + + expect(bootstrap.project).toEqual({ + serverUrl: 'https://api.example.com', + orgSlug: 'org', + projectSlug: 'app', + projectId: 'project-id-1', + orgId: 'org-id-1', + }); + expect(parseGeneratedRuntimeDeliveryBootstrap(bootstrap, stableIdentity)).toEqual(bootstrap); + expect(() => parseGeneratedRuntimeDeliveryBootstrap(bootstrap, { + ...stableIdentity, + projectId: 'other-project-id', + })).toThrow('belongs to a different'); + + const legacy = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery })!; + expect(parseGeneratedRuntimeDeliveryBootstrap(legacy, identity)).toEqual(legacy); + expect(createGeneratedRuntimeDeliveryBootstrap({ + identity: { ...identity, projectId: 'project-id-only' }, + runtimeDelivery, + })).toBeUndefined(); + }); + + it('fails closed for unsupported schemas and malformed JSON', () => { + const projectRoot = createTempProjectDir(); + roots.push(projectRoot); + expect(readGeneratedRuntimeDeliveryBootstrap({ projectRoot })).toBeNull(); + expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + schemaVersion: 2, + project: identity, + runtimeDelivery, + })).toThrow('schemaVersion 1'); + expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + schemaVersion: 1, + runtimeDelivery, + })).toThrow('missing its project identity'); + expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + schemaVersion: 1, + project: { serverUrl: 7, orgSlug: null, projectSlug: [] }, + runtimeDelivery, + })).toThrow('invalid trust configuration'); + expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + schemaVersion: 1, + project: { ...identity, projectId: 'project-id-1' }, + runtimeDelivery, + })).toThrow('invalid stable project identity'); + expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + schemaVersion: 1, + project: { ...identity, projectId: 7, orgId: 'org-id-1' }, + runtimeDelivery, + })).toThrow('invalid stable project identity'); + + const valid = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery })!; + expect(parseGeneratedRuntimeDeliveryBootstrap(valid)).toEqual(valid); + + fs.ensureDirSync(path.join(projectRoot, '.bundle-drop')); + fs.writeFileSync( + path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'), + '{not-json', + ); + expect(() => readGeneratedRuntimeDeliveryBootstrap({ projectRoot })).toThrow('not valid JSON'); + }); + + it('rejects a symlinked bootstrap ancestor without changing external files', async () => { + const projectRoot = createTempProjectDir(); + const outsideRoot = createTempProjectDir(); + roots.push(projectRoot, outsideRoot); + const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery })!; + const sentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(sentinel, 'outside-safe'); + fs.symlinkSync(outsideRoot, path.join(projectRoot, '.bundle-drop')); + + await expect(writeGeneratedRuntimeDeliveryBootstrap({ projectRoot, bootstrap })) + .rejects.toThrow('symlinked or non-directory'); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('outside-safe'); + }); + + it('atomically removes an existing bootstrap and tolerates an already-absent file', async () => { + const projectRoot = createTempProjectDir(); + roots.push(projectRoot); + const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery })!; + await writeGeneratedRuntimeDeliveryBootstrap({ projectRoot, bootstrap }); + + const bootstrapPath = await removeGeneratedRuntimeDeliveryBootstrap(projectRoot); + expect(bootstrapPath).not.toBeNull(); + expect(fs.existsSync(bootstrapPath!)).toBe(false); + await expect(removeGeneratedRuntimeDeliveryBootstrap(projectRoot)).resolves.toBeNull(); + }); + + it('refuses to remove a symlinked bootstrap target', async () => { + const projectRoot = createTempProjectDir(); + const outsideRoot = createTempProjectDir(); + roots.push(projectRoot, outsideRoot); + fs.ensureDirSync(path.join(projectRoot, '.bundle-drop')); + const sentinel = path.join(outsideRoot, 'sentinel.txt'); + fs.writeFileSync(sentinel, 'outside-safe'); + fs.symlinkSync( + sentinel, + path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'), + ); + + await expect(removeGeneratedRuntimeDeliveryBootstrap(projectRoot)) + .rejects.toThrow('symlinked or non-regular'); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('outside-safe'); + }); +}); diff --git a/src/tests/runtime-delivery/diagnostics.test.ts b/src/tests/runtime-delivery/diagnostics.test.ts new file mode 100644 index 0000000..65a97c3 --- /dev/null +++ b/src/tests/runtime-delivery/diagnostics.test.ts @@ -0,0 +1,61 @@ +const mockGetBundleDropRuntimeConfig = jest.fn(); + +jest.mock('../../runtime/initState', () => ({ + getBundleDropRuntimeConfig: () => mockGetBundleDropRuntimeConfig(), +})); + +import { + getRuntimeDeliveryDiagnosticCounters, + recordRuntimeDeliveryDiagnostic, + resetRuntimeDeliveryDiagnosticsForTests, +} from '../../runtime-delivery/diagnostics'; + +describe('runtime-delivery diagnostics', () => { + beforeEach(() => { + resetRuntimeDeliveryDiagnosticsForTests(); + mockGetBundleDropRuntimeConfig.mockReset().mockReturnValue(null); + }); + + it('keeps independent process counters and returns defensive snapshots', () => { + recordRuntimeDeliveryDiagnostic('manifest_hit', { channelName: 'General' }); + recordRuntimeDeliveryDiagnostic('manifest_hit'); + recordRuntimeDeliveryDiagnostic('origin_fallback', { reason: 'timeout' }); + + const snapshot = getRuntimeDeliveryDiagnosticCounters(); + expect(snapshot).toEqual(expect.objectContaining({ + manifest_hit: 2, + origin_fallback: 1, + invalid_signature: 0, + })); + snapshot.manifest_hit = 99; + expect(getRuntimeDeliveryDiagnosticCounters().manifest_hit).toBe(2); + }); + + it('emits structured increments to the app listener without allowing it to break checks', () => { + const listener = jest.fn(); + mockGetBundleDropRuntimeConfig.mockReturnValue({ onRuntimeDeliveryDiagnostic: listener }); + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('2026-08-17T00:00:00.000Z'); + + recordRuntimeDeliveryDiagnostic('unknown_key', { + channelName: 'General', + reason: 'unknown_key', + }); + expect(listener).toHaveBeenCalledWith({ + name: 'unknown_key', + count: 1, + timestamp: '2026-08-17T00:00:00.000Z', + details: { channelName: 'General', reason: 'unknown_key' }, + }); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + listener.mockImplementationOnce(() => { + throw new Error('sink offline'); + }); + expect(() => recordRuntimeDeliveryDiagnostic('unknown_key')).not.toThrow(); + expect(getRuntimeDeliveryDiagnosticCounters().unknown_key).toBe(2); + expect(warn).toHaveBeenCalledWith( + '[BundleDrop] runtime-delivery diagnostic listener failed:', + expect.any(Error), + ); + }); +}); diff --git a/src/tests/runtime-delivery/encoding.test.ts b/src/tests/runtime-delivery/encoding.test.ts new file mode 100644 index 0000000..f60c76b --- /dev/null +++ b/src/tests/runtime-delivery/encoding.test.ts @@ -0,0 +1,37 @@ +import { + decodeBase64UrlBytes, + decodeBase64UrlUtf8, + decodeUtf8Bytes, + encodeBase64UrlUtf8, + utf8ByteLength, +} from '../../runtime-delivery/encoding'; + +describe('runtime-delivery/encoding', () => { + it('round-trips ASCII and every UTF-8 width without relying on Buffer at runtime', () => { + const value = 'Aβह😀'; + const expected = Buffer.from(value, 'utf8').toString('base64url'); + expect(encodeBase64UrlUtf8(value)).toBe(expected); + expect(decodeBase64UrlUtf8(expected)).toBe(value); + expect(decodeBase64UrlBytes(expected)).toEqual([...Buffer.from(value, 'utf8')]); + expect(utf8ByteLength(value)).toBe(Buffer.byteLength(value)); + expect(encodeBase64UrlUtf8('a')).toBe('YQ'); + expect(encodeBase64UrlUtf8('ab')).toBe('YWI'); + expect(encodeBase64UrlUtf8('abc')).toBe('YWJj'); + }); + + it('decodes streamed UTF-8 bytes and rejects malformed or truncated sequences', () => { + expect(decodeUtf8Bytes(new Uint8Array(Buffer.from('Bundle β 🚀', 'utf8')))).toBe( + 'Bundle β 🚀', + ); + expect(() => decodeUtf8Bytes(new Uint8Array([0xc0, 0x80]))).toThrow('Invalid UTF-8'); + expect(() => decodeUtf8Bytes(new Uint8Array([0xc2, 0x41]))).toThrow('Invalid UTF-8'); + expect(() => decodeUtf8Bytes(new Uint8Array([0xf0, 0x9f, 0x9a]))).toThrow('Invalid UTF-8'); + expect(() => decodeUtf8Bytes(new Uint8Array([0xed, 0xa0, 0x80]))).toThrow('Invalid UTF-8'); + }); + + it('rejects non-base64url input, malformed quanta, and invalid UTF-8', () => { + expect(() => decodeBase64UrlBytes('$')).toThrow('Invalid base64url'); + expect(() => decodeBase64UrlBytes('A')).toThrow('Invalid base64url'); + expect(() => decodeBase64UrlUtf8('_w')).toThrow('Invalid UTF-8'); + }); +}); diff --git a/src/tests/runtime-delivery/heartbeat.test.ts b/src/tests/runtime-delivery/heartbeat.test.ts new file mode 100644 index 0000000..bd1b830 --- /dev/null +++ b/src/tests/runtime-delivery/heartbeat.test.ts @@ -0,0 +1,170 @@ +jest.mock('../../context', () => require('../mocks/context')); +jest.mock('../../native/fs', () => require('../mocks/native/fs')); +jest.mock('../../api/clientApi', () => require('../mocks/api/clientApi')); + +import { + reportActiveInstallWhenDue, + resetRuntimeDeliveryHeartbeatForTests, +} from '../../runtime-delivery/heartbeat'; +import { mockPostOtaActiveInstallHeartbeat } from '../mocks/api/clientApi'; +import { + mockWriteFile, + resetNativeFsMocks, + setMockFile, +} from '../mocks/native/fs'; + +const HEARTBEAT_STATE_PATH = '/mock/doc/bundle-drop/runtime-delivery-heartbeats.json'; + +const flushAsyncWork = () => new Promise(resolve => setTimeout(resolve, 0)); + +describe('runtime-delivery/heartbeat', () => { + beforeEach(() => { + resetNativeFsMocks(); + resetRuntimeDeliveryHeartbeatForTests(); + mockPostOtaActiveInstallHeartbeat.mockReset().mockResolvedValue({ data: undefined } as never); + }); + + it('reports unchanged v2 install state at most once per persisted seven-day window', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000); + const payload = { + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + installId: 'install-1', + currentHash: null, + environment: 'production', + }; + reportActiveInstallWhenDue('project', payload); + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(1_800_000_000_000 + 24 * 60 * 60 * 1000); + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(1_800_000_000_000 + 7 * 24 * 60 * 60 * 1000 - 1); + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(1_800_000_000_000 + 7 * 24 * 60 * 60 * 1000); + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(2); + nowSpy.mockRestore(); + }); + + it('reports state changes immediately but ignores user-property key order', async () => { + const payload = { + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + installId: 'install-1', + currentHash: 'a'.repeat(64), + environment: 'production', + userProperties: { cohort: 'beta', enabled: true }, + }; + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + + reportActiveInstallWhenDue('project', { + ...payload, + userProperties: { enabled: true, cohort: 'beta' }, + }); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(1); + + reportActiveInstallWhenDue('project', { ...payload, currentHash: 'b'.repeat(64) }); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(2); + + reportActiveInstallWhenDue('project', { + ...payload, + currentHash: 'b'.repeat(64), + userProperties: { cohort: 'stable', enabled: true }, + }); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(3); + + reportActiveInstallWhenDue('project', { + ...payload, + currentHash: 'b'.repeat(64), + environment: 'preview', + userProperties: { cohort: 'stable', enabled: true }, + }); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(4); + }); + + it('upgrades a legacy cache and reports when its timestamp is missing', async () => { + const payload = { + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + installId: 'install-1', + currentHash: null, + }; + const fingerprint = require('crypto') + .createHash('sha256') + .update(JSON.stringify({ currentHash: null, environment: null, userProperties: null })) + .digest('hex'); + setMockFile(HEARTBEAT_STATE_PATH, JSON.stringify({ + schemaVersion: 1, + reportedAt: {}, + fingerprints: { + 'project/General/android/1.0.0/install-1': fingerprint, + }, + })); + + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(1); + + setMockFile(HEARTBEAT_STATE_PATH, JSON.stringify({ schemaVersion: 1, reportedAt: {} })); + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(2); + }); + + it('does not make heartbeat failure authoritative and retries later', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + mockPostOtaActiveInstallHeartbeat.mockRejectedValueOnce(new Error('offline')); + const payload = { + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + installId: 'install-1', + currentHash: null, + }; + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); + }); + + it('recovers the serialized state queue after a cache write fails', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + mockWriteFile + .mockRejectedValueOnce(new Error('disk full')) + .mockRejectedValueOnce(new Error('disk full')); + const payload = { + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + installId: 'install-1', + currentHash: null, + }; + + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + reportActiveInstallWhenDue('project', payload); + await flushAsyncWork(); + + expect(mockPostOtaActiveInstallHeartbeat).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); + }); +}); diff --git a/src/tests/runtime-delivery/localResolver.test.ts b/src/tests/runtime-delivery/localResolver.test.ts new file mode 100644 index 0000000..f10e0c5 --- /dev/null +++ b/src/tests/runtime-delivery/localResolver.test.ts @@ -0,0 +1,359 @@ +jest.mock('../../context', () => require('../mocks/context')); +jest.mock('../../native/fs', () => require('../mocks/native/fs')); + +import { resolveRuntimeDeliveryLane, rolloutBucket } from '../../runtime-delivery/localResolver'; +import type { RuntimeDeliveryLaneManifest } from '../../runtime-delivery/types'; +import { mockSha256String, resetNativeFsMocks } from '../mocks/native/fs'; + +const hash = (character: string) => character.repeat(64); + +const release = (releaseRef: string, bundleHash: string, bundleVersion: number) => ({ + releaseRef, + bundleHash, + bundleVersion, + runtimeVersion: '1.0.0', + manifestHash: hash('b'), + jsBundleHash: hash('c'), + fullBundleHash: hash('d'), + fullBundleSizeBytes: 1000, + available: true, + expiresAt: null, +}); + +const automaticManifest = (): RuntimeDeliveryLaneManifest => ({ + schemaVersion: 3, + type: 'lane', + projectSlug: 'project', + channelName: 'General', + platform: 'android', + runtimeVersion: '1.0.0', + generation: 1, + generatedAt: '2026-08-17T00:00:00.000Z', + resolutionMode: 'local', + publishingMode: 'automatic', + rolloutAlgorithm: 'sha256-install-id-uint32be-mod100-v1', + revokedHashes: [], + releases: [release('new', hash('1'), 2), release('old', hash('2'), 1)], + publishedRollouts: [], + patchPolicy: { enabled: true, maxPatchToFullRatio: 0.7 }, + patchEdges: [], + candidateSetComplete: true, +}); + +describe('runtime-delivery/localResolver', () => { + beforeEach(resetNativeFsMocks); + + it.each([ + ['install-1', 30], + ['install-2', 52], + ['device-a', 89], + ['device-b', 14], + ['00000000-0000-0000-0000-000000000000', 52], + ['alpha', 17], + ['beta', 43], + ['test-install-id', 76], + ['a', 10], + ])('matches the backend SHA-256 rollout bucket for %s', async (installId, expected) => { + await expect(rolloutBucket(installId)).resolves.toBe(expected); + }); + + it('uses ordered automatic candidates and falls through rejected or unavailable releases', async () => { + const manifest = automaticManifest(); + manifest.releases[0].available = false; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual(expect.objectContaining({ + action: 'INSTALL', + target: expect.objectContaining({ releaseRef: 'old' }), + mode: 'full', + })); + + manifest.releases[0].available = true; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [hash('1')], + installId: 'install-1', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual(expect.objectContaining({ + action: 'INSTALL', + target: expect.objectContaining({ releaseRef: 'old' }), + })); + }); + + it('returns up-to-date for the selected current bundle and rejects invalid native digests', async () => { + const manifest = automaticManifest(); + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('1'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual({ action: 'NOOP', reason: 'UP_TO_DATE' }); + + mockSha256String.mockResolvedValueOnce('not-a-digest'); + await expect(rolloutBucket('install-1')).rejects.toThrow('invalid digest'); + }); + + it('preserves fractional managed-rollout membership using integerBucket < percentage', async () => { + const manifest = automaticManifest(); + manifest.publishingMode = 'managed'; + manifest.publishedRollouts = [{ releaseRef: 'new', rolloutPercentage: 14.5, status: 'active' }]; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'device-b', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual(expect.objectContaining({ action: 'INSTALL' })); + + manifest.publishedRollouts[0].rolloutPercentage = 14; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'device-b', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual({ action: 'NOOP', reason: 'ROLLOUT_NOT_ELIGIBLE' }); + + manifest.publishedRollouts[0] = { + releaseRef: 'new', + rolloutPercentage: 14, + status: 'completed', + }; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'device-b', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual({ action: 'NOOP', reason: 'ROLLOUT_NOT_ELIGIBLE' }); + }); + + it('rolls back a revoked current bundle only when no safe different target exists', async () => { + const manifest = automaticManifest(); + manifest.revokedHashes = [hash('1'), hash('2')]; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('1'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: true, + })).resolves.toEqual({ + action: 'ROLLBACK', + reason: 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET', + }); + + manifest.revokedHashes = [hash('2')]; + manifest.patchEdges = [{ + baseHash: hash('2'), + targetHash: hash('1'), + algorithm: 'xdelta3-vcdiff', + patchSetHash: hash('e'), + patchArtifactRef: 'patch', + patchSizeBytes: 100, + fullBundleSizeBytes: 1000, + }]; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('2'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: true, + })).resolves.toEqual(expect.objectContaining({ action: 'INSTALL', mode: 'full' })); + }); + + it('selects an eligible ordered patch edge and otherwise preserves full fallback', async () => { + const manifest = automaticManifest(); + manifest.patchEdges = [{ + baseHash: hash('2'), + targetHash: hash('1'), + algorithm: 'xdelta3-vcdiff', + patchSetHash: hash('e'), + patchArtifactRef: 'patch', + patchSizeBytes: 700, + fullBundleSizeBytes: 1000, + }]; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('2'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: true, + })).resolves.toEqual(expect.objectContaining({ action: 'INSTALL', mode: 'patch' })); + + manifest.patchEdges[0].patchSizeBytes = 701; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('2'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: true, + })).resolves.toEqual(expect.objectContaining({ action: 'INSTALL', mode: 'full' })); + }); + + it('rejects disabled, unsupported, expired, wrong-base, and wrong-target patch edges', async () => { + const manifest = automaticManifest(); + const edge = { + baseHash: hash('2'), + targetHash: hash('1'), + algorithm: 'xdelta3-vcdiff', + patchSetHash: hash('e'), + patchArtifactRef: 'patch', + patchSizeBytes: 100, + fullBundleSizeBytes: 1000, + expiresAt: null, + }; + manifest.patchEdges = [edge]; + const input = { + currentHash: hash('2'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: true, + now: Date.parse('2026-08-17T00:00:00.000Z'), + }; + + manifest.patchPolicy.enabled = false; + await expect(resolveRuntimeDeliveryLane(manifest, input)) + .resolves.toEqual(expect.objectContaining({ mode: 'full' })); + manifest.patchPolicy.enabled = true; + await expect(resolveRuntimeDeliveryLane(manifest, { ...input, currentHash: null })) + .resolves.toEqual(expect.objectContaining({ mode: 'full' })); + edge.baseHash = hash('3'); + await expect(resolveRuntimeDeliveryLane(manifest, input)) + .resolves.toEqual(expect.objectContaining({ mode: 'full' })); + edge.baseHash = hash('2'); + edge.targetHash = hash('3'); + await expect(resolveRuntimeDeliveryLane(manifest, input)) + .resolves.toEqual(expect.objectContaining({ mode: 'full' })); + edge.targetHash = hash('1'); + await expect(resolveRuntimeDeliveryLane(manifest, { ...input, patchAlgorithms: [] })) + .resolves.toEqual(expect.objectContaining({ mode: 'full' })); + edge.expiresAt = '2026-08-16T00:00:00.000Z'; + await expect(resolveRuntimeDeliveryLane(manifest, input)) + .resolves.toEqual(expect.objectContaining({ mode: 'full' })); + edge.expiresAt = '2026-08-18T00:00:00.000Z'; + await expect(resolveRuntimeDeliveryLane(manifest, input)) + .resolves.toEqual(expect.objectContaining({ mode: 'patch' })); + }); + + it('skips expired, rejected, and revoked managed candidates before a 100-percent rollout', async () => { + const manifest = automaticManifest(); + manifest.publishingMode = 'managed'; + manifest.releases = [ + { ...release('expired', hash('3'), 3), expiresAt: '2026-08-16T00:00:00.000Z' }, + release('rejected', hash('4'), 2), + release('revoked', hash('5'), 1), + release('safe', hash('6'), 0), + ]; + manifest.revokedHashes = [hash('5')]; + manifest.publishedRollouts = manifest.releases.map(item => ({ + releaseRef: item.releaseRef, + rolloutPercentage: 100, + status: 'completed' as const, + })); + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [hash('4')], + installId: 'device-a', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + now: Date.parse('2026-08-17T00:00:00.000Z'), + })).resolves.toEqual(expect.objectContaining({ + action: 'INSTALL', + target: expect.objectContaining({ releaseRef: 'safe' }), + })); + }); + + it('rejects patches from quarantined bases and any patch with missing assets without asset capability', async () => { + const manifest = automaticManifest(); + manifest.patchEdges = [{ + baseHash: hash('2'), + targetHash: hash('1'), + algorithm: 'asset-only-v1', + patchSetHash: hash('e'), + patchArtifactRef: 'asset-patch', + patchSizeBytes: 100, + fullBundleSizeBytes: 1000, + missingAssetsHash: hash('f'), + }]; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('2'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['asset-only-v1'], + supportsContentAddressedAssets: false, + })).resolves.toEqual(expect.objectContaining({ mode: 'full' })); + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('2'), + rejectedHashes: [hash('2')], + installId: 'install-1', + patchAlgorithms: ['asset-only-v1'], + supportsContentAddressedAssets: true, + })).resolves.toEqual(expect.objectContaining({ mode: 'full' })); + + manifest.patchEdges[0].algorithm = 'xdelta3-vcdiff'; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('2'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: false, + })).resolves.toEqual(expect.objectContaining({ mode: 'full' })); + + delete manifest.patchEdges[0].missingAssetsHash; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: hash('2'), + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: false, + })).resolves.toEqual(expect.objectContaining({ mode: 'patch' })); + }); + + it('uses production no-candidate reasons for automatic and managed lanes', async () => { + const manifest = automaticManifest(); + manifest.releases = []; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual({ action: 'NOOP', reason: 'NO_COMPATIBLE_BUNDLE' }); + manifest.publishingMode = 'managed'; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).resolves.toEqual({ action: 'NOOP', reason: 'NO_PUBLISHED_BUNDLE' }); + }); + + it('rejects dynamic and incomplete lanes instead of installing from them', async () => { + const manifest = automaticManifest(); + manifest.candidateSetComplete = false; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).rejects.toThrow('Incomplete candidate sets'); + manifest.candidateSetComplete = true; + manifest.resolutionMode = 'dynamic'; + await expect(resolveRuntimeDeliveryLane(manifest, { + currentHash: null, + rejectedHashes: [], + installId: 'install-1', + patchAlgorithms: [], + supportsContentAddressedAssets: true, + })).rejects.toThrow('Dynamic lanes'); + }); +}); diff --git a/src/tests/runtime-delivery/manifestState.test.ts b/src/tests/runtime-delivery/manifestState.test.ts new file mode 100644 index 0000000..6e838c6 --- /dev/null +++ b/src/tests/runtime-delivery/manifestState.test.ts @@ -0,0 +1,129 @@ +jest.mock('../../context', () => require('../mocks/context')); +jest.mock('../../native/fs', () => require('../mocks/native/fs')); + +import { + readVerifiedLaneState, + recordVerifiedLaneManifest, +} from '../../runtime-delivery/manifestState'; +import type { RuntimeDeliveryLaneManifest } from '../../runtime-delivery/types'; +import { + mockReadFile, + resetNativeFsMocks, + setMockFile, +} from '../mocks/native/fs'; + +const identity = { + projectSlug: 'project', + channelName: 'General / beta', + platform: 'android', + runtimeVersion: '1.0.0/native', +}; + +const manifest = (generation: number): RuntimeDeliveryLaneManifest => ({ + ...identity, + schemaVersion: 3, + type: 'lane', + generation, + generatedAt: '2026-08-17T00:00:00.000Z', + resolutionMode: 'local', + publishingMode: 'automatic', + rolloutAlgorithm: 'sha256-install-id-uint32be-mod100-v1', + revokedHashes: ['a'.repeat(64)], + releases: [], + publishedRollouts: [], + patchPolicy: { enabled: false, maxPatchToFullRatio: 0 }, + patchEdges: [], + candidateSetComplete: true, +}); + +describe('runtime-delivery/manifestState', () => { + beforeEach(resetNativeFsMocks); + + it('initializes empty only when state is genuinely absent', async () => { + await expect(readVerifiedLaneState(identity)).resolves.toBeNull(); + }); + + it('fails closed for malformed, unsupported, or unreadable existing state', async () => { + setMockFile('/mock/doc/bundle-drop/runtime-delivery-state.json', '{'); + await expect(readVerifiedLaneState(identity)).rejects.toThrow('malformed or unsupported'); + + const invalidStates: unknown[] = [ + null, + [], + { schemaVersion: 1, lanes: {}, extra: true }, + { schemaVersion: 3, lanes: {} }, + { schemaVersion: 1, lanes: null }, + { schemaVersion: 1, lanes: { '': {} } }, + { schemaVersion: 1, lanes: { lane: null } }, + { schemaVersion: 1, lanes: { lane: { extra: true } } }, + { schemaVersion: 1, lanes: { lane: { + highestGeneration: 0, + payloadSha256: 'a'.repeat(64), + revokedHashes: [], + verifiedAt: '2026-08-17T00:00:00.000Z', + } } }, + { schemaVersion: 1, lanes: { lane: { + highestGeneration: 1, + payloadSha256: 'bad', + revokedHashes: [], + verifiedAt: '2026-08-17T00:00:00.000Z', + } } }, + { schemaVersion: 1, lanes: { lane: { + highestGeneration: 1, + payloadSha256: 'a'.repeat(64), + revokedHashes: 'bad', + verifiedAt: '2026-08-17T00:00:00.000Z', + } } }, + { schemaVersion: 1, lanes: { lane: { + highestGeneration: 1, + payloadSha256: 'a'.repeat(64), + revokedHashes: ['bad'], + verifiedAt: '2026-08-17T00:00:00.000Z', + } } }, + { schemaVersion: 1, lanes: { lane: { + highestGeneration: 1, + payloadSha256: 'a'.repeat(64), + revokedHashes: ['b'.repeat(64), 'b'.repeat(64)], + verifiedAt: '2026-08-17T00:00:00.000Z', + } } }, + { schemaVersion: 1, lanes: { lane: { + highestGeneration: 1, + payloadSha256: 'a'.repeat(64), + revokedHashes: [], + verifiedAt: 'not-a-date', + } } }, + ]; + for (const state of invalidStates) { + setMockFile('/mock/doc/bundle-drop/runtime-delivery-state.json', JSON.stringify(state)); + await expect(readVerifiedLaneState(identity)).rejects.toThrow('malformed or unsupported'); + } + + setMockFile('/mock/doc/bundle-drop/runtime-delivery-state.json', '{}'); + mockReadFile.mockRejectedValueOnce(new Error('EIO')); + await expect(readVerifiedLaneState(identity)).rejects.toThrow('Unable to read existing'); + }); + + it('persists and reads lane state, while allowing same-payload and newer generations', async () => { + await recordVerifiedLaneManifest(manifest(1), '1'.repeat(64)); + await expect(readVerifiedLaneState(identity)).resolves.toEqual(expect.objectContaining({ + highestGeneration: 1, + payloadSha256: '1'.repeat(64), + revokedHashes: ['a'.repeat(64)], + })); + await recordVerifiedLaneManifest(manifest(1), '1'.repeat(64)); + await recordVerifiedLaneManifest(manifest(2), '2'.repeat(64)); + await expect(readVerifiedLaneState(identity)).resolves.toEqual(expect.objectContaining({ + highestGeneration: 2, + payloadSha256: '2'.repeat(64), + })); + }); + + it('rejects regressed and equivocated mutations, then releases the mutation queue', async () => { + await recordVerifiedLaneManifest(manifest(2), '2'.repeat(64)); + await expect(recordVerifiedLaneManifest(manifest(1), '1'.repeat(64))) + .rejects.toThrow('generation regressed'); + await expect(recordVerifiedLaneManifest(manifest(2), '3'.repeat(64))) + .rejects.toThrow('equivocation'); + await expect(recordVerifiedLaneManifest(manifest(3), '4'.repeat(64))).resolves.toBeUndefined(); + }); +}); diff --git a/src/tests/runtime-delivery/manifestVerifier.test.ts b/src/tests/runtime-delivery/manifestVerifier.test.ts new file mode 100644 index 0000000..f6fa823 --- /dev/null +++ b/src/tests/runtime-delivery/manifestVerifier.test.ts @@ -0,0 +1,428 @@ +jest.mock('../../context', () => require('../mocks/context')); +jest.mock('../../native/fs', () => require('../mocks/native/fs')); + +import { verifyRuntimeDeliveryManifest } from '../../runtime-delivery/manifestVerifier'; +import type { RuntimeDeliveryJws } from '../../runtime-delivery/types'; +import { mockVerifyEs256Signature, readMockJson, resetNativeFsMocks } from '../mocks/native/fs'; + +const PROTECTED = 'eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5LTIwMjYtMDgiLCJ0eXAiOiJidW5kbGVkcm9wLW1hbmlmZXN0K2p3cyJ9'; +const PAYLOAD = 'eyJzY2hlbWFWZXJzaW9uIjozLCJ0eXBlIjoibGFuZSIsInByb2plY3RTbHVnIjoiZ29sZGVuLXByb2plY3QiLCJjaGFubmVsTmFtZSI6IlByb2R1Y3Rpb24gLyDOsiIsInBsYXRmb3JtIjoiaW9zIiwicnVudGltZVZlcnNpb24iOiIxLjIuMytuYXRpdmUvNDIiLCJnZW5lcmF0aW9uIjo3LCJnZW5lcmF0ZWRBdCI6IjIwMjYtMDgtMTdUMDA6MDA6MDAuMDAwWiIsInJlc29sdXRpb25Nb2RlIjoibG9jYWwiLCJwdWJsaXNoaW5nTW9kZSI6ImF1dG9tYXRpYyIsInJvbGxvdXRBbGdvcml0aG0iOiJzaGEyNTYtaW5zdGFsbC1pZC11aW50MzJiZS1tb2QxMDAtdjEiLCJyZXZva2VkSGFzaGVzIjpbXSwicmVsZWFzZXMiOlt7InJlbGVhc2VSZWYiOiJyZWxfZ29sZGVuIiwiYnVuZGxlSGFzaCI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJidW5kbGVWZXJzaW9uIjo3LCJ2ZXJzaW9uIjoiMS4wLjciLCJydW50aW1lVmVyc2lvbiI6IjEuMi4zK25hdGl2ZS80MiIsIm1hbmlmZXN0SGFzaCI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIiLCJqc0J1bmRsZUhhc2giOiJjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjIiwiZnVsbEJ1bmRsZUhhc2giOiJkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkIiwiZnVsbEJ1bmRsZVNpemVCeXRlcyI6MTIzNDU2LCJhdmFpbGFibGUiOnRydWUsImV4cGlyZXNBdCI6bnVsbH1dLCJwdWJsaXNoZWRSb2xsb3V0cyI6W10sInBhdGNoUG9saWN5Ijp7ImVuYWJsZWQiOnRydWUsIm1heFBhdGNoVG9GdWxsUmF0aW8iOjAuN30sInBhdGNoRWRnZXMiOltdLCJjYW5kaWRhdGVTZXRDb21wbGV0ZSI6dHJ1ZX0'; +const SIGNATURE = 'jN-so6BGfybKPcO7FM7_RTJxIkgJFGb-ZewrYvw0tWedaydN7He5mmujM0HRDUKqCvD6k5jIwhkTVICGx6vCzQ'; +const KEY = { + kty: 'EC' as const, + crv: 'P-256' as const, + x: 'd-g4y_28QdARnFF6HO0T00laLEfHhVFXTmuWHqBWmfM', + y: '_Z_xWbhjDp3IVMtLA_rN3guVyprP34OvBikPWpVQfUI', +}; +const IDENTITY = { + projectSlug: 'golden-project', + channelName: 'Production / β', + platform: 'ios', + runtimeVersion: '1.2.3+native/42', +}; + +const serialize = (overrides?: Partial) => JSON.stringify({ + protected: PROTECTED, + payload: PAYLOAD, + signature: SIGNATURE, + ...overrides, +}); +const basePayload = (): Record => + JSON.parse(Buffer.from(PAYLOAD, 'base64url').toString('utf8')); +const encodedPayload = (payload: unknown) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + +describe('runtime-delivery/manifestVerifier', () => { + beforeEach(resetNativeFsMocks); + + it('verifies the shared cross-repository ES256 golden vector', async () => { + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).resolves.toEqual(expect.objectContaining({ + schemaVersion: 3, + generation: 7, + channelName: 'Production / β', + candidateSetComplete: true, + })); + }); + + it('rejects unknown keys, tampered payloads, malformed signatures, and wrong lanes', async () => { + await expect(verifyRuntimeDeliveryManifest(serialize(), IDENTITY, {})) + .rejects.toThrow('Unknown or invalid'); + await expect(verifyRuntimeDeliveryManifest( + serialize({ payload: `${PAYLOAD.slice(0, -1)}1` }), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow('signature verification failed'); + await expect(verifyRuntimeDeliveryManifest( + serialize({ signature: 'AA' }), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow('64-byte'); + await expect(verifyRuntimeDeliveryManifest( + serialize(), + { ...IDENTITY, channelName: 'Other' }, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow('identity mismatch'); + }); + + it('supports key rotation by selecting only the protected kid', async () => { + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { + old: { ...KEY, x: 'A'.repeat(43) }, + 'test-key-2026-08': KEY, + }, + )).resolves.toEqual(expect.objectContaining({ generation: 7 })); + }); + + it('rejects generation regression after persisting a higher verified generation', async () => { + await verifyRuntimeDeliveryManifest(serialize(), IDENTITY, { 'test-key-2026-08': KEY }); + const statePath = '/mock/doc/bundle-drop/runtime-delivery-state.json'; + const { setMockFile } = require('../mocks/native/fs') as typeof import('../mocks/native/fs'); + setMockFile(statePath, JSON.stringify({ + schemaVersion: 1, + lanes: { + 'golden-project/Production%20%2F%20%CE%B2/ios/1.2.3%2Bnative%2F42': { + highestGeneration: 8, + payloadSha256: 'e'.repeat(64), + revokedHashes: [], + verifiedAt: '2026-08-17T00:00:00.000Z', + }, + }, + })); + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow('generation regressed'); + }); + + it('rejects equal-generation payload equivocation without replacing last-known revocations', async () => { + await verifyRuntimeDeliveryManifest(serialize(), IDENTITY, { 'test-key-2026-08': KEY }); + mockVerifyEs256Signature.mockResolvedValue(true); + const changedPayload = JSON.parse(Buffer.from(PAYLOAD, 'base64url').toString('utf8')); + changedPayload.revokedHashes = ['f'.repeat(64)]; + + await expect(verifyRuntimeDeliveryManifest( + serialize({ + payload: Buffer.from(JSON.stringify(changedPayload)).toString('base64url'), + }), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow('generation equivocation'); + + const persisted = readMockJson<{ lanes: Record }>( + '/mock/doc/bundle-drop/runtime-delivery-state.json', + ); + const lane = Object.values(persisted?.lanes || {})[0]; + expect(lane.revokedHashes).toEqual([]); + }); + + it('rejects oversized envelopes before native signature verification', async () => { + await expect(verifyRuntimeDeliveryManifest( + `${serialize()}${' '.repeat(1024 * 1024)}`, + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow('1 MB'); + }); + + it('enforces closed JWS metadata and positive complete local generations', async () => { + mockVerifyEs256Signature.mockResolvedValue(true); + const publicKeys = { 'test-key-2026-08': KEY }; + await expect(verifyRuntimeDeliveryManifest( + JSON.stringify({ + protected: PROTECTED, + payload: PAYLOAD, + signature: SIGNATURE, + unprotected: { ignored: true }, + }), + IDENTITY, + publicKeys, + )).rejects.toThrow('unsupported fields'); + + const protectedWithExtra = Buffer.from(JSON.stringify({ + alg: 'ES256', + kid: 'test-key-2026-08', + typ: 'bundledrop-manifest+jws', + crit: [], + })).toString('base64url'); + await expect(verifyRuntimeDeliveryManifest( + serialize({ protected: protectedWithExtra }), + IDENTITY, + publicKeys, + )).rejects.toThrow('protected header contains unsupported fields'); + + const payload = JSON.parse(Buffer.from(PAYLOAD, 'base64url').toString('utf8')); + await expect(verifyRuntimeDeliveryManifest( + serialize({ + payload: Buffer.from(JSON.stringify({ ...payload, generation: 0 })).toString('base64url'), + }), + IDENTITY, + publicKeys, + )).rejects.toThrow('generation must be at least 1'); + await expect(verifyRuntimeDeliveryManifest( + serialize({ + payload: Buffer.from(JSON.stringify({ + ...payload, + resolutionMode: 'local', + candidateSetComplete: false, + })).toString('base64url'), + }), + IDENTITY, + publicKeys, + )).rejects.toThrow('complete candidate set'); + }); + + it('strictly validates the protected header and public P-256 JWK', async () => { + mockVerifyEs256Signature.mockResolvedValue(true); + const header = (value: unknown) => serialize({ + protected: Buffer.from(JSON.stringify(value)).toString('base64url'), + }); + for (const invalid of [ + null, + { alg: 'RS256', kid: 'test-key-2026-08', typ: 'bundledrop-manifest+jws' }, + { alg: 'ES256', kid: 'test-key-2026-08', typ: 'wrong' }, + { alg: 'ES256', kid: '', typ: 'bundledrop-manifest+jws' }, + { alg: 'ES256', kid: 7, typ: 'bundledrop-manifest+jws' }, + ]) { + await expect(verifyRuntimeDeliveryManifest( + header(invalid), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow(); + } + + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': { ...KEY, kty: 'RSA' } as never }, + )).rejects.toThrow('Unknown or invalid'); + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': { ...KEY, crv: 'P-384' } as never }, + )).rejects.toThrow('Unknown or invalid'); + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': { ...KEY, x: 'AA' } }, + )).rejects.toThrow('coordinates'); + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': { ...KEY, y: '*' } }, + )).rejects.toThrow('base64url'); + }); + + it('rejects malformed envelope values and mismatched lane identities', async () => { + mockVerifyEs256Signature.mockResolvedValue(true); + for (const serialized of [ + JSON.stringify(null), + JSON.stringify([]), + JSON.stringify({ protected: 1, payload: PAYLOAD, signature: SIGNATURE }), + JSON.stringify({ protected: PROTECTED, payload: '', signature: SIGNATURE }), + JSON.stringify({ protected: PROTECTED, payload: PAYLOAD, signature: null }), + ]) { + await expect(verifyRuntimeDeliveryManifest( + serialized, + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow(); + } + + for (const [field, value] of [ + ['projectSlug', 'other-project'], + ['channelName', 'Other'], + ['platform', 'android'], + ['runtimeVersion', 'other-runtime'], + ] as const) { + await expect(verifyRuntimeDeliveryManifest( + serialize(), + { ...IDENTITY, [field]: value }, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow(`${field} identity mismatch`); + } + }); + + it('rejects every unsafe lane, release, rollout, policy, and patch shape', async () => { + mockVerifyEs256Signature.mockResolvedValue(true); + const verifyMutation = (mutate: (payload: Record) => unknown) => { + const payload = basePayload(); + const result = mutate(payload); + return verifyRuntimeDeliveryManifest( + serialize({ payload: encodedPayload(result === undefined ? payload : result) }), + IDENTITY, + { 'test-key-2026-08': KEY }, + ); + }; + const patch = () => ({ + baseHash: '0'.repeat(64), + targetHash: 'a'.repeat(64), + algorithm: 'xdelta3-vcdiff', + patchSetHash: 'e'.repeat(64), + patchArtifactRef: 'patch-7', + patchSizeBytes: 100, + fullBundleSizeBytes: 123456, + }); + const rollout = () => ({ releaseRef: 'rel_golden', rolloutPercentage: 50, status: 'active' }); + + const cases: Array<[string, (payload: Record) => unknown]> = [ + ['object', () => null], + ['unsupported field', payload => { payload.extra = true; }], + ['schema', payload => { payload.schemaVersion = 2; }], + ['schema', payload => { payload.type = 'project'; }], + ['resolutionMode', payload => { payload.resolutionMode = 'other'; }], + ['publishingMode', payload => { payload.publishingMode = 'other'; }], + ['rollout algorithm', payload => { payload.rolloutAlgorithm = 'other'; }], + ['candidateSetComplete', payload => { payload.candidateSetComplete = 'yes'; }], + ['candidate arrays', payload => { payload.releases = null; }], + ['candidate arrays', payload => { payload.publishedRollouts = null; }], + ['candidate arrays', payload => { payload.patchEdges = null; }], + ['candidate arrays', payload => { payload.revokedHashes = null; }], + ['at most 21 releases', payload => { payload.releases = Array(22).fill(payload.releases[0]); }], + ['releases[0]', payload => { payload.releases = [null]; }], + ['unsupported field', payload => { payload.releases[0].extra = true; }], + ['available', payload => { payload.releases[0].available = 'yes'; }], + ['non-empty string', payload => { payload.releases[0].releaseRef = ''; }], + ['SHA-256', payload => { payload.releases[0].bundleHash = 'BAD'; }], + ['safe integer', payload => { payload.releases[0].bundleVersion = 1.5; }], + ['safe integer', payload => { payload.releases[0].bundleVersion = -1; }], + ['non-empty string', payload => { payload.releases[0].version = ''; }], + ['at least 1', payload => { payload.releases[0].fullBundleSizeBytes = 0; }], + ['ISO timestamp', payload => { payload.releases[0].expiresAt = 'tomorrow'; }], + ['publishedRollouts[0]', payload => { payload.publishedRollouts = [null]; }], + ['unsupported field', payload => { payload.publishedRollouts = [{ ...rollout(), extra: 1 }]; }], + ['finite number', payload => { payload.publishedRollouts = [{ ...rollout(), rolloutPercentage: '50' }]; }], + ['finite number', payload => { payload.publishedRollouts = [{ ...rollout(), rolloutPercentage: -1 }]; }], + ['at most 100', payload => { payload.publishedRollouts = [{ ...rollout(), rolloutPercentage: 101 }]; }], + ['status is invalid', payload => { payload.publishedRollouts = [{ ...rollout(), status: 'paused' }]; }], + ['non-empty string', payload => { payload.publishedRollouts = [{ ...rollout(), releaseRef: '' }]; }], + ['patchPolicy', payload => { payload.patchPolicy = null; }], + ['unsupported field', payload => { payload.patchPolicy.extra = true; }], + ['enabled', payload => { payload.patchPolicy.enabled = 'yes'; }], + ['finite number', payload => { payload.patchPolicy.maxPatchToFullRatio = -1; }], + ['at most 1', payload => { payload.patchPolicy.maxPatchToFullRatio = 1.1; }], + ['patchEdges[0]', payload => { payload.patchEdges = [null]; }], + ['unsupported field', payload => { payload.patchEdges = [{ ...patch(), extra: 1 }]; }], + ['SHA-256', payload => { payload.patchEdges = [{ ...patch(), missingAssetsHash: 'bad' }]; }], + ['non-empty string', payload => { payload.patchEdges = [{ ...patch(), patchArtifactRef: '' }]; }], + ['at least 1', payload => { payload.patchEdges = [{ ...patch(), patchSizeBytes: 0 }]; }], + ['at least 1', payload => { payload.patchEdges = [{ ...patch(), fullBundleSizeBytes: 0 }]; }], + ['ISO timestamp', payload => { payload.patchEdges = [{ ...patch(), expiresAt: 'later' }]; }], + ['safe integer', payload => { payload.generation = -1; }], + ['non-empty string', payload => { payload.projectSlug = ''; }], + ['ISO timestamp', payload => { payload.generatedAt = 'today'; }], + ['non-empty string', payload => { payload.dynamicReason = ''; }], + ['unique values', payload => { payload.revokedHashes = ['f'.repeat(64), 'f'.repeat(64)]; }], + ['complete candidate set', payload => { payload.candidateSetComplete = false; }], + ['available releases', payload => { payload.releases[0].available = false; }], + ['safe empty candidate shape', payload => { + payload.resolutionMode = 'dynamic'; + payload.dynamicReason = 'private_targeting'; + payload.candidateSetComplete = false; + }], + ['reason and the safe empty candidate shape', payload => { + payload.resolutionMode = 'dynamic'; + delete payload.dynamicReason; + payload.candidateSetComplete = false; + payload.releases = []; + payload.publishedRollouts = []; + payload.patchEdges = []; + }], + ['runtime identity', payload => { payload.releases[0].runtimeVersion = '2.0.0'; }], + ['duplicate release', payload => { payload.releases.push({ ...payload.releases[0] }); }], + ['duplicate release', payload => { + payload.releases.push({ + ...payload.releases[0], + releaseRef: 'other', + }); + }], + ['unknown or duplicate release', payload => { + payload.publishedRollouts = [{ ...rollout(), releaseRef: 'unknown' }]; + }], + ['unknown or duplicate release', payload => { + payload.publishedRollouts = [rollout(), rollout()]; + }], + ['inconsistent', payload => { + payload.patchEdges = [{ ...patch(), targetHash: 'f'.repeat(64) }]; + }], + ['inconsistent', payload => { + payload.patchEdges = [{ ...patch(), fullBundleSizeBytes: 123455 }]; + }], + ['duplicate patch edge', payload => { + payload.patchEdges = [patch(), patch()]; + }], + ]; + + for (const [expected, mutate] of cases) { + await expect(verifyMutation(mutate)).rejects.toThrow(expected); + } + }); + + it('accepts optional and nullable wire fields on a complete local candidate projection', async () => { + mockVerifyEs256Signature.mockResolvedValue(true); + const payload = basePayload(); + delete payload.releases[0].version; + delete payload.releases[0].expiresAt; + payload.publishedRollouts = [{ + releaseRef: 'rel_golden', + rolloutPercentage: 100, + status: 'completed', + }]; + const edge = { + baseHash: '0'.repeat(64), + targetHash: 'a'.repeat(64), + algorithm: 'xdelta3-vcdiff', + patchSetHash: 'e'.repeat(64), + patchArtifactRef: 'patch-7', + patchSizeBytes: 100, + fullBundleSizeBytes: 123456, + }; + payload.patchEdges = [{ ...edge, missingAssetsHash: null, expiresAt: null }]; + + await expect(verifyRuntimeDeliveryManifest( + serialize({ payload: encodedPayload(payload) }), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).resolves.toEqual(expect.objectContaining({ + resolutionMode: 'local', + candidateSetComplete: true, + releases: [expect.objectContaining({ version: undefined, expiresAt: undefined })], + patchEdges: [expect.objectContaining({ missingAssetsHash: null, expiresAt: null })], + })); + }); + + it('accepts an identical payload at the persisted generation', async () => { + await verifyRuntimeDeliveryManifest(serialize(), IDENTITY, { 'test-key-2026-08': KEY }); + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).resolves.toEqual(expect.objectContaining({ generation: 7 })); + }); + + it('serializes concurrent lane-state mutations without losing either lane', async () => { + mockVerifyEs256Signature.mockResolvedValue(true); + const basePayload = JSON.parse(Buffer.from(PAYLOAD, 'base64url').toString('utf8')); + const lane = (channelName: string) => ({ + serialized: serialize({ + payload: Buffer.from(JSON.stringify({ ...basePayload, channelName })).toString('base64url'), + }), + identity: { ...IDENTITY, channelName }, + }); + const first = lane('First'); + const second = lane('Second'); + await Promise.all([ + verifyRuntimeDeliveryManifest(first.serialized, first.identity, { 'test-key-2026-08': KEY }), + verifyRuntimeDeliveryManifest(second.serialized, second.identity, { 'test-key-2026-08': KEY }), + ]); + const persisted = readMockJson<{ lanes: Record }>( + '/mock/doc/bundle-drop/runtime-delivery-state.json', + ); + expect(Object.keys(persisted?.lanes || {})).toHaveLength(2); + }); +}); diff --git a/src/tests/runtime-delivery/runtimeDelivery.test.ts b/src/tests/runtime-delivery/runtimeDelivery.test.ts new file mode 100644 index 0000000..8b4f8e3 --- /dev/null +++ b/src/tests/runtime-delivery/runtimeDelivery.test.ts @@ -0,0 +1,737 @@ +jest.mock('../../context', () => require('../mocks/context')); + +import { NativeModules } from 'react-native'; + +const mockVerifyRuntimeDeliveryManifest = jest.fn(); +jest.mock('../../runtime-delivery/manifestVerifier', () => ({ + MAX_RUNTIME_MANIFEST_BYTES: 1024 * 1024, + RuntimeDeliveryManifestError: class RuntimeDeliveryManifestError extends Error { + constructor( + public readonly code: string, + message: string, + options?: { status?: number }, + ) { + super(message); + this.status = options?.status; + } + + public readonly status?: number; + }, + verifyRuntimeDeliveryManifest: (...args: unknown[]) => mockVerifyRuntimeDeliveryManifest(...args), +})); + +const mockVerifyRuntimeDeliveryAuthorityLease = jest.fn(); +jest.mock('../../runtime-delivery/authorityLeaseVerifier', () => ({ + verifyRuntimeDeliveryAuthorityLease: (...args: unknown[]) => + mockVerifyRuntimeDeliveryAuthorityLease(...args), +})); + +const mockResolveRuntimeDeliveryLane = jest.fn(); +jest.mock('../../runtime-delivery/localResolver', () => ({ + resolveRuntimeDeliveryLane: (...args: unknown[]) => mockResolveRuntimeDeliveryLane(...args), +})); + +const mockReadVerifiedLaneState = jest.fn(); +jest.mock('../../runtime-delivery/manifestState', () => ({ + readVerifiedLaneState: (...args: unknown[]) => mockReadVerifiedLaneState(...args), +})); + +const mockReportActiveInstallWhenDue = jest.fn(); +jest.mock('../../runtime-delivery/heartbeat', () => ({ + reportActiveInstallWhenDue: (...args: unknown[]) => mockReportActiveInstallWhenDue(...args), +})); + +import { + getRuntimeDeliveryDiagnosticCounters, + resetRuntimeDeliveryDiagnosticsForTests, +} from '../../runtime-delivery/diagnostics'; +import { + RuntimeDeliveryManifestError, + type RuntimeDeliveryManifestFailureCode, +} from '../../runtime-delivery/manifestVerifier'; +import { + fetchRuntimeDeliveryManifest, + reportActiveInstall, + resolveFromRuntimeDeliveryManifest, + runtimeDeliveryAuthorityLeaseUrl, + runtimeDeliveryManifestUrl, + shouldRollbackFromLastKnownRevocations, + type RuntimeDeliveryResolveContext, +} from '../../runtime-delivery/runtimeDelivery'; +import type { RuntimeDeliveryLaneManifest } from '../../runtime-delivery/types'; +import { + resetContextMocks, + setMockConfig, + setMockRuntimeVersion, +} from '../mocks/context'; +import { + initializeBundleDropRuntime, + resetBundleDropRuntimeForTests, +} from '../../runtime/initState'; + +const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + +function useReactNativeRuntime(): void { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { product: 'ReactNative' }, + }); +} + +function restoreNavigator(): void { + if (originalNavigatorDescriptor) { + Object.defineProperty(globalThis, 'navigator', originalNavigatorDescriptor); + return; + } + delete (globalThis as { navigator?: Navigator }).navigator; +} + +const hash = (character: string) => character.repeat(64); +const identity = { + projectSlug: 'bundle-drop-app', + channelName: 'Production / β', + platform: 'android', + runtimeVersion: '1.0.0', +}; +const context: RuntimeDeliveryResolveContext = { + channelName: identity.channelName, + currentHash: hash('0'), + rejectedHashes: [hash('f')], + installId: 'install-1', + patchAlgorithms: ['xdelta3-vcdiff'], + supportsContentAddressedAssets: true, + environment: 'production', + userProperties: { beta: true }, +}; +const manifest = { + ...identity, + schemaVersion: 3, + type: 'lane', + generation: 7, + generatedAt: '2026-08-17T00:00:00.000Z', + resolutionMode: 'local', + publishingMode: 'automatic', + rolloutAlgorithm: 'sha256-install-id-uint32be-mod100-v1', + revokedHashes: [], + releases: [], + publishedRollouts: [], + patchPolicy: { enabled: true, maxPatchToFullRatio: 0.7 }, + patchEdges: [], + candidateSetComplete: true, +} satisfies RuntimeDeliveryLaneManifest; + +const enableV2 = () => setMockConfig({ + runtimeDelivery: { + mode: 'v2', + manifestBaseUrl: 'https://cdn.example.com/root///', + manifestAccessId: 'access/id', + publicKeys: { + key: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }, + }, + }, +}); + +const authorityResponse = () => new Response('{"protected":"lease"}', { + status: 200, + headers: { 'content-length': '256' }, +}); + +function mockManifestFetch( + implementation: (url: string, options?: RequestInit) => Promise, +): jest.SpyInstance { + return jest.spyOn(global, 'fetch').mockImplementation((input, options) => { + const url = String(input); + return url.includes('/v2/_authority/publisher-lease.json') + ? Promise.resolve(authorityResponse()) as never + : implementation(url, options) as never; + }); +} + +describe('runtime-delivery/runtimeDelivery', () => { + beforeEach(() => { + resetContextMocks(); + jest.clearAllMocks(); + enableV2(); + mockReadVerifiedLaneState.mockResolvedValue(null); + mockVerifyRuntimeDeliveryAuthorityLease.mockResolvedValue({ + schemaVersion: 1, + type: 'publisher-lease', + manifestOrigin: 'https://cdn.example.com/root', + generatedAt: '2026-08-17T00:00:00.000Z', + expiresAt: '2026-08-17T00:00:15.000Z', + }); + resetRuntimeDeliveryDiagnosticsForTests(); + resetBundleDropRuntimeForTests(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + restoreNavigator(); + resetBundleDropRuntimeForTests(); + }); + + it('constructs the opaque lane URL and rejects missing configuration', () => { + expect(runtimeDeliveryManifestUrl(identity)).toBe( + 'https://cdn.example.com/root/v2/access%2Fid/lanes/UHJvZHVjdGlvbiAvIM6y/android/MS4wLjA/current.json', + ); + expect(runtimeDeliveryAuthorityLeaseUrl()).toBe( + 'https://cdn.example.com/root/v2/_authority/publisher-lease.json', + ); + setMockConfig({ runtimeDelivery: undefined }); + expect(() => runtimeDeliveryManifestUrl(identity)).toThrow('not configured'); + expect(() => runtimeDeliveryAuthorityLeaseUrl()).toThrow('not configured'); + }); + + it('fetches and verifies a bounded manifest with the exact lane identity', async () => { + const response = new Response('{"protected":"..."}', { + status: 200, + headers: { 'content-length': '512' }, + }); + mockManifestFetch(async () => response); + mockVerifyRuntimeDeliveryManifest.mockResolvedValue(manifest); + + await expect(fetchRuntimeDeliveryManifest(identity.channelName)).resolves.toBe(manifest); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/lanes/UHJvZHVjdGlvbiAvIM6y/android/MS4wLjA/current.json'), + expect.objectContaining({ + method: 'GET', + headers: { Accept: 'application/jose+json, application/json' }, + }), + ); + expect(mockVerifyRuntimeDeliveryManifest).toHaveBeenCalledWith( + '{"protected":"..."}', + identity, + expect.objectContaining({ key: expect.any(Object) }), + ); + expect(mockVerifyRuntimeDeliveryAuthorityLease).toHaveBeenCalledWith( + '{"protected":"lease"}', + 'https://cdn.example.com/root///', + expect.objectContaining({ key: expect.any(Object) }), + ); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_hit).toBe(1); + }); + + it('starts the authority-lease and lane-manifest requests in parallel', async () => { + const starts: string[] = []; + let releaseLease!: (response: Response) => void; + let releaseManifest!: (response: Response) => void; + jest.spyOn(global, 'fetch').mockImplementation(input => { + const url = String(input); + starts.push(url); + return new Promise(resolve => { + if (url.includes('/v2/_authority/')) releaseLease = resolve; + else releaseManifest = resolve; + }) as never; + }); + mockVerifyRuntimeDeliveryManifest.mockResolvedValue(manifest); + + const request = fetchRuntimeDeliveryManifest(identity.channelName); + await Promise.resolve(); + expect(starts).toHaveLength(2); + expect(starts.some(url => url.includes('/v2/_authority/publisher-lease.json'))).toBe(true); + expect(starts.some(url => url.includes('/current.json'))).toBe(true); + + releaseLease(authorityResponse()); + releaseManifest(new Response('{"protected":"manifest"}')); + await expect(request).resolves.toBe(manifest); + }); + + it('fails closed on an invalid authority lease before persisting the manifest', async () => { + mockManifestFetch(async () => new Response('{"protected":"manifest"}')); + mockVerifyRuntimeDeliveryAuthorityLease.mockRejectedValueOnce( + new RuntimeDeliveryManifestError('authority_expired', 'authority expired'), + ); + + await expect(fetchRuntimeDeliveryManifest(identity.channelName)) + .rejects.toThrow('authority expired'); + + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + expect(getRuntimeDeliveryDiagnosticCounters().authority_lease_expired).toBe(1); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_hit).toBe(0); + }); + + it('reports a signed global authority disable separately before reading the manifest', async () => { + mockManifestFetch(async () => new Response('{"protected":"manifest"}')); + mockVerifyRuntimeDeliveryAuthorityLease.mockRejectedValueOnce( + new RuntimeDeliveryManifestError('authority_disabled', 'authority disabled'), + ); + + await expect(fetchRuntimeDeliveryManifest(identity.channelName)) + .rejects.toThrow('authority disabled'); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + expect(getRuntimeDeliveryDiagnosticCounters().authority_lease_disabled).toBe(1); + }); + + it('reports authority transport failures separately from manifest failures', async () => { + jest.spyOn(global, 'fetch').mockImplementation(input => Promise.resolve( + String(input).includes('/v2/_authority/') + ? new Response('unavailable', { status: 503 }) + : new Response('{"protected":"manifest"}'), + ) as never); + + await expect(fetchRuntimeDeliveryManifest(identity.channelName)).rejects.toThrow('HTTP 503'); + + expect(getRuntimeDeliveryDiagnosticCounters().authority_lease_http_error).toBe(1); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_http_error).toBe(0); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + }); + + it('rejects disabled, identity-less, failed, and oversized manifest requests', async () => { + setMockConfig({ runtimeDelivery: undefined }); + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('not enabled'); + setMockConfig({ + runtimeDelivery: { + mode: 'v1', + manifestBaseUrl: 'https://cdn.example.com', + manifestAccessId: 'id', + publicKeys: {}, + }, + }); + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('not enabled'); + + enableV2(); + setMockRuntimeVersion(undefined); + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('Runtime version'); + setMockRuntimeVersion('1.0.0'); + + const manifestFetch = jest.fn(); + mockManifestFetch((url, options) => manifestFetch(url, options)); + manifestFetch.mockResolvedValueOnce(new Response('unavailable', { status: 503 })); + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('HTTP 503'); + + manifestFetch.mockResolvedValueOnce(new Response('not-read', { + status: 200, + headers: { 'content-length': String(1024 * 1024 + 1) }, + })); + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('1 MB'); + expect(getRuntimeDeliveryDiagnosticCounters()).toEqual(expect.objectContaining({ + manifest_http_error: 1, + manifest_too_large: 1, + })); + }); + + it('enforces the byte limit while streaming chunked bodies and cancels immediately', async () => { + const cancel = jest.fn().mockRejectedValue(new Error('synthetic cancel failure')); + const releaseLock = jest.fn(); + const chunks = [ + new Uint8Array(700_000), + new Uint8Array(400_000), + new Uint8Array(64), + ]; + const read = jest.fn() + .mockResolvedValueOnce({ done: false, value: chunks[0] }) + .mockResolvedValueOnce({ done: false, value: chunks[1] }) + .mockResolvedValueOnce({ done: false, value: chunks[2] }); + mockManifestFetch(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + body: { getReader: () => ({ read, cancel, releaseLock }) }, + } as never)); + + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('1 MB'); + expect(read).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledTimes(1); + expect(releaseLock).toHaveBeenCalledTimes(1); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_too_large).toBe(1); + }); + + it('aborts and cancels a declared oversized response before reading its body', async () => { + const cancel = jest.fn().mockRejectedValue(new Error('synthetic cancel failure')); + const read = jest.fn(); + const releaseLock = jest.fn(); + let signal: AbortSignal | null | undefined; + mockManifestFetch(async (_url, options) => { + signal = options?.signal; + return { + ok: true, + status: 200, + headers: new Headers({ 'content-length': String(1024 * 1024 + 1) }), + body: { getReader: () => ({ read, cancel, releaseLock }) }, + } as never; + }); + + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('1 MB'); + + expect(signal?.aborted).toBe(true); + expect(cancel).toHaveBeenCalledWith('Runtime manifest exceeds the 1 MB safety limit'); + expect(read).not.toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalledTimes(1); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_too_large).toBe(1); + }); + + it('fails closed when the fetch implementation cannot expose a response stream', async () => { + mockManifestFetch(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + body: null, + text: jest.fn(), + } as never)); + + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('readable byte stream'); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_stream_unavailable).toBe(1); + }); + + it('uses a bounded native download on React Native when fetch has no Response.body', async () => { + useReactNativeRuntime(); + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + body: null, + } as never); + const serialized = '{"protected":"native"}'; + NativeModules.BundleDrop.fsReadFile.mockResolvedValue( + Buffer.from(serialized, 'utf8').toString('base64'), + ); + NativeModules.BundleDrop.fsUnlink.mockRejectedValue(new Error('synthetic cleanup failure')); + mockVerifyRuntimeDeliveryManifest.mockResolvedValue(manifest); + + await expect(fetchRuntimeDeliveryManifest(identity.channelName)).resolves.toBe(manifest); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(NativeModules.BundleDrop.fsDownloadFileBounded).toHaveBeenCalledTimes(2); + expect(NativeModules.BundleDrop.fsDownloadFileBounded).toHaveBeenCalledWith( + expect.stringContaining('/current.json'), + expect.stringMatching(/^\/mock\/lib\/bundle-drop\/runtime-delivery\/manifest-.+\.jws$/), + 1024 * 1024, + 5000, + ); + const manifestDownload = NativeModules.BundleDrop.fsDownloadFileBounded.mock.calls.find( + ([url]: [string]) => url.includes('/current.json'), + ); + const tempPath = manifestDownload?.[1]; + expect(NativeModules.BundleDrop.fsReadFile).toHaveBeenCalledWith(tempPath, 'base64'); + expect(NativeModules.BundleDrop.fsUnlink).toHaveBeenCalledWith(tempPath); + expect(mockVerifyRuntimeDeliveryManifest).toHaveBeenCalledWith( + serialized, + identity, + expect.objectContaining({ key: expect.any(Object) }), + ); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_hit).toBe(1); + }); + + it('maps native temp-file read failures to network errors and still cleans up', async () => { + useReactNativeRuntime(); + NativeModules.BundleDrop.fsReadFile.mockImplementation((path: string) => + path.includes('authority-lease-') + ? Promise.resolve(Buffer.from('lease', 'utf8').toString('base64')) + : Promise.reject(new Error('read failed')), + ); + + const failure = await fetchRuntimeDeliveryManifest('General').catch(error => error); + + expect(failure).toMatchObject({ code: 'network_error' }); + expect(failure.message).toContain('body read failed'); + expect(NativeModules.BundleDrop.fsUnlink).toHaveBeenCalledTimes(2); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + }); + + it('rejects malformed UTF-8 from a bounded native manifest and cleans up', async () => { + useReactNativeRuntime(); + NativeModules.BundleDrop.fsReadFile.mockImplementation((path: string) => Promise.resolve( + path.includes('authority-lease-') + ? Buffer.from('lease', 'utf8').toString('base64') + : Buffer.from([0xc2, 0x41]).toString('base64'), + ), + ); + + const failure = await fetchRuntimeDeliveryManifest('General').catch(error => error); + + expect(failure).toMatchObject({ code: 'invalid_manifest' }); + expect(failure.message).toContain('valid UTF-8'); + expect(NativeModules.BundleDrop.fsUnlink).toHaveBeenCalledTimes(2); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + }); + + it.each([ + ['ERR_DOWNLOAD_TOO_LARGE', 'Download exceeds 1 MB limit', 'body_too_large', '1 MB', 'manifest_too_large', undefined], + ['ERR_DOWNLOAD_TIMEOUT', 'The request timed out', 'timeout', 'timed out', 'manifest_timeout', undefined], + ['ERR_DOWNLOAD_HTTP', 'HTTP 503: Service Unavailable', 'http_error', 'HTTP 503', 'manifest_http_error', 503], + ['ERR_DOWNLOAD_HTTP', 'HTTP request rejected', 'http_error', 'an HTTP error', 'manifest_http_error', undefined], + ['ERR_DOWNLOAD_NETWORK', 'Connection reset', 'network_error', 'request failed', 'manifest_network_error', undefined], + ] as const)( + 'maps the native %s failure without reading a partial manifest', + async (code, message, expectedCode, expectedMessage, diagnosticName, status) => { + useReactNativeRuntime(); + NativeModules.BundleDrop.fsDownloadFileBounded.mockImplementation((url: string) => + url.includes('/v2/_authority/') + ? Promise.resolve(undefined) + : Promise.reject(Object.assign(new Error(message), { code })), + ); + NativeModules.BundleDrop.fsReadFile.mockResolvedValue( + Buffer.from('lease', 'utf8').toString('base64'), + ); + + const failure = await fetchRuntimeDeliveryManifest('General').catch(error => error); + + expect(failure).toMatchObject({ + code: expectedCode, + ...(status === undefined ? {} : { status }), + }); + expect(failure.message).toContain(expectedMessage); + expect(NativeModules.BundleDrop.fsReadFile).toHaveBeenCalledTimes(1); + expect(NativeModules.BundleDrop.fsReadFile.mock.calls[0][0]).toContain( + 'authority-lease-', + ); + expect(NativeModules.BundleDrop.fsUnlink).toHaveBeenCalledTimes(2); + expect(getRuntimeDeliveryDiagnosticCounters()[diagnosticName]).toBe(1); + }, + ); + + it('maps an unstructured native rejection to the network failure fallback', async () => { + useReactNativeRuntime(); + NativeModules.BundleDrop.fsDownloadFileBounded.mockImplementation((url: string) => + url.includes('/v2/_authority/') ? Promise.resolve(undefined) : Promise.reject({}), + ); + NativeModules.BundleDrop.fsReadFile.mockResolvedValue( + Buffer.from('lease', 'utf8').toString('base64'), + ); + + const failure = await fetchRuntimeDeliveryManifest('General').catch(error => error); + + expect(failure).toMatchObject({ code: 'network_error' }); + expect(failure.message).toContain('request failed'); + expect(NativeModules.BundleDrop.fsUnlink).toHaveBeenCalledTimes(2); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_network_error).toBe(1); + }); + + it('defensively rejects and cleans a native manifest that exceeds the bounded bridge contract', async () => { + useReactNativeRuntime(); + NativeModules.BundleDrop.fsReadFile.mockImplementation((path: string) => Promise.resolve( + path.includes('authority-lease-') + ? Buffer.from('lease', 'utf8').toString('base64') + : Buffer.alloc(1024 * 1024 + 1).toString('base64'), + ), + ); + + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('1 MB'); + + expect(NativeModules.BundleDrop.fsUnlink).toHaveBeenCalledTimes(2); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_too_large).toBe(1); + }); + + it('rejects non-byte stream chunks and malformed streamed UTF-8', async () => { + const manifestFetch = jest.fn(); + mockManifestFetch((url, options) => manifestFetch(url, options)); + manifestFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + body: { + getReader: () => ({ + read: jest.fn().mockResolvedValueOnce({ done: false, value: 'not bytes' }), + releaseLock: jest.fn(), + }), + }, + } as never); + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('non-byte chunk'); + + manifestFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + body: { + getReader: () => ({ + read: jest.fn() + .mockResolvedValueOnce({ done: false, value: new Uint8Array([0xc2, 0x41]) }) + .mockResolvedValueOnce({ done: true }), + releaseLock: jest.fn(), + }), + }, + } as never); + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow('valid UTF-8'); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_invalid).toBe(2); + expect(mockVerifyRuntimeDeliveryManifest).not.toHaveBeenCalled(); + }); + + it('aborts manifest requests that exceed the bounded fetch timeout', async () => { + jest.useFakeTimers(); + mockManifestFetch((_url, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }) as never, + ); + + const request = fetchRuntimeDeliveryManifest('General'); + jest.advanceTimersByTime(5000); + await expect(request).rejects.toThrow('timed out'); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_timeout).toBe(1); + }); + + it('maps a reader rejection caused by the fetch deadline to a timeout', async () => { + jest.useFakeTimers(); + const releaseLock = jest.fn(); + mockManifestFetch((_url, options) => Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + body: { + getReader: () => ({ + read: () => new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted read'))); + }), + releaseLock, + }), + }, + }) as never); + + const request = fetchRuntimeDeliveryManifest('General'); + await Promise.resolve(); + jest.advanceTimersByTime(5000); + + await expect(request).rejects.toThrow('timed out'); + expect(releaseLock).toHaveBeenCalledTimes(1); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_timeout).toBe(1); + expect(getRuntimeDeliveryDiagnosticCounters().manifest_network_error).toBe(0); + }); + + it.each([ + ['invalid_signature', 'invalid_signature'], + ['unknown_key', 'unknown_key'], + ['lane_mismatch', 'lane_mismatch'], + ['generation_regression', 'generation_regression'], + ['generation_equivocation', 'generation_equivocation'], + ['invalid_manifest', 'manifest_invalid'], + ['network_error', 'manifest_network_error'], + ] as Array<[ + RuntimeDeliveryManifestFailureCode, + keyof ReturnType, + ]>)('counts %s manifest failures as %s', async (failureCode, diagnosticName) => { + mockManifestFetch(async () => new Response('{}')); + mockVerifyRuntimeDeliveryManifest.mockRejectedValueOnce( + new RuntimeDeliveryManifestError(failureCode, `failure: ${failureCode}`), + ); + + await expect(fetchRuntimeDeliveryManifest('General')).rejects.toThrow(`failure: ${failureCode}`); + expect(getRuntimeDeliveryDiagnosticCounters()[diagnosticName]).toBe(1); + }); + + it('maps local NOOP, incompatibility, rollback, full, and patch decisions', async () => { + mockResolveRuntimeDeliveryLane.mockResolvedValueOnce({ action: 'NOOP', reason: 'UP_TO_DATE' }); + await expect(resolveFromRuntimeDeliveryManifest(manifest, context)).resolves.toEqual( + expect.objectContaining({ action: 'NOOP', upToDate: true, incompatible: undefined }), + ); + + mockResolveRuntimeDeliveryLane.mockResolvedValueOnce({ + action: 'NOOP', + reason: 'NO_COMPATIBLE_BUNDLE', + }); + await expect(resolveFromRuntimeDeliveryManifest(manifest, context)).resolves.toEqual( + expect.objectContaining({ + action: 'NOOP', + upToDate: false, + incompatible: true, + requestedRuntimeVersion: '1.0.0', + }), + ); + + mockResolveRuntimeDeliveryLane.mockResolvedValueOnce({ action: 'ROLLBACK', reason: 'revoked' }); + await expect(resolveFromRuntimeDeliveryManifest(manifest, context)).resolves.toEqual({ + action: 'ROLLBACK', + channelName: identity.channelName, + reason: 'revoked', + runtimeVersion: '1.0.0', + }); + + const target = { + releaseRef: 'release-7', + bundleHash: hash('a'), + bundleVersion: 7, + version: '1.0.7', + runtimeVersion: '1.0.0', + manifestHash: hash('b'), + jsBundleHash: hash('c'), + fullBundleHash: hash('d'), + fullBundleSizeBytes: 1000, + available: true, + }; + mockResolveRuntimeDeliveryLane.mockResolvedValueOnce({ action: 'INSTALL', target, mode: 'full' }); + await expect(resolveFromRuntimeDeliveryManifest(manifest, context)).resolves.toEqual( + expect.objectContaining({ + action: 'INSTALL', + mode: 'full', + baseHash: undefined, + runtimeDelivery: expect.objectContaining({ selectedMode: 'full', patchArtifactRef: undefined }), + }), + ); + + const patchEdge = { + baseHash: hash('0'), + targetHash: hash('a'), + algorithm: 'xdelta3-vcdiff', + patchSetHash: hash('e'), + patchArtifactRef: 'patch-7', + patchSizeBytes: 100, + fullBundleSizeBytes: 1000, + missingAssetsHash: hash('f'), + }; + mockResolveRuntimeDeliveryLane.mockResolvedValueOnce({ + action: 'INSTALL', target, mode: 'patch', patchEdge, + }); + await expect(resolveFromRuntimeDeliveryManifest(manifest, context)).resolves.toEqual( + expect.objectContaining({ + mode: 'patch', + baseHash: hash('0'), + runtimeDelivery: expect.objectContaining({ + patchAlgorithm: 'xdelta3-vcdiff', + patchArtifactRef: 'patch-7', + missingAssetsHash: hash('f'), + }), + }), + ); + expect(mockResolveRuntimeDeliveryLane).toHaveBeenCalledWith(manifest, expect.objectContaining({ + supportsContentAddressedAssets: true, + })); + }); + + it('sends optional heartbeat context only when runtime identity is available', () => { + reportActiveInstall(context); + expect(mockReportActiveInstallWhenDue).toHaveBeenCalledWith('bundle-drop-app', { + channelName: identity.channelName, + platform: 'android', + runtimeVersion: '1.0.0', + installId: 'install-1', + currentHash: hash('0'), + environment: 'production', + userProperties: { beta: true }, + }); + + reportActiveInstall({ ...context, environment: null, userProperties: {} }); + expect(mockReportActiveInstallWhenDue).toHaveBeenLastCalledWith( + 'bundle-drop-app', + expect.objectContaining({ environment: undefined, userProperties: undefined }), + ); + setMockRuntimeVersion(undefined); + reportActiveInstall(context); + expect(mockReportActiveInstallWhenDue).toHaveBeenCalledTimes(2); + }); + + it('checks last-known revocations without treating absent identity or state as revoked', async () => { + await expect(shouldRollbackFromLastKnownRevocations('General', null)).resolves.toBe(false); + setMockRuntimeVersion(undefined); + await expect(shouldRollbackFromLastKnownRevocations('General', hash('a'))).resolves.toBe(false); + setMockRuntimeVersion('1.0.0'); + await expect(shouldRollbackFromLastKnownRevocations('General', hash('a'))).resolves.toBe(false); + mockReadVerifiedLaneState.mockResolvedValueOnce({ + highestGeneration: 1, + payloadSha256: hash('b'), + revokedHashes: [hash('a')], + verifiedAt: '2026-08-17T00:00:00.000Z', + }); + await expect(shouldRollbackFromLastKnownRevocations('General', hash('a'))).resolves.toBe(true); + mockReadVerifiedLaneState.mockResolvedValueOnce({ + highestGeneration: 1, + payloadSha256: hash('b'), + revokedHashes: [hash('c')], + verifiedAt: '2026-08-17T00:00:00.000Z', + }); + await expect(shouldRollbackFromLastKnownRevocations('General', hash('a'))).resolves.toBe(false); + }); + +}); diff --git a/src/tests/runtime/initState.test.ts b/src/tests/runtime/initState.test.ts index dd62fe8..c8e4fc9 100644 --- a/src/tests/runtime/initState.test.ts +++ b/src/tests/runtime/initState.test.ts @@ -47,6 +47,7 @@ describe('runtime/initState', () => { channelName: 'General', policy: 'manual', onStatusUpdate: undefined, + onRuntimeDeliveryDiagnostic: undefined, checkOnly: false, }); @@ -67,6 +68,7 @@ describe('runtime/initState', () => { channelName: 'Beta', policy: 'immediate', onStatusUpdate, + onRuntimeDeliveryDiagnostic: undefined, checkOnly: true, }, }); @@ -88,21 +90,26 @@ describe('runtime/initState', () => { ).toThrow('requires a non-empty environment'); const firstStatusHandler = jest.fn(); + const firstDiagnosticHandler = jest.fn(); initializeBundleDropRuntime({ environment: 'production', channelName: 'General', onStatusUpdate: firstStatusHandler, + onRuntimeDeliveryDiagnostic: firstDiagnosticHandler, }); const secondStatusHandler = jest.fn(); + const secondDiagnosticHandler = jest.fn(); const reinitialized = initializeBundleDropRuntime({ environment: 'production', channelName: 'General', onStatusUpdate: secondStatusHandler, + onRuntimeDeliveryDiagnostic: secondDiagnosticHandler, }); expect(reinitialized.alreadyInitialized).toBe(true); expect(reinitialized.config.onStatusUpdate).toBe(secondStatusHandler); + expect(reinitialized.config.onRuntimeDeliveryDiagnostic).toBe(secondDiagnosticHandler); expect(setBundleDropChannel(' Beta ')).toEqual({ environment: 'production', @@ -110,6 +117,7 @@ describe('runtime/initState', () => { channelName: 'Beta', policy: 'manual', onStatusUpdate: secondStatusHandler, + onRuntimeDeliveryDiagnostic: secondDiagnosticHandler, checkOnly: false, }); expect(getBundleDropRuntimeConfig()).toEqual({ @@ -118,6 +126,7 @@ describe('runtime/initState', () => { channelName: 'Beta', policy: 'manual', onStatusUpdate: secondStatusHandler, + onRuntimeDeliveryDiagnostic: secondDiagnosticHandler, checkOnly: false, }); @@ -156,6 +165,7 @@ describe('runtime/initState', () => { channelName: 'Dev', policy: 'manual', onStatusUpdate: undefined, + onRuntimeDeliveryDiagnostic: undefined, checkOnly: false, }); expect(getBundleDropRuntimeConfigOrWarn()).toEqual(initialized.config); diff --git a/src/tests/runtime/service.test.ts b/src/tests/runtime/service.test.ts index 6ced0f0..ee80586 100644 --- a/src/tests/runtime/service.test.ts +++ b/src/tests/runtime/service.test.ts @@ -4,6 +4,17 @@ const INIT_ERROR = 'BundleDrop has not been initialized. Call BundleDrop.init({ environment, ... }) before using OTA APIs or useBundleDrop().'; const DISABLED_STATUS = 'BundleDrop is disabled'; +const createBundleListItem = (hash = 'hash-12', bundleVersion = 12) => ({ + hash, + bundleVersion, + version: `1.0.${bundleVersion}`, + platform: 'android' as const, + runtimeVersion: '1.0.0', + releaseNotes: null, + createdAt: '2026-03-29T00:00:00.000Z', + downloadUrl: `https://cdn.example.com/${hash}.zip`, +}); + const loadRuntimeServiceModule = (overrides?: { rollbackResult?: { rolledBack: boolean; reason?: string }; pendingState?: { hasBundle: boolean; info?: unknown; pendingApply: boolean }; @@ -33,6 +44,7 @@ const loadRuntimeServiceModule = (overrides?: { setOtaEnabledPromise?: Promise; nativeModuleAvailable?: boolean; expoOtaStartupEnabled?: boolean; + runtimeDeliveryMode?: 'v1' | 'shadow' | 'v2'; }) => { jest.resetModules(); @@ -160,7 +172,19 @@ const loadRuntimeServiceModule = (overrides?: { readBundleInfo, })); jest.doMock('../../context', () => ({ - config: { projectType: 'expo' }, + config: { + projectType: 'expo', + runtimeDelivery: overrides?.runtimeDeliveryMode + ? overrides.runtimeDeliveryMode === 'v2' + ? { + mode: 'v2', + manifestBaseUrl: 'https://manifests.example.com', + manifestAccessId: 'access-id', + publicKeys: { key: {} }, + } + : { mode: overrides.runtimeDeliveryMode } + : undefined, + }, defaultChannel: 'General', })); jest.doMock('../../fs/bundlePointer', () => ({ @@ -776,6 +800,7 @@ describe('runtime/service', () => { await rollback.service.waitForBundleDropStartupForTests(); expect(rollbackStatus).toHaveBeenCalledWith('↩️ Server requested rollback...'); expect(rollback.mocks.rollbackToPreviousOrNative).toHaveBeenCalledTimes(1); + expect(rollback.mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); expect(rollback.mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); const incompatible = loadRuntimeServiceModule({ @@ -821,6 +846,83 @@ describe('runtime/service', () => { expect(upToDateStatus).toHaveBeenCalledWith('✅ You have the latest version'); }); + it.each([ + 'CURRENT_REVOKED_NO_COMPATIBLE_TARGET', + 'CURRENT_REVOKED_NO_SAFE_TARGET', + 'CURRENT_REVOKED_ORIGIN_UNAVAILABLE', + ])('forces native rollback for %s', async reason => { + const { service, mocks } = loadRuntimeServiceModule({ + decision: { action: 'ROLLBACK', reason }, + }); + + service.initBundleDrop({ environment: 'production', checkOnly: true }); + await service.waitForBundleDropStartupForTests(); + + expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + }); + + it('preserves previous-or-native behavior for non-revocation rollback reasons', async () => { + const { service, mocks } = loadRuntimeServiceModule({ + decision: { action: 'ROLLBACK', reason: 'SERVER_REQUESTED_ROLLBACK' }, + }); + + service.initBundleDrop({ environment: 'production', checkOnly: true }); + await service.waitForBundleDropStartupForTests(); + + expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith(); + }); + + it.each(['immediate', 'on-next-launch'] as const)( + 'applies an authorization rollback during %s startup', + async policy => { + const { service, mocks } = loadRuntimeServiceModule({ + decision: { + action: 'INSTALL', + hash: 'new-hash', + mode: 'full', + runtimeDelivery: { + generation: 7, + targetReleaseRef: 'release-7', + selectedMode: 'full', + }, + }, + downloadResult: { + status: 'rollback', + reason: 'CURRENT_REVOKED_NO_SAFE_TARGET', + }, + }); + + service.initBundleDrop({ environment: 'production', policy }); + await service.waitForBundleDropStartupForTests(); + + expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1); + expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); + expect(mocks.applyUpdate).not.toHaveBeenCalled(); + }, + ); + + it('preserves previous-or-native behavior for a non-revocation authorization rollback', async () => { + const { service, mocks } = loadRuntimeServiceModule({ + decision: { + action: 'INSTALL', + hash: 'new-hash', + downloadUrl: 'https://cdn.example/new.zip', + }, + downloadResult: { + status: 'rollback', + reason: 'SERVER_REQUESTED_ROLLBACK', + }, + }); + + service.initBundleDrop({ environment: 'production', policy: 'immediate' }); + await service.waitForBundleDropStartupForTests(); + + expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith(); + expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); + expect(mocks.applyUpdate).not.toHaveBeenCalled(); + }); + it('guards missing resolved targets and handles staged, up-to-date, and incompatible startup downloads', async () => { const missingTarget = loadRuntimeServiceModule({ pendingState: { @@ -1027,6 +1129,65 @@ describe('runtime/service', () => { expect(incompatible.mocks.applyUpdate).not.toHaveBeenCalled(); }); + it('preserves v2 authorization context through manual, next-launch, immediate, and check-only startup policies', async () => { + const v2Decision = { + action: 'INSTALL', + hash: 'hash-v2', + bundleVersion: 7, + runtimeVersion: '1.0.0', + mode: 'full', + runtimeDelivery: { + generation: 7, + targetReleaseRef: 'release-v2', + selectedMode: 'full', + }, + }; + + const manual = loadRuntimeServiceModule({ + pendingState: { hasBundle: false, info: null, pendingApply: false }, + decision: v2Decision, + }); + manual.service.initBundleDrop({ environment: 'production', policy: 'manual' }); + await manual.service.waitForBundleDropStartupForTests(); + expect(manual.mocks.downloadUpdate).not.toHaveBeenCalled(); + + const nextLaunch = loadRuntimeServiceModule({ + pendingState: { hasBundle: false, info: null, pendingApply: false }, + decision: v2Decision, + }); + nextLaunch.service.initBundleDrop({ environment: 'production', policy: 'on-next-launch' }); + await nextLaunch.service.waitForBundleDropStartupForTests(); + expect(nextLaunch.mocks.downloadUpdate).toHaveBeenCalledWith({ + channelName: 'General', + resolvedTarget: expect.objectContaining({ + hash: 'hash-v2', + downloadUrl: undefined, + runtimeDelivery: v2Decision.runtimeDelivery, + }), + }, expect.any(Function)); + + const immediate = loadRuntimeServiceModule({ + pendingState: { hasBundle: false, info: null, pendingApply: false }, + decision: v2Decision, + }); + immediate.service.initBundleDrop({ environment: 'production', policy: 'immediate' }); + await immediate.service.waitForBundleDropStartupForTests(); + expect(immediate.mocks.downloadUpdate).toHaveBeenCalled(); + expect(immediate.mocks.applyUpdate).toHaveBeenCalled(); + + const checkOnly = loadRuntimeServiceModule({ + pendingState: { hasBundle: false, info: null, pendingApply: false }, + decision: v2Decision, + }); + checkOnly.service.initBundleDrop({ + environment: 'production', + policy: 'immediate', + checkOnly: true, + }); + await checkOnly.service.waitForBundleDropStartupForTests(); + expect(checkOnly.mocks.downloadUpdate).not.toHaveBeenCalled(); + }); + it('exposes runtime actions, fetch fallbacks, and install helpers through the singleton service', async () => { const { service, mocks } = loadRuntimeServiceModule({ decision: { @@ -1155,10 +1316,17 @@ describe('runtime/service', () => { }, status: '↩️ Rollback requested: CURRENT_REVOKED_NO_SAFE_TARGET', }); + expect(rollbackDownload.mocks.rollbackToPreviousOrNative).not.toHaveBeenCalled(); + expect(rollbackDownload.mocks.restartReactNativeNative).not.toHaveBeenCalled(); + await expect(rollbackDownload.service.downloadAndStage()).resolves.toEqual({ result: { status: 'rollback', reason: 'CURRENT_REVOKED_NO_SAFE_TARGET' }, status: '↩️ Rollback requested: CURRENT_REVOKED_NO_SAFE_TARGET', }); + expect(rollbackDownload.mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ + forceNative: true, + }); + expect(rollbackDownload.mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); const noBundleApply = loadRuntimeServiceModule({ applyResult: { status: 'noBundle' }, @@ -1235,8 +1403,8 @@ describe('runtime/service', () => { }); }); - it('maps rollback statuses without explicit reasons', async () => { - const { service } = loadRuntimeServiceModule({ + it('executes previous-or-native rollback for manual downloads without an explicit reason', async () => { + const { service, mocks } = loadRuntimeServiceModule({ decision: { action: 'ROLLBACK', }, @@ -1250,10 +1418,15 @@ describe('runtime/service', () => { response: { action: 'ROLLBACK' }, status: '↩️ Rollback requested', }); + expect(mocks.rollbackToPreviousOrNative).not.toHaveBeenCalled(); + expect(mocks.restartReactNativeNative).not.toHaveBeenCalled(); + await expect(service.downloadAndStage()).resolves.toEqual({ result: { status: 'rollback' }, status: '↩️ Rollback requested', }); + expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith(); + expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); }); it('returns empty fetch fallbacks and guards bundle list items without download urls', async () => { @@ -1288,8 +1461,9 @@ describe('runtime/service', () => { }); }); - it('uses resolved patch transport when a selected bundle matches the server target', async () => { + it('uses resolved patch transport when an authoritative v2 target matches the selected bundle', async () => { const { service, mocks } = loadRuntimeServiceModule({ + runtimeDeliveryMode: 'v2', decision: { action: 'INSTALL', hash: 'hash-12', @@ -1317,16 +1491,7 @@ describe('runtime/service', () => { await service.waitForBundleDropStartupForTests(); await expect( - service.installBundleFromListItem({ - hash: 'hash-12', - bundleVersion: 12, - version: '1.0.12', - platform: 'android', - runtimeVersion: '1.0.0', - releaseNotes: null, - createdAt: '2026-03-29T00:00:00.000Z', - downloadUrl: 'https://cdn.example.com/hash-12.zip', - }), + service.installBundleFromListItem(createBundleListItem()), ).resolves.toEqual({ result: { status: 'staged' }, status: '✅ v12 downloaded. Will apply on next launch or when you call applyUpdate.', @@ -1351,43 +1516,172 @@ describe('runtime/service', () => { expect(mocks.installBundle).not.toHaveBeenCalled(); }); - it('falls back to direct full install when list-item resolve fails', async () => { + it('applies an authoritative rollback instead of installing a selected v2 bundle', async () => { const { service, mocks } = loadRuntimeServiceModule({ - checkError: new Error('resolve unavailable'), - installResult: { status: 'staged' }, + runtimeDeliveryMode: 'v2', + decision: { + action: 'ROLLBACK', + reason: 'CURRENT_REVOKED_NO_SAFE_TARGET', + }, }); service.initBundleDrop({ environment: 'production' }); await service.waitForBundleDropStartupForTests(); await expect( - service.installBundleFromListItem({ - hash: 'hash-13', - bundleVersion: 13, - version: '1.0.13', - platform: 'android', - runtimeVersion: '1.0.0', - releaseNotes: null, - createdAt: '2026-03-29T00:00:00.000Z', - downloadUrl: 'https://cdn.example.com/hash-13.zip', - }), + service.installBundleFromListItem(createBundleListItem()), ).resolves.toEqual({ - result: { status: 'staged' }, - status: '✅ v13 downloaded. Will apply on next launch or when you call applyUpdate.', + result: { status: 'rollback', reason: 'CURRENT_REVOKED_NO_SAFE_TARGET' }, + status: '↩️ Rollback requested: CURRENT_REVOKED_NO_SAFE_TARGET', }); - expect(mocks.installBundle).toHaveBeenCalledWith( - 'hash-13', - 'https://cdn.example.com/hash-13.zip', - 13, - '1.0.13', - '1.0.0', - expect.objectContaining({ - channelName: 'General', - }), + expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); + expect(mocks.downloadUpdate).not.toHaveBeenCalled(); + expect(mocks.installBundle).not.toHaveBeenCalled(); + }); + + it('applies a rollback returned by v2 list-item artifact authorization', async () => { + const { service, mocks } = loadRuntimeServiceModule({ + runtimeDeliveryMode: 'v2', + decision: { + action: 'INSTALL', + hash: 'hash-12', + runtimeDelivery: { + generation: 12, + targetReleaseRef: 'release-12', + selectedMode: 'full', + }, + }, + downloadResult: { + status: 'rollback', + reason: 'CURRENT_REVOKED_NO_SAFE_TARGET', + }, + }); + + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); + + await expect( + service.installBundleFromListItem(createBundleListItem()), + ).resolves.toEqual({ + result: { status: 'rollback', reason: 'CURRENT_REVOKED_NO_SAFE_TARGET' }, + status: '↩️ Rollback requested: CURRENT_REVOKED_NO_SAFE_TARGET', + }); + + expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1); + expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); + expect(mocks.installBundle).not.toHaveBeenCalled(); + }); + + it.each([ + ['a different INSTALL target', { action: 'INSTALL', hash: 'other-hash' }, undefined], + ['a NOOP decision', { action: 'NOOP', reason: 'UP_TO_DATE' }, undefined], + ['an empty decision', null, undefined], + ['a resolve failure', null, new Error('resolve unavailable')], + ])('fails closed for managed list installs after %s', async (_case, decision, checkError) => { + const { service, mocks } = loadRuntimeServiceModule({ + runtimeDeliveryMode: 'v2', + decision, + checkError, + }); + + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); + + await expect( + service.installBundleFromListItem(createBundleListItem()), + ).resolves.toEqual({ + result: { status: 'incompatible' }, + status: '⚠️ Selected bundle is not authorized by the current runtime delivery decision', + }); + + expect(mocks.downloadUpdate).not.toHaveBeenCalled(); + expect(mocks.installBundle).not.toHaveBeenCalled(); + }); + + it.each(['v1', 'shadow'] as const)( + 'preserves direct list-item fallback for the deprecated %s config when resolve fails', + async runtimeDeliveryMode => { + const { service, mocks } = loadRuntimeServiceModule({ + runtimeDeliveryMode, + checkError: new Error('resolve unavailable'), + installResult: { status: 'staged' }, + }); + + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); + + await expect( + service.installBundleFromListItem(createBundleListItem('hash-13', 13)), + ).resolves.toEqual({ + result: { status: 'staged' }, + status: '✅ v13 downloaded. Will apply on next launch or when you call applyUpdate.', + }); + + expect(mocks.installBundle).toHaveBeenCalledWith( + 'hash-13', + 'https://cdn.example.com/hash-13.zip', + 13, + '1.0.13', + '1.0.0', + expect.objectContaining({ + channelName: 'General', + }), + ); + }, + ); + + it('blocks direct URL installs with managed runtime delivery', async () => { + const { service, mocks } = loadRuntimeServiceModule({ runtimeDeliveryMode: 'v2' }); + const statusSpy = jest.fn(); + + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); + + await expect( + service.installBundle( + 'hash-12', + 'https://cdn.example.com/hash-12.zip', + 12, + '1.0.12', + '1.0.0', + statusSpy, + ), + ).resolves.toEqual({ status: 'incompatible' }); + + expect(statusSpy).toHaveBeenCalledWith( + '⚠️ Direct URL installs are unavailable with managed runtime delivery; use downloadUpdate or a bundle-list item', ); + expect(mocks.installBundle).not.toHaveBeenCalled(); }); + it.each(['v1', 'shadow'] as const)( + 'preserves direct URL installs for the deprecated %s config', + async runtimeDeliveryMode => { + const { service, mocks } = loadRuntimeServiceModule({ + runtimeDeliveryMode, + installResult: { status: 'staged' }, + }); + + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); + + await expect( + service.installBundle( + 'hash-12', + 'https://cdn.example.com/hash-12.zip', + 12, + '1.0.12', + '1.0.0', + ), + ).resolves.toEqual({ status: 'staged' }); + + expect(mocks.installBundle).toHaveBeenCalledTimes(1); + }, + ); + it('uses the active runtime channel for singleton actions while still allowing bundle list overrides', async () => { const { service, mocks } = loadRuntimeServiceModule({ decision: { diff --git a/src/tests/setupEnv.ts b/src/tests/setupEnv.ts index 7b0434a..f0d92a9 100644 --- a/src/tests/setupEnv.ts +++ b/src/tests/setupEnv.ts @@ -23,6 +23,12 @@ type BundleDropConfigModule = { healthCheckMode?: 'auto' | 'manual'; healthyAfterSec?: number; }; + runtimeDelivery?: { + mode: 'v1' | 'shadow' | 'v2'; + manifestBaseUrl: string; + manifestAccessId: string; + publicKeys: Record; + }; }; const bundleDropConfig = require('bundle-drop-config') as BundleDropConfigModule; @@ -72,12 +78,15 @@ const resetReactNativeModule = () => { NativeModules.BundleDrop.fsMoveFile.mockReset().mockResolvedValue(undefined); NativeModules.BundleDrop.fsCopyFile.mockReset().mockResolvedValue(undefined); NativeModules.BundleDrop.fsSha256File.mockReset().mockResolvedValue('hash'); + NativeModules.BundleDrop.fsSha256String.mockReset().mockResolvedValue('0'.repeat(64)); + NativeModules.BundleDrop.fsVerifyEs256Signature.mockReset().mockResolvedValue(true); NativeModules.BundleDrop.fsFileSize.mockReset().mockResolvedValue(0); NativeModules.BundleDrop.fsApplyXdelta.mockReset().mockResolvedValue(undefined); NativeModules.BundleDrop.fsVerifyBundleFiles.mockReset().mockResolvedValue({ verified: true }); NativeModules.BundleDrop.fsSupportsXdelta.mockReset().mockResolvedValue(true); NativeModules.BundleDrop.fsUnzip.mockReset().mockResolvedValue([]); NativeModules.BundleDrop.fsDownloadFile.mockReset().mockResolvedValue(undefined); + NativeModules.BundleDrop.fsDownloadFileBounded.mockReset().mockResolvedValue(undefined); NativeModules.BundleDrop.getDownloadedBundlePath.mockReset().mockResolvedValue(null); NativeModules.BundleDrop.getImageManifestSync.mockReset().mockReturnValue(null); NativeModules.BundleDrop.getImageManifest.mockReset().mockResolvedValue(null);