diff --git a/BundleDrop.podspec b/BundleDrop.podspec index 5dcf5b9..a1b9f2b 100644 --- a/BundleDrop.podspec +++ b/BundleDrop.podspec @@ -1,7 +1,85 @@ +require "digest" +require "fileutils" require "json" +require "open3" +require "shellwords" + +bundle_drop_native_runtime_identity_resource = lambda do + return nil unless defined?(Pod::Config) + + project_root = File.expand_path("..", Pod::Config.instance.installation_root.to_s) + config_path = File.join(project_root, "bundle.drop.config.js") + writer_path = File.join( + __dir__, + "lib", + "CLI", + "scripts", + "native", + "write-runtime-identity.js" + ) + unless File.file?(writer_path) + raise Pod::Informative, + "Bundle Drop native runtime identity writer is missing: #{writer_path}" + end + + resource_path = File.join( + "ios", + "build", + "generated", + "runtime-identity", + Digest::SHA256.hexdigest(project_root)[0, 16], + "bundle-drop-build-identity.json" + ) + output_path = File.join(__dir__, resource_path) + + if File.file?(config_path) + stdout, stderr, status = Open3.capture3( + ENV.fetch("NODE_BINARY", "node"), + writer_path, + "--project-root", project_root, + "--platform", "ios", + "--output", output_path + ) + unless status.success? + detail = stderr.strip.empty? ? stdout.strip : stderr.strip + raise Pod::Informative, + "Bundle Drop could not generate the iOS runtime identity: #{detail}" + end + + identity = JSON.parse(File.read(output_path)) + return nil if identity["source"] == "expo" + unless identity["runtimeVersion"].is_a?(String) && !identity["runtimeVersion"].empty? + raise Pod::Informative, "Bundle Drop generated an invalid iOS runtime identity." + end + else + # CocoaPods evaluates the podspec before `bundle-drop login` in the documented + # fresh-install flow. Keep the resource and build phase in the Pods project so + # the first native build can replace this inert placeholder after setup. + FileUtils.mkdir_p(File.dirname(output_path)) + File.write( + output_path, + JSON.generate({ + "schemaVersion" => 1, + "platform" => "ios", + "source" => "unconfigured" + }) + "\n" + ) + end + resource_path +end package = JSON.parse(File.read(File.join(__dir__, "package.json"))) native_version = package["nativeVersion"] || package["version"] +native_runtime_identity_resource = bundle_drop_native_runtime_identity_resource.call +native_runtime_identity_project_root = if native_runtime_identity_resource + File.expand_path("..", Pod::Config.instance.installation_root.to_s) +end +native_runtime_identity_writer = if native_runtime_identity_resource + File.join(__dir__, "lib", "CLI", "scripts", "native", "write-runtime-identity.js") +end +native_runtime_identity_output = if native_runtime_identity_resource + File.join(__dir__, native_runtime_identity_resource) +end Pod::Spec.new do |s| s.name = "BundleDrop" @@ -15,6 +93,24 @@ Pod::Spec.new do |s| s.source = { :git => ".git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}", "third_party/xdelta/**/*.{c,h}" + s.resources = native_runtime_identity_resource if native_runtime_identity_resource + if native_runtime_identity_resource + # Intentionally omit output files so Xcode reruns this when a bare app builds, + # even when CocoaPods has not been reinstalled since its config changed. + s.script_phase = { + :name => "Regenerate Bundle Drop runtime identity", + :execution_position => :before_compile, + :show_env_vars_in_log => "0", + :script => <<-SCRIPT +set -e +"${NODE_BINARY:-node}" \ + #{Shellwords.escape(native_runtime_identity_writer)} \ + --project-root #{Shellwords.escape(native_runtime_identity_project_root)} \ + --platform ios \ + --output #{Shellwords.escape(native_runtime_identity_output)} +SCRIPT + } + end s.public_header_files = "ios/BundleDropLocator.h", "ios/BundleDropZipExtractor.h" s.private_header_files = "third_party/xdelta/**/*.h" s.pod_target_xcconfig = { diff --git a/Package.swift b/Package.swift index ab1f5b1..3549846 100644 --- a/Package.swift +++ b/Package.swift @@ -75,6 +75,8 @@ let package = Package( "BundleDropBundleVerifier.swift", "BundleDropLocator.swift", "BundleDropOtaResolver.swift", + "BundleDropStartupRecovery.swift", + "BundleDropStartupRecoveryAdapter.swift", "BundleDropRuntimeCrypto.swift", ] ), diff --git a/README.md b/README.md index 74d80b0..8595a2b 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,14 @@ 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` +runtime-delivery lockfile 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 +The runtime-delivery lockfile 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. +ignored while allowing `.bundle-drop/runtime-delivery.lock.json` into source +control. Projects with the former `runtime-delivery.generated.json` filename remain +readable; run `bundle-drop sync` to migrate and remove that legacy file. ### Manual setup without AI planning @@ -189,9 +190,9 @@ npx bundle-drop sync npx bundle-drop doctor ``` -`sync` creates `.bundle-drop/runtime-delivery.generated.json`, recreates it if it or +`sync` creates `.bundle-drop/runtime-delivery.lock.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, +`.gitignore` rules. The lockfile contains public identity and verification material, not secrets, so commit it with the application. ## Initialize the Runtime @@ -320,6 +321,30 @@ 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. +## Native Startup Recovery + +Bundle Drop records every OTA startup attempt in native storage before React Native +receives the bundle path. If an attempt does not reach its configured health boundary, +the next distinct app launch counts it as incomplete. The candidate is retried until +`rollback.maxCrashCount` is reached, then quarantined locally and replaced with the +previous native-proven healthy OTA bundle. If that bundle is missing, corrupt, +incompatible, or locally revoked, startup falls back to the bundle embedded in the app. + +Automatic health waits for React content to appear and then applies +`rollback.healthyAfterSec`. Apps with a stronger readiness boundary can opt into +`healthCheckMode: 'manual'` and call `BundleDrop.reportHealthy()` after hydration, +migrations, authentication bootstrap, or navigation setup completes. Health is committed +against the exact native launch attempt, so a delayed callback from an older React runtime +cannot approve a newer launch. + +Recovery is local and offline; it never waits for a network request during startup. With +`maxCrashCount: 0`, launch-health counting and automatic crash-loop rollback are disabled, +while integrity, runtime compatibility, quarantine, and locally persisted revocation checks +still apply. Native startup recovery requires a binary built with the corresponding SDK +`nativeVersion`; it cannot be added to an older installed binary through OTA JavaScript. +Crashes after an attempt is healthy do not trigger this rollback path and should use normal +crash reporting plus fix-forward or explicit rollback controls. + ## Managed Runtime Delivery Runtime delivery is package-managed. `bundle.drop.config.js` stays focused on @@ -330,14 +355,14 @@ 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 +identity-bound `.bundle-drop/runtime-delivery.lock.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. +migration. Only the identity-bound runtime-delivery lockfile 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. @@ -529,7 +554,7 @@ Import the runtime API from `@gfean/react-native-bundle-drop`. | `BundleDrop.init(options)` | Initialize the runtime for the app process. | | `BundleDrop.setChannel(name)` / `setChannel` | Change the active channel for singleton actions. | | `BundleDrop.getChannelName()` / `getChannelName` | Read the active channel. | -| `BundleDrop.reportHealthy()` / `reportHealthy` | Mark the running OTA candidate healthy for this device. | +| `BundleDrop.reportHealthy()` / `reportHealthy` | In manual health mode, ask native recovery to mark the exact running OTA attempt healthy. | **`BundleDrop.init(options)`** diff --git a/android/build.gradle b/android/build.gradle index 32477e3..ee8e6bc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,3 +1,19 @@ +import javax.inject.Inject +import org.gradle.process.ExecOperations + +abstract class BundleDropNativeIdentityCommandExecutor { + @Inject + abstract ExecOperations getExecOperations() + + void execute(File workingDirectory, String executable, List arguments) { + execOperations.exec { spec -> + spec.workingDir workingDirectory + spec.executable executable + spec.args arguments + }.assertNormalExitValue() + } +} + buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['BundleDrop_' + name] @@ -88,6 +104,49 @@ android { } } +if (!project.hasProperty("standalone") && rootProject.findProject(":bundledrop-expo") == null) { + def bundleDropProjectRoot = rootProject.projectDir.parentFile.canonicalFile + def bundleDropConfigFile = new File(bundleDropProjectRoot, "bundle.drop.config.js") + if (bundleDropConfigFile.isFile()) { + def bundleDropIdentityWriter = file( + "../lib/CLI/scripts/native/write-runtime-identity.js" + ).canonicalFile + if (!bundleDropIdentityWriter.isFile()) { + throw new GradleException( + "Bundle Drop native runtime identity writer is missing: ${bundleDropIdentityWriter}" + ) + } + def bundleDropGeneratedAssets = file("${buildDir}/generated/bundleDropIdentity/assets") + def bundleDropIdentityOutput = new File( + bundleDropGeneratedAssets, + "bundle-drop/build-identity.json" + ) + android.sourceSets.main.assets.srcDir(bundleDropGeneratedAssets) + def bundleDropCommandExecutor = objects.newInstance( + BundleDropNativeIdentityCommandExecutor + ) + def generateBundleDropNativeRuntimeIdentity = tasks.register( + "generateBundleDropNativeRuntimeIdentity" + ) { task -> + task.inputs.file(bundleDropConfigFile) + task.inputs.file(bundleDropIdentityWriter) + task.outputs.file(bundleDropIdentityOutput) + task.doLast { + def nodeBinary = System.getenv("NODE_BINARY") ?: "node" + bundleDropCommandExecutor.execute(bundleDropProjectRoot, nodeBinary, [ + bundleDropIdentityWriter.path, + "--project-root", bundleDropProjectRoot.path, + "--platform", "android", + "--output", bundleDropIdentityOutput.path + ]) + } + } + tasks.named("preBuild").configure { task -> + task.dependsOn(generateBundleDropNativeRuntimeIdentity) + } + } +} + afterEvaluate { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinTask -> def javaTask = tasks.findByName(kotlinTask.name.replace("Kotlin", "JavaWithJavac")) @@ -200,6 +259,12 @@ if (project.hasProperty("standalone")) { limit { counter = "LINE"; minimum = 0.90 } limit { counter = "BRANCH"; minimum = 0.85 } } + rule { + element = "CLASS" + includes = ["com.bundledrop.BundleDropStartupRecoveryController"] + limit { counter = "LINE"; minimum = 0.90 } + limit { counter = "BRANCH"; minimum = 0.70 } + } rule { element = "CLASS" includes = ["com.bundledrop.BundleDropFileOps"] diff --git a/android/src/main/java/com/bundledrop/BundleDropModule.kt b/android/src/main/java/com/bundledrop/BundleDropModule.kt index 0e3c349..2760278 100644 --- a/android/src/main/java/com/bundledrop/BundleDropModule.kt +++ b/android/src/main/java/com/bundledrop/BundleDropModule.kt @@ -6,22 +6,19 @@ import android.util.Log import com.facebook.react.bridge.* import com.facebook.react.modules.network.OkHttpClientProvider import java.io.File +import org.json.JSONArray +import org.json.JSONObject class BundleDropModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { - private var downloadedBundlePath: String? = null - companion object { - var latestBundlePath: String? = null - private set - fun getDownloadedBundlePath(context: Context): String? = - BundleDropNativePaths.getDownloadedBundlePath(context) + BundleDropNativePaths.getDownloadedBundlePathPassive(context) @JvmStatic fun resolveJSBundleFile(context: Context, fallback: String?): String? { - val path = getDownloadedBundlePath(context) + val path = BundleDropNativePaths.getDownloadedBundlePath(context) return if (!path.isNullOrEmpty()) path else fallback } } @@ -30,10 +27,7 @@ class BundleDropModule(reactContext: ReactApplicationContext) : @ReactMethod fun getDownloadedBundlePath(promise: Promise) { - val path = getDownloadedBundlePath(reactApplicationContext) - downloadedBundlePath = path - latestBundlePath = path - promise.resolve(path) + promise.resolve(getDownloadedBundlePath(reactApplicationContext)) } @ReactMethod @@ -69,6 +63,95 @@ class BundleDropModule(reactContext: ReactApplicationContext) : } } + @ReactMethod + fun activateStartupCandidate( + hash: String, + maxCrashCount: Double, + healthCheckMode: String, + healthyAfterSec: Double, + promise: Promise, + ) { + try { + require(maxCrashCount.isFinite() && maxCrashCount >= 0 && maxCrashCount % 1.0 == 0.0) { + "maxCrashCount must be a non-negative integer" + } + require(maxCrashCount <= Int.MAX_VALUE) { "maxCrashCount is outside the supported range" } + val result = BundleDropStartupRecovery.controller(reactApplicationContext) + .activateCandidate(hash, maxCrashCount.toInt(), healthCheckMode, healthyAfterSec) + promise.resolve(Arguments.createMap().apply { + putString("hash", result.hash) + putString("bundlePath", result.bundlePath) + }) + } catch (error: Exception) { + promise.reject("ERR_STARTUP_RECOVERY_ACTIVATE", error.message, error) + } + } + + @ReactMethod + fun markStartupHealthy(hash: String, attemptId: String, promise: Promise) { + try { + val marked = BundleDropStartupRecovery.controller(reactApplicationContext) + .markHealthy(hash, attemptId) + promise.resolve(marked) + } catch (error: Exception) { + promise.reject("ERR_STARTUP_RECOVERY_HEALTH", error.message, error) + } + } + + @ReactMethod + fun getStartupRecoveryState(promise: Promise) { + try { + val state = BundleDropStartupRecovery.controller(reactApplicationContext).snapshot() + promise.resolve(jsonObjectToWritableMap(state)) + } catch (error: Exception) { + promise.reject("ERR_STARTUP_RECOVERY_STATE", error.message, error) + } + } + + @ReactMethod + fun setStartupRecoveryRevokedHashes(hashes: ReadableArray, promise: Promise) { + try { + val values = buildSet { + for (index in 0 until hashes.size()) { + val hash = hashes.getString(index) + ?: throw IllegalArgumentException("Revoked bundle hashes must be strings") + add(hash) + } + } + BundleDropStartupRecovery.controller(reactApplicationContext).setRevokedHashes(values) + promise.resolve(true) + } catch (error: Exception) { + promise.reject("ERR_STARTUP_RECOVERY_REVOKE", error.message, error) + } + } + + @ReactMethod + fun acknowledgeStartupRecovery(eventId: String, promise: Promise) { + try { + promise.resolve( + BundleDropStartupRecovery.controller(reactApplicationContext) + .acknowledgeRecovery(eventId), + ) + } catch (error: Exception) { + promise.reject("ERR_STARTUP_RECOVERY_ACK", error.message, error) + } + } + + @ReactMethod + fun rollbackStartupBundle(forceEmbedded: Boolean, promise: Promise) { + try { + val result = BundleDropStartupRecovery.controller(reactApplicationContext) + .rollbackStartupBundle(forceEmbedded) + promise.resolve(Arguments.createMap().apply { + putBoolean("rolledBack", result.rolledBack) + putBoolean("toEmbedded", result.toEmbedded) + result.hash?.let { putString("hash", it) } + }) + } catch (error: Exception) { + promise.reject("ERR_STARTUP_RECOVERY_ROLLBACK", error.message, error) + } + } + @ReactMethod(isBlockingSynchronousMethod = true) fun getImageManifestSync(): String? { return readImageManifestRaw() @@ -332,12 +415,53 @@ class BundleDropModule(reactContext: ReactApplicationContext) : override fun getConstants(): MutableMap { val map = mutableMapOf() - downloadedBundlePath?.let { - map["downloadedBundlePath"] = it + map["startupRecoveryProtocolVersion"] = BundleDropStartupRecoveryController.PROTOCOL_VERSION + @Suppress("UNCHECKED_CAST") + (map as MutableMap)["startupRecoverySelectedHash"] = + BundleDropStartupRecovery.startupSelectedHash() + BundleDropStartupRecovery.startupAttempt()?.let { (hash, attemptId) -> + map["startupRecoveryAttemptHash"] = hash + map["startupRecoveryAttemptId"] = attemptId } val context = reactApplicationContext map["DocumentDirectoryPath"] = context.filesDir.absolutePath map["LibraryDirectoryPath"] = context.filesDir.absolutePath return map } + + private fun jsonObjectToWritableMap(json: JSONObject): WritableMap { + val map = Arguments.createMap() + val keys = json.keys() + while (keys.hasNext()) { + val key = keys.next() + putJsonValue(map, key, json.opt(key)) + } + return map + } + + private fun jsonArrayToWritableArray(json: JSONArray): WritableArray { + val array = Arguments.createArray() + for (index in 0 until json.length()) { + when (val value = json.opt(index)) { + null, JSONObject.NULL -> array.pushNull() + is JSONObject -> array.pushMap(jsonObjectToWritableMap(value)) + is JSONArray -> array.pushArray(jsonArrayToWritableArray(value)) + is Boolean -> array.pushBoolean(value) + is Number -> array.pushDouble(value.toDouble()) + else -> array.pushString(value.toString()) + } + } + return array + } + + private fun putJsonValue(map: WritableMap, key: String, value: Any?) { + when (value) { + null, JSONObject.NULL -> map.putNull(key) + is JSONObject -> map.putMap(key, jsonObjectToWritableMap(value)) + is JSONArray -> map.putArray(key, jsonArrayToWritableArray(value)) + is Boolean -> map.putBoolean(key, value) + is Number -> map.putDouble(key, value.toDouble()) + else -> map.putString(key, value.toString()) + } + } } diff --git a/android/src/main/java/com/bundledrop/BundleDropNativePaths.kt b/android/src/main/java/com/bundledrop/BundleDropNativePaths.kt index 6ffdebf..aaa2ef8 100644 --- a/android/src/main/java/com/bundledrop/BundleDropNativePaths.kt +++ b/android/src/main/java/com/bundledrop/BundleDropNativePaths.kt @@ -8,8 +8,8 @@ import org.json.JSONObject /** * Resolves the on-disk OTA JS bundle path (no React / bridge types). * - * Shared by [BundleDropModule] so cold-start `resolveJSBundleFile`, the JS bridge - * `getDownloadedBundlePath`, and tests use the same logic — including the OTA-disabled gate. + * Cold-start resolution records a launch attempt, while bridge lookups are passive and never + * change the attempt selected for the current React runtime. */ object BundleDropNativePaths { private const val KEY_BINARY_VERSION = "binary_version" @@ -40,6 +40,9 @@ object BundleDropNativePaths { return binaryVersionKey(versionName, versionCode, runtimeVersion) } + internal fun currentBinaryIdentity(context: Context): String = + getBinaryVersionKey(context, readEmbeddedRuntimeVersion(context)) + internal fun readEmbeddedRuntimeVersion(context: Context): String? { return try { context.assets.open(BUILD_IDENTITY_ASSET).bufferedReader().use { reader -> @@ -64,11 +67,37 @@ object BundleDropNativePaths { @JvmStatic fun getDownloadedBundlePath(context: Context): String? { - if (!BundleDropOtaPrefs.isOtaEnabled(context)) return null - return getDownloadedBundlePath(context, readEmbeddedRuntimeVersion(context)) + if (!BundleDropOtaPrefs.isOtaEnabled(context)) { + BundleDropStartupRecovery.clearStartupSelection() + return null + } + resolveForBinary(context, readEmbeddedRuntimeVersion(context)) + val selection = BundleDropStartupRecovery.selectForStartup(context) + logResolvedPath(selection.bundlePath) + return selection.bundlePath } internal fun getDownloadedBundlePath(context: Context, runtimeVersion: String?): String? { + if (!BundleDropOtaPrefs.isOtaEnabled(context)) { + BundleDropStartupRecovery.clearStartupSelection() + return null + } + resolveForBinary(context, runtimeVersion) + val selection = BundleDropStartupRecovery.selectForStartup(context) + logResolvedPath(selection.bundlePath) + return selection.bundlePath + } + + internal fun getDownloadedBundlePathPassive(context: Context): String? { + if (!BundleDropOtaPrefs.isOtaEnabled(context)) return null + val resolved = resolveForBinary(context, readEmbeddedRuntimeVersion(context)) + if (resolved == null) return null + val path = BundleDropStartupRecovery.controller(context).resolvePassive() + logResolvedPath(path) + return path + } + + private fun resolveForBinary(context: Context, runtimeVersion: String?): String? { val bundleDropRoot = File(context.filesDir, "bundle-drop") val result = BundleDropOtaResolver.resolve( bundleDropRoot = bundleDropRoot, @@ -79,16 +108,17 @@ object BundleDropNativePaths { result.storedVersion?.let { setStoredBinaryVersion(context, it) } - if (result.bundlePath == null) { - if (result.clearedOta) { - Log.d("BundleDrop", "Binary updated, clearing OTA bundle") - } else { - Log.d("BundleDrop", "📦 No OTA bundle found.") - } - } else { - Log.d("BundleDrop", "🔁 Using OTA bundle at: ${result.bundlePath}") + if (result.clearedOta) { + Log.d("BundleDrop", "Binary updated, clearing OTA bundle") } - return result.bundlePath } + + private fun logResolvedPath(path: String?) { + if (path == null) { + Log.d("BundleDrop", "📦 No OTA bundle found.") + } else { + Log.d("BundleDrop", "🔁 Using OTA bundle at: $path") + } + } } diff --git a/android/src/main/java/com/bundledrop/BundleDropOtaResolver.kt b/android/src/main/java/com/bundledrop/BundleDropOtaResolver.kt index 12d53b2..038fd05 100644 --- a/android/src/main/java/com/bundledrop/BundleDropOtaResolver.kt +++ b/android/src/main/java/com/bundledrop/BundleDropOtaResolver.kt @@ -43,6 +43,39 @@ object BundleDropOtaResolver { } } + /** Verify an installed bundle independently of the active pointer. */ + internal fun readBundleForHash(bundleDropRoot: File, hash: String): String? { + if (!bundleHashPattern.matches(hash)) return null + return try { + val bundleDir = File(File(bundleDropRoot, "bundles"), hash) + val bundleFile = File(bundleDir, "main.jsbundle") + val manifestFile = File(bundleDir, "bundle-manifest.json") + if (!bundleFile.exists() || !manifestFile.exists()) return null + val manifest = JSONObject(manifestFile.readText()) + if ( + manifest.optInt("manifestVersion", -1) != 1 || + manifest.optString("bundleHash", "") != hash || + !verifyBundleDir(bundleDir, manifest, hash) + ) { + return null + } + bundleFile.absolutePath + } catch (_: Exception) { + null + } + } + + internal fun readBundleRuntimeVersion(bundleDropRoot: File, hash: String): String? { + readBundleForHash(bundleDropRoot, hash) ?: return null + return try { + JSONObject(File(File(File(bundleDropRoot, "bundles"), hash), "bundle-manifest.json").readText()) + .optString("runtimeVersion", "") + .takeIf(String::isNotEmpty) + } catch (_: Exception) { + null + } + } + private fun verifyBundleDir(bundleDir: File, manifest: JSONObject, expectedHash: String): Boolean { val files = manifest.optJSONArray("files") ?: return false if (manifest.optString("platform", "") != "android") { @@ -184,6 +217,7 @@ object BundleDropOtaResolver { File(bundleDropRoot, "current.json"), File(bundleDropRoot, "previous.json"), File(bundleDropRoot, "state.json"), + File(bundleDropRoot, BundleDropStartupRecoveryController.RECOVERY_LEDGER), File(filesDir, "bundle-info.json"), ) filesToClear.forEach { @@ -196,6 +230,7 @@ object BundleDropOtaResolver { File(bundleDropRoot, "current.json"), File(bundleDropRoot, "previous.json"), File(bundleDropRoot, "state.json"), + File(bundleDropRoot, BundleDropStartupRecoveryController.RECOVERY_LEDGER), File(filesDir, "bundle-info.json"), ).any { it.exists() } } diff --git a/android/src/main/java/com/bundledrop/BundleDropStartupRecovery.kt b/android/src/main/java/com/bundledrop/BundleDropStartupRecovery.kt new file mode 100644 index 0000000..c6f61c7 --- /dev/null +++ b/android/src/main/java/com/bundledrop/BundleDropStartupRecovery.kt @@ -0,0 +1,1117 @@ +package com.bundledrop + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.AtomicFile +import android.util.Log +import com.facebook.react.bridge.ReactMarker +import com.facebook.react.bridge.ReactMarkerConstants +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +/** + * Native source of truth for OTA launch probation. + * + * The ledger is persisted before an OTA path is returned to React Native. A later process can + * therefore recover from Java, JNI, Hermes, or JS failures that happen before the bridge starts. + */ +internal class BundleDropStartupRecoveryController( + private val bundleDropRoot: File, + private val processToken: String, + private val binaryIdentity: String, + private val expectedRuntimeVersion: String?, + private val nowMillis: () -> Long = { System.currentTimeMillis() }, + private val newId: () -> String = { UUID.randomUUID().toString() }, + private val failpoint: (String) -> Unit = {}, +) { + data class ActivationResult(val hash: String, val bundlePath: String) + data class RollbackResult( + val rolledBack: Boolean, + val toEmbedded: Boolean, + val hash: String?, + ) + + data class StartupSelection( + val bundlePath: String?, + val attemptHash: String? = null, + val attemptId: String? = null, + ) + + private data class RecoveryPolicy( + val maxCrashCount: Int, + val healthCheckMode: String, + val healthyAfterSec: Double, + ) + + private data class ActiveAttempt( + val hash: String, + val attemptId: String, + val processToken: String, + val unacknowledgedLaunchCount: Int, + ) + + private data class RecoveryEvent( + val id: String, + val failedHash: String, + val recoveryTarget: String, + val recoveredHash: String?, + val crashCount: Int, + val failedAt: Long, + ) + + private data class Ledger( + val revision: Long = 0, + val binaryIdentity: String? = null, + val phase: String = PHASE_IDLE, + val candidateHash: String? = null, + val candidateRuntimeVersion: String? = null, + val stableHash: String? = null, + val stableRuntimeVersion: String? = null, + val previousStableHash: String? = null, + val previousStableRuntimeVersion: String? = null, + val policy: RecoveryPolicy? = null, + val reservedAttemptId: String? = null, + val activeAttempt: ActiveAttempt? = null, + val lastHealthyAttemptId: String? = null, + val quarantinedHashes: Set = emptySet(), + val revokedHashes: Set = emptySet(), + val pendingRecoveryEvents: List = emptyList(), + val legacyStateImported: Boolean = false, + val rollbackFailedHash: String? = null, + val rollbackCrashCount: Int = 0, + val rollbackReason: String? = null, + ) + + fun activateCandidate( + hash: String, + maxCrashCount: Int, + healthCheckMode: String, + healthyAfterSec: Double, + ): ActivationResult = synchronized(STORAGE_LOCK) { + requireHash(hash) + require(maxCrashCount >= 0) { "maxCrashCount must not be negative" } + require(healthCheckMode == HEALTH_AUTO || healthCheckMode == HEALTH_MANUAL) { + "healthCheckMode must be auto or manual" + } + require(healthyAfterSec.isFinite() && healthyAfterSec >= 0) { + "healthyAfterSec must be a finite non-negative number" + } + + val bundlePath = BundleDropOtaResolver.readBundleForHash(bundleDropRoot, hash) + ?: throw IllegalArgumentException("Candidate bundle is missing or failed native verification") + val candidateRuntimeVersion = BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, hash) + ?: throw IllegalArgumentException("Candidate runtime identity is missing") + require(!expectedRuntimeVersion.isNullOrBlank()) { + "Embedded runtime identity is missing; OTA startup activation is disabled" + } + require(candidateRuntimeVersion == expectedRuntimeVersion) { + "Candidate runtime identity does not match the embedded binary" + } + var ledger = readLedgerWithLegacyImport() + require(hash !in ledger.quarantinedHashes && hash !in ledger.revokedHashes) { + "Candidate bundle is quarantined or revoked" + } + failpoint(FAIL_AFTER_VERIFICATION) + + val currentHash = currentHash() + val requestedPolicy = RecoveryPolicy(maxCrashCount, healthCheckMode, healthyAfterSec) + if ( + currentHash == hash && + ledger.stableHash == hash && + (ledger.phase == PHASE_STABLE || ledger.phase == PHASE_RECOVERED) + ) { + return@synchronized ActivationResult(hash, bundlePath) + } + if ( + currentHash == hash && + ledger.candidateHash == hash && + (ledger.phase == PHASE_ARMED || ledger.phase == PHASE_LAUNCHING) + ) { + var updated = ledger.copy(policy = requestedPolicy) + if (maxCrashCount == 0) { + updated = updated.copy( + phase = PHASE_ARMED, + reservedAttemptId = null, + activeAttempt = null, + lastHealthyAttemptId = null, + ) + } else if (updated.phase == PHASE_ARMED && updated.reservedAttemptId == null) { + updated = updated.copy(reservedAttemptId = newId()) + } + if (updated != ledger) persist(updated) + return@synchronized ActivationResult(hash, bundlePath) + } + + val previousStableHash = selectKnownGoodHash(ledger, excludedHash = hash) + val previousStableRuntimeVersion = when (previousStableHash) { + ledger.stableHash -> ledger.stableRuntimeVersion + ledger.previousStableHash -> ledger.previousStableRuntimeVersion + else -> null + } + ledger = ledger.copy( + binaryIdentity = binaryIdentity, + phase = PHASE_ARMED, + candidateHash = hash, + candidateRuntimeVersion = candidateRuntimeVersion, + previousStableHash = previousStableHash, + previousStableRuntimeVersion = previousStableRuntimeVersion, + policy = requestedPolicy, + reservedAttemptId = if (maxCrashCount == 0) null else newId(), + activeAttempt = null, + lastHealthyAttemptId = null, + rollbackFailedHash = null, + rollbackCrashCount = 0, + rollbackReason = null, + ) + ledger = persist(ledger) + failpoint(FAIL_AFTER_ARMED) + + if (previousStableHash != null) { + writePointer(PREVIOUS_POINTER, previousStableHash) + failpoint(FAIL_AFTER_PREVIOUS_POINTER) + } else { + deletePointer(PREVIOUS_POINTER) + } + writePointer(CURRENT_POINTER, hash) + failpoint(FAIL_AFTER_CURRENT_POINTER) + + ActivationResult(hash, bundlePath) + } + + fun selectForStartup(): StartupSelection = synchronized(STORAGE_LOCK) { + var ledger = try { + readLedgerWithLegacyImport() + } catch (_: IncompatibleLedgerException) { + return@synchronized resetForBinaryChange() + } catch (_: CorruptLedgerException) { + return@synchronized recoverFromCorruptLedger() + } + + if (ledger.phase == PHASE_ROLLBACK_REQUIRED) { + return@synchronized completeRecovery( + ledger, + recordCrashLoop = ledger.rollbackReason != ROLLBACK_REVOKED, + ) + } + + val path = BundleDropOtaResolver.readCurrentPointer(bundleDropRoot) + val hash = path?.let(::hashFromBundlePath) + if (ledger.phase == PHASE_ARMED && hash != ledger.candidateHash) { + return@synchronized discardUnpublishedArm(ledger, path, hash) + } + if (path == null || hash == null) return@synchronized StartupSelection(null) + + if ( + ledger.binaryIdentity != binaryIdentity || + expectedRuntimeVersion.isNullOrBlank() || + BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, hash) != expectedRuntimeVersion + ) { + return@synchronized resetForBinaryChange() + } + + if (hash in ledger.revokedHashes) { + ledger = persist(ledger.copy( + phase = PHASE_ROLLBACK_REQUIRED, + rollbackFailedHash = hash, + rollbackCrashCount = 0, + rollbackReason = ROLLBACK_REVOKED, + )) + return@synchronized completeRecovery(ledger, recordCrashLoop = false) + } + + if (hash in ledger.quarantinedHashes) { + val crashCount = ledger.activeAttempt?.unacknowledgedLaunchCount ?: 0 + ledger = persist(ledger.copy( + phase = PHASE_ROLLBACK_REQUIRED, + rollbackFailedHash = hash, + rollbackCrashCount = crashCount, + rollbackReason = ROLLBACK_CRASH_LOOP, + )) + return@synchronized completeRecovery(ledger, recordCrashLoop = true) + } + + if ( + ledger.stableHash == hash && + (ledger.phase == PHASE_STABLE || ledger.phase == PHASE_RECOVERED) + ) { + return@synchronized StartupSelection(path) + } + + val policy = ledger.policy + if ( + ledger.candidateHash != hash || + ledger.candidateRuntimeVersion != expectedRuntimeVersion || + policy == null || + (ledger.phase != PHASE_ARMED && ledger.phase != PHASE_LAUNCHING) + ) { + deletePointer(CURRENT_POINTER) + return@synchronized StartupSelection(null) + } + + // Zero explicitly disables crash classification while retaining revocation and integrity gates. + if (policy.maxCrashCount == 0) { + return@synchronized StartupSelection(path) + } + + val activeAttempt = ledger.activeAttempt + if (activeAttempt?.hash == hash && activeAttempt.processToken == processToken) { + return@synchronized StartupSelection(path, hash, activeAttempt.attemptId) + } + + val failedLaunchCount = if (activeAttempt?.hash == hash) { + activeAttempt.unacknowledgedLaunchCount + 1 + } else { + 0 + } + val threshold = policy.maxCrashCount + if (threshold > 0 && failedLaunchCount >= threshold) { + ledger = persist(ledger.copy( + phase = PHASE_ROLLBACK_REQUIRED, + rollbackFailedHash = hash, + rollbackCrashCount = failedLaunchCount, + rollbackReason = ROLLBACK_CRASH_LOOP, + quarantinedHashes = ledger.quarantinedHashes + hash, + )) + failpoint(FAIL_AFTER_ROLLBACK_REQUIRED) + return@synchronized completeRecovery(ledger, recordCrashLoop = true) + } + + val attempt = ActiveAttempt( + hash = hash, + attemptId = ledger.reservedAttemptId ?: newId(), + processToken = processToken, + unacknowledgedLaunchCount = failedLaunchCount, + ) + persist(ledger.copy( + phase = PHASE_LAUNCHING, + reservedAttemptId = null, + activeAttempt = attempt, + )) + failpoint(FAIL_AFTER_LAUNCH_PERSISTED) + StartupSelection(path, hash, attempt.attemptId) + } + + fun resolvePassive(): String? = synchronized(STORAGE_LOCK) { + val path = BundleDropOtaResolver.readCurrentPointer(bundleDropRoot) ?: return@synchronized null + val hash = hashFromBundlePath(path) ?: return@synchronized null + val ledger = try { + readLedgerWithLegacyImport() + } catch (_: IncompatibleLedgerException) { + return@synchronized null + } catch (_: CorruptLedgerException) { + return@synchronized null + } + if (ledger.phase == PHASE_ROLLBACK_REQUIRED) return@synchronized null + if (hash in ledger.quarantinedHashes || hash in ledger.revokedHashes) return@synchronized null + + val recordedRuntimeVersion = when { + ledger.stableHash == hash && + (ledger.phase == PHASE_STABLE || ledger.phase == PHASE_RECOVERED) -> ledger.stableRuntimeVersion + ledger.candidateHash == hash && + (ledger.phase == PHASE_ARMED || ledger.phase == PHASE_LAUNCHING) && + ledger.policy != null -> ledger.candidateRuntimeVersion + else -> null + } + if (!isCurrentBundleEligible(ledger, hash, recordedRuntimeVersion)) null else path + } + + fun markHealthy(hash: String, attemptId: String): Boolean = synchronized(STORAGE_LOCK) { + requireHash(hash) + val ledger = try { + readLedgerWithLegacyImport() + } catch (_: IncompatibleLedgerException) { + return@synchronized false + } catch (_: CorruptLedgerException) { + return@synchronized false + } + val attempt = ledger.activeAttempt + val recordedRuntimeVersion = when { + ledger.phase == PHASE_STABLE && + ledger.stableHash == hash && + ledger.lastHealthyAttemptId == attemptId -> ledger.stableRuntimeVersion + ledger.phase == PHASE_LAUNCHING && + ledger.candidateHash == hash && + attempt?.hash == hash && + attempt.attemptId == attemptId -> ledger.candidateRuntimeVersion + else -> null + } + if (!isCurrentBundleEligible(ledger, hash, recordedRuntimeVersion)) { + return@synchronized false + } + if ( + ledger.phase == PHASE_STABLE && + ledger.stableHash == hash && + ledger.lastHealthyAttemptId == attemptId + ) { + return@synchronized true + } + if ( + ledger.phase != PHASE_LAUNCHING || + attempt?.hash != hash || + attempt.attemptId != attemptId + ) { + return@synchronized false + } + + persist(ledger.copy( + phase = PHASE_STABLE, + candidateHash = null, + candidateRuntimeVersion = null, + stableHash = hash, + stableRuntimeVersion = recordedRuntimeVersion, + policy = null, + reservedAttemptId = null, + activeAttempt = null, + lastHealthyAttemptId = attemptId, + rollbackFailedHash = null, + rollbackCrashCount = 0, + rollbackReason = null, + )) + failpoint(FAIL_AFTER_HEALTH_COMMITTED) + true + } + + fun setRevokedHashes(hashes: Set): Unit = synchronized(STORAGE_LOCK) { + hashes.forEach(::requireHash) + val ledger = readLedgerWithLegacyImport() + if (ledger.revokedHashes == hashes) return@synchronized + persist(ledger.copy(revokedHashes = hashes)) + } + + fun rollbackStartupBundle(forceEmbedded: Boolean): RollbackResult = synchronized(STORAGE_LOCK) { + val ledger = readLedgerWithLegacyImport() + val currentHash = currentHash() + val targetHash = if (forceEmbedded) { + null + } else { + selectKnownGoodHash(ledger, excludedHash = currentHash) + } + if (targetHash != null) { + val targetRuntimeVersion = BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, targetHash) + writePointer(CURRENT_POINTER, targetHash) + deletePointer(PREVIOUS_POINTER) + persist(ledger.copy( + phase = PHASE_STABLE, + candidateHash = targetHash, + candidateRuntimeVersion = targetRuntimeVersion, + stableHash = targetHash, + stableRuntimeVersion = targetRuntimeVersion, + previousStableHash = null, + previousStableRuntimeVersion = null, + reservedAttemptId = null, + activeAttempt = null, + lastHealthyAttemptId = null, + policy = null, + rollbackFailedHash = null, + rollbackCrashCount = 0, + rollbackReason = null, + )) + } else { + deletePointer(CURRENT_POINTER) + deletePointer(PREVIOUS_POINTER) + persist(ledger.copy( + phase = PHASE_IDLE, + candidateHash = null, + candidateRuntimeVersion = null, + stableHash = null, + stableRuntimeVersion = null, + previousStableHash = null, + previousStableRuntimeVersion = null, + reservedAttemptId = null, + activeAttempt = null, + lastHealthyAttemptId = null, + policy = null, + rollbackFailedHash = null, + rollbackCrashCount = 0, + rollbackReason = null, + )) + } + RollbackResult( + rolledBack = currentHash != null, + toEmbedded = targetHash == null, + hash = targetHash, + ) + } + + fun acknowledgeRecovery(eventId: String): Boolean = synchronized(STORAGE_LOCK) { + val ledger = readLedgerWithLegacyImport() + if (ledger.pendingRecoveryEvents.none { it.id == eventId }) return@synchronized false + persist(ledger.copy( + pendingRecoveryEvents = ledger.pendingRecoveryEvents.filterNot { it.id == eventId }, + )) + true + } + + fun snapshot(): JSONObject = synchronized(STORAGE_LOCK) { + val ledger = readLedgerWithLegacyImport() + val output = JSONObject() + .put("protocolVersion", PROTOCOL_VERSION) + .put("revision", ledger.revision) + .put("phase", externalPhase(ledger.phase)) + .put("activeAttempt", ledger.activeAttempt?.let(::attemptJson) ?: JSONObject.NULL) + .put("quarantinedHashes", JSONArray(ledger.quarantinedHashes.sorted())) + .put("pendingRecoveryEvents", JSONArray(ledger.pendingRecoveryEvents.map(::eventJson))) + ledger.candidateHash?.let { output.put("candidateHash", it) } + ledger.stableHash?.let { output.put("stableHash", it) } + ledger.policy?.let { + output.put("policy", JSONObject() + .put("maxCrashCount", it.maxCrashCount) + .put("healthCheckMode", it.healthCheckMode) + .put("healthyAfterSec", it.healthyAfterSec)) + } + output + } + + fun activeAttempt(): Pair? = synchronized(STORAGE_LOCK) { + val attempt = try { + readLedgerWithLegacyImport().activeAttempt + } catch (_: CorruptLedgerException) { + null + } ?: return@synchronized null + attempt.hash to attempt.attemptId + } + + fun scheduleContentAppearedHealth(handler: Handler = Handler(Looper.getMainLooper())) { + val ledger = synchronized(STORAGE_LOCK) { + try { + readLedgerWithLegacyImport() + } catch (_: CorruptLedgerException) { + null + } + } ?: return + val attempt = ledger.activeAttempt ?: return + val policy = ledger.policy ?: return + if (ledger.phase != PHASE_LAUNCHING || policy.healthCheckMode != HEALTH_AUTO) return + val delayMillis = (policy.healthyAfterSec * 1000.0).toLong().coerceAtLeast(0) + handler.postDelayed({ markHealthy(attempt.hash, attempt.attemptId) }, delayMillis) + } + + private fun completeRecovery(ledger: Ledger, recordCrashLoop: Boolean): StartupSelection { + val failedHash = ledger.rollbackFailedHash ?: ledger.candidateHash + ?: return StartupSelection(null) + val crashCount = ledger.rollbackCrashCount + val alreadyPromotedTarget = currentHash() + ?.takeIf { it != failedHash && isKnownGoodHash(ledger, it) } + val targetHash = alreadyPromotedTarget ?: selectKnownGoodHash(ledger, failedHash) + ?.takeIf { readPointerHash(PREVIOUS_POINTER) == it } + val recoveryTarget: String + + if (targetHash != null) { + writePointer(CURRENT_POINTER, targetHash) + deletePointer(PREVIOUS_POINTER) + recoveryTarget = RECOVERY_PREVIOUS + } else { + deletePointer(CURRENT_POINTER) + deletePointer(PREVIOUS_POINTER) + recoveryTarget = RECOVERY_EMBEDDED + } + failpoint(FAIL_AFTER_RECOVERY_POINTER) + + val events = if (recordCrashLoop) { + val event = RecoveryEvent( + id = newId(), + failedHash = failedHash, + recoveryTarget = recoveryTarget, + recoveredHash = targetHash, + crashCount = crashCount, + failedAt = nowMillis() / 1000, + ) + (ledger.pendingRecoveryEvents + event).takeLast(MAX_PENDING_EVENTS) + } else { + ledger.pendingRecoveryEvents + } + persist(ledger.copy( + phase = PHASE_RECOVERED, + candidateHash = targetHash, + candidateRuntimeVersion = targetHash?.let { + BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, it) + }, + stableHash = targetHash, + stableRuntimeVersion = targetHash?.let { + BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, it) + }, + previousStableHash = null, + previousStableRuntimeVersion = null, + policy = null, + reservedAttemptId = null, + activeAttempt = null, + lastHealthyAttemptId = null, + quarantinedHashes = if (recordCrashLoop) { + ledger.quarantinedHashes + failedHash + } else { + ledger.quarantinedHashes + }, + pendingRecoveryEvents = events, + rollbackFailedHash = null, + rollbackCrashCount = 0, + rollbackReason = null, + )) + failpoint(FAIL_AFTER_RECOVERED) + return StartupSelection( + bundlePath = targetHash?.let { BundleDropOtaResolver.readBundleForHash(bundleDropRoot, it) }, + ) + } + + private fun discardUnpublishedArm( + ledger: Ledger, + currentPath: String?, + currentHash: String?, + ): StartupSelection { + val stableCurrentHash = currentHash?.takeIf { isKnownGoodHash(ledger, it) } + deletePointer(PREVIOUS_POINTER) + + if (stableCurrentHash == null) { + deletePointer(CURRENT_POINTER) + persist(ledger.copy( + phase = PHASE_IDLE, + candidateHash = null, + candidateRuntimeVersion = null, + policy = null, + reservedAttemptId = null, + activeAttempt = null, + lastHealthyAttemptId = null, + rollbackFailedHash = null, + rollbackCrashCount = 0, + rollbackReason = null, + )) + return StartupSelection(null) + } + + val stableRuntimeVersion = BundleDropOtaResolver.readBundleRuntimeVersion( + bundleDropRoot, + stableCurrentHash, + ) + val previousStableHash = selectKnownGoodHash(ledger, excludedHash = stableCurrentHash) + val previousStableRuntimeVersion = previousStableHash?.let { + BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, it) + } + persist(ledger.copy( + phase = PHASE_STABLE, + candidateHash = stableCurrentHash, + candidateRuntimeVersion = stableRuntimeVersion, + stableHash = stableCurrentHash, + stableRuntimeVersion = stableRuntimeVersion, + previousStableHash = previousStableHash, + previousStableRuntimeVersion = previousStableRuntimeVersion, + policy = null, + reservedAttemptId = null, + activeAttempt = null, + lastHealthyAttemptId = null, + rollbackFailedHash = null, + rollbackCrashCount = 0, + rollbackReason = null, + )) + return StartupSelection(currentPath) + } + + private fun recoverFromCorruptLedger(): StartupSelection { + val failedHash = currentHash() + deletePointer(CURRENT_POINTER) + deletePointer(PREVIOUS_POINTER) + val quarantined = failedHash?.let(::setOf) ?: emptySet() + persist(Ledger( + binaryIdentity = binaryIdentity, + phase = PHASE_RECOVERED, + quarantinedHashes = quarantined, + legacyStateImported = true, + ), replaceExisting = true) + return StartupSelection(null) + } + + private fun resetForBinaryChange(): StartupSelection { + deletePointer(CURRENT_POINTER) + deletePointer(PREVIOUS_POINTER) + persist( + Ledger(binaryIdentity = binaryIdentity, legacyStateImported = true), + replaceExisting = true, + ) + return StartupSelection(null) + } + + private fun selectKnownGoodHash(ledger: Ledger, excludedHash: String?): String? { + val candidates = listOfNotNull(ledger.stableHash, ledger.previousStableHash).distinct() + return candidates.firstOrNull { hash -> hash != excludedHash && isKnownGoodHash(ledger, hash) } + } + + private fun isKnownGoodHash(ledger: Ledger, hash: String): Boolean { + val recordedRuntimeVersion = when (hash) { + ledger.stableHash -> ledger.stableRuntimeVersion + ledger.previousStableHash -> ledger.previousStableRuntimeVersion + else -> null + } + return ledger.binaryIdentity == binaryIdentity && + !recordedRuntimeVersion.isNullOrBlank() && + hash !in ledger.quarantinedHashes && + hash !in ledger.revokedHashes && + recordedRuntimeVersion == expectedRuntimeVersion && + BundleDropOtaResolver.readBundleForHash(bundleDropRoot, hash) != null && + BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, hash) == expectedRuntimeVersion + } + + private fun isCurrentBundleEligible( + ledger: Ledger, + hash: String, + recordedRuntimeVersion: String?, + ): Boolean { + return ledger.binaryIdentity == binaryIdentity && + !expectedRuntimeVersion.isNullOrBlank() && + recordedRuntimeVersion == expectedRuntimeVersion && + hash !in ledger.quarantinedHashes && + hash !in ledger.revokedHashes && + currentHash() == hash && + BundleDropOtaResolver.readBundleRuntimeVersion(bundleDropRoot, hash) == expectedRuntimeVersion + } + + private fun readLedgerWithLegacyImport(): Ledger { + val file = ledgerFile() + val ledger = if (!file.baseFile.exists() && !File(file.baseFile.path + ".new").exists()) { + Ledger(binaryIdentity = binaryIdentity) + } else { + try { + ledgerFromJson(JSONObject(String(file.readFully(), Charsets.UTF_8))) + } catch (error: Exception) { + throw CorruptLedgerException(error) + } + } + if (ledger.binaryIdentity != binaryIdentity) throw IncompatibleLedgerException() + if (ledger.legacyStateImported) return ledger + + val legacy = readLegacyState() + val imported = ledger.copy( + quarantinedHashes = ledger.quarantinedHashes + legacy, + legacyStateImported = true, + ) + return persist(imported) + } + + private fun readLegacyState(): Set { + return try { + val stateFile = File(bundleDropRoot, LEGACY_STATE) + if (!stateFile.exists()) return emptySet() + val json = JSONObject(stateFile.readText()) + val failedBundles = json.optJSONObject("failedBundles") + val failed = mutableListOf() + failedBundles?.keys()?.forEach { hash -> + if (isValidHash(hash)) { + val timestamp = failedBundles.optJSONObject(hash) + ?.optDouble("failedAt", Double.NaN) + ?.takeIf { it.isFinite() && it >= 0 } + failed.add(LegacyFailedHash(hash, timestamp)) + } + } + failed.sortedWith( + compareByDescending { it.failedAt != null } + .thenByDescending { it.failedAt ?: Double.NEGATIVE_INFINITY } + .thenBy { it.hash }, + ).take(MAX_LEGACY_FAILED_HASHES).mapTo(linkedSetOf()) { it.hash } + } catch (_: Exception) { + emptySet() + } + } + + private data class LegacyFailedHash(val hash: String, val failedAt: Double?) + + private fun persist(ledger: Ledger, replaceExisting: Boolean = false): Ledger { + failpoint(FAIL_BEFORE_LEDGER_COMMIT) + if (!replaceExisting) { + val diskRevision = readPersistedRevision() + check(diskRevision == ledger.revision) { + "Startup recovery ledger changed before commit " + + "(expected revision ${ledger.revision}, found $diskRevision)" + } + } + val next = ledger.copy(revision = ledger.revision + 1) + writeAtomic(ledgerFile(), ledgerJson(next).toString()) + return next + } + + private fun readPersistedRevision(): Long { + val file = ledgerFile() + if (!file.baseFile.exists() && !File(file.baseFile.path + ".new").exists()) return 0 + return JSONObject(String(file.readFully(), Charsets.UTF_8)).getLong("revision") + } + + private fun ledgerJson(ledger: Ledger): JSONObject { + val json = JSONObject() + .put("schemaVersion", PROTOCOL_VERSION) + .put("revision", ledger.revision) + .put("binaryIdentity", ledger.binaryIdentity) + .put("phase", ledger.phase) + .put("quarantinedHashes", JSONArray(ledger.quarantinedHashes.sorted())) + .put("revokedHashes", JSONArray(ledger.revokedHashes.sorted())) + .put("pendingRecoveryEvents", JSONArray(ledger.pendingRecoveryEvents.map(::eventJson))) + .put("legacyStateImported", ledger.legacyStateImported) + .put("rollbackCrashCount", ledger.rollbackCrashCount) + ledger.reservedAttemptId?.let { json.put("reservedAttemptId", it) } + ledger.candidateHash?.let { json.put("candidateHash", it) } + ledger.candidateRuntimeVersion?.let { json.put("candidateRuntimeVersion", it) } + ledger.stableHash?.let { json.put("stableHash", it) } + ledger.stableRuntimeVersion?.let { json.put("stableRuntimeVersion", it) } + ledger.previousStableHash?.let { json.put("previousStableHash", it) } + ledger.previousStableRuntimeVersion?.let { json.put("previousStableRuntimeVersion", it) } + ledger.rollbackFailedHash?.let { json.put("rollbackFailedHash", it) } + ledger.rollbackReason?.let { json.put("rollbackReason", it) } + ledger.lastHealthyAttemptId?.let { json.put("lastHealthyAttemptId", it) } + ledger.policy?.let { + json.put("policy", JSONObject() + .put("maxCrashCount", it.maxCrashCount) + .put("healthCheckMode", it.healthCheckMode) + .put("healthyAfterSec", it.healthyAfterSec)) + } + ledger.activeAttempt?.let { + json.put("activeAttempt", attemptJson(it).put("processToken", it.processToken)) + } + return json + } + + private fun ledgerFromJson(json: JSONObject): Ledger { + if (json.getInt("schemaVersion") != PROTOCOL_VERSION) { + throw IllegalArgumentException("Unsupported startup recovery schema") + } + val policyJson = json.optionalObject("policy") + val policy = policyJson?.let { + RecoveryPolicy( + maxCrashCount = it.getInt("maxCrashCount"), + healthCheckMode = it.getString("healthCheckMode"), + healthyAfterSec = it.getDouble("healthyAfterSec"), + ) + } + val attemptJson = json.optionalObject("activeAttempt") + val attempt = attemptJson?.let { + ActiveAttempt( + hash = it.getString("hash"), + attemptId = it.getString("attemptId"), + processToken = it.getString("processToken"), + unacknowledgedLaunchCount = it.getInt("unacknowledgedLaunchCount"), + ) + } + val events = json.requiredObjectList("pendingRecoveryEvents").map { + require(it.getString("reason") == ROLLBACK_CRASH_LOOP) { + "Invalid startup recovery event reason" + } + RecoveryEvent( + id = it.getString("id"), + failedHash = it.getString("failedHash"), + recoveryTarget = it.getString("recoveryTarget"), + recoveredHash = it.optionalString("recoveredHash"), + crashCount = it.getInt("crashCount"), + failedAt = it.getLong("failedAt"), + ) + } + return Ledger( + revision = json.getLong("revision"), + binaryIdentity = json.getString("binaryIdentity"), + phase = json.getString("phase"), + candidateHash = json.optionalString("candidateHash"), + candidateRuntimeVersion = json.optionalString("candidateRuntimeVersion"), + stableHash = json.optionalString("stableHash"), + stableRuntimeVersion = json.optionalString("stableRuntimeVersion"), + previousStableHash = json.optionalString("previousStableHash"), + previousStableRuntimeVersion = json.optionalString("previousStableRuntimeVersion"), + policy = policy, + reservedAttemptId = json.optionalString("reservedAttemptId"), + activeAttempt = attempt, + lastHealthyAttemptId = json.optionalString("lastHealthyAttemptId"), + quarantinedHashes = json.requiredHashSet("quarantinedHashes"), + revokedHashes = json.requiredHashSet("revokedHashes"), + pendingRecoveryEvents = events, + legacyStateImported = json.getBoolean("legacyStateImported"), + rollbackFailedHash = json.optionalString("rollbackFailedHash"), + rollbackCrashCount = json.getInt("rollbackCrashCount"), + rollbackReason = json.optionalString("rollbackReason"), + ).also(::validateLedger) + } + + private fun validateLedger(ledger: Ledger) { + require(ledger.revision >= 0) { "Invalid startup recovery revision" } + require(!ledger.binaryIdentity.isNullOrBlank()) { "Startup recovery ledger is missing its binary identity" } + require(ledger.phase in setOf( + PHASE_IDLE, + PHASE_ARMED, + PHASE_LAUNCHING, + PHASE_STABLE, + PHASE_ROLLBACK_REQUIRED, + PHASE_RECOVERED, + )) { "Invalid startup recovery phase" } + ledger.candidateHash?.let(::requireHash) + ledger.stableHash?.let(::requireHash) + ledger.previousStableHash?.let(::requireHash) + ledger.rollbackFailedHash?.let(::requireHash) + ledger.activeAttempt?.let { + requireHash(it.hash) + require(it.attemptId.isNotBlank()) { "Active startup attempt is missing its ID" } + require(it.processToken.isNotBlank()) { "Active startup attempt is missing its process token" } + require(it.unacknowledgedLaunchCount >= 0) { "Invalid startup launch count" } + } + ledger.policy?.let { + require(it.maxCrashCount >= 0) { "Invalid startup crash limit" } + require(it.healthCheckMode == HEALTH_AUTO || it.healthCheckMode == HEALTH_MANUAL) { + "Invalid startup health mode" + } + require(it.healthyAfterSec.isFinite() && it.healthyAfterSec >= 0) { + "Invalid startup health delay" + } + } + if (ledger.phase == PHASE_ARMED && (ledger.policy?.maxCrashCount ?: 0) > 0) { + require(ledger.candidateHash != null && !ledger.reservedAttemptId.isNullOrBlank()) { + "Armed startup candidate is missing its reserved attempt ID" + } + } + if (ledger.phase == PHASE_LAUNCHING) { + require(ledger.activeAttempt != null && ledger.reservedAttemptId == null) { + "Launching startup candidate has invalid attempt state" + } + } + require(ledger.rollbackReason == null || ledger.rollbackReason in setOf( + ROLLBACK_CRASH_LOOP, + ROLLBACK_REVOKED, + )) { "Invalid startup rollback reason" } + require(ledger.rollbackCrashCount >= 0) { "Invalid startup rollback crash count" } + ledger.pendingRecoveryEvents.forEach { event -> + require(event.id.isNotBlank()) { "Startup recovery event is missing its ID" } + requireHash(event.failedHash) + require(event.crashCount >= 0) { "Invalid startup recovery event crash count" } + require(event.failedAt >= 0) { "Invalid startup recovery event timestamp" } + require( + (event.recoveryTarget == RECOVERY_EMBEDDED && event.recoveredHash == null) || + (event.recoveryTarget == RECOVERY_PREVIOUS && + event.recoveredHash?.let(::isValidHash) == true) + ) { "Invalid startup recovery event target" } + } + } + + private fun attemptJson(attempt: ActiveAttempt): JSONObject = JSONObject() + .put("hash", attempt.hash) + .put("attemptId", attempt.attemptId) + .put("status", PHASE_LAUNCHING) + .put("unacknowledgedLaunchCount", attempt.unacknowledgedLaunchCount) + + private fun eventJson(event: RecoveryEvent): JSONObject { + val json = JSONObject() + .put("id", event.id) + .put("failedHash", event.failedHash) + .put("recoveryTarget", event.recoveryTarget) + .put("crashCount", event.crashCount) + .put("reason", "crash_loop") + .put("failedAt", event.failedAt) + event.recoveredHash?.let { json.put("recoveredHash", it) } + return json + } + + private fun externalPhase(phase: String): String = when (phase) { + PHASE_ROLLBACK_REQUIRED -> PHASE_LAUNCHING + else -> phase + } + + private fun currentHash(): String? = BundleDropOtaResolver.readCurrentPointer(bundleDropRoot) + ?.let(::hashFromBundlePath) + + private fun readPointerHash(name: String): String? { + return try { + val pointer = File(bundleDropRoot, name) + if (!pointer.isFile) return null + JSONObject(pointer.readText()).optString("hash", "").takeIf(::isValidHash) + } catch (_: Exception) { + null + } + } + + private fun hashFromBundlePath(path: String): String? = File(path).parentFile?.name?.takeIf(::isValidHash) + + private fun writePointer(name: String, hash: String) { + requireHash(hash) + bundleDropRoot.mkdirs() + val json = JSONObject() + .put("hash", hash) + .put("updatedAt", isoTimestamp(nowMillis())) + writeAtomic(AtomicFile(File(bundleDropRoot, name)), json.toString()) + } + + private fun deletePointer(name: String) { + AtomicFile(File(bundleDropRoot, name)).delete() + } + + private fun ledgerFile(): AtomicFile = AtomicFile(File(bundleDropRoot, RECOVERY_LEDGER)) + + private fun isoTimestamp(timestampMillis: Long): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(timestampMillis)) + + private fun writeAtomic(file: AtomicFile, content: String) { + file.baseFile.parentFile?.mkdirs() + var stream: FileOutputStream? = null + try { + stream = file.startWrite() + stream.write(content.toByteArray(Charsets.UTF_8)) + file.finishWrite(stream) + } catch (error: Exception) { + if (stream != null) file.failWrite(stream) + throw error + } + } + + private fun JSONObject.optionalObject(key: String): JSONObject? { + if (!has(key) || isNull(key)) return null + return getJSONObject(key) + } + + private fun JSONObject.optionalString(key: String): String? { + if (!has(key) || isNull(key)) return null + return getString(key) + } + + private fun JSONObject.requiredHashSet(key: String): Set { + val array = getJSONArray(key) + val values = linkedSetOf() + for (index in 0 until array.length()) { + val value = array.getString(index) + requireHash(value) + require(values.add(value)) { "Startup recovery ledger contains duplicate $key entries" } + } + return values + } + + private fun JSONObject.requiredObjectList(key: String): List { + val array = getJSONArray(key) + return (0 until array.length()).map(array::getJSONObject) + } + + private fun requireHash(hash: String) { + require(isValidHash(hash)) { "Bundle hash must be a canonical lowercase SHA-256" } + } + + private fun isValidHash(hash: String): Boolean = BUNDLE_HASH.matches(hash) + + private class CorruptLedgerException(cause: Throwable) : Exception(cause) + private class IncompatibleLedgerException : Exception() + + companion object { + const val PROTOCOL_VERSION = 1 + const val RECOVERY_LEDGER = "startup-recovery.json" + const val FAIL_AFTER_ARMED = "after_armed" + const val FAIL_AFTER_VERIFICATION = "after_verification" + const val FAIL_BEFORE_LEDGER_COMMIT = "before_ledger_commit" + const val FAIL_AFTER_PREVIOUS_POINTER = "after_previous_pointer" + const val FAIL_AFTER_CURRENT_POINTER = "after_current_pointer" + const val FAIL_AFTER_LAUNCH_PERSISTED = "after_launch_persisted" + const val FAIL_AFTER_HEALTH_COMMITTED = "after_health_committed" + const val FAIL_AFTER_ROLLBACK_REQUIRED = "after_rollback_required" + const val FAIL_AFTER_RECOVERY_POINTER = "after_recovery_pointer" + const val FAIL_AFTER_RECOVERED = "after_recovered" + + private const val CURRENT_POINTER = "current.json" + private const val PREVIOUS_POINTER = "previous.json" + private const val LEGACY_STATE = "state.json" + private const val MAX_PENDING_EVENTS = 20 + private const val MAX_LEGACY_FAILED_HASHES = 20 + private const val HEALTH_AUTO = "auto" + private const val HEALTH_MANUAL = "manual" + private const val PHASE_IDLE = "idle" + private const val PHASE_ARMED = "armed" + private const val PHASE_LAUNCHING = "launching" + private const val PHASE_STABLE = "stable" + private const val PHASE_ROLLBACK_REQUIRED = "rollback_required" + private const val PHASE_RECOVERED = "recovered" + private const val RECOVERY_PREVIOUS = "previous" + private const val RECOVERY_EMBEDDED = "embedded" + private const val ROLLBACK_CRASH_LOOP = "crash_loop" + private const val ROLLBACK_REVOKED = "revoked" + private val BUNDLE_HASH = Regex("^[a-f0-9]{64}$") + private val STORAGE_LOCK = Any() + } +} + +/** Process-scoped facade used by cold-start resolution and the React Native bridge. */ +internal object BundleDropStartupRecovery { + private val processToken = UUID.randomUUID().toString() + + @Volatile private var startupAttemptHash: String? = null + @Volatile private var startupAttemptId: String? = null + @Volatile private var startupSelectedHash: String? = null + @Volatile private var contentListenerInstalled = false + @Volatile private var scheduledHealthAttemptKey: String? = null + + fun controller(context: Context): BundleDropStartupRecoveryController = + BundleDropStartupRecoveryController( + bundleDropRoot = File(context.filesDir, "bundle-drop"), + processToken = processToken, + binaryIdentity = BundleDropNativePaths.currentBinaryIdentity(context), + expectedRuntimeVersion = BundleDropNativePaths.readEmbeddedRuntimeVersion(context), + ) + + fun selectForStartup( + context: Context, + select: () -> BundleDropStartupRecoveryController.StartupSelection = { + controller(context).selectForStartup() + }, + ): BundleDropStartupRecoveryController.StartupSelection { + val selection = try { + select() + } catch (error: Exception) { + Log.e("BundleDrop", "Startup recovery failed closed to the embedded bundle", error) + BundleDropStartupRecoveryController.StartupSelection(null) + } + startupAttemptHash = selection.attemptHash + startupAttemptId = selection.attemptId + startupSelectedHash = selection.bundlePath + ?.let(::File) + ?.parentFile + ?.name + ?.takeIf { it.matches(Regex("^[a-f0-9]{64}$")) } + if (selection.attemptId != null) installContentAppearedListener(context) + return selection + } + + fun startupAttempt(): Pair? { + val hash = startupAttemptHash ?: return null + val id = startupAttemptId ?: return null + return hash to id + } + + fun startupSelectedHash(): String? = startupSelectedHash + + fun clearStartupSelection() { + startupAttemptHash = null + startupAttemptId = null + startupSelectedHash = null + } + + internal fun scheduleContentAppearedHealthOnce( + hash: String, + attemptId: String, + schedule: () -> Unit, + ): Boolean = synchronized(this) { + val attemptKey = "$hash:$attemptId" + if (scheduledHealthAttemptKey == attemptKey) return@synchronized false + scheduledHealthAttemptKey = attemptKey + schedule() + true + } + + private fun installContentAppearedListener(context: Context) { + if (contentListenerInstalled) return + synchronized(this) { + if (contentListenerInstalled) return + ReactMarker.addListener(object : ReactMarker.MarkerListener { + override fun logMarker(name: ReactMarkerConstants, tag: String?, instanceKey: Int) { + if (name == ReactMarkerConstants.CONTENT_APPEARED) { + val (hash, attemptId) = startupAttempt() ?: return + scheduleContentAppearedHealthOnce(hash, attemptId) { + controller(context).scheduleContentAppearedHealth() + } + } + } + }) + contentListenerInstalled = true + } + } +} diff --git a/android/src/test/java/com/bundledrop/BundleDropNativePathsTest.kt b/android/src/test/java/com/bundledrop/BundleDropNativePathsTest.kt index c736f75..305e7cd 100644 --- a/android/src/test/java/com/bundledrop/BundleDropNativePathsTest.kt +++ b/android/src/test/java/com/bundledrop/BundleDropNativePathsTest.kt @@ -12,6 +12,8 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.io.File +import org.json.JSONArray +import org.json.JSONObject @RunWith(RobolectricTestRunner::class) @Config(sdk = [28]) @@ -28,6 +30,13 @@ class BundleDropNativePathsTest { @Test fun `getDownloadedBundlePath returns null when OTA disabled even if current json points at bundle`() { val ctx = ApplicationProvider.getApplicationContext() + val selectedHash = "a".repeat(64) + BundleDropStartupRecovery.selectForStartup(ctx) { + BundleDropStartupRecoveryController.StartupSelection( + File(ctx.filesDir, "bundle-drop/bundles/$selectedHash/main.jsbundle").absolutePath, + ) + } + assertEquals(selectedHash, BundleDropStartupRecovery.startupSelectedHash()) BundleDropOtaPrefs.writeOtaEnabled(ctx, false) val root = File(ctx.filesDir, "bundle-drop") @@ -43,6 +52,7 @@ class BundleDropNativePathsTest { ) assertNull(BundleDropNativePaths.getDownloadedBundlePath(ctx)) + assertNull(BundleDropStartupRecovery.startupSelectedHash()) } @Test @@ -66,6 +76,8 @@ class BundleDropNativePathsTest { val ctx = ApplicationProvider.getApplicationContext() val root = File(ctx.filesDir, "bundle-drop").apply { mkdirs() } val currentPointer = File(root, "current.json").apply { writeText("stale OTA pointer") } + val recoveryLedger = File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER) + .apply { writeText("stale recovery identity") } BundleDropOtaPrefs.preferences(ctx).edit() .putString("binary_version", "runtime:runtime-1|binary:1.2.3-8") .commit() @@ -73,8 +85,55 @@ class BundleDropNativePathsTest { assertNull(BundleDropNativePaths.getDownloadedBundlePath(ctx, "runtime-2")) assertFalse(currentPointer.exists()) + assertTrue(recoveryLedger.exists()) + val resetLedger = JSONObject(recoveryLedger.readText()) + assertEquals("idle", resetLedger.getString("phase")) + assertEquals(BundleDropNativePaths.currentBinaryIdentity(ctx), resetLedger.getString("binaryIdentity")) val storedVersion = BundleDropOtaPrefs.preferences(ctx) .getString("binary_version", null) assertTrue(storedVersion?.startsWith("runtime:runtime-2|binary:") == true) } + + @Test + fun `startup controller completes pointerless rollback required after compatibility resolution`() { + val ctx = ApplicationProvider.getApplicationContext() + val root = File(ctx.filesDir, "bundle-drop").apply { mkdirs() } + val failedHash = "a".repeat(64) + val binaryIdentity = BundleDropNativePaths.currentBinaryIdentity(ctx) + File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER).writeText( + JSONObject() + .put("schemaVersion", BundleDropStartupRecoveryController.PROTOCOL_VERSION) + .put("revision", 4) + .put("binaryIdentity", binaryIdentity) + .put("phase", "rollback_required") + .put("quarantinedHashes", JSONArray()) + .put("revokedHashes", JSONArray()) + .put("pendingRecoveryEvents", JSONArray()) + .put("legacyStateImported", true) + .put("rollbackFailedHash", failedHash) + .put("rollbackCrashCount", 2) + .put("rollbackReason", "crash_loop") + .toString(), + ) + BundleDropOtaPrefs.writeOtaEnabled(ctx, true) + BundleDropOtaPrefs.preferences(ctx).edit() + .putString("binary_version", binaryIdentity) + .commit() + + assertNull(BundleDropNativePaths.getDownloadedBundlePath( + ctx, + BundleDropNativePaths.readEmbeddedRuntimeVersion(ctx), + )) + + val recovered = JSONObject( + File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER).readText(), + ) + assertEquals("recovered", recovered.getString("phase")) + assertEquals(5, recovered.getLong("revision")) + assertEquals(failedHash, recovered.getJSONArray("quarantinedHashes").getString(0)) + val event = recovered.getJSONArray("pendingRecoveryEvents").getJSONObject(0) + assertEquals(failedHash, event.getString("failedHash")) + assertEquals("embedded", event.getString("recoveryTarget")) + assertEquals(2, event.getInt("crashCount")) + } } diff --git a/android/src/test/java/com/bundledrop/BundleDropOtaResolverTest.kt b/android/src/test/java/com/bundledrop/BundleDropOtaResolverTest.kt index f6ee3b2..a9a550c 100644 --- a/android/src/test/java/com/bundledrop/BundleDropOtaResolverTest.kt +++ b/android/src/test/java/com/bundledrop/BundleDropOtaResolverTest.kt @@ -38,6 +38,20 @@ class BundleDropOtaResolverTest { assertEquals(bundleFile.absolutePath, result) } + @Test + fun `readBundleForHash verifies an installed bundle without trusting a pointer`() { + val root = tempFolder.newFolder("bundle-drop-by-hash") + val bundleFile = makeBundle(root) + + assertEquals(bundleFile.absolutePath, BundleDropOtaResolver.readBundleForHash(root, validHash)) + assertEquals("1.0.0", BundleDropOtaResolver.readBundleRuntimeVersion(root, validHash)) + assertNull(BundleDropOtaResolver.readBundleForHash(root, "not-a-hash")) + assertNull(BundleDropOtaResolver.readBundleRuntimeVersion(root, "a".repeat(64))) + + File(bundleFile.parentFile, "main.jsbundle").delete() + assertNull(BundleDropOtaResolver.readBundleForHash(root, validHash)) + } + @Test fun `readCurrentPointer rejects old manifest version hash domain`() { val root = tempFolder.newFolder("bundle-drop") diff --git a/android/src/test/java/com/bundledrop/BundleDropStartupRecoveryTest.kt b/android/src/test/java/com/bundledrop/BundleDropStartupRecoveryTest.kt new file mode 100644 index 0000000..493bd7a --- /dev/null +++ b/android/src/test/java/com/bundledrop/BundleDropStartupRecoveryTest.kt @@ -0,0 +1,1107 @@ +package com.bundledrop + +import android.os.Handler +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class BundleDropStartupRecoveryTest { + @get:Rule val tempFolder = TemporaryFolder() + + private val binaryIdentity = "runtime:runtime-1|binary:1.0-1" + + @Test + fun `activation imports only legacy failed hashes and writes only proven previous pointers`() { + val root = tempFolder.newFolder("bundle-drop") + val filesDir = root.parentFile!! + val unprovenHash = writeBundle(root, "unproven") + val failedHash = "f".repeat(64) + val candidateHash = writeBundle(root, "candidate") + File(root, "current.json").writeText("""{"hash":"$unprovenHash"}""") + File(root, "state.json").writeText( + """{"lastGoodHash":"$unprovenHash","failedBundles":{"$failedHash":{"reason":"crash_loop"}}}""", + ) + + controller(root, filesDir, "activation", now = 1_000) + .activateCandidate(candidateHash, 3, "manual", 10.0) + + assertEquals(candidateHash, pointerHash(root, "current.json")) + assertFalse(File(root, "previous.json").exists()) + val pointerTimestamp = JSONObject(File(root, "current.json").readText()).getString("updatedAt") + assertTrue(pointerTimestamp.matches(Regex("\\d{4}-\\d{2}-\\d{2}T.*Z"))) + val snapshot = controller(root, filesDir, "snapshot").snapshot() + assertFalse(snapshot.has("stableHash")) + assertEquals(listOf(failedHash), jsonStrings(snapshot, "quarantinedHashes")) + } + + @Test + fun `legacy failed hash import keeps only the twenty newest valid records`() { + val root = tempFolder.newFolder("legacy-cap-root") + val filesDir = root.parentFile!! + val failedBundles = JSONObject() + val timestampedHashes = (0 until 22).map { index -> + index.toString(16).padStart(64, '0').also { hash -> + failedBundles.put(hash, JSONObject().put("failedAt", 1_000 + index)) + } + } + failedBundles.put("e".repeat(64), JSONObject()) + failedBundles.put("f".repeat(64), JSONObject().put("failedAt", "invalid")) + failedBundles.put("not-a-hash", JSONObject().put("failedAt", Long.MAX_VALUE)) + File(root, "state.json").writeText( + JSONObject() + .put("failedBundles", failedBundles) + .put("lastGoodHash", "d".repeat(64)) + .put("candidateHash", "c".repeat(64)) + .put("crashCount", 99) + .toString(), + ) + + val imported = controller(root, filesDir, "importer").snapshot() + val quarantined = jsonStrings(imported, "quarantinedHashes").toSet() + + assertEquals(20, quarantined.size) + assertEquals(timestampedHashes.drop(2).toSet(), quarantined) + assertFalse("d".repeat(64) in quarantined) + assertFalse("c".repeat(64) in quarantined) + assertFalse("e".repeat(64) in quarantined) + assertFalse("f".repeat(64) in quarantined) + } + + @Test + fun `attempt ids are process scoped and health requires both hash and id`() { + val root = tempFolder.newFolder("attempt-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val firstProcess = controller(root, filesDir, "process-1") + firstProcess.activateCandidate(hash, 2, "manual", 0.0) + + val first = firstProcess.selectForStartup() + val duplicate = firstProcess.selectForStartup() + assertEquals(first.attemptId, duplicate.attemptId) + assertFalse(firstProcess.markHealthy(hash, "wrong-attempt")) + assertFalse(firstProcess.markHealthy("a".repeat(64), first.attemptId!!)) + assertTrue(firstProcess.markHealthy(hash, first.attemptId!!)) + assertTrue(firstProcess.markHealthy(hash, first.attemptId!!)) + assertEquals("stable", firstProcess.snapshot().getString("phase")) + } + + @Test + fun `healthy bundle remains stable across launches and duplicate health revalidates current`() { + val root = tempFolder.newFolder("healthy-stable-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val controller = controller(root, filesDir, "first-process", ids = ids("attempt-1")) + controller.activateCandidate(hash, 2, "manual", 0.0) + val attempt = controller.selectForStartup() + assertTrue(controller.markHealthy(hash, attempt.attemptId!!)) + + val stableSelection = controller(root, filesDir, "next-process").selectForStartup() + assertEquals(bundlePath(root, hash), stableSelection.bundlePath) + assertNull(stableSelection.attemptId) + val stableState = controller(root, filesDir, "stable-state").snapshot() + assertEquals("stable", stableState.getString("phase")) + assertFalse(stableState.has("candidateHash")) + assertTrue(controller.markHealthy(hash, attempt.attemptId!!)) + + val otherHash = writeBundle(root, "other") + File(root, "current.json").writeText("""{"hash":"$otherHash"}""") + assertFalse(controller.markHealthy(hash, attempt.attemptId!!)) + } + + @Test + fun `health commit rejects missing bundle revoked candidate and stale binary identity`() { + val missingRoot = tempFolder.newFolder("health-missing-root") + val missingFilesDir = missingRoot.parentFile!! + val missingHash = writeBundle(missingRoot, "candidate") + val missing = controller(missingRoot, missingFilesDir, "process", ids = ids("attempt-missing")) + missing.activateCandidate(missingHash, 2, "manual", 0.0) + val missingAttempt = missing.selectForStartup() + File(missingRoot, "bundles/$missingHash/main.jsbundle").delete() + assertFalse(missing.markHealthy(missingHash, missingAttempt.attemptId!!)) + + val revokedRoot = tempFolder.newFolder("health-revoked-root") + val revokedFilesDir = revokedRoot.parentFile!! + val revokedHash = writeBundle(revokedRoot, "candidate") + val revoked = controller(revokedRoot, revokedFilesDir, "process", ids = ids("attempt-revoked")) + revoked.activateCandidate(revokedHash, 2, "manual", 0.0) + val revokedAttempt = revoked.selectForStartup() + revoked.setRevokedHashes(setOf(revokedHash)) + assertFalse(revoked.markHealthy(revokedHash, revokedAttempt.attemptId!!)) + + val identityRoot = tempFolder.newFolder("health-identity-root") + val identityFilesDir = identityRoot.parentFile!! + val identityHash = writeBundle(identityRoot, "candidate") + val original = controller(identityRoot, identityFilesDir, "process", ids = ids("attempt-identity")) + original.activateCandidate(identityHash, 2, "manual", 0.0) + val identityAttempt = original.selectForStartup() + assertFalse( + controller(identityRoot, identityFilesDir, "new-binary", identity = "different-binary") + .markHealthy(identityHash, identityAttempt.attemptId!!), + ) + } + + @Test + fun `unknown current pointer is passive-ineligible and fails startup closed`() { + val root = tempFolder.newFolder("unknown-current-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "unproven") + File(root, "current.json").writeText("""{"hash":"$hash"}""") + val controller = controller(root, filesDir, "process") + controller.snapshot() + + assertNull(controller.resolvePassive()) + assertTrue(File(root, "current.json").exists()) + assertNull(controller.selectForStartup().bundlePath) + assertFalse(File(root, "current.json").exists()) + } + + @Test + fun `same hash activation preserves stable state and an active attempt count`() { + val root = tempFolder.newFolder("same-hash-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val first = controller(root, filesDir, "first", ids = ids("attempt-1")) + first.activateCandidate(hash, 3, "manual", 0.0) + val firstAttempt = first.selectForStartup() + assertTrue(first.markHealthy(hash, firstAttempt.attemptId!!)) + val stableRevision = first.snapshot().getLong("revision") + + first.activateCandidate(hash, 5, "auto", 9.0) + assertEquals(stableRevision, first.snapshot().getLong("revision")) + assertNull(controller(root, filesDir, "stable-launch").selectForStartup().attemptId) + + val nextHash = writeBundle(root, "next-candidate") + first.activateCandidate(nextHash, 3, "manual", 0.0) + first.selectForStartup() + val retryProcess = controller(root, filesDir, "retry", ids = ids("attempt-2")) + val retryAttempt = retryProcess.selectForStartup() + val countBefore = retryProcess.snapshot().getJSONObject("activeAttempt") + .getInt("unacknowledgedLaunchCount") + + retryProcess.activateCandidate(nextHash, 5, "manual", 4.0) + + val after = retryProcess.snapshot().getJSONObject("activeAttempt") + assertEquals(retryAttempt.attemptId, after.getString("attemptId")) + assertEquals(countBefore, after.getInt("unacknowledgedLaunchCount")) + assertTrue(retryProcess.markHealthy(nextHash, retryAttempt.attemptId!!)) + } + + @Test + fun `failed candidate recovers before React and preserves metadata for JS reconciliation`() { + val root = tempFolder.newFolder("previous-root") + val filesDir = root.parentFile!! + val stableHash = writeBundle(root, "stable") + val failedHash = writeBundle(root, "failed") + val installer = controller(root, filesDir, "installer", ids = ids("stable-attempt", "event-1")) + + installer.activateCandidate(stableHash, 1, "manual", 0.0) + val stableAttempt = installer.selectForStartup() + assertTrue(installer.markHealthy(stableHash, stableAttempt.attemptId!!)) + installer.activateCandidate(failedHash, 1, "manual", 0.0) + assertEquals(stableHash, pointerHash(root, "previous.json")) + File(filesDir, "bundle-info.json").writeText("stale failed metadata") + + controller(root, filesDir, "bad-process", ids = ids("bad-attempt")).selectForStartup() + val recovered = controller(root, filesDir, "recovery-process", ids = ids("recovery-event")) + .selectForStartup() + + assertEquals(bundlePath(root, stableHash), recovered.bundlePath) + assertEquals(stableHash, pointerHash(root, "current.json")) + assertEquals("stale failed metadata", File(filesDir, "bundle-info.json").readText()) + val event = controller(root, filesDir, "snapshot").snapshot() + .getJSONArray("pendingRecoveryEvents").getJSONObject(0) + assertEquals(failedHash, event.getString("failedHash")) + assertEquals("previous", event.getString("recoveryTarget")) + assertEquals(stableHash, event.getString("recoveredHash")) + assertEquals(1, event.getInt("crashCount")) + assertEquals("crash_loop", event.getString("reason")) + val eventId = event.getString("id") + val acknowledger = controller(root, filesDir, "acknowledger") + assertFalse(acknowledger.acknowledgeRecovery("unknown")) + assertTrue(acknowledger.acknowledgeRecovery(eventId)) + assertEquals(0, acknowledger.snapshot().getJSONArray("pendingRecoveryEvents").length()) + } + + @Test + fun `failed candidate falls back to embedded when no proven bundle exists`() { + val root = tempFolder.newFolder("embedded-root") + val filesDir = root.parentFile!! + val failedHash = writeBundle(root, "failed") + controller(root, filesDir, "installer").activateCandidate(failedHash, 1, "manual", 0.0) + controller(root, filesDir, "bad-process").selectForStartup() + + val recovered = controller(root, filesDir, "recovery-process").selectForStartup() + + assertNull(recovered.bundlePath) + assertFalse(File(root, "current.json").exists()) + val snapshot = controller(root, filesDir, "snapshot").snapshot() + assertEquals(listOf(failedHash), jsonStrings(snapshot, "quarantinedHashes")) + assertEquals( + "embedded", + snapshot.getJSONArray("pendingRecoveryEvents").getJSONObject(0).getString("recoveryTarget"), + ) + } + + @Test + fun `corrupt recovery ledger fails closed to embedded`() { + val root = tempFolder.newFolder("corrupt-ledger-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "installer").activateCandidate(hash, 2, "manual", 0.0) + File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER).writeText("{\"revision\":") + + val selected = controller(root, filesDir, "startup").selectForStartup() + + assertNull(selected.bundlePath) + assertNull(selected.attemptId) + assertFalse(File(root, "current.json").exists()) + val snapshot = controller(root, filesDir, "snapshot").snapshot() + assertEquals(listOf(hash), jsonStrings(snapshot, "quarantinedHashes")) + assertEquals(0, snapshot.getJSONArray("pendingRecoveryEvents").length()) + } + + @Test + fun `malformed revoked hashes cannot erase a persisted revocation`() { + listOf("missing", "wrong-type", "invalid-element", "duplicate").forEach { corruption -> + val root = tempFolder.newFolder("corrupt-revocations-$corruption") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "stable-$corruption") + val installer = controller(root, filesDir, "installer-$corruption") + installer.activateCandidate(hash, 2, "manual", 0.0) + val attempt = installer.selectForStartup() + assertTrue(installer.markHealthy(hash, attempt.attemptId!!)) + installer.setRevokedHashes(setOf(hash)) + + val ledgerFile = File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER) + val ledger = JSONObject(ledgerFile.readText()) + when (corruption) { + "missing" -> ledger.remove("revokedHashes") + "wrong-type" -> ledger.put("revokedHashes", "not-an-array") + "invalid-element" -> ledger.put("revokedHashes", JSONArray().put(123)) + "duplicate" -> ledger.put("revokedHashes", JSONArray().put(hash).put(hash)) + } + ledgerFile.writeText(ledger.toString()) + + val selected = controller(root, filesDir, "restart-$corruption").selectForStartup() + + assertNull(corruption, selected.bundlePath) + assertFalse(corruption, File(root, "current.json").exists()) + assertEquals( + corruption, + listOf(hash), + jsonStrings(controller(root, filesDir, "snapshot-$corruption").snapshot(), "quarantinedHashes"), + ) + } + } + + @Test + fun `malformed quarantine and event collections fail closed to embedded`() { + listOf( + "missing-quarantine", + "wrong-quarantine", + "invalid-quarantine", + "duplicate-quarantine", + "missing-events", + "wrong-events", + "scalar-event", + "invalid-event", + ).forEach { corruption -> + val root = tempFolder.newFolder("corrupt-collections-$corruption") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "stable-$corruption") + val installer = controller(root, filesDir, "installer-$corruption") + installer.activateCandidate(hash, 2, "manual", 0.0) + val attempt = installer.selectForStartup() + assertTrue(installer.markHealthy(hash, attempt.attemptId!!)) + + val ledgerFile = File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER) + val ledger = JSONObject(ledgerFile.readText()) + when (corruption) { + "missing-quarantine" -> ledger.remove("quarantinedHashes") + "wrong-quarantine" -> ledger.put("quarantinedHashes", "not-an-array") + "invalid-quarantine" -> ledger.put("quarantinedHashes", JSONArray().put(123)) + "duplicate-quarantine" -> ledger.put( + "quarantinedHashes", + JSONArray().put("f".repeat(64)).put("f".repeat(64)), + ) + "missing-events" -> ledger.remove("pendingRecoveryEvents") + "wrong-events" -> ledger.put("pendingRecoveryEvents", "not-an-array") + "scalar-event" -> ledger.put("pendingRecoveryEvents", JSONArray().put("not-an-object")) + "invalid-event" -> ledger.put( + "pendingRecoveryEvents", + JSONArray().put( + JSONObject() + .put("id", "event-1") + .put("failedHash", "f".repeat(64)) + .put("recoveryTarget", "embedded") + .put("crashCount", 1) + .put("reason", "not-crash-loop") + .put("failedAt", 1), + ), + ) + } + ledgerFile.writeText(ledger.toString()) + + val selected = controller(root, filesDir, "restart-$corruption").selectForStartup() + + assertNull(corruption, selected.bundlePath) + assertFalse(corruption, File(root, "current.json").exists()) + assertEquals( + corruption, + listOf(hash), + jsonStrings(controller(root, filesDir, "snapshot-$corruption").snapshot(), "quarantinedHashes"), + ) + } + } + + @Test + fun `malformed required ledger metadata fails closed to embedded`() { + val corruptions: List Unit>> = listOf( + "unsupported-schema" to { it.put("schemaVersion", 2) }, + "missing-revision" to { it.remove("revision") }, + "negative-revision" to { it.put("revision", -1) }, + "missing-binary-identity" to { it.remove("binaryIdentity") }, + "blank-binary-identity" to { it.put("binaryIdentity", "") }, + "missing-phase" to { it.remove("phase") }, + "invalid-phase" to { it.put("phase", "unknown") }, + "missing-import-marker" to { it.remove("legacyStateImported") }, + "missing-rollback-count" to { it.remove("rollbackCrashCount") }, + "negative-rollback-count" to { it.put("rollbackCrashCount", -1) }, + "invalid-policy-type" to { it.put("policy", "not-an-object") }, + "invalid-attempt-type" to { it.put("activeAttempt", "not-an-object") }, + ) + + corruptions.forEach { (corruption, mutate) -> + val root = tempFolder.newFolder("corrupt-metadata-$corruption") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "stable-$corruption") + val installer = controller(root, filesDir, "installer-$corruption") + installer.activateCandidate(hash, 2, "manual", 0.0) + val attempt = installer.selectForStartup() + assertTrue(installer.markHealthy(hash, attempt.attemptId!!)) + + val ledgerFile = File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER) + val ledger = JSONObject(ledgerFile.readText()) + mutate(ledger) + ledgerFile.writeText(ledger.toString()) + + val selected = controller(root, filesDir, "restart-$corruption").selectForStartup() + + assertNull(corruption, selected.bundlePath) + assertFalse(corruption, File(root, "current.json").exists()) + assertEquals( + corruption, + listOf(hash), + jsonStrings(controller(root, filesDir, "snapshot-$corruption").snapshot(), "quarantinedHashes"), + ) + } + } + + @Test + fun `corrupt current pointer discards armed candidate without an attempt`() { + val root = tempFolder.newFolder("corrupt-current-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "installer").activateCandidate(hash, 2, "manual", 0.0) + File(root, "current.json").writeText("{\"hash\":") + + val selected = controller( + root, + filesDir, + process = "startup", + ids = { throw AssertionError("Corrupt current pointer must not allocate an attempt") }, + ).selectForStartup() + + assertNull(selected.bundlePath) + assertNull(selected.attemptId) + assertFalse(File(root, "current.json").exists()) + assertEquals("idle", controller(root, filesDir, "snapshot").snapshot().getString("phase")) + } + + @Test + fun `corrupt previous pointer makes crash recovery fall back to embedded`() { + val root = tempFolder.newFolder("corrupt-previous-root") + val filesDir = root.parentFile!! + val stableHash = writeBundle(root, "stable") + val failedHash = writeBundle(root, "failed") + val installer = controller(root, filesDir, "installer") + installer.activateCandidate(stableHash, 1, "manual", 0.0) + val stableAttempt = installer.selectForStartup() + installer.markHealthy(stableHash, stableAttempt.attemptId!!) + installer.activateCandidate(failedHash, 1, "manual", 0.0) + controller(root, filesDir, "failed-launch").selectForStartup() + File(root, "previous.json").writeText("{\"hash\":") + + val recovered = controller(root, filesDir, "recovery").selectForStartup() + + assertNull(recovered.bundlePath) + assertFalse(File(root, "current.json").exists()) + val snapshot = controller(root, filesDir, "snapshot").snapshot() + assertEquals(listOf(failedHash), jsonStrings(snapshot, "quarantinedHashes")) + val event = snapshot.getJSONArray("pendingRecoveryEvents").getJSONObject(0) + assertEquals(failedHash, event.getString("failedHash")) + assertEquals("embedded", event.getString("recoveryTarget")) + assertFalse(event.has("recoveredHash")) + } + + @Test + fun `candidate files removed after activation fail closed without an attempt`() { + val root = tempFolder.newFolder("missing-candidate-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "installer").activateCandidate(hash, 2, "manual", 0.0) + File(root, "bundles/$hash/main.jsbundle").delete() + + val selected = controller( + root, + filesDir, + process = "startup", + ids = { throw AssertionError("Missing candidate files must not allocate an attempt") }, + ).selectForStartup() + + assertNull(selected.bundlePath) + assertNull(selected.attemptId) + assertFalse(File(root, "current.json").exists()) + assertEquals(JSONObject.NULL, controller(root, filesDir, "snapshot").snapshot().get("activeAttempt")) + } + + @Test + fun `passive resolution does not mutate a launching attempt`() { + val root = tempFolder.newFolder("passive-launch-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val controller = controller(root, filesDir, "launch-process", ids = ids("attempt-passive")) + controller.activateCandidate(hash, 3, "manual", 0.0) + val launching = controller.selectForStartup() + val ledgerFile = File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER) + val before = ledgerFile.readText() + val beforeJson = JSONObject(before) + + assertEquals(bundlePath(root, hash), controller.resolvePassive()) + assertEquals(bundlePath(root, hash), controller.resolvePassive()) + + val after = ledgerFile.readText() + val afterJson = JSONObject(after) + assertEquals(before, after) + assertEquals(beforeJson.getLong("revision"), afterJson.getLong("revision")) + assertEquals("launching", afterJson.getString("phase")) + assertEquals(launching.attemptId, afterJson.getJSONObject("activeAttempt").getString("attemptId")) + assertEquals("launch-process", afterJson.getJSONObject("activeAttempt").getString("processToken")) + assertEquals(0, afterJson.getJSONObject("activeAttempt").getInt("unacknowledgedLaunchCount")) + } + + @Test + fun `zero crash limit never creates or retries health attempts`() { + val root = tempFolder.newFolder("disabled-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "installer").activateCandidate(hash, 0, "auto", 0.0) + + repeat(4) { index -> + val selected = controller(root, filesDir, "process-$index").selectForStartup() + assertEquals(bundlePath(root, hash), selected.bundlePath) + assertNull(selected.attemptId) + } + assertEquals(JSONObject.NULL, controller(root, filesDir, "snapshot").snapshot().get("activeAttempt")) + } + + @Test + fun `binary identity mismatch clears OTA without trusting prior stable proof`() { + val root = tempFolder.newFolder("binary-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "old-process").activateCandidate(hash, 2, "manual", 0.0) + + val selected = controller( + root, + filesDir, + process = "new-process", + identity = "runtime:runtime-2|binary:2.0-2", + ).selectForStartup() + + assertNull(selected.bundlePath) + assertFalse(File(root, "current.json").exists()) + } + + @Test + fun `activation rejects missing or mismatched embedded runtime identity`() { + val root = tempFolder.newFolder("runtime-mismatch-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + + assertThrows(IllegalArgumentException::class.java) { + controller(root, filesDir, "missing-runtime", expectedRuntime = null) + .activateCandidate(hash, 2, "manual", 0.0) + } + assertThrows(IllegalArgumentException::class.java) { + controller(root, filesDir, "wrong-runtime", expectedRuntime = "runtime-2") + .activateCandidate(hash, 2, "manual", 0.0) + } + assertFalse(File(root, "current.json").exists()) + } + + @Test + fun `verification failpoint leaves no armed candidate and activation can retry`() { + val root = tempFolder.newFolder("verification-failpoint-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val interrupted = controller(root, filesDir, "installer", failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_VERIFICATION) { + throw SimulatedProcessDeath() + } + }) + + try { + interrupted.activateCandidate(hash, 2, "manual", 0.0) + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + val beforeRetry = controller(root, filesDir, "snapshot").snapshot() + assertEquals("idle", beforeRetry.getString("phase")) + assertFalse(beforeRetry.has("candidateHash")) + assertFalse(File(root, "current.json").exists()) + + controller(root, filesDir, "retry").activateCandidate(hash, 2, "manual", 0.0) + assertEquals(hash, pointerHash(root, "current.json")) + } + + @Test + fun `launch persistence failpoint reuses the committed attempt in the same process`() { + val root = tempFolder.newFolder("launch-failpoint-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "installer", ids = ids("reserved-attempt")) + .activateCandidate(hash, 2, "manual", 0.0) + val interrupted = controller(root, filesDir, "launch-process", failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_LAUNCH_PERSISTED) { + throw SimulatedProcessDeath() + } + }) + + try { + interrupted.selectForStartup() + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + val retry = interrupted.selectForStartup() + assertEquals("reserved-attempt", retry.attemptId) + assertEquals( + "reserved-attempt", + interrupted.snapshot().getJSONObject("activeAttempt").getString("attemptId"), + ) + } + + @Test + fun `health commit failpoint leaves an idempotently healthy attempt`() { + val root = tempFolder.newFolder("health-failpoint-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val interrupted = controller(root, filesDir, "process", failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_HEALTH_COMMITTED) { + throw SimulatedProcessDeath() + } + }) + interrupted.activateCandidate(hash, 2, "manual", 0.0) + val attempt = interrupted.selectForStartup() + + try { + interrupted.markHealthy(hash, attempt.attemptId!!) + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + val retry = controller(root, filesDir, "retry") + val committedRevision = retry.snapshot().getLong("revision") + assertEquals("stable", retry.snapshot().getString("phase")) + assertTrue(retry.markHealthy(hash, attempt.attemptId!!)) + assertEquals(committedRevision, retry.snapshot().getLong("revision")) + } + + @Test + fun `recovery transition resumes after a crash at rollback required failpoint`() { + val root = tempFolder.newFolder("failpoint-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "installer").activateCandidate(hash, 1, "manual", 0.0) + controller(root, filesDir, "bad-process").selectForStartup() + val crashing = controller(root, filesDir, "crashing-recovery", failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_ROLLBACK_REQUIRED) { + throw SimulatedProcessDeath() + } + }) + + try { + crashing.selectForStartup() + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + assertEquals(hash, pointerHash(root, "current.json")) + assertEquals( + listOf(hash), + jsonStrings(controller(root, filesDir, "durability-snapshot").snapshot(), "quarantinedHashes"), + ) + assertNull(controller(root, filesDir, "next-process").selectForStartup().bundlePath) + val events = controller(root, filesDir, "snapshot").snapshot() + .getJSONArray("pendingRecoveryEvents") + assertEquals(1, events.length()) + } + + @Test + fun `recovery resumes idempotently after previous fallback pointer is promoted`() { + val root = tempFolder.newFolder("fallback-pointer-failpoint-root") + val filesDir = root.parentFile!! + val stableHash = writeBundle(root, "stable") + val failedHash = writeBundle(root, "failed") + val installer = controller(root, filesDir, "installer") + installer.activateCandidate(stableHash, 1, "manual", 0.0) + val stableAttempt = installer.selectForStartup() + installer.markHealthy(stableHash, stableAttempt.attemptId!!) + installer.activateCandidate(failedHash, 1, "manual", 0.0) + controller(root, filesDir, "failed-launch").selectForStartup() + val interrupted = controller(root, filesDir, "recovery", failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_RECOVERY_POINTER) { + throw SimulatedProcessDeath() + } + }) + + try { + interrupted.selectForStartup() + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + assertEquals(stableHash, pointerHash(root, "current.json")) + assertFalse(File(root, "previous.json").exists()) + val resumed = controller(root, filesDir, "resumed-recovery").selectForStartup() + assertEquals(bundlePath(root, stableHash), resumed.bundlePath) + val snapshot = controller(root, filesDir, "snapshot").snapshot() + assertEquals(listOf(failedHash), jsonStrings(snapshot, "quarantinedHashes")) + assertEquals(1, snapshot.getJSONArray("pendingRecoveryEvents").length()) + assertEquals( + "previous", + snapshot.getJSONArray("pendingRecoveryEvents").getJSONObject(0).getString("recoveryTarget"), + ) + } + + @Test + fun `activation crash before pointer promotion leaves the current bundle unchanged`() { + val root = tempFolder.newFolder("activation-failpoint-root") + val filesDir = root.parentFile!! + val stableHash = writeBundle(root, "stable") + val candidateHash = writeBundle(root, "candidate") + val stable = controller(root, filesDir, "stable-process") + stable.activateCandidate(stableHash, 2, "manual", 0.0) + val attempt = stable.selectForStartup() + stable.markHealthy(stableHash, attempt.attemptId!!) + val crashing = controller(root, filesDir, "installer", failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_ARMED) { + throw SimulatedProcessDeath() + } + }) + + try { + crashing.activateCandidate(candidateHash, 2, "manual", 0.0) + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + assertEquals(stableHash, pointerHash(root, "current.json")) + val selected = controller(root, filesDir, "next-process").selectForStartup() + assertEquals(bundlePath(root, stableHash), selected.bundlePath) + assertNull(selected.attemptId) + val snapshot = controller(root, filesDir, "snapshot").snapshot() + assertEquals("stable", snapshot.getString("phase")) + assertEquals(stableHash, snapshot.getString("candidateHash")) + assertEquals(JSONObject.NULL, snapshot.get("activeAttempt")) + assertTrue(snapshot.getLong("revision") > 0) + } + + @Test + fun `unpublished arm without a proven current pointer is discarded without an attempt`() { + val root = tempFolder.newFolder("discarded-arm-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val installer = controller( + root, + filesDir, + process = "installer", + ids = ids("reserved-but-unpublished"), + failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_ARMED) { + throw SimulatedProcessDeath() + } + }, + ) + + try { + installer.activateCandidate(hash, 2, "manual", 0.0) + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + val selected = controller( + root, + filesDir, + process = "first-startup", + ids = { throw AssertionError("Discarding an unpublished arm must not allocate an attempt") }, + ).selectForStartup() + assertNull(selected.bundlePath) + assertNull(selected.attemptId) + val snapshot = controller(root, filesDir, "snapshot").snapshot() + assertEquals("idle", snapshot.getString("phase")) + assertFalse(snapshot.has("candidateHash")) + assertEquals(JSONObject.NULL, snapshot.get("activeAttempt")) + val ledger = JSONObject(File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER).readText()) + assertFalse(ledger.has("reservedAttemptId")) + } + + @Test + fun `unpublished arm ignores an unproven mismatched current pointer`() { + val root = tempFolder.newFolder("mismatched-arm-root") + val filesDir = root.parentFile!! + val candidateHash = writeBundle(root, "candidate") + val unprovenHash = writeBundle(root, "unproven-current") + val installer = controller( + root, + filesDir, + process = "installer", + failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_ARMED) { + throw SimulatedProcessDeath() + } + }, + ) + + try { + installer.activateCandidate(candidateHash, 2, "manual", 0.0) + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + File(root, "current.json").writeText("""{"hash":"$unprovenHash"}""") + + val selected = controller( + root, + filesDir, + process = "first-startup", + ids = { throw AssertionError("Discarding an unpublished arm must not allocate an attempt") }, + ).selectForStartup() + assertNull(selected.bundlePath) + assertNull(selected.attemptId) + assertFalse(File(root, "current.json").exists()) + assertEquals("idle", controller(root, filesDir, "snapshot").snapshot().getString("phase")) + } + + @Test + fun `stale revision cannot overwrite a newer committed ledger`() { + val root = tempFolder.newFolder("stale-revision-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "seed").snapshot() + val ledgerFile = File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER) + var injectedRevision = -1L + var injected = false + val staleWriter = controller( + root, + filesDir, + process = "stale-writer", + failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_BEFORE_LEDGER_COMMIT && !injected) { + injected = true + val newer = JSONObject(ledgerFile.readText()) + injectedRevision = newer.getLong("revision") + 1 + newer.put("revision", injectedRevision) + ledgerFile.writeText(newer.toString()) + } + }, + ) + + assertThrows(IllegalStateException::class.java) { + staleWriter.activateCandidate(hash, 2, "manual", 0.0) + } + + val committed = JSONObject(ledgerFile.readText()) + assertEquals(injectedRevision, committed.getLong("revision")) + assertEquals("idle", committed.getString("phase")) + assertFalse(File(root, "current.json").exists()) + } + + @Test + fun `shared startup recovery contract fixture matches production Android snapshot`() { + val workingDirectory = File(System.getProperty("user.dir")) + val fixture = listOf( + File(workingDirectory, "test-fixtures/startup-recovery-contract-v1.json"), + File(workingDirectory.parentFile, "test-fixtures/startup-recovery-contract-v1.json"), + ).firstOrNull(File::isFile) ?: throw AssertionError("Shared startup recovery fixture not found") + val contract = JSONObject(fixture.readText()) + val root = tempFolder.newFolder("contract-root") + val filesDir = root.parentFile!! + val attempt = JSONObject(contract.getJSONObject("activeAttempt").toString()) + .put("processToken", "process-contract-v1") + val ledger = JSONObject() + .put("schemaVersion", BundleDropStartupRecoveryController.PROTOCOL_VERSION) + .put("revision", contract.getLong("revision")) + .put("binaryIdentity", binaryIdentity) + .put("phase", contract.getString("phase")) + .put("candidateHash", contract.getString("candidateHash")) + .put("candidateRuntimeVersion", "runtime-1") + .put("stableHash", contract.getString("stableHash")) + .put("stableRuntimeVersion", "runtime-1") + .put("policy", contract.getJSONObject("policy")) + .put("activeAttempt", attempt) + .put("quarantinedHashes", contract.getJSONArray("quarantinedHashes")) + .put("revokedHashes", org.json.JSONArray()) + .put("pendingRecoveryEvents", contract.getJSONArray("pendingRecoveryEvents")) + .put("legacyStateImported", true) + .put("rollbackCrashCount", 0) + File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER).writeText(ledger.toString()) + + val snapshot = controller( + root, + filesDir, + "process-contract-v1", + ).snapshot() + assertEquals(canonicalJson(contract), canonicalJson(snapshot)) + } + + @Test + fun `automatic health waits for the configured content appeared grace period`() { + val root = tempFolder.newFolder("content-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val controller = controller(root, filesDir, "process") + controller.activateCandidate(hash, 2, "auto", 2.0) + controller.selectForStartup() + + controller.scheduleContentAppearedHealth(Handler(Looper.getMainLooper())) + assertEquals("launching", controller.snapshot().getString("phase")) + Shadows.shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS) + assertEquals("stable", controller.snapshot().getString("phase")) + } + + @Test + fun `content appeared schedules health only once per attempt id`() { + val hash = "a".repeat(64) + var schedules = 0 + + assertTrue(BundleDropStartupRecovery.scheduleContentAppearedHealthOnce(hash, "attempt-dedupe") { + schedules += 1 + }) + assertFalse(BundleDropStartupRecovery.scheduleContentAppearedHealthOnce(hash, "attempt-dedupe") { + schedules += 1 + }) + assertTrue(BundleDropStartupRecovery.scheduleContentAppearedHealthOnce(hash, "attempt-newer") { + schedules += 1 + }) + assertEquals(2, schedules) + } + + @Test + fun `production facade captures selected hash and fails ordinary exceptions closed`() { + val context = ApplicationProvider.getApplicationContext() + val hash = "a".repeat(64) + val bundlePath = File(context.filesDir, "bundle-drop/bundles/$hash/main.jsbundle").absolutePath + + val selected = BundleDropStartupRecovery.selectForStartup(context) { + BundleDropStartupRecoveryController.StartupSelection(bundlePath) + } + assertEquals(bundlePath, selected.bundlePath) + assertEquals(hash, BundleDropStartupRecovery.startupSelectedHash()) + assertNull(BundleDropStartupRecovery.startupAttempt()) + + val failedClosed = BundleDropStartupRecovery.selectForStartup(context) { + throw IllegalStateException("storage unavailable") + } + assertNull(failedClosed.bundlePath) + assertNull(BundleDropStartupRecovery.startupSelectedHash()) + assertNull(BundleDropStartupRecovery.startupAttempt()) + } + + @Test + fun `revoked candidates recover offline without crash quarantine or telemetry`() { + val root = tempFolder.newFolder("event-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + controller(root, filesDir, "installer").activateCandidate(hash, 2, "manual", 0.0) + val controller = controller(root, filesDir, "revoker") + controller.setRevokedHashes(setOf(hash)) + val revisionAfterRevoke = controller.snapshot().getLong("revision") + controller.setRevokedHashes(setOf(hash)) + assertEquals(revisionAfterRevoke, controller.snapshot().getLong("revision")) + assertNull(controller.selectForStartup().bundlePath) + assertNull(controller.resolvePassive()) + + val snapshot = controller.snapshot() + assertEquals(0, snapshot.getJSONArray("pendingRecoveryEvents").length()) + assertEquals(0, snapshot.getJSONArray("quarantinedHashes").length()) + assertFalse(controller.acknowledgeRecovery("unknown")) + } + + @Test + fun `attempt id reserved by activation survives interruption before first startup selection`() { + val root = tempFolder.newFolder("reserved-attempt-root") + val filesDir = root.parentFile!! + val hash = writeBundle(root, "candidate") + val installer = controller( + root, + filesDir, + process = "installer", + ids = ids("reserved-first-attempt"), + failpoint = { stage -> + if (stage == BundleDropStartupRecoveryController.FAIL_AFTER_CURRENT_POINTER) { + throw SimulatedProcessDeath() + } + }, + ) + + try { + installer.activateCandidate(hash, 2, "manual", 0.0) + throw AssertionError("Expected simulated process death") + } catch (_: SimulatedProcessDeath) {} + + val ledger = JSONObject(File(root, BundleDropStartupRecoveryController.RECOVERY_LEDGER).readText()) + assertEquals("reserved-first-attempt", ledger.getString("reservedAttemptId")) + val selected = controller(root, filesDir, "first-startup", ids = ids("unexpected-id")) + .selectForStartup() + assertEquals("reserved-first-attempt", selected.attemptId) + assertEquals( + "reserved-first-attempt", + controller(root, filesDir, "snapshot").snapshot() + .getJSONObject("activeAttempt").getString("attemptId"), + ) + } + + @Test + fun `authoritative rollback selects only the prior proven healthy bundle without quarantine`() { + val root = tempFolder.newFolder("authoritative-root") + val filesDir = root.parentFile!! + val firstHash = writeBundle(root, "first-stable") + val secondHash = writeBundle(root, "second-stable") + val controller = controller(root, filesDir, "process") + + controller.activateCandidate(firstHash, 2, "manual", 0.0) + val firstAttempt = controller.selectForStartup() + controller.markHealthy(firstHash, firstAttempt.attemptId!!) + controller.activateCandidate(secondHash, 2, "manual", 0.0) + val secondAttempt = controller.selectForStartup() + controller.markHealthy(secondHash, secondAttempt.attemptId!!) + + val rollback = controller.rollbackStartupBundle(forceEmbedded = false) + + assertTrue(rollback.rolledBack) + assertFalse(rollback.toEmbedded) + assertEquals(firstHash, rollback.hash) + assertEquals(firstHash, pointerHash(root, "current.json")) + val snapshot = controller.snapshot() + assertEquals(0, snapshot.getJSONArray("pendingRecoveryEvents").length()) + assertEquals(0, snapshot.getJSONArray("quarantinedHashes").length()) + + val embedded = controller.rollbackStartupBundle(forceEmbedded = true) + assertTrue(embedded.rolledBack) + assertTrue(embedded.toEmbedded) + assertNull(embedded.hash) + assertFalse(File(root, "current.json").exists()) + + val alreadyEmbedded = controller.rollbackStartupBundle(forceEmbedded = true) + assertFalse(alreadyEmbedded.rolledBack) + assertTrue(alreadyEmbedded.toEmbedded) + assertNull(alreadyEmbedded.hash) + } + + @Suppress("UNUSED_PARAMETER") + private fun controller( + root: File, + filesDir: File, + process: String, + identity: String = binaryIdentity, + expectedRuntime: String? = "runtime-1", + now: Long = 1_700_000_000_000, + ids: () -> String = { "id-${System.nanoTime()}" }, + failpoint: (String) -> Unit = {}, + ): BundleDropStartupRecoveryController = BundleDropStartupRecoveryController( + bundleDropRoot = root, + processToken = process, + binaryIdentity = identity, + expectedRuntimeVersion = expectedRuntime, + nowMillis = { now }, + newId = ids, + failpoint = failpoint, + ) + + private fun ids(vararg values: String): () -> String { + val remaining = ArrayDeque(values.toList()) + return { remaining.removeFirstOrNull() ?: "id-${System.nanoTime()}" } + } + + private fun pointerHash(root: File, name: String): String = + JSONObject(File(root, name).readText()).getString("hash") + + private fun jsonStrings(json: JSONObject, key: String): List { + val array = json.getJSONArray(key) + return (0 until array.length()).map(array::getString) + } + + private fun canonicalJson(value: Any?): String = when (value) { + is JSONObject -> value.keys().asSequence().toList().sorted() + .joinToString(prefix = "{", postfix = "}") { key -> + "${JSONObject.quote(key)}:${canonicalJson(value.get(key))}" + } + is org.json.JSONArray -> (0 until value.length()) + .joinToString(prefix = "[", postfix = "]") { index -> canonicalJson(value.get(index)) } + JSONObject.NULL, null -> "null" + is String -> JSONObject.quote(value) + is Number, is Boolean -> value.toString() + else -> JSONObject.quote(value.toString()) + } + + private fun bundlePath(root: File, hash: String): String = + File(root, "bundles/$hash/main.jsbundle").absolutePath + + private fun writeBundle(root: File, bundleContent: String): String { + val files = listOf( + TestFile("main.jsbundle", "jsbundle", bundleContent), + TestFile("metadata-android.json", "metadata", "{}"), + TestFile("image-manifest.json", "androidImageManifest", "{}"), + ) + val canonicalFiles = files.sortedBy { it.path }.joinToString(",", transform = ::fileJson) + val hash = sha256("{\"files\":[$canonicalFiles],\"manifestVersion\":1}") + val jsHash = files.first().sha256 + val manifestHash = sha256( + "{\"bundleHash\":\"$hash\",\"files\":[$canonicalFiles],\"jsBundleHash\":\"$jsHash\",\"manifestVersion\":1,\"platform\":\"android\",\"runtimeVersion\":\"runtime-1\",\"version\":\"1.0.0\"}", + ) + val bundleDir = File(root, "bundles/$hash").apply { mkdirs() } + files.forEach { File(bundleDir, it.path).writeText(it.content) } + File(bundleDir, "bundle-manifest.json").writeText( + "{\"manifestVersion\":1,\"bundleHash\":\"$hash\",\"jsBundleHash\":\"$jsHash\",\"platform\":\"android\",\"runtimeVersion\":\"runtime-1\",\"version\":\"1.0.0\",\"manifestHash\":\"$manifestHash\",\"files\":[${files.joinToString(",", transform = ::fileJson)}]}", + ) + return hash + } + + private data class TestFile(val path: String, val role: String, val content: String) { + val size = content.toByteArray(Charsets.UTF_8).size + val sha256 = sha256(content) + } + + private fun fileJson(file: TestFile): String = + "{\"path\":\"${file.path}\",\"role\":\"${file.role}\",\"sha256\":\"${file.sha256}\",\"size\":${file.size}}" + + private class SimulatedProcessDeath : RuntimeException() + + companion object { + private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } +} diff --git a/ios-tests/BundleDropLocatorTests.swift b/ios-tests/BundleDropLocatorTests.swift index 338af9c..1ae49cf 100644 --- a/ios-tests/BundleDropLocatorTests.swift +++ b/ios-tests/BundleDropLocatorTests.swift @@ -52,6 +52,28 @@ final class BundleDropLocatorTests: XCTestCase { ) } + func testBundleURLRunsStartupSelectionWhenCompatibilityResolverReturnsNil() throws { + let docs = try makeDirectory("docs") + let root = try makeDirectory("library/bundle-drop") + let selected = tempRoot.appendingPathComponent("selected-by-recovery.jsbundle") + var selectionCalls = 0 + + let bundleURL = BundleDropLocatorCore.bundleURL( + bundleDropRoot: root, + documentsDirectory: docs, + currentBinaryVersion: "1.0.0-1", + userDefaults: userDefaults, + shouldLogBinaryUpdate: false, + startupSelection: { + selectionCalls += 1 + return selected + } + ) + + XCTAssertEqual(selectionCalls, 1) + XCTAssertEqual(bundleURL, selected) + } + func testBundleURLReturnsPointerWhenStoredBinaryVersionMatches() throws { let docs = try makeDirectory("docs") let root = try makeDirectory("library/bundle-drop") @@ -84,11 +106,13 @@ final class BundleDropLocatorTests: XCTestCase { let current = root.appendingPathComponent("current.json") let previous = root.appendingPathComponent("previous.json") let state = root.appendingPathComponent("state.json") + let recoveryLedger = root.appendingPathComponent("recovery-ledger.json") let bundleInfo = docs.appendingPathComponent("bundle-info.json") try write("{\"hash\":\"\(validHash)\",\"bundlePath\":\"\(bundle.path)\"}", to: current) try write("{}", to: previous) try write("{}", to: state) + try write("{}", to: recoveryLedger) try write("{}", to: bundleInfo) userDefaults.set("1.0.0-1", forKey: BundleDropLocatorCore.binaryVersionKey) var logs: [String] = [] @@ -110,6 +134,7 @@ final class BundleDropLocatorTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: current.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: previous.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: state.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: recoveryLedger.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: bundleInfo.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: bundle.path)) XCTAssertEqual( @@ -164,6 +189,27 @@ final class BundleDropLocatorTests: XCTestCase { XCTAssertEqual(BundleDropLocatorCore.getRuntimeVersion(bundle: bundle), "signed-runtime") } + func testBareEmbeddedBuildIdentityUsesCocoaPodsCompatibleFilename() throws { + let bundle = try makeBundleFixture( + version: "3.4.5", + build: "67", + runtimeVersion: "legacy-runtime" + ) + let candidate: [String: Any] = [ + "schemaVersion": 1, + "platform": "ios", + "runtimeVersion": "bare-runtime", + ] + let candidateData = try JSONSerialization.data(withJSONObject: candidate) + try candidateData.write( + to: bundle.bundleURL.appendingPathComponent( + BundleDropLocatorCore.bareEmbeddedBuildIdentityFilename + ) + ) + + XCTAssertEqual(BundleDropLocatorCore.getRuntimeVersion(bundle: bundle), "bare-runtime") + } + func testEmbeddedRuntimeChangeChangesTheBinaryVersionKey() throws { let runtimeTwo = try makeBundleFixture( version: "3.4.5", diff --git a/ios-tests/BundleDropOtaResolverTests.swift b/ios-tests/BundleDropOtaResolverTests.swift index 4f77617..20a99ea 100644 --- a/ios-tests/BundleDropOtaResolverTests.swift +++ b/ios-tests/BundleDropOtaResolverTests.swift @@ -212,12 +212,14 @@ final class BundleDropOtaResolverTests: XCTestCase { let current = root.appendingPathComponent("current.json") let previous = root.appendingPathComponent("previous.json") let state = root.appendingPathComponent("state.json") + let recoveryLedger = root.appendingPathComponent("recovery-ledger.json") let bundleInfo = docs.appendingPathComponent("bundle-info.json") let kept = keptDir.appendingPathComponent("main.jsbundle") try write("{}", to: current) try write("{}", to: previous) try write("{}", to: state) + try write("{}", to: recoveryLedger) try write("{}", to: bundleInfo) try write("bundle", to: kept) @@ -229,6 +231,7 @@ final class BundleDropOtaResolverTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: current.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: previous.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: state.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: recoveryLedger.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: bundleInfo.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: kept.path)) } diff --git a/ios-tests/BundleDropStartupRecoveryContractTests.swift b/ios-tests/BundleDropStartupRecoveryContractTests.swift new file mode 100644 index 0000000..a25492e --- /dev/null +++ b/ios-tests/BundleDropStartupRecoveryContractTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import BundleDropIOSCore + +final class BundleDropStartupRecoveryContractTests: XCTestCase { + func testSharedProtocolV1FixtureMatchesProductionIOSSnapshot() throws { + let fixtureURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("test-fixtures/startup-recovery-contract-v1.json") + let fixture = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] + ) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("bundle-drop-contract-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let candidateHash = String(repeating: "a", count: 64) + let quarantinedHash = String(repeating: "b", count: 64) + let stableHash = String(repeating: "c", count: 64) + let ledger = BundleDropStartupRecoveryLedger( + revision: 7, + binaryIdentity: "binary-contract-v1", + runtimeIdentity: "runtime-contract-v1", + legacyFailuresImported: true, + phase: .launching, + candidateHash: candidateHash, + stableHash: stableHash, + activeAttempt: BundleDropStartupRecoveryAttempt( + hash: candidateHash, + attemptId: "attempt-contract-v1", + processToken: "process-contract-v1", + startedAt: 1_700_000_000, + unacknowledgedLaunchCount: 2, + contentAppeared: false + ), + policy: BundleDropStartupRecoveryPolicy( + maxCrashCount: 3, + healthCheckMode: "manual", + healthyAfterSec: 4.5 + ), + quarantinedHashes: [quarantinedHash], + pendingRecoveryEvents: [ + BundleDropStartupRecoveryEvent( + id: "event-contract-v1", + failedHash: candidateHash, + recoveryTarget: "previous", + recoveredHash: stableHash, + crashCount: 3, + reason: "crash_loop", + failedAt: 1_700_000_000 + ), + ] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try encoder.encode(ledger).write(to: root.appendingPathComponent("recovery-ledger.json")) + + let snapshot = try BundleDropStartupRecoveryController( + bundleDropRoot: root, + expectedRuntimeVersion: "runtime-contract-v1", + expectedBinaryIdentity: "binary-contract-v1", + processToken: "process-contract-v1" + ).snapshot() + + XCTAssertEqual(NSDictionary(dictionary: snapshot), NSDictionary(dictionary: fixture)) + } +} diff --git a/ios-tests/BundleDropStartupRecoveryTests.swift b/ios-tests/BundleDropStartupRecoveryTests.swift new file mode 100644 index 0000000..2f24167 --- /dev/null +++ b/ios-tests/BundleDropStartupRecoveryTests.swift @@ -0,0 +1,1253 @@ +import CryptoKit +import XCTest +@testable import BundleDropIOSCore + +final class BundleDropStartupRecoveryTests: XCTestCase { + func testActivationRejectsInvalidPolicyInsteadOfClampingIt() throws { + let candidate = try makeBundle(contents: "invalid-policy") + let controller = makeController() + + for maxCrashCount in [-1, Int(Int32.max) + 1] { + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: maxCrashCount, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) + } + for healthyAfterSec in [-1.0, Double.infinity, Double.nan] { + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: healthyAfterSec + )) + } + } + + private var tempRoot: URL! + private let runtimeVersion = "1.0.0" + + override func setUpWithError() throws { + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("BundleDropStartupRecoveryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + if let tempRoot { + try? FileManager.default.removeItem(at: tempRoot) + } + tempRoot = nil + } + + func testActivationArmsLedgerBeforePublishingCurrentPointer() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + var observedFailpoints: [String] = [] + let controller = makeController(failpoint: { name in + observedFailpoints.append(name) + if name == "afterCandidateArmed" { throw TestError.interrupted } + }) + + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) + + XCTAssertEqual(readPointer("current.json")?.hash, stable.hash) + let ledger = try readLedger() + XCTAssertEqual(ledger.phase, .armed) + XCTAssertEqual(ledger.candidateHash, candidate.hash) + XCTAssertEqual(ledger.stableHash, stable.hash) + XCTAssertTrue(observedFailpoints.contains("afterLedgerWrite")) + XCTAssertTrue(observedFailpoints.contains("afterCandidateArmed")) + + let nextLaunch = makeController().selectStartupBundle() + XCTAssertEqual(nextLaunch.bundleURL, stable.bundleURL) + XCTAssertEqual(try readLedger().phase, .stable) + XCTAssertNil(try readLedger().candidateHash) + } + + func testCandidateVerificationFailpointPrecedesAnyArmSideEffect() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(failpoint: { name in + if name == "afterCandidateVerified" { throw TestError.interrupted } + }) + + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) + + XCTAssertNil(readPointer("current.json")) + XCTAssertFalse(FileManager.default.fileExists( + atPath: tempRoot.appendingPathComponent("recovery-ledger.json").path + )) + } + + func testCrashLoopReusesAttemptWithinProcessThenRecoversPreviousBundle() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let controller = makeController(ids: ["attempt-1"], processToken: "process-1") + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + + let first = controller.selectStartupBundle() + XCTAssertEqual(first.attemptId, "attempt-1") + let revisionAfterFirstLaunch = try readLedger().revision + + let sameProcess = controller.selectStartupBundle() + XCTAssertEqual(sameProcess, first) + XCTAssertEqual(try readLedger().revision, revisionAfterFirstLaunch) + + let secondLaunch = makeController( + ids: ["attempt-2"], + processToken: "process-2" + ).selectStartupBundle() + XCTAssertEqual(secondLaunch.attemptId, "attempt-2") + XCTAssertEqual(try readLedger().activeAttempt?.unacknowledgedLaunchCount, 1) + + let recovered = makeController( + ids: ["event-1"], + processToken: "process-3" + ).selectStartupBundle() + XCTAssertEqual(recovered.bundleURL, stable.bundleURL) + XCTAssertNil(recovered.attemptId) + XCTAssertEqual(readPointer("current.json")?.hash, stable.hash) + + let ledger = try readLedger() + XCTAssertEqual(ledger.phase, .recovered) + XCTAssertEqual(ledger.quarantinedHashes, [candidate.hash]) + XCTAssertEqual(ledger.pendingRecoveryEvents, [ + BundleDropStartupRecoveryEvent( + id: "event-1", + failedHash: candidate.hash, + recoveryTarget: "previous", + recoveredHash: stable.hash, + crashCount: 2, + reason: "crash_loop", + failedAt: 1_700_000_000 + ), + ]) + + let recoveredRevision = ledger.revision + _ = try controller.activateCandidate( + hash: stable.hash, + maxCrashCount: 3, + healthCheckMode: "manual", + healthyAfterSec: 5 + ) + XCTAssertEqual(try readLedger().revision, recoveredRevision) + XCTAssertEqual(try readLedger().phase, .recovered) + XCTAssertNil(controller.selectStartupBundle().attemptId) + } + + func testActivationReservesTheFirstAttemptIdBeforePublishingCandidate() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["reserved-at-activation"]) + + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + + let armed = try readLedger() + XCTAssertEqual(armed.phase, .armed) + XCTAssertEqual(armed.runtimeIdentity, runtimeVersion) + XCTAssertEqual(armed.binaryIdentity, "binary-1") + XCTAssertEqual(armed.reservedAttemptId, "reserved-at-activation") + XCTAssertNil(armed.activeAttempt) + + let firstLaunch = makeController(ids: ["must-not-be-used"]).selectStartupBundle() + XCTAssertEqual(firstLaunch.attemptId, "reserved-at-activation") + XCTAssertNil(try readLedger().reservedAttemptId) + XCTAssertEqual(try readLedger().activeAttempt?.attemptId, "reserved-at-activation") + } + + func testMissingCurrentPointerDiscardsUnpublishedArmWithoutAttempt() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["reserved-at-activation"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + try BundleDropOtaResolver.deletePointer( + named: "current.json", + bundleDropRoot: tempRoot + ) + + let selection = controller.selectStartupBundle() + + XCTAssertNil(selection.bundleURL) + XCTAssertNil(selection.attemptId) + let ledger = try readLedger() + XCTAssertEqual(ledger.phase, .idle) + XCTAssertNil(ledger.candidateHash) + XCTAssertNil(ledger.reservedAttemptId) + XCTAssertNil(ledger.activeAttempt) + } + + func testStartupFinalizesPendingEmbeddedRecoveryAfterPointerDeletion() throws { + let candidate = try makeBundle(contents: "candidate") + let firstLaunch = makeController( + ids: ["attempt-1"], + processToken: "process-1" + ) + _ = try firstLaunch.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = firstLaunch.selectStartupBundle() + let interrupted = makeController( + ids: ["event-1"], + processToken: "process-2", + failpoint: { name in + if name == "afterRecoveryCurrentDelete" { throw TestError.interrupted } + } + ) + XCTAssertNil(interrupted.selectStartupBundle().bundleURL) + XCTAssertNil(readPointer("current.json")) + XCTAssertEqual(try readLedger().pendingTransition?.kind, "recovery") + + let nextLaunch = makeController().selectStartupBundle() + + XCTAssertNil(nextLaunch.bundleURL) + XCTAssertNil(try readLedger().pendingTransition) + XCTAssertEqual(try readLedger().pendingRecoveryEvents.map(\.id), ["event-1"]) + } + + func testManualHealthRequiresExactAttemptAndPreservesPreviousProof() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let controller = makeController(ids: ["attempt-1"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 30 + ) + let attempt = controller.selectStartupBundle() + + let revisionBeforeContent = try readLedger().revision + XCTAssertNil(controller.markContentAppeared(hash: candidate.hash, attemptId: attempt.attemptId!)) + XCTAssertEqual(try readLedger().revision, revisionBeforeContent) + XCTAssertFalse(try XCTUnwrap(readLedger().activeAttempt).contentAppeared) + XCTAssertFalse(controller.markHealthy(hash: stable.hash, attemptId: attempt.attemptId!)) + XCTAssertFalse(controller.markHealthy(hash: candidate.hash, attemptId: "wrong-attempt")) + XCTAssertTrue(controller.markHealthy(hash: candidate.hash, attemptId: attempt.attemptId!)) + + let ledger = try readLedger() + XCTAssertEqual(ledger.phase, .stable) + XCTAssertEqual(ledger.stableHash, candidate.hash) + XCTAssertEqual(ledger.previousStableHash, stable.hash) + XCTAssertNil(ledger.activeAttempt) + } + + func testAutoContentAppearanceReturnsConfiguredHealthDelay() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["attempt-1"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: 12.5 + ) + let attempt = controller.selectStartupBundle() + + XCTAssertEqual( + controller.markContentAppeared(hash: candidate.hash, attemptId: attempt.attemptId!), + 12.5 + ) + let revisionAfterContent = try readLedger().revision + XCTAssertTrue(try XCTUnwrap(readLedger().activeAttempt).contentAppeared) + XCTAssertNil(controller.markContentAppeared(hash: candidate.hash, attemptId: attempt.attemptId!)) + XCTAssertEqual(try readLedger().revision, revisionAfterContent) + } + + func testZeroCrashLimitDisablesAutomaticRecovery() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController() + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 0, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + + for _ in 0..<6 { + XCTAssertEqual(controller.selectStartupBundle().bundleURL, candidate.bundleURL) + } + + let ledger = try readLedger() + XCTAssertEqual(ledger.phase, .armed) + XCTAssertNil(ledger.activeAttempt) + XCTAssertTrue(ledger.quarantinedHashes.isEmpty) + XCTAssertTrue(ledger.pendingRecoveryEvents.isEmpty) + } + + func testHealthyMarkIsIdempotentOnlyForExactStableAttempt() throws { + let first = try makeBundle(contents: "first") + let second = try makeBundle(contents: "second") + let controller = makeController(ids: ["first-attempt", "second-attempt"]) + _ = try controller.activateCandidate( + hash: first.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + let firstLaunch = controller.selectStartupBundle() + XCTAssertTrue(controller.markHealthy(hash: first.hash, attemptId: firstLaunch.attemptId!)) + let stableRevision = try readLedger().revision + + XCTAssertTrue(controller.markHealthy(hash: first.hash, attemptId: firstLaunch.attemptId!)) + XCTAssertEqual(try readLedger().revision, stableRevision) + XCTAssertFalse(controller.markHealthy(hash: first.hash, attemptId: "different-attempt")) + + _ = try controller.activateCandidate( + hash: second.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + XCTAssertFalse(controller.markHealthy(hash: first.hash, attemptId: firstLaunch.attemptId!)) + XCTAssertFalse(controller.markHealthy(hash: second.hash, attemptId: firstLaunch.attemptId!)) + XCTAssertNil(try readLedger().lastHealthyAttemptId) + } + + func testHealthyMarkRevalidatesFilesRevocationAndBinaryIdentity() throws { + let missing = try makeBundle(contents: "missing") + let controller = makeController(ids: ["missing-attempt", "revoked-attempt", "identity-attempt"]) + _ = try controller.activateCandidate( + hash: missing.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + let missingAttempt = controller.selectStartupBundle() + try FileManager.default.removeItem(at: missing.bundleURL) + XCTAssertFalse(controller.markHealthy(hash: missing.hash, attemptId: missingAttempt.attemptId!)) + + let revoked = try makeBundle(contents: "revoked") + _ = try controller.activateCandidate( + hash: revoked.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + let revokedAttempt = controller.selectStartupBundle() + try controller.setRevokedHashes([revoked.hash]) + XCTAssertFalse(controller.markHealthy(hash: revoked.hash, attemptId: revokedAttempt.attemptId!)) + + try controller.setRevokedHashes([]) + let identity = try makeBundle(contents: "identity") + _ = try controller.activateCandidate( + hash: identity.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + let identityAttempt = controller.selectStartupBundle() + XCTAssertFalse( + makeController(binaryIdentity: "binary-2") + .markHealthy(hash: identity.hash, attemptId: identityAttempt.attemptId!) + ) + } + + func testRepeatedRevocationSetIsAnAcceptedNoop() throws { + let controller = makeController() + _ = try controller.snapshot() + let initialRevision = try readLedger().revision + + try controller.setRevokedHashes([]) + + XCTAssertEqual(try readLedger().revision, initialRevision) + } + + func testAdapterCapturesSelectedHashIndependentlyOfAttemptIdentity() { + let stableHash = String(repeating: "a", count: 64) + let stableURL = tempRoot + .appendingPathComponent("bundles/\(stableHash)") + .appendingPathComponent("main.jsbundle") + BundleDropStartupRecoveryAdapter.captureStartupSelection(BundleDropStartupSelection( + bundleURL: stableURL, + attemptHash: nil, + attemptId: nil + )) + + XCTAssertEqual(BundleDropStartupRecoveryAdapter.capturedSelectedHash(), stableHash) + XCTAssertNil(BundleDropStartupRecoveryAdapter.capturedAttempt().attemptId) + + BundleDropStartupRecoveryAdapter.clearCapturedSelection() + XCTAssertNil(BundleDropStartupRecoveryAdapter.capturedSelectedHash()) + } + + func testSameProcessReloadStartsNewAttemptWithoutChargingCrash() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["attempt-1", "attempt-reload"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = controller.selectStartupBundle() + + let reload = controller.selectStartupBundle(beginReload: true) + + XCTAssertEqual(reload.attemptId, "attempt-reload") + XCTAssertEqual(try readLedger().activeAttempt?.unacknowledgedLaunchCount, 0) + XCTAssertTrue(try readLedger().pendingRecoveryEvents.isEmpty) + } + + func testSameProcessResolutionReusesAttemptWithoutVolatileAdapterState() throws { + let candidate = try makeBundle(contents: "candidate") + let firstController = makeController(ids: ["attempt-1"], processToken: "same-process") + _ = try firstController.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + let first = firstController.selectStartupBundle() + let revision = try readLedger().revision + + // A new controller mirrors losing the adapter's volatile capture while the + // native process itself is still alive. + let repeated = makeController(processToken: "same-process").selectStartupBundle() + + XCTAssertEqual(repeated, first) + XCTAssertEqual(try readLedger().revision, revision) + XCTAssertEqual(try readLedger().activeAttempt?.unacknowledgedLaunchCount, 0) + } + + func testContentRootRemainsBoundToItsOriginalReloadAttempt() { + final class TestRoot {} + let bindings = BundleDropStartupContentBindings() + let oldRoot = TestRoot() + let newRoot = TestRoot() + let oldHash = String(repeating: "a", count: 64) + let newHash = String(repeating: "b", count: 64) + let oldURL = URL(fileURLWithPath: "/tmp/bundle-old/main.jsbundle") + let newURL = URL(fileURLWithPath: "/tmp/bundle-new/main.jsbundle") + let unrelatedURL = URL(fileURLWithPath: "/tmp/unrelated/main.jsbundle") + + bindings.capture(hash: oldHash, attemptId: "attempt-old", bundleURL: oldURL) + bindings.runtimeDidLoad(bundleURL: oldURL) + let oldBinding = bindings.binding(for: oldRoot) + bindings.capture(hash: newHash, attemptId: "attempt-new", bundleURL: newURL) + + XCTAssertEqual(bindings.binding(for: oldRoot), oldBinding) + XCTAssertNil(bindings.binding(for: newRoot)) + bindings.runtimeDidLoad(bundleURL: unrelatedURL) + XCTAssertNil(bindings.binding(for: newRoot)) + bindings.runtimeDidLoad(bundleURL: newURL) + XCTAssertEqual(bindings.binding(for: newRoot), .init( + generation: 2, + hash: newHash, + attemptId: "attempt-new", + bundlePath: newURL.path + )) + XCTAssertEqual(bindings.binding(for: oldRoot), .init( + generation: 2, + hash: newHash, + attemptId: "attempt-new", + bundlePath: newURL.path + )) + } + + func testRuntimeLoadUsesTheBridgeBundleURLAndRejectsUnrelatedProviders() { + final class TestBridge: NSObject { + @objc let bundleURL: URL + + init(bundleURL: URL) { + self.bundleURL = bundleURL + } + } + let selectedURL = URL(fileURLWithPath: "/tmp/selected/main.jsbundle") + let bridge = TestBridge(bundleURL: selectedURL) + let notification = Notification( + name: Notification.Name("RCTJavaScriptDidLoadNotification"), + object: NSObject(), + userInfo: ["bridge": bridge] + ) + + XCTAssertEqual(BundleDropStartupRecoveryAdapter.bundleURL(from: notification), selectedURL) + XCTAssertNil(BundleDropStartupRecoveryAdapter.bundleURL(from: Notification( + name: Notification.Name("RCTJavaScriptDidLoadNotification"), + object: NSObject() + ))) + } + + func testRuntimeLoadReadsBundleURLImplementedByAProxyThatDeniesRespondsCheck() { + final class ProxyLikeBridge: NSObject { + @objc let bundleURL: URL + + init(bundleURL: URL) { + self.bundleURL = bundleURL + } + + override func responds(to selector: Selector!) -> Bool { + selector == NSSelectorFromString("bundleURL") ? false : super.responds(to: selector) + } + } + let selectedURL = URL(fileURLWithPath: "/tmp/selected/main.jsbundle") + let notification = Notification( + name: Notification.Name("RCTJavaScriptDidLoadNotification"), + userInfo: ["bridge": ProxyLikeBridge(bundleURL: selectedURL)] + ) + + XCTAssertEqual(BundleDropStartupRecoveryAdapter.bundleURL(from: notification), selectedURL) + } + + func testLaunchPersistedFailpointLeavesDurableUnfinishedAttempt() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController( + ids: ["attempt-1"], + failpoint: { name in + if name == "afterLaunchPersisted" { throw TestError.interrupted } + } + ) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + + XCTAssertNil(controller.selectStartupBundle().bundleURL) + XCTAssertEqual(try readLedger().phase, .launching) + XCTAssertEqual(try readLedger().activeAttempt?.attemptId, "attempt-1") + } + + func testHealthCommitFailpointCanRetryAsIdempotentSuccess() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController( + ids: ["attempt-1"], + failpoint: { name in + if name == "afterHealthCommitted" { throw TestError.interrupted } + } + ) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + let attempt = controller.selectStartupBundle() + + XCTAssertFalse(controller.markHealthy(hash: candidate.hash, attemptId: attempt.attemptId!)) + XCTAssertEqual(try readLedger().phase, .stable) + XCTAssertTrue(controller.markHealthy(hash: candidate.hash, attemptId: attempt.attemptId!)) + } + + func testRollbackUsesLedgerProvenPreviousWithoutCrashTelemetry() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let controller = makeController(ids: ["attempt-1"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = controller.selectStartupBundle() + + let result = try controller.rollback(forceEmbedded: false) + + XCTAssertEqual(result, BundleDropStartupRollbackResult( + rolledBack: true, + toEmbedded: false, + hash: stable.hash + )) + XCTAssertEqual(readPointer("current.json")?.hash, stable.hash) + let ledger = try readLedger() + XCTAssertTrue(ledger.quarantinedHashes.isEmpty) + XCTAssertTrue(ledger.pendingRecoveryEvents.isEmpty) + } + + func testForcedRollbackSelectsEmbeddedWithoutCrashTelemetry() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["attempt-1"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = controller.selectStartupBundle() + + let result = try controller.rollback(forceEmbedded: true) + + XCTAssertEqual(result, BundleDropStartupRollbackResult( + rolledBack: true, + toEmbedded: true, + hash: nil + )) + XCTAssertNil(readPointer("current.json")) + XCTAssertTrue(try readLedger().pendingRecoveryEvents.isEmpty) + } + + func testRevokedCandidateFallsBackButIsNotQuarantined() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let controller = makeController(ids: ["event-1"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + try controller.setRevokedHashes([candidate.hash]) + + let selection = controller.selectStartupBundle() + + XCTAssertEqual(selection.bundleURL, stable.bundleURL) + XCTAssertFalse(try readLedger().quarantinedHashes.contains(candidate.hash)) + XCTAssertTrue(try readLedger().pendingRecoveryEvents.isEmpty) + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) { error in + XCTAssertEqual(error as? BundleDropStartupRecoveryError, .candidateIneligible) + } + } + + func testLegacyFailedHashesAreImportedAsQuarantined() throws { + let candidate = try makeBundle(contents: "candidate") + try writeJson( + [ + "failedBundles": [candidate.hash: ["attempts": 3]], + "lastGoodHash": candidate.hash, + "candidateHash": candidate.hash, + "candidateCommitted": true, + ], + to: tempRoot.appendingPathComponent("state.json") + ) + let controller = makeController() + + let snapshot = try controller.snapshot() + + XCTAssertEqual(snapshot["quarantinedHashes"] as? [String], [candidate.hash]) + XCTAssertNil(snapshot["stableHash"]) + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) + } + + func testLegacyFailureImportKeepsOnlyTwentyNewestHashesOnce() throws { + var failedBundles: [String: Any] = [:] + let hashes = (1...25).map { String(format: "%064x", $0) } + for (index, hash) in hashes.enumerated() { + if index >= 6 { + failedBundles[hash] = ["failedAt": index + 1] + } else if index.isMultiple(of: 2) { + failedBundles[hash] = ["failedAt": "invalid"] + } else { + failedBundles[hash] = [:] + } + } + try writeJson( + ["failedBundles": failedBundles], + to: tempRoot.appendingPathComponent("state.json") + ) + let controller = makeController() + + _ = try controller.snapshot() + + let imported = try readLedger() + XCTAssertEqual(imported.quarantinedHashes.count, 20) + XCTAssertTrue(imported.quarantinedHashes.contains(hashes[0])) + XCTAssertTrue(Set(hashes[6...24]).isSubset(of: Set(imported.quarantinedHashes))) + XCTAssertFalse(imported.quarantinedHashes.contains(hashes[1])) + XCTAssertEqual(imported.legacyFailuresImported, true) + + let revisionAfterImport = imported.revision + let laterHash = String(repeating: "f", count: 64) + failedBundles[laterHash] = ["failedAt": 9_999_999] + try writeJson( + ["failedBundles": failedBundles], + to: tempRoot.appendingPathComponent("state.json") + ) + _ = try controller.snapshot() + + XCTAssertEqual(try readLedger().revision, revisionAfterImport) + XCTAssertFalse(try readLedger().quarantinedHashes.contains(laterHash)) + } + + func testPassiveLookupRequiresAnEligibleLedgerWithoutMutatingIt() throws { + let candidate = try makeBundle(contents: "candidate") + try BundleDropOtaResolver.writePointer( + named: "current.json", + hash: candidate.hash, + bundleDropRoot: tempRoot + ) + let controller = makeController() + + XCTAssertNil(controller.passiveCurrentBundle()) + XCTAssertFalse(FileManager.default.fileExists( + atPath: tempRoot.appendingPathComponent("recovery-ledger.json").path + )) + + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + XCTAssertEqual(controller.passiveCurrentBundle(), candidate.bundleURL) + XCTAssertNil(makeController(binaryIdentity: "binary-2").passiveCurrentBundle()) + + let ledgerURL = tempRoot.appendingPathComponent("recovery-ledger.json") + let corrupt = Data("not-json".utf8) + try corrupt.write(to: ledgerURL) + XCTAssertNil(controller.passiveCurrentBundle()) + XCTAssertEqual(try Data(contentsOf: ledgerURL), corrupt) + } + + func testCorruptLedgerFailsStartupClosedToEmbedded() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["attempt-1"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + try Data("{\"revision\":".utf8).write( + to: tempRoot.appendingPathComponent("recovery-ledger.json") + ) + + let selection = makeController().selectStartupBundle() + + XCTAssertNil(selection.bundleURL) + XCTAssertNil(selection.attemptId) + XCTAssertNil(readPointer("current.json")) + let repaired = try readLedger() + XCTAssertEqual(repaired.phase, .recovered) + XCTAssertEqual(repaired.quarantinedHashes, [candidate.hash]) + + let healthy = try makeBundle(contents: "healthy-after-repair") + let activation = try makeController(ids: ["healthy-attempt"]).activateCandidate( + hash: healthy.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + XCTAssertEqual(activation.hash, healthy.hash) + XCTAssertEqual(readPointer("current.json")?.hash, healthy.hash) + } + + func testCorruptCurrentPointerFailsStartupClosedToEmbedded() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["attempt-1"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + try Data("{\"hash\":".utf8).write(to: tempRoot.appendingPathComponent("current.json")) + + let selection = controller.selectStartupBundle() + + XCTAssertNil(selection.bundleURL) + XCTAssertNil(selection.attemptId) + XCTAssertEqual(try readLedger().phase, .idle) + XCTAssertNil(try readLedger().activeAttempt) + } + + func testCorruptPreviousPointerRecoversToEmbeddedAndRecordsCrash() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let controller = makeController(ids: ["attempt-1"], processToken: "process-1") + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = controller.selectStartupBundle() + try Data("not-json".utf8).write(to: tempRoot.appendingPathComponent("previous.json")) + + let recovery = makeController( + ids: ["event-1"], + processToken: "process-2" + ).selectStartupBundle() + + XCTAssertNil(recovery.bundleURL) + XCTAssertNil(readPointer("current.json")) + let ledger = try readLedger() + XCTAssertEqual(ledger.quarantinedHashes, [candidate.hash]) + XCTAssertEqual(ledger.pendingRecoveryEvents.first?.id, "event-1") + XCTAssertEqual(ledger.pendingRecoveryEvents.first?.recoveryTarget, "embedded") + XCTAssertNil(ledger.pendingRecoveryEvents.first?.recoveredHash) + } + + func testCandidateFilesRemovedAfterActivationFailStartupClosedWithoutAttempt() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["reserved-but-not-launched"]) + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + try FileManager.default.removeItem(at: candidate.bundleURL) + + let selection = controller.selectStartupBundle() + + XCTAssertNil(selection.bundleURL) + XCTAssertNil(selection.attemptId) + let ledger = try readLedger() + XCTAssertEqual(ledger.phase, .idle) + XCTAssertNil(ledger.activeAttempt) + XCTAssertNil(ledger.reservedAttemptId) + XCTAssertTrue(ledger.pendingRecoveryEvents.isEmpty) + XCTAssertTrue(ledger.quarantinedHashes.isEmpty) + } + + func testRuntimeMismatchIsRejectedDuringActivation() throws { + let candidate = try makeBundle(contents: "candidate", runtimeVersion: "2.0.0") + let controller = makeController() + + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) { error in + XCTAssertEqual(error as? BundleDropStartupRecoveryError, .candidateUnavailable) + } + } + + func testMissingEmbeddedRuntimeIdentityRejectsActivation() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = BundleDropStartupRecoveryController( + bundleDropRoot: tempRoot, + expectedRuntimeVersion: nil, + expectedBinaryIdentity: "binary-1" + ) + + XCTAssertThrowsError(try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) { error in + XCTAssertEqual(error as? BundleDropStartupRecoveryError, .candidateUnavailable) + } + XCTAssertNil(controller.selectStartupBundle().bundleURL) + XCTAssertNil(controller.passiveCurrentBundle()) + } + + func testBinaryIdentityMismatchResetsLedgerAndPointers() throws { + let candidate = try makeBundle(contents: "candidate") + let firstBinary = makeController(ids: ["attempt-1"]) + _ = try firstBinary.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + + let nextBinary = makeController(binaryIdentity: "binary-2") + XCTAssertNil(nextBinary.selectStartupBundle().bundleURL) + + let ledger = try readLedger() + XCTAssertEqual(ledger.binaryIdentity, "binary-2") + XCTAssertEqual(ledger.runtimeIdentity, runtimeVersion) + XCTAssertEqual(ledger.phase, .idle) + XCTAssertNil(ledger.candidateHash) + XCTAssertNil(readPointer("current.json")) + } + + func testStaleLedgerWriterCannotOverwriteNewerRevision() throws { + let candidate = try makeBundle(contents: "candidate") + _ = try makeController().snapshot() + let newerHash = String(repeating: "d", count: 64) + let newerController = makeController() + var injectedNewerWrite = false + let staleController = makeController( + ids: ["stale-attempt"], + failpoint: { name in + guard name == "beforeLedgerWrite", !injectedNewerWrite else { return } + injectedNewerWrite = true + _ = try newerController.setRevokedHashes([newerHash]) + } + ) + + XCTAssertThrowsError(try staleController.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + )) { error in + XCTAssertEqual(error as? BundleDropStartupRecoveryError, .staleLedger) + } + + let ledger = try readLedger() + XCTAssertEqual(ledger.revokedHashes, [newerHash]) + XCTAssertNil(ledger.candidateHash) + XCTAssertNil(readPointer("current.json")) + } + + func testLedgerWriteFailureFailsClosedWithoutExposingUntrackedCandidate() throws { + let candidate = try makeBundle(contents: "candidate") + let setup = makeController(ids: ["unused"]) + _ = try setup.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + let failing = makeController(failpoint: { name in + if name == "beforeLedgerWrite" { throw TestError.interrupted } + }) + + let selection = failing.selectStartupBundle() + + XCTAssertNil(selection.bundleURL) + XCTAssertEqual(readPointer("current.json")?.hash, candidate.hash) + XCTAssertEqual(try readLedger().phase, .armed) + } + + func testRecoveryEventCanBeAcknowledgedExactlyOnce() throws { + let candidate = try makeBundle(contents: "candidate") + let controller = makeController(ids: ["attempt-1"], processToken: "process-1") + _ = try controller.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = controller.selectStartupBundle() + _ = makeController(ids: ["event-1"], processToken: "process-2").selectStartupBundle() + + XCTAssertTrue(try controller.acknowledgeRecovery(eventId: "event-1")) + XCTAssertFalse(try controller.acknowledgeRecovery(eventId: "event-1")) + XCTAssertTrue(try readLedger().pendingRecoveryEvents.isEmpty) + } + + func testInterruptedRecoveryTransitionCompletesOnceOnNextLaunch() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let firstLaunch = makeController( + ids: ["attempt-1"], + processToken: "process-1" + ) + _ = try firstLaunch.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = firstLaunch.selectStartupBundle() + let interrupted = makeController( + ids: ["event-1"], + processToken: "process-2", + failpoint: { name in + if name == "afterRecoveryPrepared" { throw TestError.interrupted } + } + ) + + XCTAssertNil(interrupted.selectStartupBundle().bundleURL) + XCTAssertEqual(readPointer("current.json")?.hash, candidate.hash) + XCTAssertEqual(try readLedger().pendingTransition?.kind, "recovery") + + let recovered = makeController().selectStartupBundle() + XCTAssertEqual(recovered.bundleURL, stable.bundleURL) + XCTAssertEqual(readPointer("current.json")?.hash, stable.hash) + XCTAssertNil(try readLedger().pendingTransition) + XCTAssertEqual(try readLedger().pendingRecoveryEvents.map(\.id), ["event-1"]) + } + + func testInterruptedRecoveryDowngradesUnavailablePreviousToEmbedded() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let firstLaunch = makeController( + ids: ["attempt-1"], + processToken: "process-1" + ) + _ = try firstLaunch.activateCandidate( + hash: candidate.hash, + maxCrashCount: 1, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + _ = firstLaunch.selectStartupBundle() + let interrupted = makeController( + ids: ["event-1"], + processToken: "process-2", + failpoint: { name in + if name == "afterRecoveryPrepared" { throw TestError.interrupted } + } + ) + _ = interrupted.selectStartupBundle() + try FileManager.default.removeItem(at: stable.bundleURL) + + let recovered = makeController(processToken: "process-3").selectStartupBundle() + + XCTAssertNil(recovered.bundleURL) + XCTAssertNil(readPointer("current.json")) + XCTAssertNil(readPointer("previous.json")) + let ledger = try readLedger() + XCTAssertNil(ledger.pendingTransition) + XCTAssertNil(ledger.stableHash) + XCTAssertEqual(ledger.quarantinedHashes, [candidate.hash]) + XCTAssertEqual(ledger.pendingRecoveryEvents.map(\.id), ["event-1"]) + XCTAssertEqual(ledger.pendingRecoveryEvents.first?.recoveryTarget, "embedded") + XCTAssertNil(ledger.pendingRecoveryEvents.first?.recoveredHash) + } + + func testInterruptedRollbackTransitionCompletesWithoutCrashEvent() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let interrupted = makeController( + ids: ["attempt-1"], + failpoint: { name in + if name == "afterRollbackPrepared" { throw TestError.interrupted } + } + ) + _ = try interrupted.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + + XCTAssertThrowsError(try interrupted.rollback(forceEmbedded: false)) + XCTAssertEqual(readPointer("current.json")?.hash, candidate.hash) + XCTAssertEqual(try readLedger().pendingTransition?.kind, "rollback") + + let recovered = makeController().selectStartupBundle() + XCTAssertEqual(recovered.bundleURL, stable.bundleURL) + XCTAssertNil(try readLedger().pendingTransition) + XCTAssertTrue(try readLedger().pendingRecoveryEvents.isEmpty) + XCTAssertTrue(try readLedger().quarantinedHashes.isEmpty) + } + + func testInterruptedRollbackDowngradesUnavailablePreviousToEmbedded() throws { + let stable = try makeBundle(contents: "stable") + let candidate = try makeBundle(contents: "candidate") + try establishNativeStable(stable.hash) + let interrupted = makeController(failpoint: { name in + if name == "afterRollbackPrepared" { throw TestError.interrupted } + }) + _ = try interrupted.activateCandidate( + hash: candidate.hash, + maxCrashCount: 2, + healthCheckMode: "manual", + healthyAfterSec: 0 + ) + + XCTAssertThrowsError(try interrupted.rollback(forceEmbedded: false)) + try FileManager.default.removeItem(at: stable.bundleURL) + + let recovered = makeController().selectStartupBundle() + + XCTAssertNil(recovered.bundleURL) + XCTAssertNil(readPointer("current.json")) + XCTAssertNil(readPointer("previous.json")) + let ledger = try readLedger() + XCTAssertNil(ledger.pendingTransition) + XCTAssertNil(ledger.stableHash) + XCTAssertTrue(ledger.pendingRecoveryEvents.isEmpty) + XCTAssertTrue(ledger.quarantinedHashes.isEmpty) + } + + func testActivationCompletesInterruptedEmbeddedRollbackFirst() throws { + let firstCandidate = try makeBundle(contents: "first") + let nextCandidate = try makeBundle(contents: "next") + let interrupted = makeController( + ids: ["attempt-1"], + failpoint: { name in + if name == "afterRollbackCurrentDelete" { throw TestError.interrupted } + } + ) + _ = try interrupted.activateCandidate( + hash: firstCandidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + XCTAssertThrowsError(try interrupted.rollback(forceEmbedded: true)) + XCTAssertNil(readPointer("current.json")) + XCTAssertEqual(try readLedger().pendingTransition?.kind, "rollback") + + _ = try makeController(ids: ["next-attempt"]).activateCandidate( + hash: nextCandidate.hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + + XCTAssertEqual(readPointer("current.json")?.hash, nextCandidate.hash) + XCTAssertNil(try readLedger().pendingTransition) + XCTAssertEqual(try readLedger().reservedAttemptId, "next-attempt") + } + + private enum TestError: Error { + case interrupted + } + + private struct TestBundle { + let hash: String + let bundleURL: URL + } + + private struct ManifestFile { + let path: String + let role: String + let sha256: String + let size: Int + } + + private func makeController( + ids: [String] = [], + binaryIdentity: String = "binary-1", + processToken: String = UUID().uuidString, + failpoint: BundleDropStartupRecoveryController.Failpoint? = nil + ) -> BundleDropStartupRecoveryController { + var remainingIds = ids + return BundleDropStartupRecoveryController( + bundleDropRoot: tempRoot, + expectedRuntimeVersion: runtimeVersion, + expectedBinaryIdentity: binaryIdentity, + processToken: processToken, + now: { 1_700_000_000 }, + makeId: { remainingIds.isEmpty ? UUID().uuidString : remainingIds.removeFirst() }, + failpoint: failpoint + ) + } + + private func makeBundle(contents: String, runtimeVersion: String? = nil) throws -> TestBundle { + let files = [ + ManifestFile( + path: "main.jsbundle", + role: "jsbundle", + sha256: sha256(contents), + size: contents.utf8.count + ), + ManifestFile( + path: "metadata-ios.json", + role: "metadata", + sha256: sha256("{}"), + size: 2 + ), + ] + let canonicalFiles = files.sorted { $0.path.utf8.lexicographicallyPrecedes($1.path.utf8) } + let fileJson = canonicalFiles.map(fileJson).joined(separator: ",") + let bundleHash = sha256("{\"files\":[\(fileJson)],\"manifestVersion\":1}") + let bundleDirectory = tempRoot.appendingPathComponent("bundles/\(bundleHash)", isDirectory: true) + try FileManager.default.createDirectory(at: bundleDirectory, withIntermediateDirectories: true) + try contents.write( + to: bundleDirectory.appendingPathComponent("main.jsbundle"), + atomically: true, + encoding: .utf8 + ) + try "{}".write( + to: bundleDirectory.appendingPathComponent("metadata-ios.json"), + atomically: true, + encoding: .utf8 + ) + let actualRuntime = runtimeVersion ?? self.runtimeVersion + let jsHash = files[0].sha256 + let manifestHashFields = [ + "\"bundleHash\":\(jsonString(bundleHash))", + "\"files\":[\(fileJson)]", + "\"jsBundleHash\":\(jsonString(jsHash))", + "\"manifestVersion\":1", + "\"platform\":\(jsonString("ios"))", + "\"runtimeVersion\":\(jsonString(actualRuntime))", + "\"version\":\(jsonString("1.0.0"))", + ].joined(separator: ",") + let manifestHash = sha256("{\(manifestHashFields)}") + let manifest = "{\"manifestVersion\":1,\"bundleHash\":\(jsonString(bundleHash)),\"jsBundleHash\":\(jsonString(jsHash)),\"platform\":\(jsonString("ios")),\"runtimeVersion\":\(jsonString(actualRuntime)),\"version\":\(jsonString("1.0.0")),\"manifestHash\":\(jsonString(manifestHash)),\"files\":[\(fileJson)]}" + try manifest.write( + to: bundleDirectory.appendingPathComponent("bundle-manifest.json"), + atomically: true, + encoding: .utf8 + ) + return TestBundle( + hash: bundleHash, + bundleURL: bundleDirectory.appendingPathComponent("main.jsbundle") + ) + } + + private func establishNativeStable(_ hash: String) throws { + let controller = makeController(ids: ["stable-attempt"]) + _ = try controller.activateCandidate( + hash: hash, + maxCrashCount: 2, + healthCheckMode: "auto", + healthyAfterSec: 0 + ) + let attempt = controller.selectStartupBundle() + XCTAssertTrue(controller.markHealthy(hash: hash, attemptId: try XCTUnwrap(attempt.attemptId))) + } + + private func readPointer(_ name: String) -> BundleDropOtaPointer? { + BundleDropOtaResolver.readPointer( + named: name, + bundleDropRoot: tempRoot, + expectedRuntimeVersion: runtimeVersion + ) + } + + private func readLedger() throws -> BundleDropStartupRecoveryLedger { + try JSONDecoder().decode( + BundleDropStartupRecoveryLedger.self, + from: Data(contentsOf: tempRoot.appendingPathComponent("recovery-ledger.json")) + ) + } + + private func fileJson(_ file: ManifestFile) -> String { + "{\"path\":\(jsonString(file.path)),\"role\":\(jsonString(file.role)),\"sha256\":\(jsonString(file.sha256)),\"size\":\(file.size)}" + } + + private func jsonString(_ value: String) -> String { + let data = try! JSONSerialization.data(withJSONObject: [value]) + return String(data: data, encoding: .utf8)! + .dropFirst() + .dropLast() + .replacingOccurrences(of: "\\/", with: "/") + } + + private func sha256(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private func writeJson(_ object: Any, to url: URL) throws { + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + try data.write(to: url, options: .atomic) + } +} diff --git a/ios/BundleDropBridge.m b/ios/BundleDropBridge.m index 0582fca..1849c47 100644 --- a/ios/BundleDropBridge.m +++ b/ios/BundleDropBridge.m @@ -5,6 +5,33 @@ @interface RCT_EXTERN_REMAP_MODULE(BundleDrop, BundleDropModule, NSObject) RCT_EXTERN_METHOD(getDownloadedBundlePath:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(activateStartupCandidate:(NSString *)hash + maxCrashCount:(nonnull NSNumber *)maxCrashCount + healthCheckMode:(NSString *)healthCheckMode + healthyAfterSec:(nonnull NSNumber *)healthyAfterSec + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(markStartupHealthy:(NSString *)hash + attemptId:(NSString *)attemptId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getStartupRecoveryState:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(setStartupRecoveryRevokedHashes:(NSArray *)hashes + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(acknowledgeStartupRecovery:(NSString *)eventId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(rollbackStartupBundle:(BOOL)forceEmbedded + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(restartReactNative) RCT_EXTERN_METHOD(setOtaEnabled:(BOOL)enabled diff --git a/ios/BundleDropLocator.swift b/ios/BundleDropLocator.swift index 8f35a78..95ee5c1 100644 --- a/ios/BundleDropLocator.swift +++ b/ios/BundleDropLocator.swift @@ -6,6 +6,7 @@ import Foundation static let runtimeVersionInfoKey = "BundleDropRuntimeVersion" static let expoEnabledInfoKey = "BundleDropExpoEnabled" static let embeddedBuildIdentityFilename = ".bundle-drop-build-identity.json" + static let bareEmbeddedBuildIdentityFilename = "bundle-drop-build-identity.json" private struct EmbeddedBuildIdentity: Decodable { let schemaVersion: Int @@ -44,20 +45,23 @@ import Foundation } 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 + for filename in [embeddedBuildIdentityFilename, bareEmbeddedBuildIdentityFilename] { + let candidateURL = bundle.bundleURL.appendingPathComponent(filename) + 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 { + continue + } + return normalizeRuntimeVersion(candidate.runtimeVersion) } - return normalizeRuntimeVersion(candidate.runtimeVersion) + return nil } private static func normalizeRuntimeVersion(_ runtimeVersion: String?) -> String? { @@ -68,9 +72,14 @@ import Foundation @objc public static func bundleURL() -> URL? { guard hasRuntimeIdentityForOta() else { + BundleDropStartupRecoveryAdapter.clearCapturedSelection() print("BundleDrop: Expo runtime identity is missing; using the embedded bundle") return nil } + guard isOtaEnabled() else { + BundleDropStartupRecoveryAdapter.clearCapturedSelection() + 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 } @@ -80,7 +89,10 @@ import Foundation return bundleURL( bundleDropRoot: root, documentsDirectory: docs, - currentBinaryVersion: currentVersion + currentBinaryVersion: currentVersion, + startupSelection: { + BundleDropStartupRecoveryAdapter.selectStartupBundle(bundleDropRoot: root) + } ) } @@ -91,7 +103,8 @@ import Foundation userDefaults: UserDefaults = .standard, fileManager: FileManager = .default, shouldLogBinaryUpdate: Bool = true, - log: (String) -> Void = { print($0) } + log: (String) -> Void = { print($0) }, + startupSelection: (() -> URL?)? = nil ) -> URL? { // Single gate for public and internal entrypoints (tests inject `userDefaults`). if !isOtaEnabled(userDefaults: userDefaults) { return nil } @@ -109,6 +122,9 @@ import Foundation } userDefaults.set(result.storedVersion, forKey: binaryVersionKey) + if let startupSelection { + return startupSelection() + } return result.bundleURL } diff --git a/ios/BundleDropModule.swift b/ios/BundleDropModule.swift index 9708547..6f821bb 100644 --- a/ios/BundleDropModule.swift +++ b/ios/BundleDropModule.swift @@ -30,7 +30,7 @@ final class BundleDropModule: NSObject { _ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock ) { - if let url = BundleDropLocatorCore.bundleURL() { + if let url = BundleDropStartupRecoveryAdapter.downloadedBundleURL() { print("📦 Found downloaded bundle at: \(url.path)") resolve(url.path) return @@ -39,6 +39,99 @@ final class BundleDropModule: NSObject { resolve(nil) } + @objc + func activateStartupCandidate( + _ hash: String, + maxCrashCount: NSNumber, + healthCheckMode: String, + healthyAfterSec: NSNumber, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + runFileOperation(errorCode: "ERR_STARTUP_RECOVERY_ACTIVATE", resolve: resolve, reject: reject) { + let crashCount = maxCrashCount.doubleValue + let healthDelay = healthyAfterSec.doubleValue + guard crashCount.isFinite, + crashCount >= 0, + crashCount.rounded(.towardZero) == crashCount, + crashCount <= Double(Int32.max), + healthDelay.isFinite, + healthDelay >= 0 else { + throw BundleDropStartupRecoveryError.invalidPolicy + } + let result = try BundleDropStartupRecoveryAdapter.activateCandidate( + hash: hash, + maxCrashCount: Int(crashCount), + healthCheckMode: healthCheckMode, + healthyAfterSec: healthDelay + ) + return ["hash": result.hash, "bundlePath": result.bundleURL.path] + } + } + + @objc + func markStartupHealthy( + _ hash: String, + attemptId: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + runFileOperation(errorCode: "ERR_STARTUP_RECOVERY_HEALTH", resolve: resolve, reject: reject) { + BundleDropStartupRecoveryAdapter.markHealthy(hash: hash, attemptId: attemptId) + } + } + + @objc + func getStartupRecoveryState( + _ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + runFileOperation(errorCode: "ERR_STARTUP_RECOVERY_STATE", resolve: resolve, reject: reject) { + try BundleDropStartupRecoveryAdapter.snapshot() + } + } + + @objc + func setStartupRecoveryRevokedHashes( + _ hashes: [String], + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + runFileOperation(errorCode: "ERR_STARTUP_RECOVERY_REVOKE", resolve: resolve, reject: reject) { + try BundleDropStartupRecoveryAdapter.setRevokedHashes(hashes) + } + } + + @objc + func acknowledgeStartupRecovery( + _ eventId: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + runFileOperation(errorCode: "ERR_STARTUP_RECOVERY_ACK", resolve: resolve, reject: reject) { + try BundleDropStartupRecoveryAdapter.acknowledgeRecovery(eventId: eventId) + } + } + + @objc + func rollbackStartupBundle( + _ forceEmbedded: Bool, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + runFileOperation(errorCode: "ERR_STARTUP_RECOVERY_ROLLBACK", resolve: resolve, reject: reject) { + let result = try BundleDropStartupRecoveryAdapter.rollback(forceEmbedded: forceEmbedded) + var response: [String: Any] = [ + "rolledBack": result.rolledBack, + "toEmbedded": result.toEmbedded, + ] + if let hash = result.hash { + response["hash"] = hash + } + return response + } + } + @objc func setOtaEnabled( _ enabled: Bool, @@ -57,17 +150,22 @@ final class BundleDropModule: NSObject { return } - guard let url = BundleDropLocatorCore.bundleURL() else { + guard let stagedURL = BundleDropStartupRecoveryAdapter.downloadedBundleURL() else { print("⚠️ No downloaded bundle found; skipping restart.") return } - let size = BundleDropLocatorCore.fileSize(at: url) + let size = BundleDropLocatorCore.fileSize(at: stagedURL) if size < 1024 { print("⚠️ Bundle exists but looks invalid (size=\(size)). Skipping restart.") return } + guard let url = BundleDropStartupRecoveryAdapter.beginReload() else { + print("⚠️ Downloaded bundle became unavailable; skipping restart.") + return + } + Self.isReloading = true print("🔄 Restarting RN from \(url.path) size=\(size)") @@ -87,9 +185,14 @@ final class BundleDropModule: NSObject { let fm = FileManager.default let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first?.path ?? "" let lib = fm.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? "" + let attempt = BundleDropStartupRecoveryAdapter.capturedAttempt() return [ "DocumentDirectoryPath": docs, "LibraryDirectoryPath": lib, + "startupRecoveryProtocolVersion": BundleDropStartupRecoveryAdapter.protocolVersion, + "startupRecoverySelectedHash": BundleDropStartupRecoveryAdapter.capturedSelectedHash() ?? NSNull(), + "startupRecoveryAttemptHash": attempt.hash ?? NSNull(), + "startupRecoveryAttemptId": attempt.attemptId ?? NSNull(), ] } diff --git a/ios/BundleDropOtaResolver.swift b/ios/BundleDropOtaResolver.swift index f52a7d8..4ae14b1 100644 --- a/ios/BundleDropOtaResolver.swift +++ b/ios/BundleDropOtaResolver.swift @@ -7,50 +7,152 @@ struct BundleDropOtaResolveResult { let storedVersion: String } +struct BundleDropOtaPointer { + let hash: String + let bundleURL: URL + let runtimeVersion: String +} + enum BundleDropOtaResolver { static func readCurrentPointer( bundleDropRoot: URL, + expectedRuntimeVersion: String? = nil, fileManager: FileManager = .default ) -> URL? { - let current = bundleDropRoot.appendingPathComponent("current.json") - guard fileManager.fileExists(atPath: current.path) else { return nil } + readPointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + )?.bundleURL + } + + static func readPointer( + named filename: String, + bundleDropRoot: URL, + expectedRuntimeVersion: String? = nil, + fileManager: FileManager = .default + ) -> BundleDropOtaPointer? { + let pointerURL = bundleDropRoot.appendingPathComponent(filename) + guard fileManager.fileExists(atPath: pointerURL.path) else { return nil } do { - let data = try Data(contentsOf: current) + let data = try Data(contentsOf: pointerURL) let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] guard let hash = obj?["hash"] as? String, hash.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else { return nil } - - let expectedURL = bundleDropRoot - .appendingPathComponent("bundles") - .appendingPathComponent(hash) - .appendingPathComponent("main.jsbundle") - let manifestURL = expectedURL - .deletingLastPathComponent() - .appendingPathComponent("bundle-manifest.json") - guard fileManager.fileExists(atPath: manifestURL.path), - let manifestData = try? Data(contentsOf: manifestURL), - let manifest = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], - (manifest["manifestVersion"] as? NSNumber)?.intValue == 1, - manifest["bundleHash"] as? String == hash else { - return nil - } - guard verifyBundleDir( - bundleDir: expectedURL.deletingLastPathComponent(), - manifest: manifest, - expectedHash: hash, + return verifiedBundle( + hash: hash, + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, fileManager: fileManager - ) else { - return nil - } - return fileManager.fileExists(atPath: expectedURL.path) ? expectedURL : nil + ) } catch { return nil } } + static func readBundle( + hash: String, + bundleDropRoot: URL, + expectedRuntimeVersion: String? = nil, + fileManager: FileManager = .default + ) -> BundleDropOtaPointer? { + guard hash.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else { + return nil + } + return verifiedBundle( + hash: hash, + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) + } + + private static func verifiedBundle( + hash: String, + bundleDropRoot: URL, + expectedRuntimeVersion: String?, + fileManager: FileManager + ) -> BundleDropOtaPointer? { + let bundleURL = bundleDropRoot + .appendingPathComponent("bundles") + .appendingPathComponent(hash) + .appendingPathComponent("main.jsbundle") + let manifestURL = bundleURL + .deletingLastPathComponent() + .appendingPathComponent("bundle-manifest.json") + guard fileManager.fileExists(atPath: manifestURL.path), + let manifestData = try? Data(contentsOf: manifestURL), + let manifest = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + (manifest["manifestVersion"] as? NSNumber)?.intValue == 1, + manifest["bundleHash"] as? String == hash, + let runtimeVersion = manifest["runtimeVersion"] as? String, + expectedRuntimeVersion == nil || runtimeVersion == expectedRuntimeVersion, + verifyBundleDir( + bundleDir: bundleURL.deletingLastPathComponent(), + manifest: manifest, + expectedHash: hash, + fileManager: fileManager + ), + fileManager.fileExists(atPath: bundleURL.path) else { + return nil + } + return BundleDropOtaPointer(hash: hash, bundleURL: bundleURL, runtimeVersion: runtimeVersion) + } + + static func writePointer( + named filename: String, + hash: String, + bundleDropRoot: URL, + fileManager: FileManager = .default + ) throws { + guard hash.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else { + throw NSError( + domain: "BundleDropStartupRecovery", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Bundle pointer hash is invalid"] + ) + } + try fileManager.createDirectory(at: bundleDropRoot, withIntermediateDirectories: true) + let pointerURL = bundleDropRoot.appendingPathComponent(filename) + let pointer: [String: Any] = [ + "hash": hash, + "updatedAt": ISO8601DateFormatter().string(from: Date()), + ] + let temporaryURL = bundleDropRoot.appendingPathComponent( + ".\(filename)-\(UUID().uuidString).tmp" + ) + do { + try JSONSerialization.data(withJSONObject: pointer, options: [.sortedKeys]) + .write(to: temporaryURL) + let handle = try FileHandle(forWritingTo: temporaryURL) + try handle.synchronize() + try handle.close() + if fileManager.fileExists(atPath: pointerURL.path) { + _ = try fileManager.replaceItemAt(pointerURL, withItemAt: temporaryURL) + } else { + try fileManager.moveItem(at: temporaryURL, to: pointerURL) + } + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + } + + static func deletePointer( + named filename: String, + bundleDropRoot: URL, + fileManager: FileManager = .default + ) throws { + let pointerURL = bundleDropRoot.appendingPathComponent(filename) + if fileManager.fileExists(atPath: pointerURL.path) { + try fileManager.removeItem(at: pointerURL) + } + } + private static func verifyBundleDir( bundleDir: URL, manifest: [String: Any], @@ -174,27 +276,32 @@ enum BundleDropOtaResolver { return String(encoded.dropFirst().dropLast()).replacingOccurrences(of: "\\/", with: "/") } + @discardableResult static func clearOtaState( bundleDropRoot: URL, documentsDirectory: URL, fileManager: FileManager = .default - ) { + ) -> Bool { let filesToClear = [ bundleDropRoot.appendingPathComponent("current.json"), bundleDropRoot.appendingPathComponent("previous.json"), bundleDropRoot.appendingPathComponent("state.json"), + bundleDropRoot.appendingPathComponent("recovery-ledger.json"), documentsDirectory.appendingPathComponent("bundle-info.json"), ] + var clearedAllFiles = true filesToClear.forEach { url in do { if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } } catch { + clearedAllFiles = false // Best effort cleanup; a single bad file should not block native fallback. } } + return clearedAllFiles } static func hasOtaState( @@ -206,6 +313,7 @@ enum BundleDropOtaResolver { bundleDropRoot.appendingPathComponent("current.json"), bundleDropRoot.appendingPathComponent("previous.json"), bundleDropRoot.appendingPathComponent("state.json"), + bundleDropRoot.appendingPathComponent("recovery-ledger.json"), documentsDirectory.appendingPathComponent("bundle-info.json"), ].contains { url in fileManager.fileExists(atPath: url.path) @@ -226,7 +334,7 @@ enum BundleDropOtaResolver { documentsDirectory: documentsDirectory, fileManager: fileManager ) - clearOtaState( + let clearedAllFiles = clearOtaState( bundleDropRoot: bundleDropRoot, documentsDirectory: documentsDirectory, fileManager: fileManager @@ -234,7 +342,7 @@ enum BundleDropOtaResolver { return BundleDropOtaResolveResult( bundleURL: nil, clearedOta: hadOtaState, - storedVersion: currentBinaryVersion + storedVersion: clearedAllFiles ? currentBinaryVersion : storedBinaryVersion ) } diff --git a/ios/BundleDropStartupRecovery.swift b/ios/BundleDropStartupRecovery.swift new file mode 100644 index 0000000..3c016e7 --- /dev/null +++ b/ios/BundleDropStartupRecovery.swift @@ -0,0 +1,1108 @@ +import Foundation + +enum BundleDropStartupRecoveryError: LocalizedError { + case invalidHash + case invalidHealthCheckMode + case invalidPolicy + case candidateUnavailable + case candidateIneligible + case ledgerCorrupt + case storageUnavailable + case staleLedger + + var errorDescription: String? { + switch self { + case .invalidHash: + return "Bundle Drop startup recovery received an invalid bundle hash" + case .invalidHealthCheckMode: + return "Bundle Drop startup recovery healthCheckMode must be auto or manual" + case .invalidPolicy: + return "Bundle Drop startup recovery policy must use a non-negative 32-bit integer maxCrashCount and a finite non-negative healthyAfterSec" + case .candidateUnavailable: + return "Bundle Drop startup recovery could not verify the candidate bundle" + case .candidateIneligible: + return "Bundle Drop startup recovery rejected a quarantined or revoked candidate" + case .ledgerCorrupt: + return "Bundle Drop startup recovery ledger is malformed or unsupported" + case .storageUnavailable: + return "Bundle Drop startup recovery storage is unavailable" + case .staleLedger: + return "Bundle Drop startup recovery rejected a stale ledger transition" + } + } +} + +enum BundleDropStartupRecoveryPhase: String, Codable { + case idle + case armed + case launching + case stable + case recovered +} + +struct BundleDropStartupRecoveryPolicy: Codable, Equatable { + let maxCrashCount: Int + let healthCheckMode: String + let healthyAfterSec: Double +} + +struct BundleDropStartupRecoveryAttempt: Codable, Equatable { + let hash: String + let attemptId: String + let processToken: String + let startedAt: Int64 + var unacknowledgedLaunchCount: Int + var contentAppeared: Bool +} + +struct BundleDropStartupRecoveryTransition: Codable, Equatable { + let kind: String + let targetHash: String? +} + +struct BundleDropStartupRecoveryEvent: Codable, Equatable { + let id: String + let failedHash: String + let recoveryTarget: String + let recoveredHash: String? + let crashCount: Int + let reason: String + let failedAt: Int64 +} + +struct BundleDropStartupRecoveryLedger: Codable, Equatable { + var schemaVersion = 1 + var revision = 0 + var binaryIdentity: String? + var runtimeIdentity: String? + var legacyFailuresImported: Bool? + var phase = BundleDropStartupRecoveryPhase.idle + var candidateHash: String? + var stableHash: String? + var previousStableHash: String? + var lastHealthyAttemptId: String? + var activeAttempt: BundleDropStartupRecoveryAttempt? + var reservedAttemptId: String? + var pendingTransition: BundleDropStartupRecoveryTransition? + var policy: BundleDropStartupRecoveryPolicy? + var quarantinedHashes: [String] = [] + var revokedHashes: [String] = [] + var pendingRecoveryEvents: [BundleDropStartupRecoveryEvent] = [] +} + +struct BundleDropStartupSelection: Equatable { + let bundleURL: URL? + let attemptHash: String? + let attemptId: String? +} + +struct BundleDropStartupActivationResult: Equatable { + let hash: String + let bundleURL: URL +} + +struct BundleDropStartupRollbackResult: Equatable { + let rolledBack: Bool + let toEmbedded: Bool + let hash: String? +} + +/// Native-owned startup ledger. JavaScript may request transitions through the bridge, +/// but it never writes this file directly. +final class BundleDropStartupRecoveryController { + static let protocolVersion = 1 + private static let ledgerCommitLock = NSLock() + + typealias Failpoint = (String) throws -> Void + + private let bundleDropRoot: URL + private let expectedRuntimeVersion: String? + private let expectedBinaryIdentity: String? + private let processToken: String + private let fileManager: FileManager + private let now: () -> Int64 + private let makeId: () -> String + private let failpoint: Failpoint? + private let lock = NSRecursiveLock() + + private var ledgerURL: URL { + bundleDropRoot.appendingPathComponent("recovery-ledger.json") + } + + private var legacyStateURL: URL { + bundleDropRoot.appendingPathComponent("state.json") + } + + init( + bundleDropRoot: URL, + expectedRuntimeVersion: String?, + expectedBinaryIdentity: String? = nil, + processToken: String = UUID().uuidString.lowercased(), + fileManager: FileManager = .default, + now: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970) }, + makeId: @escaping () -> String = { UUID().uuidString.lowercased() }, + failpoint: Failpoint? = nil + ) { + self.bundleDropRoot = bundleDropRoot + self.expectedRuntimeVersion = expectedRuntimeVersion + self.expectedBinaryIdentity = expectedBinaryIdentity + self.processToken = processToken + self.fileManager = fileManager + self.now = now + self.makeId = makeId + self.failpoint = failpoint + } + + func activateCandidate( + hash: String, + maxCrashCount: Int, + healthCheckMode: String, + healthyAfterSec: Double + ) throws -> BundleDropStartupActivationResult { + try withLock { + guard hasExpectedIdentity else { + throw BundleDropStartupRecoveryError.candidateUnavailable + } + guard Self.isCanonicalHash(hash) else { + throw BundleDropStartupRecoveryError.invalidHash + } + guard healthCheckMode == "auto" || healthCheckMode == "manual" else { + throw BundleDropStartupRecoveryError.invalidHealthCheckMode + } + guard maxCrashCount >= 0, + maxCrashCount <= Int(Int32.max), + healthyAfterSec.isFinite, + healthyAfterSec >= 0 else { + throw BundleDropStartupRecoveryError.invalidPolicy + } + guard let candidate = BundleDropOtaResolver.readBundle( + hash: hash, + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) else { + throw BundleDropStartupRecoveryError.candidateUnavailable + } + try failpoint?("afterCandidateVerified") + + var ledger = try readLedgerImportingLegacyState() + if ledger.pendingTransition != nil { + try completePendingTransition(ledger: &ledger) + } + guard !ledger.quarantinedHashes.contains(hash), !ledger.revokedHashes.contains(hash) else { + throw BundleDropStartupRecoveryError.candidateIneligible + } + + let current = BundleDropOtaResolver.readPointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) + let requestedPolicy = BundleDropStartupRecoveryPolicy( + maxCrashCount: maxCrashCount, + healthCheckMode: healthCheckMode, + healthyAfterSec: healthyAfterSec + ) + if current?.hash == hash, + ledger.stableHash == hash, + ledger.phase == .stable || ledger.phase == .recovered { + return BundleDropStartupActivationResult(hash: hash, bundleURL: candidate.bundleURL) + } + if current?.hash == hash, + ledger.candidateHash == hash, + ledger.phase == .armed || ledger.phase == .launching { + var changed = ledger.policy != requestedPolicy + ledger.policy = requestedPolicy + if requestedPolicy.maxCrashCount == 0 { + changed = changed || ledger.phase != .armed || ledger.activeAttempt != nil || ledger.reservedAttemptId != nil + ledger.phase = .armed + ledger.activeAttempt = nil + ledger.reservedAttemptId = nil + ledger.lastHealthyAttemptId = nil + } else if ledger.phase == .armed, ledger.reservedAttemptId == nil { + ledger.reservedAttemptId = makeId() + changed = true + } + if changed { + try commit(&ledger) + } + return BundleDropStartupActivationResult(hash: hash, bundleURL: candidate.bundleURL) + } + + let previousPointer = BundleDropOtaResolver.readPointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) + let currentIsProvenStable = current?.hash == ledger.stableHash + let previousIsProvenStable = previousPointer?.hash == ledger.stableHash + let fallbackHash: String? + if currentIsProvenStable, let currentHash = current?.hash { + fallbackHash = currentHash + try writePointer(named: "previous.json", hash: currentHash, failpointPrefix: "previous") + } else if current?.hash == hash && previousIsProvenStable { + // Transitional compatibility for a JS client that changed current.json first. + fallbackHash = previousPointer?.hash + } else { + fallbackHash = nil + try? BundleDropOtaResolver.deletePointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + } + + ledger.phase = .armed + ledger.candidateHash = hash + ledger.stableHash = fallbackHash + ledger.previousStableHash = nil + ledger.lastHealthyAttemptId = nil + ledger.activeAttempt = nil + ledger.policy = requestedPolicy + ledger.reservedAttemptId = requestedPolicy.maxCrashCount > 0 ? makeId() : nil + try commit(&ledger) + try failpoint?("afterCandidateArmed") + + if current?.hash != hash { + try writePointer(named: "current.json", hash: hash, failpointPrefix: "current") + } + return BundleDropStartupActivationResult(hash: hash, bundleURL: candidate.bundleURL) + } + } + + func selectStartupBundle(beginReload: Bool = false) -> BundleDropStartupSelection { + withLock { + guard hasExpectedIdentity else { + return BundleDropStartupSelection(bundleURL: nil, attemptHash: nil, attemptId: nil) + } + do { + var ledger = try readLedgerImportingLegacyState() + if ledger.pendingTransition != nil { + try completePendingTransition(ledger: &ledger) + } + guard let current = BundleDropOtaResolver.readPointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) else { + if ledger.phase == .armed { + let fallback = ledger.stableHash.flatMap { hash in + verifiedPreviousPointer(hash: hash, ledger: ledger) + } + if let fallback { + try writePointer( + named: "current.json", + hash: fallback.hash, + failpointPrefix: "abortedActivationCurrent" + ) + } + ledger.phase = fallback == nil ? .idle : .stable + ledger.candidateHash = nil + ledger.stableHash = fallback?.hash + ledger.previousStableHash = nil + ledger.activeAttempt = nil + ledger.reservedAttemptId = nil + ledger.policy = nil + try? BundleDropOtaResolver.deletePointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + try commit(&ledger) + return BundleDropStartupSelection( + bundleURL: fallback?.bundleURL, + attemptHash: nil, + attemptId: nil + ) + } + return BundleDropStartupSelection(bundleURL: nil, attemptHash: nil, attemptId: nil) + } + + // Activation writes the ledger before publishing current.json. If the + // process stops between those writes, abandon the unpublished candidate + // and continue booting the previously proven bundle. + if ledger.phase == .armed, + ledger.candidateHash != current.hash, + ledger.stableHash == current.hash { + ledger.phase = .stable + ledger.candidateHash = nil + ledger.activeAttempt = nil + ledger.reservedAttemptId = nil + ledger.policy = nil + try? BundleDropOtaResolver.deletePointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + try commit(&ledger) + return BundleDropStartupSelection(bundleURL: current.bundleURL, attemptHash: nil, attemptId: nil) + } + + if ledger.revokedHashes.contains(current.hash) || ledger.quarantinedHashes.contains(current.hash) { + return try recover( + failedHash: current.hash, + crashCount: ledger.activeAttempt?.unacknowledgedLaunchCount ?? 0, + quarantine: ledger.quarantinedHashes.contains(current.hash), + emitEvent: false, + ledger: &ledger + ) + } + + if ledger.stableHash == current.hash, + ledger.phase == .stable || ledger.phase == .recovered { + return BundleDropStartupSelection( + bundleURL: current.bundleURL, + attemptHash: nil, + attemptId: nil + ) + } + + guard ledger.candidateHash == current.hash, + ledger.phase == .armed || ledger.phase == .launching, + let policy = ledger.policy else { + // An OTA without native proof must never become startup-visible. + try BundleDropOtaResolver.deletePointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + return BundleDropStartupSelection(bundleURL: nil, attemptHash: nil, attemptId: nil) + } + + // A zero limit explicitly disables launch-health classification. The + // candidate remains ledger-tracked for integrity and revocation checks, + // but it is never treated as health-proven or charged a failed attempt. + if policy.maxCrashCount == 0 { + return BundleDropStartupSelection( + bundleURL: current.bundleURL, + attemptHash: nil, + attemptId: nil + ) + } + + if !beginReload, + ledger.activeAttempt?.processToken == processToken, + ledger.activeAttempt?.hash == current.hash { + return BundleDropStartupSelection( + bundleURL: current.bundleURL, + attemptHash: current.hash, + attemptId: ledger.activeAttempt?.attemptId + ) + } + + var unacknowledgedLaunchCount = ledger.activeAttempt?.unacknowledgedLaunchCount ?? 0 + if ledger.phase == .launching, !beginReload { + unacknowledgedLaunchCount += 1 + } + + if policy.maxCrashCount > 0, + unacknowledgedLaunchCount >= policy.maxCrashCount { + return try recover( + failedHash: current.hash, + crashCount: unacknowledgedLaunchCount, + quarantine: true, + emitEvent: true, + ledger: &ledger + ) + } + + let attemptId = ledger.reservedAttemptId ?? makeId() + ledger.phase = .launching + ledger.reservedAttemptId = nil + ledger.activeAttempt = BundleDropStartupRecoveryAttempt( + hash: current.hash, + attemptId: attemptId, + processToken: processToken, + startedAt: now(), + unacknowledgedLaunchCount: unacknowledgedLaunchCount, + contentAppeared: false + ) + try commit(&ledger) + try failpoint?("afterLaunchPersisted") + return BundleDropStartupSelection( + bundleURL: current.bundleURL, + attemptHash: current.hash, + attemptId: attemptId + ) + } catch BundleDropStartupRecoveryError.ledgerCorrupt { + return repairCorruptLedger() + } catch { + // Failing closed here prevents an untracked candidate from booting. + return BundleDropStartupSelection(bundleURL: nil, attemptHash: nil, attemptId: nil) + } + } + } + + func markContentAppeared(hash: String, attemptId: String) -> Double? { + withLock { + do { + var ledger = try readLedgerImportingLegacyState() + guard ledger.phase == .launching, + ledger.candidateHash == hash, + ledger.activeAttempt?.hash == hash, + ledger.activeAttempt?.attemptId == attemptId, + ledger.activeAttempt?.contentAppeared == false, + ledger.policy?.healthCheckMode == "auto" else { + return nil + } + ledger.activeAttempt?.contentAppeared = true + try commit(&ledger) + return ledger.policy?.healthyAfterSec ?? 0 + } catch { + return nil + } + } + } + + func markHealthy(hash: String, attemptId: String) -> Bool { + withLock { + do { + var ledger = try readLedgerImportingLegacyState() + guard isCurrentBundleEligible(hash: hash, ledger: ledger) else { + return false + } + if ledger.phase == .stable, + ledger.stableHash == hash, + ledger.lastHealthyAttemptId == attemptId { + return true + } + guard ledger.phase == .launching, + ledger.candidateHash == hash, + ledger.activeAttempt?.hash == hash, + ledger.activeAttempt?.attemptId == attemptId else { + return false + } + ledger.phase = .stable + ledger.previousStableHash = ledger.stableHash + ledger.stableHash = hash + ledger.lastHealthyAttemptId = attemptId + ledger.candidateHash = nil + ledger.activeAttempt = nil + ledger.reservedAttemptId = nil + ledger.policy = nil + try commit(&ledger) + try failpoint?("afterHealthCommitted") + return true + } catch { + return false + } + } + } + + func passiveCurrentBundle() -> URL? { + withLock { + guard hasExpectedIdentity else { return nil } + do { + guard let current = BundleDropOtaResolver.readPointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ), let ledger = try readLedgerFromDisk() else { + return nil + } + guard ledger.binaryIdentity == expectedBinaryIdentity, + ledger.runtimeIdentity == expectedRuntimeVersion, + !ledger.revokedHashes.contains(current.hash), + !ledger.quarantinedHashes.contains(current.hash) else { + return nil + } + let isStable = ledger.stableHash == current.hash && + (ledger.phase == .stable || ledger.phase == .recovered) + let isCandidate = ledger.candidateHash == current.hash && + (ledger.phase == .armed || ledger.phase == .launching) && + ledger.policy != nil + return isStable || isCandidate ? current.bundleURL : nil + } catch { + return nil + } + } + } + + func setRevokedHashes(_ hashes: [String]) throws { + try withLock { + guard hashes.allSatisfy(Self.isCanonicalHash) else { + throw BundleDropStartupRecoveryError.invalidHash + } + var ledger = try readLedgerImportingLegacyState() + let normalized = Array(Set(hashes)).sorted() + guard normalized != ledger.revokedHashes else { return } + ledger.revokedHashes = normalized + try commit(&ledger) + } + } + + func acknowledgeRecovery(eventId: String) throws -> Bool { + try withLock { + var ledger = try readLedgerImportingLegacyState() + let originalCount = ledger.pendingRecoveryEvents.count + ledger.pendingRecoveryEvents.removeAll { $0.id == eventId } + guard ledger.pendingRecoveryEvents.count != originalCount else { return false } + try commit(&ledger) + return true + } + } + + func rollback(forceEmbedded: Bool) throws -> BundleDropStartupRollbackResult { + try withLock { + var ledger = try readLedgerImportingLegacyState() + let current = BundleDropOtaResolver.readPointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) + let fallbackHash = forceEmbedded ? nil : provenFallbackHash( + failedHash: current?.hash, + ledger: ledger + ) + let fallback = fallbackHash.flatMap { hash in + verifiedPreviousPointer(hash: hash, ledger: ledger) + } + + ledger.phase = fallback == nil ? .idle : .stable + ledger.candidateHash = nil + ledger.previousStableHash = nil + ledger.stableHash = fallback?.hash + ledger.lastHealthyAttemptId = nil + ledger.activeAttempt = nil + ledger.reservedAttemptId = nil + ledger.policy = nil + ledger.pendingTransition = BundleDropStartupRecoveryTransition( + kind: "rollback", + targetHash: fallback?.hash + ) + try commit(&ledger) + try failpoint?("afterRollbackPrepared") + try completePendingTransition(ledger: &ledger) + return BundleDropStartupRollbackResult( + rolledBack: current != nil, + toEmbedded: fallback == nil, + hash: fallback?.hash + ) + } + } + + func snapshot() throws -> [String: Any] { + try withLock { + let ledger = try readLedgerImportingLegacyState() + var result: [String: Any] = [ + "protocolVersion": Self.protocolVersion, + "revision": ledger.revision, + "phase": ledger.phase.rawValue, + "quarantinedHashes": ledger.quarantinedHashes.sorted(), + "pendingRecoveryEvents": ledger.pendingRecoveryEvents.map(Self.eventDictionary), + ] + if let candidateHash = ledger.candidateHash { + result["candidateHash"] = candidateHash + } + if let stableHash = ledger.stableHash { + result["stableHash"] = stableHash + } + if let policy = ledger.policy { + result["policy"] = [ + "maxCrashCount": policy.maxCrashCount, + "healthCheckMode": policy.healthCheckMode, + "healthyAfterSec": policy.healthyAfterSec, + ] + } + if let attempt = ledger.activeAttempt, ledger.phase == .launching { + result["activeAttempt"] = [ + "hash": attempt.hash, + "attemptId": attempt.attemptId, + "status": "launching", + "unacknowledgedLaunchCount": attempt.unacknowledgedLaunchCount, + ] + } else { + result["activeAttempt"] = NSNull() + } + return result + } + } + + private func recover( + failedHash: String, + crashCount: Int, + quarantine: Bool, + emitEvent: Bool, + ledger: inout BundleDropStartupRecoveryLedger + ) throws -> BundleDropStartupSelection { + let fallbackHash = provenFallbackHash(failedHash: failedHash, ledger: ledger) + let fallback = fallbackHash.flatMap { hash in + verifiedPreviousPointer(hash: hash, ledger: ledger) + } + + if quarantine { + ledger.quarantinedHashes = Array(Set(ledger.quarantinedHashes + [failedHash])).sorted() + } + if emitEvent { + ledger.pendingRecoveryEvents.append(BundleDropStartupRecoveryEvent( + id: makeId(), + failedHash: failedHash, + recoveryTarget: fallback == nil ? "embedded" : "previous", + recoveredHash: fallback?.hash, + crashCount: crashCount, + reason: "crash_loop", + failedAt: now() + )) + if ledger.pendingRecoveryEvents.count > 20 { + ledger.pendingRecoveryEvents.removeFirst(ledger.pendingRecoveryEvents.count - 20) + } + } + + ledger.phase = .recovered + ledger.candidateHash = failedHash + ledger.previousStableHash = nil + ledger.stableHash = fallback?.hash + ledger.lastHealthyAttemptId = nil + ledger.activeAttempt = nil + ledger.reservedAttemptId = nil + ledger.policy = nil + ledger.pendingTransition = BundleDropStartupRecoveryTransition( + kind: "recovery", + targetHash: fallback?.hash + ) + try commit(&ledger) + try failpoint?("afterRecoveryPrepared") + try completePendingTransition(ledger: &ledger) + return BundleDropStartupSelection( + bundleURL: fallback?.bundleURL, + attemptHash: nil, + attemptId: nil + ) + } + + private func completePendingTransition( + ledger: inout BundleDropStartupRecoveryLedger + ) throws { + guard var transition = ledger.pendingTransition else { return } + if let targetHash = transition.targetHash, + !isPendingTransitionTargetEligible(targetHash, ledger: ledger) { + transition = BundleDropStartupRecoveryTransition( + kind: transition.kind, + targetHash: nil + ) + ledger.stableHash = nil + ledger.previousStableHash = nil + ledger.pendingTransition = transition + if transition.kind == "recovery", + let eventIndex = ledger.pendingRecoveryEvents.lastIndex(where: { + $0.recoveryTarget == "previous" && $0.recoveredHash == targetHash + }) { + let event = ledger.pendingRecoveryEvents[eventIndex] + ledger.pendingRecoveryEvents[eventIndex] = BundleDropStartupRecoveryEvent( + id: event.id, + failedHash: event.failedHash, + recoveryTarget: "embedded", + recoveredHash: nil, + crashCount: event.crashCount, + reason: event.reason, + failedAt: event.failedAt + ) + } + // Persist the downgrade before touching pointers so another interruption + // can only resume toward embedded, never toward the unavailable target. + try commit(&ledger) + } + try applyPendingPointerTransition(transition) + ledger.pendingTransition = nil + try commit(&ledger) + } + + private func applyPendingPointerTransition( + _ transition: BundleDropStartupRecoveryTransition + ) throws { + guard transition.kind == "recovery" || transition.kind == "rollback" else { + throw BundleDropStartupRecoveryError.ledgerCorrupt + } + let failpointPrefix = transition.kind == "recovery" ? "recoveryCurrent" : "rollbackCurrent" + if let targetHash = transition.targetHash { + guard BundleDropOtaResolver.readBundle( + hash: targetHash, + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) != nil else { + throw BundleDropStartupRecoveryError.candidateUnavailable + } + try writePointer(named: "current.json", hash: targetHash, failpointPrefix: failpointPrefix) + } else { + try deleteCurrentPointer(failpointPrefix: failpointPrefix) + } + try? BundleDropOtaResolver.deletePointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + } + + private func isPendingTransitionTargetEligible( + _ hash: String, + ledger: BundleDropStartupRecoveryLedger + ) -> Bool { + guard hasExpectedIdentity, + ledger.binaryIdentity == expectedBinaryIdentity, + ledger.runtimeIdentity == expectedRuntimeVersion, + !ledger.quarantinedHashes.contains(hash), + !ledger.revokedHashes.contains(hash) else { + return false + } + return BundleDropOtaResolver.readBundle( + hash: hash, + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) != nil + } + + private func provenFallbackHash( + failedHash: String?, + ledger: BundleDropStartupRecoveryLedger + ) -> String? { + guard let failedHash else { return nil } + if ledger.candidateHash == failedHash { + return ledger.stableHash + } + if ledger.stableHash == failedHash { + return ledger.previousStableHash + } + return nil + } + + private func verifiedPreviousPointer( + hash: String, + ledger: BundleDropStartupRecoveryLedger + ) -> BundleDropOtaPointer? { + guard hasExpectedIdentity, + !ledger.quarantinedHashes.contains(hash), + !ledger.revokedHashes.contains(hash) else { + return nil + } + let previous = BundleDropOtaResolver.readPointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) + return previous?.hash == hash ? previous : nil + } + + private func isCurrentBundleEligible( + hash: String, + ledger: BundleDropStartupRecoveryLedger + ) -> Bool { + guard hasExpectedIdentity, + ledger.binaryIdentity == expectedBinaryIdentity, + ledger.runtimeIdentity == expectedRuntimeVersion, + !ledger.quarantinedHashes.contains(hash), + !ledger.revokedHashes.contains(hash), + let current = BundleDropOtaResolver.readPointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ), + current.hash == hash else { + return false + } + return BundleDropOtaResolver.readBundle( + hash: hash, + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + ) != nil + } + + private func readLedgerImportingLegacyState() throws -> BundleDropStartupRecoveryLedger { + var ledger = try readLedgerFromDisk() ?? BundleDropStartupRecoveryLedger() + try bindLedgerToExpectedIdentity(&ledger) + guard ledger.legacyFailuresImported != true else { + return ledger + } + + if fileManager.fileExists(atPath: legacyStateURL.path), + let data = try? Data(contentsOf: legacyStateURL), + let legacy = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let failedBundles = legacy["failedBundles"] as? [String: Any] { + let imported = Self.newestLegacyFailureHashes(failedBundles) + let merged = Array(Set(ledger.quarantinedHashes + imported)).sorted() + ledger.quarantinedHashes = merged + } + ledger.legacyFailuresImported = true + try commit(&ledger) + return ledger + } + + private var hasExpectedIdentity: Bool { + expectedRuntimeVersion?.isEmpty == false && expectedBinaryIdentity?.isEmpty == false + } + + private func bindLedgerToExpectedIdentity( + _ ledger: inout BundleDropStartupRecoveryLedger + ) throws { + guard let runtimeIdentity = expectedRuntimeVersion, + !runtimeIdentity.isEmpty, + let binaryIdentity = expectedBinaryIdentity, + !binaryIdentity.isEmpty else { + throw BundleDropStartupRecoveryError.candidateUnavailable + } + guard ledger.runtimeIdentity != runtimeIdentity || ledger.binaryIdentity != binaryIdentity else { + return + } + + let previousRevision = ledger.revision + ledger = BundleDropStartupRecoveryLedger() + ledger.revision = previousRevision + ledger.runtimeIdentity = runtimeIdentity + ledger.binaryIdentity = binaryIdentity + try BundleDropOtaResolver.deletePointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + try BundleDropOtaResolver.deletePointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + try commit(&ledger) + } + + private func readLedgerFromDisk() throws -> BundleDropStartupRecoveryLedger? { + guard fileManager.fileExists(atPath: ledgerURL.path) else { return nil } + do { + let ledger = try JSONDecoder().decode( + BundleDropStartupRecoveryLedger.self, + from: Data(contentsOf: ledgerURL) + ) + guard Self.isValid(ledger) else { + throw BundleDropStartupRecoveryError.ledgerCorrupt + } + return ledger + } catch let error as BundleDropStartupRecoveryError { + throw error + } catch { + throw BundleDropStartupRecoveryError.ledgerCorrupt + } + } + + private func repairCorruptLedger() -> BundleDropStartupSelection { + guard let runtimeIdentity = expectedRuntimeVersion, + !runtimeIdentity.isEmpty, + let binaryIdentity = expectedBinaryIdentity, + !binaryIdentity.isEmpty else { + return BundleDropStartupSelection(bundleURL: nil, attemptHash: nil, attemptId: nil) + } + + let currentHash = BundleDropOtaResolver.readPointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: expectedRuntimeVersion, + fileManager: fileManager + )?.hash + var replacement = BundleDropStartupRecoveryLedger() + replacement.revision = 1 + replacement.binaryIdentity = binaryIdentity + replacement.runtimeIdentity = runtimeIdentity + replacement.legacyFailuresImported = true + replacement.phase = .recovered + replacement.quarantinedHashes = currentHash.map { [$0] } ?? [] + + do { + Self.ledgerCommitLock.lock() + defer { Self.ledgerCommitLock.unlock() } + try writeLedgerAtomically(replacement) + try BundleDropOtaResolver.deletePointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + try BundleDropOtaResolver.deletePointer( + named: "previous.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + } catch { + // The caller still fails closed to embedded. A later launch can retry the + // repair without trusting any health claim from the corrupt ledger. + } + return BundleDropStartupSelection(bundleURL: nil, attemptHash: nil, attemptId: nil) + } + + private func commit(_ ledger: inout BundleDropStartupRecoveryLedger) throws { + ledger.schemaVersion = Self.protocolVersion + try fileManager.createDirectory(at: bundleDropRoot, withIntermediateDirectories: true) + try failpoint?("beforeLedgerWrite") + Self.ledgerCommitLock.lock() + do { + let persistedRevision = try readLedgerFromDisk()?.revision ?? 0 + guard persistedRevision == ledger.revision else { + throw BundleDropStartupRecoveryError.staleLedger + } + ledger.revision += 1 + } catch { + Self.ledgerCommitLock.unlock() + throw error + } + do { + try writeLedgerAtomically(ledger) + } catch { + Self.ledgerCommitLock.unlock() + throw error + } + Self.ledgerCommitLock.unlock() + try failpoint?("afterLedgerWrite") + } + + private func writeLedgerAtomically(_ ledger: BundleDropStartupRecoveryLedger) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let temporaryURL = bundleDropRoot.appendingPathComponent( + ".recovery-ledger-\(UUID().uuidString).tmp" + ) + do { + try encoder.encode(ledger).write(to: temporaryURL) + let handle = try FileHandle(forWritingTo: temporaryURL) + try handle.synchronize() + try handle.close() + if fileManager.fileExists(atPath: ledgerURL.path) { + _ = try fileManager.replaceItemAt(ledgerURL, withItemAt: temporaryURL) + } else { + try fileManager.moveItem(at: temporaryURL, to: ledgerURL) + } + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + } + + private func writePointer(named filename: String, hash: String, failpointPrefix: String) throws { + try failpoint?("before\(failpointPrefix.prefix(1).uppercased())\(failpointPrefix.dropFirst())Write") + try BundleDropOtaResolver.writePointer( + named: filename, + hash: hash, + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + try failpoint?("after\(failpointPrefix.prefix(1).uppercased())\(failpointPrefix.dropFirst())Write") + } + + private func deleteCurrentPointer(failpointPrefix: String) throws { + try failpoint?("before\(failpointPrefix.prefix(1).uppercased())\(failpointPrefix.dropFirst())Delete") + try BundleDropOtaResolver.deletePointer( + named: "current.json", + bundleDropRoot: bundleDropRoot, + fileManager: fileManager + ) + try failpoint?("after\(failpointPrefix.prefix(1).uppercased())\(failpointPrefix.dropFirst())Delete") + } + + private func withLock(_ operation: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try operation() + } + + private static func isCanonicalHash(_ value: String) -> Bool { + value.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil + } + + private static func newestLegacyFailureHashes( + _ failedBundles: [String: Any] + ) -> [String] { + failedBundles.compactMap { hash, value -> (hash: String, failedAt: Double?)? in + guard isCanonicalHash(hash) else { return nil } + let record = value as? [String: Any] + let rawFailedAt = record?["failedAt"] + let number = rawFailedAt is Bool ? nil : rawFailedAt as? NSNumber + let failedAt = number?.doubleValue + return ( + hash, + failedAt.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil } + ) + } + .sorted { left, right in + switch (left.failedAt, right.failedAt) { + case let (leftDate?, rightDate?) where leftDate != rightDate: + return leftDate > rightDate + case (_?, nil): + return true + case (nil, _?): + return false + default: + return left.hash < right.hash + } + } + .prefix(20) + .map(\.hash) + } + + private static func isValid(_ ledger: BundleDropStartupRecoveryLedger) -> Bool { + guard ledger.schemaVersion == protocolVersion, + ledger.revision >= 0, + ledger.binaryIdentity.map({ !$0.isEmpty }) ?? true, + ledger.runtimeIdentity.map({ !$0.isEmpty }) ?? true, + [ledger.candidateHash, ledger.stableHash, ledger.previousStableHash] + .compactMap({ $0 }) + .allSatisfy(isCanonicalHash), + ledger.quarantinedHashes.allSatisfy(isCanonicalHash), + ledger.revokedHashes.allSatisfy(isCanonicalHash), + Set(ledger.quarantinedHashes).count == ledger.quarantinedHashes.count, + Set(ledger.revokedHashes).count == ledger.revokedHashes.count else { + return false + } + if let policy = ledger.policy, + policy.maxCrashCount < 0 || + !policy.healthyAfterSec.isFinite || + policy.healthyAfterSec < 0 || + (policy.healthCheckMode != "auto" && policy.healthCheckMode != "manual") { + return false + } + if let attempt = ledger.activeAttempt, + !isCanonicalHash(attempt.hash) || + attempt.attemptId.isEmpty || + attempt.processToken.isEmpty || + attempt.unacknowledgedLaunchCount < 0 { + return false + } + if let reservedAttemptId = ledger.reservedAttemptId, reservedAttemptId.isEmpty { + return false + } + if let lastHealthyAttemptId = ledger.lastHealthyAttemptId, lastHealthyAttemptId.isEmpty { + return false + } + if let transition = ledger.pendingTransition, + (transition.kind != "recovery" && transition.kind != "rollback") || + (transition.targetHash != nil && !isCanonicalHash(transition.targetHash!)) { + return false + } + return ledger.pendingRecoveryEvents.allSatisfy { event in + !event.id.isEmpty && + isCanonicalHash(event.failedHash) && + event.crashCount >= 0 && + event.failedAt >= 0 && + event.reason == "crash_loop" && + ((event.recoveryTarget == "embedded" && event.recoveredHash == nil) || + (event.recoveryTarget == "previous" && + event.recoveredHash.map(isCanonicalHash) == true)) + } + } + + private static func eventDictionary(_ event: BundleDropStartupRecoveryEvent) -> [String: Any] { + var result: [String: Any] = [ + "id": event.id, + "failedHash": event.failedHash, + "recoveryTarget": event.recoveryTarget, + "crashCount": event.crashCount, + "reason": event.reason, + "failedAt": event.failedAt, + ] + if let recoveredHash = event.recoveredHash { + result["recoveredHash"] = recoveredHash + } + return result + } +} diff --git a/ios/BundleDropStartupRecoveryAdapter.swift b/ios/BundleDropStartupRecoveryAdapter.swift new file mode 100644 index 0000000..20afaed --- /dev/null +++ b/ios/BundleDropStartupRecoveryAdapter.swift @@ -0,0 +1,301 @@ +import Foundation +import ObjectiveC.runtime + +/// Process-level integration between the startup ledger and React Native's +/// lifecycle. The controller itself remains Foundation-only and directly testable. +enum BundleDropStartupRecoveryAdapter { + static let protocolVersion = BundleDropStartupRecoveryController.protocolVersion + + private static let lock = NSRecursiveLock() + private static let processToken = UUID().uuidString.lowercased() + private static var recoveryController: BundleDropStartupRecoveryController? + private static var activeAttemptHash: String? + private static var activeAttemptId: String? + private static var selectedHash: String? + private static var observersInstalled = false + private static let contentBindings = BundleDropStartupContentBindings() + + static func selectStartupBundle(bundleDropRoot: URL) -> URL? { + installReactLifecycleObservers() + let selection = controller(bundleDropRoot: bundleDropRoot).selectStartupBundle() + captureStartupSelection(selection) + return selection.bundleURL + } + + static func downloadedBundleURL() -> URL? { + guard BundleDropLocatorCore.hasRuntimeIdentityForOta(), + BundleDropLocatorCore.isOtaEnabled(), + let root = defaultRoot() else { + return nil + } + return controller(bundleDropRoot: root).passiveCurrentBundle() + } + + static func activateCandidate( + hash: String, + maxCrashCount: Int, + healthCheckMode: String, + healthyAfterSec: Double + ) throws -> BundleDropStartupActivationResult { + try requireDefaultController().activateCandidate( + hash: hash, + maxCrashCount: maxCrashCount, + healthCheckMode: healthCheckMode, + healthyAfterSec: healthyAfterSec + ) + } + + static func beginReload() -> URL? { + guard let controller = defaultController() else { return nil } + installReactLifecycleObservers() + let selection = controller.selectStartupBundle(beginReload: true) + captureStartupSelection(selection) + return selection.bundleURL + } + + static func markHealthy(hash: String, attemptId: String) -> Bool { + guard let controller = defaultController() else { return false } + let marked = controller.markHealthy(hash: hash, attemptId: attemptId) + if marked { clearCapturedAttempt() } + return marked + } + + static func snapshot() throws -> [String: Any] { + try requireDefaultController().snapshot() + } + + static func setRevokedHashes(_ hashes: [String]) throws -> Bool { + try requireDefaultController().setRevokedHashes(hashes) + return true + } + + static func acknowledgeRecovery(eventId: String) throws -> Bool { + try requireDefaultController().acknowledgeRecovery(eventId: eventId) + } + + static func rollback(forceEmbedded: Bool) throws -> BundleDropStartupRollbackResult { + let result = try requireDefaultController().rollback(forceEmbedded: forceEmbedded) + clearCapturedAttempt() + return result + } + + static func capturedAttempt() -> (hash: String?, attemptId: String?) { + lock.lock() + defer { lock.unlock() } + return (activeAttemptHash, activeAttemptId) + } + + static func clearCapturedAttempt() { + captureAttempt(hash: nil, attemptId: nil) + } + + static func capturedSelectedHash() -> String? { + lock.lock() + defer { lock.unlock() } + return selectedHash + } + + static func clearCapturedSelection() { + lock.lock() + activeAttemptHash = nil + activeAttemptId = nil + selectedHash = nil + contentBindings.capture(hash: nil, attemptId: nil, bundleURL: nil) + lock.unlock() + } + + static func captureStartupSelection(_ selection: BundleDropStartupSelection) { + lock.lock() + activeAttemptHash = selection.attemptHash + activeAttemptId = selection.attemptId + let bundleHash = selection.bundleURL? + .deletingLastPathComponent() + .lastPathComponent + selectedHash = bundleHash?.range( + of: "^[a-f0-9]{64}$", + options: .regularExpression + ) == nil ? nil : bundleHash + contentBindings.capture( + hash: selection.attemptHash, + attemptId: selection.attemptId, + bundleURL: selection.bundleURL + ) + lock.unlock() + } + + private static func defaultRoot() -> URL? { + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first? + .appendingPathComponent("bundle-drop", isDirectory: true) + } + + private static func defaultController() -> BundleDropStartupRecoveryController? { + defaultRoot().map(controller) + } + + private static func requireDefaultController() throws -> BundleDropStartupRecoveryController { + guard let controller = defaultController() else { + throw BundleDropStartupRecoveryError.storageUnavailable + } + return controller + } + + private static func controller(bundleDropRoot: URL) -> BundleDropStartupRecoveryController { + lock.lock() + defer { lock.unlock() } + if let recoveryController { return recoveryController } + let created = BundleDropStartupRecoveryController( + bundleDropRoot: bundleDropRoot, + expectedRuntimeVersion: BundleDropLocatorCore.getRuntimeVersion(), + expectedBinaryIdentity: BundleDropLocatorCore.getBinaryVersionKey(), + processToken: processToken + ) + recoveryController = created + return created + } + + private static func captureAttempt(hash: String?, attemptId: String?) { + lock.lock() + activeAttemptHash = hash + activeAttemptId = attemptId + lock.unlock() + } + + private static func installReactLifecycleObservers() { + lock.lock() + guard !observersInstalled else { + lock.unlock() + return + } + observersInstalled = true + lock.unlock() + + NotificationCenter.default.addObserver( + forName: Notification.Name("RCTJavaScriptDidLoadNotification"), + object: nil, + queue: nil + ) { notification in + contentBindings.runtimeDidLoad(bundleURL: bundleURL(from: notification)) + } + + NotificationCenter.default.addObserver( + forName: Notification.Name("RCTContentDidAppearNotification"), + object: nil, + queue: nil + ) { notification in + guard let attempt = contentBindings.binding(for: notification.object) else { return } + let hash = attempt.hash + let attemptId = attempt.attemptId + guard let delay = defaultController()?.markContentAppeared( + hash: hash, + attemptId: attemptId + ) else { return } + let markHealthy = { + _ = BundleDropStartupRecoveryAdapter.markHealthy(hash: hash, attemptId: attemptId) + } + if delay <= 0 { + markHealthy() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: markHealthy) + } + } + } + + static func bundleURL(from notification: Notification) -> URL? { + let selector = NSSelectorFromString("bundleURL") + let providers = [notification.userInfo?["bridge"], notification.object].compactMap { $0 } + for provider in providers { + let object = provider as AnyObject + // RCTBridgeProxy forwards -bundleURL but reports responds(to:) as false. + // Checking the concrete Objective-C implementation keeps URL identity + // available in bridgeless RN without accepting an anonymous global event. + guard object.responds(to: selector) || + class_getInstanceMethod(type(of: object), selector) != nil else { + continue + } + if let url = object.perform(selector)?.takeUnretainedValue() as? URL { + return url + } + } + return nil + } +} + +/// Associates each React root with the startup attempt that selected its runtime. +/// Legacy and Fabric both post `RCTContentDidAppearNotification` with their root +/// object, so a notification arriving late from an older root remains bound to +/// the older attempt instead of blessing the newest reload. +final class BundleDropStartupContentBindings { + struct Binding: Equatable { + let generation: UInt64 + let hash: String + let attemptId: String + let bundlePath: String + } + + private final class RootEntry { + weak var root: AnyObject? + let binding: Binding + + init(root: AnyObject, binding: Binding) { + self.root = root + self.binding = binding + } + } + + private let lock = NSLock() + private var generation: UInt64 = 0 + private var activeBinding: Binding? + private var activeRuntimeLoaded = false + private var roots: [ObjectIdentifier: RootEntry] = [:] + + func capture(hash: String?, attemptId: String?, bundleURL: URL?) { + lock.lock() + defer { lock.unlock() } + generation &+= 1 + if let hash, let attemptId, let bundleURL { + activeBinding = Binding( + generation: generation, + hash: hash, + attemptId: attemptId, + bundlePath: Self.normalizedPath(bundleURL) + ) + } else { + activeBinding = nil + } + activeRuntimeLoaded = false + roots = roots.filter { $0.value.root != nil } + } + + func runtimeDidLoad(bundleURL: URL?) { + lock.lock() + defer { lock.unlock() } + guard let activeBinding, + let bundleURL, + Self.normalizedPath(bundleURL) == activeBinding.bundlePath else { + return + } + activeRuntimeLoaded = true + // React may reuse an RCTRootView across legacy reloads. Once the selected + // runtime has loaded, roots belong to the new generation when they next + // report content appearance. + roots.removeAll() + } + + func binding(for root: Any?) -> Binding? { + guard let root else { return nil } + let rootObject = root as AnyObject + lock.lock() + defer { lock.unlock() } + let identifier = ObjectIdentifier(rootObject) + if let existing = roots[identifier], existing.root === rootObject { + return existing.binding + } + guard activeRuntimeLoaded, let activeBinding else { return nil } + roots[identifier] = RootEntry(root: rootObject, binding: activeBinding) + return activeBinding + } + + private static func normalizedPath(_ url: URL) -> String { + url.standardizedFileURL.resolvingSymlinksInPath().path + } +} diff --git a/package.json b/package.json index 38bb872..9cf4a35 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gfean/react-native-bundle-drop", "version": "0.6.0", - "nativeVersion": "0.5.0", + "nativeVersion": "0.6.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", @@ -47,6 +47,9 @@ "test:expo:plugin": "node --test plugin/__tests__/*.test.js", "package:check": "node scripts/check-package-contents.cjs", "audit:production": "yarn npm audit --environment production --recursive --severity high", + "codeql:local:fast": "node scripts/run-codeql-local.cjs fast", + "codeql:local:full": "node scripts/run-codeql-local.cjs full", + "codeql:local:compare": "node scripts/run-codeql-local.cjs compare", "verify:quick": "yarn build && yarn coverage:gate && yarn test:expo:plugin && yarn package:check", "verify:native": "yarn test:android && yarn test:ios", "verify:release": "yarn verify:quick && yarn verify:native && yarn audit:production", diff --git a/plugin/__tests__/xcodeBuildPhase.test.js b/plugin/__tests__/xcodeBuildPhase.test.js index 1531769..40e4404 100644 --- a/plugin/__tests__/xcodeBuildPhase.test.js +++ b/plugin/__tests__/xcodeBuildPhase.test.js @@ -46,7 +46,7 @@ test('adds the iOS receipt phase exactly once and leaves it last', () => { assert.equal(phases.BUNDLE_DROP_PHASE.name, `"${PHASE_NAME}"`); assert.equal( phases.BUNDLE_DROP_PHASE.shellScript, - `"${SHELL_SCRIPT.replace(/"/g, '\\"')}"`, + `"${SHELL_SCRIPT.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`, ); }); @@ -56,6 +56,7 @@ test('uses quoted Xcode paths and never patches generated application sources', assert.match(SHELL_SCRIPT, /"\$TARGET_BUILD_DIR\/\$WRAPPER_NAME"/); assert.doesNotMatch(SHELL_SCRIPT, /MARKETING_VERSION|CURRENT_PROJECT_VERSION/); assert.doesNotMatch(SHELL_SCRIPT, /AppDelegate|MainApplication/); + assert.doesNotMatch(SHELL_SCRIPT, /write-runtime-identity|bundle-drop-build-identity/); }); test('Expo Android target embeds identity before signing and proves the packaged artifact afterward', () => { diff --git a/plugin/xcodeBuildPhase.js b/plugin/xcodeBuildPhase.js index 4ef7c69..b781c98 100644 --- a/plugin/xcodeBuildPhase.js +++ b/plugin/xcodeBuildPhase.js @@ -28,7 +28,8 @@ function updatePhase(project, phaseUuid) { const phase = project.hash.project.objects.PBXShellScriptBuildPhase[phaseUuid]; phase.name = `"${PHASE_NAME}"`; phase.shellPath = '/bin/sh'; - phase.shellScript = `"${SHELL_SCRIPT.replace(/"/g, '\\"')}"`; + const escapedShellScript = SHELL_SCRIPT.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + phase.shellScript = `"${escapedShellScript}"`; } function movePhaseToEnd(target, phaseUuid) { diff --git a/scripts/check-package-contents.cjs b/scripts/check-package-contents.cjs index 0b38c17..5a019ac 100644 --- a/scripts/check-package-contents.cjs +++ b/scripts/check-package-contents.cjs @@ -63,6 +63,8 @@ const blockedPathPatterns = [ /(^|\/)lib\/CLI\/scripts\/aipowered\/(?:apply-plan|init-native-config)\.(?:js|d\.ts)\/?$/, /(^|\/)lib\/CLI\/scripts\/init-metro-config\.(?:js|d\.ts)\/?$/, /(^|\/)scripts\/harness(\/|$)/, + /(^|\/)scripts\/run-codeql-local\.cjs$/, + /(^|\/)security(\/|$)/, /(^|\/)src\/tests(\/|$)/, /\.tgz$/, ]; @@ -87,7 +89,10 @@ const requiredFiles = [ 'android/build.gradle', 'android/src/main/AndroidManifest.xml', 'android/src/main/java/com/bundledrop/BundleDropModule.kt', + 'android/src/main/java/com/bundledrop/BundleDropStartupRecovery.kt', 'ios/BundleDropModule.swift', + 'ios/BundleDropStartupRecovery.swift', + 'ios/BundleDropStartupRecoveryAdapter.swift', 'plugin/index.js', 'plugin/xcodeBuildPhase.js', 'expo/android/build.gradle', @@ -110,6 +115,7 @@ const requiredFiles = [ 'lib/CLI/scripts/sight-session.js', 'lib/CLI/scripts/expo/write-build-receipt.js', 'lib/CLI/scripts/expo/write-eas-build-receipt.js', + 'lib/CLI/scripts/native/write-runtime-identity.js', 'third_party/xdelta/NOTICE', 'third_party/xdelta/PROVENANCE.md', 'third_party/xdelta/xdelta3/LICENSE', @@ -166,6 +172,9 @@ const expectedScripts = new Set([ 'test:expo:plugin', 'package:check', 'audit:production', + 'codeql:local:fast', + 'codeql:local:full', + 'codeql:local:compare', 'verify:quick', 'verify:native', 'verify:release', diff --git a/scripts/run-codeql-local.cjs b/scripts/run-codeql-local.cjs new file mode 100644 index 0000000..38b1c87 --- /dev/null +++ b/scripts/run-codeql-local.cjs @@ -0,0 +1,266 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { spawnSync } = require('child_process'); + +const repositoryRoot = path.resolve(__dirname, '..'); +const manifestPath = path.join(repositoryRoot, 'security', 'codeql-local.json'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const mode = process.argv[2]; +const supportedModes = new Set(['fast', 'full', 'compare']); +const querySuiteFilenames = { + default: { + actions: 'actions-code-scanning.qls', + 'javascript-typescript': 'javascript-code-scanning.qls', + 'c-cpp': 'cpp-code-scanning.qls', + swift: 'swift-code-scanning.qls', + }, +}; +const threatModelArguments = { + // CodeQL enables remote sources by default; adding local selects the same + // remote-and-local threat model used by GitHub default setup. + remote_and_local: ['--threat-model=local'], +}; + +if (!supportedModes.has(mode)) { + throw new Error('Usage: node scripts/run-codeql-local.cjs '); +} + +function validateManifestConfiguration() { + if (manifest.schemaVersion !== 1) { + throw new Error(`Unsupported CodeQL manifest schemaVersion: ${manifest.schemaVersion}.`); + } + + const suiteFilenames = querySuiteFilenames[manifest.querySuite]; + if (!suiteFilenames) { + throw new Error(`Unsupported CodeQL query suite: ${manifest.querySuite}.`); + } + if (!threatModelArguments[manifest.threatModel]) { + throw new Error(`Unsupported CodeQL threat model: ${manifest.threatModel}.`); + } + + for (const [language, configuration] of Object.entries(manifest.languages)) { + const expectedFilename = suiteFilenames[language]; + if (!expectedFilename) { + throw new Error(`Unsupported CodeQL language for ${manifest.querySuite}: ${language}.`); + } + if (path.basename(configuration.suite) !== expectedFilename) { + throw new Error( + `CodeQL suite for ${language} does not match ${manifest.querySuite}: ${configuration.suite}.`, + ); + } + } +} + +validateManifestConfiguration(); + +const codeqlExecutable = process.env.CODEQL_BIN; +const configuredWorkDirectory = process.env.BUNDLE_DROP_CODEQL_WORKDIR; +if (!codeqlExecutable || !configuredWorkDirectory) { + throw new Error('CODEQL_BIN and BUNDLE_DROP_CODEQL_WORKDIR are both required.'); +} + +const workDirectory = path.resolve(configuredWorkDirectory); +const relativeWorkDirectory = path.relative(repositoryRoot, workDirectory); +if ( + relativeWorkDirectory === '' || + (!relativeWorkDirectory.startsWith(`..${path.sep}`) && + relativeWorkDirectory !== '..' && + !path.isAbsolute(relativeWorkDirectory)) +) { + throw new Error('BUNDLE_DROP_CODEQL_WORKDIR must be outside the repository.'); +} + +const runDirectory = path.join(workDirectory, 'current'); +const sourceDirectory = path.join(runDirectory, 'source'); +const resultsDirectory = path.join(runDirectory, 'results'); + +function run(executable, args, options = {}) { + const result = spawnSync(executable, args, { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: options.capture ? 'pipe' : 'inherit', + shell: false, + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${path.basename(executable)} exited with status ${result.status}`); + } + return result; +} + +function verifyCodeqlVersion() { + const result = run(codeqlExecutable, ['version', '--format=json'], { + capture: true, + }); + const version = JSON.parse(result.stdout).version; + if (version !== manifest.codeqlVersion) { + throw new Error(`Expected CodeQL ${manifest.codeqlVersion}, received ${version}.`); + } +} + +function resetRunDirectory() { + const relativeRunDirectory = path.relative(workDirectory, runDirectory); + if (relativeRunDirectory !== 'current') { + throw new Error('Refusing to clear an unexpected CodeQL run directory.'); + } + fs.rmSync(runDirectory, { recursive: true, force: true }); + fs.mkdirSync(sourceDirectory, { recursive: true }); + fs.mkdirSync(resultsDirectory, { recursive: true }); +} + +function trackedSourceFiles() { + const result = run( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { capture: true }, + ); + return result.stdout.split('\0').filter(Boolean); +} + +function copySourceSnapshot() { + for (const relativePath of trackedSourceFiles()) { + const sourcePath = path.resolve(repositoryRoot, relativePath); + const destinationPath = path.resolve(sourceDirectory, relativePath); + if ( + !sourcePath.startsWith(`${repositoryRoot}${path.sep}`) || + !destinationPath.startsWith(`${sourceDirectory}${path.sep}`) + ) { + throw new Error(`Refusing to copy an unsafe repository path: ${relativePath}`); + } + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + const stat = fs.lstatSync(sourcePath); + if (stat.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(sourcePath), destinationPath); + } else if (stat.isFile()) { + fs.copyFileSync(sourcePath, destinationPath); + } + } +} + +function scanLanguage(language) { + const configuration = manifest.languages[language]; + const databasePath = path.join(runDirectory, `${language}-database`); + const sarifPath = path.join(resultsDirectory, `${language}.sarif`); + const codeqlRoot = path.dirname(fs.realpathSync(codeqlExecutable)); + const suitePath = path.join(codeqlRoot, configuration.suite); + if (!fs.existsSync(suitePath)) { + throw new Error(`The pinned ${language} query suite is missing: ${suitePath}`); + } + + run(codeqlExecutable, [ + 'database', + 'create', + databasePath, + `--language=${language}`, + `--source-root=${sourceDirectory}`, + `--build-mode=${configuration.buildMode}`, + '--overwrite', + ]); + run(codeqlExecutable, [ + 'database', + 'analyze', + databasePath, + suitePath, + '--format=sarifv2.1.0', + `--output=${sarifPath}`, + `--sarif-category=/language:${language}`, + '--threads=0', + ...threatModelArguments[manifest.threatModel], + ]); +} + +function normalizeFinding(language, result) { + const location = result.locations?.[0]?.physicalLocation; + const message = result.message?.text || ''; + return { + language, + ruleId: result.ruleId, + path: location?.artifactLocation?.uri, + line: location?.region?.startLine, + messageSha256: crypto.createHash('sha256').update(message).digest('hex'), + }; +} + +function findingKey(finding) { + return JSON.stringify([ + finding.language, + finding.ruleId, + finding.path, + finding.line, + finding.messageSha256, + ]); +} + +function countFindings(findings) { + const counts = new Map(); + for (const finding of findings) { + const key = findingKey(finding); + counts.set(key, (counts.get(key) || 0) + (finding.count || 1)); + } + return counts; +} + +function findingFromKey(key, count) { + const [language, ruleId, findingPath, line, messageSha256] = JSON.parse(key); + return { language, ruleId, path: findingPath, line, messageSha256, count }; +} + +function compareResults() { + if (!fs.existsSync(resultsDirectory)) { + throw new Error('No local CodeQL results exist. Run the fast or full gate first.'); + } + const resultFiles = fs.readdirSync(resultsDirectory).filter(file => file.endsWith('.sarif')); + const scannedLanguages = new Set(); + const actualFindings = []; + for (const resultFile of resultFiles) { + const language = resultFile.replace(/\.sarif$/, ''); + scannedLanguages.add(language); + const sarif = JSON.parse(fs.readFileSync(path.join(resultsDirectory, resultFile), 'utf8')); + for (const runResult of sarif.runs || []) { + for (const result of runResult.results || []) { + actualFindings.push(normalizeFinding(language, result)); + } + } + } + + const expectedFindings = manifest.acceptedFindings.filter(finding => + scannedLanguages.has(finding.language), + ); + const actualCounts = countFindings(actualFindings); + const expectedCounts = countFindings(expectedFindings); + const unexpected = []; + const stale = []; + for (const [key, count] of actualCounts) { + const excess = count - (expectedCounts.get(key) || 0); + if (excess > 0) unexpected.push(findingFromKey(key, excess)); + } + for (const [key, count] of expectedCounts) { + const missing = count - (actualCounts.get(key) || 0); + if (missing > 0) stale.push(findingFromKey(key, missing)); + } + if (unexpected.length || stale.length) { + console.error(JSON.stringify({ unexpected, stale }, null, 2)); + throw new Error( + `CodeQL comparison failed: ${unexpected.length} unexpected and ${stale.length} stale accepted findings.`, + ); + } + console.log(`CodeQL comparison passed with ${actualFindings.length} reviewed findings.`); +} + +verifyCodeqlVersion(); +if (mode === 'compare') { + compareResults(); + process.exit(0); +} + +resetRunDirectory(); +copySourceSnapshot(); +const languages = mode === 'fast' + ? ['javascript-typescript', 'c-cpp'] + : ['actions', 'javascript-typescript', 'c-cpp', 'swift']; +for (const language of languages) scanLanguage(language); +compareResults(); diff --git a/scripts/run-ios-tests.cjs b/scripts/run-ios-tests.cjs index a7d301a..e31bb51 100644 --- a/scripts/run-ios-tests.cjs +++ b/scripts/run-ios-tests.cjs @@ -8,6 +8,7 @@ const repoRoot = path.resolve(__dirname, '..'); const FILE_THRESHOLDS = { 'ios/BundleDropOtaResolver.swift': 90, + 'ios/BundleDropStartupRecovery.swift': 90, 'ios/BundleDropFileOps.swift': 75, 'ios/BundleDropLocator.swift': 60, 'ios/BundleDropZipExtractor.m': 65, diff --git a/security/codeql-local.json b/security/codeql-local.json new file mode 100644 index 0000000..6f006f1 --- /dev/null +++ b/security/codeql-local.json @@ -0,0 +1,276 @@ +{ + "schemaVersion": 1, + "codeqlVersion": "2.26.3", + "querySuite": "default", + "threatModel": "remote_and_local", + "languages": { + "actions": { + "buildMode": "none", + "suite": "qlpacks/codeql/actions-queries/0.6.33/codeql-suites/actions-code-scanning.qls" + }, + "javascript-typescript": { + "buildMode": "none", + "suite": "qlpacks/codeql/javascript-queries/2.4.3/codeql-suites/javascript-code-scanning.qls" + }, + "c-cpp": { + "buildMode": "none", + "suite": "qlpacks/codeql/cpp-queries/1.8.1/codeql-suites/cpp-code-scanning.qls" + }, + "swift": { + "buildMode": "autobuild", + "suite": "qlpacks/codeql/swift-queries/1.3.8/codeql-suites/swift-code-scanning.qls" + } + }, + "acceptedFindings": [ + { + "language": "c-cpp", + "ruleId": "cpp/comparison-with-wider-type", + "path": "third_party/xdelta/xdelta3/xdelta3.c", + "line": 857, + "messageSha256": "c78fa66e4dbecb98b965177f4bfccf49a89af87423794ce6a3f431d5411e12bc", + "count": 1, + "justification": "Vendored xdelta default-table width comparison; inputs and table widths are statically bounded by the upstream format." + }, + { + "language": "javascript-typescript", + "ruleId": "js/command-line-injection", + "path": "scripts/android-gradle-env.cjs", + "line": 11, + "messageSha256": "e3bd223c0e4ac5eb053b4b087416a149f33775d980bfa027b01ebce5600baf66", + "count": 1, + "justification": "Maintainer host-test launcher uses explicit argument arrays and no shell; executable paths come from validated Java/Gradle locations." + }, + { + "language": "javascript-typescript", + "ruleId": "js/command-line-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 81, + "messageSha256": "c6fefa543e4076106b8e4fb11e35e8af829a2d84ef0aa7e65a04ee4b92aacee5", + "count": 1, + "justification": "Maintainer-only gate requires an explicit pinned CodeQL executable and always invokes it with argument arrays and shell disabled." + }, + { + "language": "javascript-typescript", + "ruleId": "js/command-line-injection", + "path": "src/CLI/scripts/expo/write-build-receipt.ts", + "line": 59, + "messageSha256": "83755f6f7080b679a02644b2563b660d8857660c4b73e687fc0fdebc6578bf35", + "count": 1, + "justification": "Android signature tools are resolved from the configured SDK or platform and invoked with explicit arguments and shell disabled." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/android-gradle-env.cjs", + "line": 29, + "messageSha256": "745b4f362542507992e779bbeeb84bbe02b3385f68d4fc79ecce9339d14ac4a5", + "count": 1, + "justification": "Maintainer test launcher inspects an explicitly configured Java home and verifies the expected executable exists before use." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/inspect-package-tarball.cjs", + "line": 36, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 2, + "justification": "Read-only maintainer inspection of the explicitly selected npm tarball after regular-file validation." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/inspect-package-tarball.cjs", + "line": 134, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Read-only hashing of the same explicitly selected and validated npm tarball." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 110, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "External run root is mandatory, proven outside the repository, and cleanup is restricted to its literal current child." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 111, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Creates only the contained source child of the validated external run root." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 112, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Creates only the contained results child of the validated external run root." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 150, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Checks a pinned query-suite path underneath the verified CodeQL installation." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 213, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Reads only the fixed results directory beneath the validated external work root." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 216, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Enumerates SARIF files only inside the fixed contained results directory." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "scripts/run-codeql-local.cjs", + "line": 222, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Reads only filenames produced by the scanner within its fixed results directory." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/CLI/scripts/expo/write-build-receipt.ts", + "line": 72, + "messageSha256": "8fe6bd88d6342f0f72a3f2d539b6f6f60dc404023a3a97e5fb6aa08f40b5c356", + "count": 1, + "justification": "Reads Android SDK build-tools selected by the native build environment; no destructive operation occurs." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/CLI/scripts/expo/write-build-receipt.ts", + "line": 75, + "messageSha256": "8fe6bd88d6342f0f72a3f2d539b6f6f60dc404023a3a97e5fb6aa08f40b5c356", + "count": 1, + "justification": "Enumerates version children of the configured Android SDK build-tools directory." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/CLI/scripts/expo/write-build-receipt.ts", + "line": 77, + "messageSha256": "8fe6bd88d6342f0f72a3f2d539b6f6f60dc404023a3a97e5fb6aa08f40b5c356", + "count": 1, + "justification": "Accepts only an existing apksigner candidate under the configured SDK build-tools directory." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/scripts/bundle.ts", + "line": 274, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 2, + "justification": "Deletes only fixed generated artifact names after explicit containment under the package dist directory." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/scripts/canonicalArtifact.ts", + "line": 211, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Writes deterministic metadata to the caller-selected generated artifact directory; it does not delete user content." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/scripts/download-bundle.ts", + "line": 100, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Writes one fixed bundle filename under the package dist root; the overridable root is internal test dependency injection." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/tests/scripts/download-bundle.test.ts", + "line": 106, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Test-only read from an isolated temporary package directory." + }, + { + "language": "javascript-typescript", + "ruleId": "js/path-injection", + "path": "src/tests/scripts/download-bundle.test.ts", + "line": 144, + "messageSha256": "c00a435fca4f003c882557433dd6e852d59f97b52586ca941e11f6037140a0a2", + "count": 1, + "justification": "Test-only read from an isolated temporary package directory." + }, + { + "language": "javascript-typescript", + "ruleId": "js/request-forgery", + "path": "src/CLI/cli.ts", + "line": 121, + "messageSha256": "056fcdf6522a90bedc0fd57f1daef4017c18f0d489e0a60e7acd858d89ba4044", + "count": 1, + "justification": "Stored-token requests use the HTTP(S)-validated origin recorded by login; project/config origin mismatches are rejected before authenticated requests." + }, + { + "language": "javascript-typescript", + "ruleId": "js/request-forgery", + "path": "src/CLI/scripts/aipowered/backend-client.ts", + "line": 43, + "messageSha256": "239b81f153ffe6d1b47bd0cabb87ba80adbb4b7715b3c5e573308f0849285a13", + "count": 1, + "justification": "AI planning is called only after scanner validation restricts the destination to Bundle Drop hosts or explicit local development." + }, + { + "language": "javascript-typescript", + "ruleId": "js/request-forgery", + "path": "src/CLI/scripts/init-config.ts", + "line": 192, + "messageSha256": "7bfc0701e5a038247774069182042dbc3eec2576343bc583e006334f2402c3b6", + "count": 1, + "justification": "The authenticated origin is normalized and must match the existing project configuration before the token is sent." + }, + { + "language": "javascript-typescript", + "ruleId": "js/request-forgery", + "path": "src/CLI/scripts/login-cli.ts", + "line": 172, + "messageSha256": "239b81f153ffe6d1b47bd0cabb87ba80adbb4b7715b3c5e573308f0849285a13", + "count": 1, + "justification": "Login destination is an explicitly selected normalized HTTP(S) server; no stored credential is sent to create a session." + }, + { + "language": "javascript-typescript", + "ruleId": "js/request-forgery", + "path": "src/CLI/scripts/login-cli.ts", + "line": 187, + "messageSha256": "239b81f153ffe6d1b47bd0cabb87ba80adbb4b7715b3c5e573308f0849285a13", + "count": 1, + "justification": "Session exchange returns the token from the same normalized HTTP(S) origin selected for login." + } + ], + "documentedAdvisories": [ + { + "package": "fast-xml-parser", + "advisory": "GHSA-gh4j-gqv2-49f6", + "disposition": "Dev-only transitive dependency; the vulnerable XMLBuilder feature is unused. Do not force an incompatible major override." + } + ] +} diff --git a/src/CLI/cli.ts b/src/CLI/cli.ts index 3e59b80..03ba2fc 100644 --- a/src/CLI/cli.ts +++ b/src/CLI/cli.ts @@ -11,6 +11,7 @@ import { runSightCommand } from './scripts/sight-cli'; import { runPostInitPrompts } from './scripts/post-init'; import type { ProjectType } from '../expo'; import { buildBundleDropLogo } from './logo'; +import { normalizeServerUrl } from './serverUrl'; import pkg from '../../package.json'; const logo = buildBundleDropLogo(); @@ -39,7 +40,6 @@ type CliContextResponse = { memberships?: unknown[]; }; -const DEFAULT_SERVER_URL = 'https://api.bundledrop.app'; const DOCS_CLI_URL = 'https://bundledrop.app/docs/cli'; const DOCS_UPLOADING_URL = 'https://bundledrop.app/docs/uploading'; const DOCS_CI_CD_URL = 'https://bundledrop.app/docs/ci-cd'; @@ -80,8 +80,6 @@ const runSetupWithManualFallback = async (params: { const getTokenPath = () => path.join(os.homedir(), '.bundle-drop', 'auth.json'); -const normalizeServerUrl = (url?: string) => url?.replace(/\/$/, '') || DEFAULT_SERVER_URL; - const readStoredAuthData = (): { tokenPath: string; exists: boolean; diff --git a/src/CLI/scripts/doctor.ts b/src/CLI/scripts/doctor.ts index 0499a07..90b613d 100644 --- a/src/CLI/scripts/doctor.ts +++ b/src/CLI/scripts/doctor.ts @@ -20,7 +20,11 @@ import { resolveExpoUploadIdentity, } from './expo/build-receipt'; import { findProjectRoot } from './aipowered/scanner'; -import { readGeneratedRuntimeDeliveryBootstrap } from '../../runtime-delivery/bootstrapConfig'; +import { + inspectRuntimeDeliveryBootstrap, + LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH, + RUNTIME_DELIVERY_BOOTSTRAP_PATH, +} from '../../runtime-delivery/bootstrapConfig'; import { findNativeEntrypointAuthorityIssue } from './native-entrypoint-authority'; import { findSingleMetroConfig, @@ -107,8 +111,8 @@ const inspectMetroConfig = (projectRoot: string) => { const runtimeDeliveryBootstrapGitState = ( projectRoot: string, + relativePath: 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', @@ -144,7 +148,7 @@ const checkRuntimeDeliveryBootstrap = (projectRoot: string): DoctorCheck => { 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({ + const inspected = inspectRuntimeDeliveryBootstrap({ projectRoot, expectedIdentity: { serverUrl: config.serverUrl, @@ -152,14 +156,18 @@ const checkRuntimeDeliveryBootstrap = (projectRoot: string): DoctorCheck => { projectSlug: config.project.slug, }, }); - if (bootstrap) { - const gitState = runtimeDeliveryBootstrapGitState(projectRoot); + if (inspected) { + const { bootstrap, source } = inspected; + const activePath = source === 'legacy' + ? LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH + : RUNTIME_DELIVERY_BOOTSTRAP_PATH; + const gitState = runtimeDeliveryBootstrapGitState(projectRoot, activePath); 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. ' + + 'The runtime delivery lockfile is ignored by Git and will be missing from clean builds. ' + 'Run `bundle-drop sync` to repair .gitignore.', }; } @@ -168,15 +176,33 @@ const checkRuntimeDeliveryBootstrap = (projectRoot: string): DoctorCheck => { name: 'Runtime delivery bootstrap', status: 'warning', message: - `Runtime delivery bootstrap is valid with ` + + `Runtime delivery lockfile is valid with ` + `${Object.keys(bootstrap.runtimeDelivery.publicKeys).length} public key(s), ` + 'but it is not committed yet.', }; } + if (source === 'legacy') { + return { + name: 'Runtime delivery bootstrap', + status: 'warning', + message: + 'The legacy runtime-delivery.generated.json bootstrap is valid. ' + + 'Run `bundle-drop sync` to migrate it to runtime-delivery.lock.json.', + }; + } + if (source === 'matching-dual') { + return { + name: 'Runtime delivery bootstrap', + status: 'warning', + message: + 'The runtime delivery lockfile matches the legacy bootstrap. ' + + 'Run `bundle-drop sync` to remove the legacy file.', + }; + } return { name: 'Runtime delivery bootstrap', status: 'pass', - message: `Runtime delivery bootstrap is pinned with ${Object.keys(bootstrap.runtimeDelivery.publicKeys).length} public key(s).`, + message: `Runtime delivery lockfile is pinned with ${Object.keys(bootstrap.runtimeDelivery.publicKeys).length} public key(s).`, }; } if (config.runtimeDelivery) { @@ -192,7 +218,7 @@ const checkRuntimeDeliveryBootstrap = (projectRoot: string): DoctorCheck => { return { name: 'Runtime delivery bootstrap', status: 'warning', - message: 'No runtime delivery bootstrap is pinned. Run `bundle-drop sync` to create or repair it.', + message: 'No runtime delivery lockfile is pinned. Run `bundle-drop sync` to create or repair it.', }; } catch (error) { return { diff --git a/src/CLI/scripts/expo/configure-expo.ts b/src/CLI/scripts/expo/configure-expo.ts index e6f2ff5..a7fff4f 100644 --- a/src/CLI/scripts/expo/configure-expo.ts +++ b/src/CLI/scripts/expo/configure-expo.ts @@ -1,7 +1,11 @@ import crypto from 'crypto'; import path from 'path'; import { setBundleDropProjectType } from '../../../expo/projectType'; -import { addRuntimeDeliveryBootstrapGitignoreRules } from '../../../runtime-delivery/bootstrapConfig'; +import { + addRuntimeDeliveryBootstrapGitignoreRules, + LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH, + RUNTIME_DELIVERY_BOOTSTRAP_PATH, +} from '../../../runtime-delivery/bootstrapConfig'; import { createSafeBackupDirectory, inspectProjectFile, @@ -241,7 +245,8 @@ const assertSetupPathAllowed = (file: string) => { file === 'package.json' || file === '.fingerprintignore' || file === '.gitignore' || - file === '.bundle-drop/runtime-delivery.generated.json' || + file === RUNTIME_DELIVERY_BOOTSTRAP_PATH || + file === LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH || file === 'bundle.drop.config.js' || file === 'app.json' || /^app\.config\.(js|ts|cjs|mjs)$/.test(file) || diff --git a/src/CLI/scripts/init-config.ts b/src/CLI/scripts/init-config.ts index e8e0eb1..fa2a7d2 100644 --- a/src/CLI/scripts/init-config.ts +++ b/src/CLI/scripts/init-config.ts @@ -7,19 +7,22 @@ import prompts from 'prompts'; import type { ProjectType } from '../../expo'; import { - createGeneratedRuntimeDeliveryBootstrap, + createRuntimeDeliveryBootstrapLockfile, ensureRuntimeDeliveryBootstrapGitignore, normalizeRuntimeDeliveryBootstrap, - removeGeneratedRuntimeDeliveryBootstrap, + readRuntimeDeliveryLockfile, + removeAllRuntimeDeliveryBootstraps, + removeLegacyRuntimeDeliveryBootstrap, runtimeDeliveryBootstrapPath, - serializeGeneratedRuntimeDeliveryBootstrap, - writeGeneratedRuntimeDeliveryBootstrap, - type GeneratedRuntimeDeliveryBootstrap, + serializeRuntimeDeliveryBootstrapLockfile, + writeRuntimeDeliveryBootstrapLockfile, + type RuntimeDeliveryBootstrapLockfile, } from '../../runtime-delivery/bootstrapConfig'; import { inspectProjectFile, writeProjectFileAtomically, } from './safe-file-transaction'; +import { assertMatchingServerOrigin, normalizeServerUrl } from '../serverUrl'; const DOCS_PROJECT_CREATION_URL = 'https://bundledrop.app/docs/project-creation'; const DOCS_INSTALLATION_URL = 'https://bundledrop.app/docs/installation'; @@ -50,10 +53,6 @@ type BundleDropConfigValues = { 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); } @@ -215,7 +214,7 @@ async function fetchProjectCredentials(params: { type RuntimeDeliveryBootstrapResult = { bootstrapPath?: string; bootstrapContent?: string; - bootstrap?: GeneratedRuntimeDeliveryBootstrap; + bootstrap?: RuntimeDeliveryBootstrapLockfile; runtimeDeliveryAvailable?: boolean; bootstrapRetired?: boolean; }; @@ -239,7 +238,7 @@ function createBootstrapResult(params: { bootstrapRetired: true, }; } - const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ + const bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity: { serverUrl: params.serverUrl, orgSlug: params.orgSlug, @@ -257,7 +256,7 @@ function createBootstrapResult(params: { return { bootstrap, bootstrapPath: runtimeDeliveryBootstrapPath(params.projectRoot), - bootstrapContent: serializeGeneratedRuntimeDeliveryBootstrap(bootstrap), + bootstrapContent: serializeRuntimeDeliveryBootstrapLockfile(bootstrap), runtimeDeliveryAvailable: true, }; } @@ -267,21 +266,35 @@ async function persistBootstrap( result: RuntimeDeliveryBootstrapResult, ): Promise { if (result.bootstrap) { - const bootstrapPath = await writeGeneratedRuntimeDeliveryBootstrap({ + const bootstrapPath = await writeRuntimeDeliveryBootstrapLockfile({ projectRoot, bootstrap: result.bootstrap, }); + const persisted = readRuntimeDeliveryLockfile({ + projectRoot, + expectedIdentity: result.bootstrap.project, + }); + if ( + !persisted || + serializeRuntimeDeliveryBootstrapLockfile(persisted) !== + serializeRuntimeDeliveryBootstrapLockfile(result.bootstrap) + ) { + throw new Error( + 'Runtime delivery lockfile validation failed after writing. The legacy bootstrap was preserved.', + ); + } + await removeLegacyRuntimeDeliveryBootstrap(projectRoot); await ensureRuntimeDeliveryBootstrapGitignore(projectRoot); - console.log(chalk.green(`✅ Synced Bundle Drop runtime delivery bootstrap at ${bootstrapPath}`)); + console.log(chalk.green(`✅ Synced Bundle Drop runtime delivery lockfile at ${bootstrapPath}`)); return; } if (result.bootstrapRetired) { - const bootstrapPath = await removeGeneratedRuntimeDeliveryBootstrap(projectRoot); + const removedPaths = await removeAllRuntimeDeliveryBootstraps(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.', + removedPaths.length + ? `✅ Removed runtime delivery bootstrap files because delivery is disabled for this project: ${removedPaths.join(', ')}` + : '✅ Runtime delivery is disabled for this project; no bootstrap file is present.', ), ); } @@ -351,6 +364,7 @@ export async function initConfig(params: { const existing = loadExistingConfig(configPath, existingConfigFile.content); let bootstrapResult: RuntimeDeliveryBootstrapResult = {}; if (existing && params.authToken) { + assertMatchingServerOrigin(existing.serverUrl, params.serverUrl); const credentials = await fetchProjectCredentials({ serverUrl: existing.serverUrl, orgSlug: existing.orgSlug, diff --git a/src/CLI/scripts/login-cli.ts b/src/CLI/scripts/login-cli.ts index a6644fa..117b226 100644 --- a/src/CLI/scripts/login-cli.ts +++ b/src/CLI/scripts/login-cli.ts @@ -12,6 +12,7 @@ import { Socket } from 'net'; import { hasExistingBundleDropConfig, initConfig } from './init-config'; import { runPostInitPrompts } from './post-init'; import { detectProjectType } from '../../expo'; +import { normalizeServerUrl } from '../serverUrl'; type CliSessionResponse = { sessionId: string; @@ -57,10 +58,7 @@ type AuthFilePayload = { const LOGIN_TIMEOUT_MS = 10 * 60 * 1000; const DOCS_MANUAL_SETUP_URL = 'https://bundledrop.app/docs/manual-setup'; -export const getBaseUrl = () => - process.env.BUNDLE_DROP_SERVER_URL - ? process.env.BUNDLE_DROP_SERVER_URL.replace(/\/$/, '') - : 'https://api.bundledrop.app'; +export const getBaseUrl = () => normalizeServerUrl(process.env.BUNDLE_DROP_SERVER_URL); export const getClientName = () => { const hostname = os.hostname(); diff --git a/src/CLI/scripts/native/write-runtime-identity.ts b/src/CLI/scripts/native/write-runtime-identity.ts new file mode 100644 index 0000000..4d3643c --- /dev/null +++ b/src/CLI/scripts/native/write-runtime-identity.ts @@ -0,0 +1,121 @@ +import fs from 'fs-extra'; +import path from 'path'; + +import { resolveBundleDropRuntimeVersionAuthority } from '../../../expo'; +import type { MobilePlatform } from '../../../expo'; + +export type NativeRuntimeIdentity = + | { + schemaVersion: 1; + platform: MobilePlatform; + source: 'bundle-drop'; + runtimeVersion: string; + } + | { + schemaVersion: 1; + platform: MobilePlatform; + source: 'expo'; + }; + +export type WriteNativeRuntimeIdentityOptions = { + projectRoot: string; + platform: MobilePlatform; + outputPath?: string; +}; + +export function resolveNativeRuntimeIdentity( + projectRoot: string, + platform: MobilePlatform, +): NativeRuntimeIdentity { + const authority = resolveBundleDropRuntimeVersionAuthority(projectRoot, platform); + if (authority.source === 'expo') { + return { schemaVersion: 1, platform, source: 'expo' }; + } + return { + schemaVersion: 1, + platform, + source: 'bundle-drop', + runtimeVersion: authority.runtimeVersion, + }; +} + +function writeAtomically(outputPath: string, content: string): void { + const absoluteOutputPath = path.resolve(outputPath); + fs.ensureDirSync(path.dirname(absoluteOutputPath)); + const temporaryPath = path.join( + path.dirname(absoluteOutputPath), + `.${path.basename(absoluteOutputPath)}-${process.pid}-${Date.now()}.tmp`, + ); + try { + fs.writeFileSync(temporaryPath, content, 'utf8'); + const descriptor = fs.openSync(temporaryPath, 'r'); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fs.renameSync(temporaryPath, absoluteOutputPath); + } catch (error) { + try { + fs.removeSync(temporaryPath); + } catch { + // Preserve the original failure. + } + throw error; + } +} + +export function writeNativeRuntimeIdentity( + options: WriteNativeRuntimeIdentityOptions, +): NativeRuntimeIdentity { + const identity = resolveNativeRuntimeIdentity(options.projectRoot, options.platform); + const content = `${JSON.stringify(identity)}\n`; + if (options.outputPath) { + writeAtomically(options.outputPath, content); + } + return identity; +} + +export function parseNativeRuntimeIdentityArguments( + argv: string[], +): WriteNativeRuntimeIdentityOptions { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + if (!['--project-root', '--platform', '--output'].includes(flag) || !value) { + throw new Error( + 'Usage: write-runtime-identity --project-root --platform ios|android [--output ]', + ); + } + if (values.has(flag)) throw new Error(`Duplicate argument ${flag}.`); + values.set(flag, value); + } + const projectRoot = values.get('--project-root'); + const platform = values.get('--platform'); + if (!projectRoot || (platform !== 'ios' && platform !== 'android')) { + throw new Error( + 'Usage: write-runtime-identity --project-root --platform ios|android [--output ]', + ); + } + return { + projectRoot, + platform, + ...(values.get('--output') ? { outputPath: values.get('--output') } : {}), + }; +} + +/* istanbul ignore next */ +if (require.main === module) { + try { + const identity = writeNativeRuntimeIdentity( + parseNativeRuntimeIdentityArguments(process.argv.slice(2)), + ); + if (!process.argv.includes('--output')) { + process.stdout.write(`${JSON.stringify(identity)}\n`); + } + } catch (error) { + process.stderr.write(`${(error as Error).message}\n`); + process.exitCode = 1; + } +} diff --git a/src/CLI/scripts/upload-cli.ts b/src/CLI/scripts/upload-cli.ts index ace8bc2..a1650c1 100644 --- a/src/CLI/scripts/upload-cli.ts +++ b/src/CLI/scripts/upload-cli.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { execSync } from 'child_process'; +import { spawnSync } from 'child_process'; import FormData from 'form-data'; import * as fs from 'fs'; import * as path from 'path'; @@ -13,6 +13,8 @@ import { evaluateExpoConfig, resolveBundleDropRuntimeVersionAuthority, } from '../../expo'; +import { resolveModuleFrom, type ModuleResolver } from '../../scripts/resolveModule'; +import { assertMatchingServerOrigin } from '../serverUrl'; const DOCS_INSTALLATION_URL = 'https://bundledrop.app/docs/installation'; const DOCS_UPLOADING_URL = 'https://bundledrop.app/docs/uploading'; @@ -24,8 +26,44 @@ const BUNDLE_DROP_ARTIFACT_FILES = [ 'bundle-drop-result.json', ]; -const getPackageRoot = () => - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE || path.resolve(__dirname, '..', '..', '..'); +type UploadOptions = { + plistFile?: string; + version?: string; + channel?: string; + buildGradlePath?: string; + releaseNotes?: string; + token?: string; + author?: string; + sourcemap?: boolean; + artifactDir?: string; + buildReceipt?: string; +}; + +type UploadDependencies = { + packageRoot: string; + spawnProcess: typeof spawnSync; + resolveModule: ModuleResolver; +}; + +const defaultUploadDependencies = (): UploadDependencies => ({ + packageRoot: path.resolve(__dirname, '..', '..', '..'), + spawnProcess: spawnSync, + resolveModule: resolveModuleFrom, +}); + +const assertPathInside = (targetPath: string, parentPath: string, label: string) => { + const resolvedParent = path.resolve(parentPath); + const resolvedTarget = path.resolve(targetPath); + const relative = path.relative(resolvedParent, resolvedTarget); + const escapesParent = + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + if (!escapesParent) { + return; + } + throw new Error(`${label} escaped its generated output directory: ${resolvedTarget}`); +}; const readPackageVersion = (packageRoot: string): string | undefined => { try { @@ -105,9 +143,10 @@ const bareArtifactPaths = ( }; }; -export default async function upload( +export async function runUpload( platform: string, - options: { plistFile?: string; version?: string; channel?: string; buildGradlePath?: string; releaseNotes?: string; token?: string; author?: string; sourcemap?: boolean; artifactDir?: string; buildReceipt?: string } + options: UploadOptions, + dependencies: UploadDependencies = defaultUploadDependencies(), ) { console.log(); // blank line log.info('🚀 React Native OTA Upload Initialized'); @@ -169,7 +208,24 @@ export default async function upload( ); process.exit(1); } - token = JSON.parse(fs.readFileSync(authPath, 'utf-8')).token; + let storedAuth: { token?: string; serverUrl?: string; baseUrl?: string }; + try { + storedAuth = JSON.parse(fs.readFileSync(authPath, 'utf-8')); + } catch { + log.error('❌ Failed to read CLI auth session. Run `bundle-drop login` again or pass --token.'); + process.exit(1); + } + if (!storedAuth?.token) { + log.error('❌ CLI auth session is missing a token. Run `bundle-drop login` again or pass --token.'); + process.exit(1); + } + try { + assertMatchingServerOrigin(serverUrl, storedAuth.serverUrl || storedAuth.baseUrl); + } catch (error) { + log.error(`❌ ${(error as Error).message}`); + process.exit(1); + } + token = storedAuth.token; } // Validate channel @@ -279,7 +335,7 @@ export default async function upload( console.log(); log.arrow(`Bundling ${platform} app...`); - const packageRoot = getPackageRoot(); + const packageRoot = dependencies.packageRoot; const packageVersion = readPackageVersion(packageRoot); const compiledBundleScript = path.join(packageRoot, 'lib', 'scripts', 'bundle.js'); const tsBundleScript = path.join(packageRoot, 'src', 'scripts', 'bundle.ts'); @@ -296,17 +352,28 @@ export default async function upload( buildIdentity: expoBuildIdentity, }); } else { - const bundleCommand = fs.existsSync(compiledBundleScript) - ? `node "${compiledBundleScript}"` - : `ts-node "${tsBundleScript}"`; - const sourcemapArg = options.sourcemap ? ' --sourcemap' : ''; - execSync(`${bundleCommand} ${platform}${sourcemapArg}`, { + const useCompiledScript = fs.existsSync(compiledBundleScript); + const script = useCompiledScript + ? compiledBundleScript + : dependencies.resolveModule('ts-node/dist/bin.js', [packageRoot, __dirname]); + const args = [ + script, + ...(useCompiledScript ? [] : [tsBundleScript]), + platform, + ]; + if (options.sourcemap) args.push('--sourcemap'); + const result = dependencies.spawnProcess(process.execPath, args, { stdio: 'inherit', + shell: false, env: { ...process.env, BUNDLE_DROP_APP_VERSION: version, }, }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Bundle process exited with status ${result.status}`); + } } } catch (error) { log.error( @@ -433,6 +500,10 @@ export default async function upload( return; } finally { try { + const generatedRoot = isExpoProject + ? path.join(projectRoot, '.bundle-drop', 'artifacts') + : path.join(packageRoot, 'dist'); + assertPathInside(artifact.outputDir, generatedRoot, 'Artifact output'); const cleanupFiles = [ artifact.zipPath, artifact.metadataPath, @@ -445,11 +516,16 @@ export default async function upload( path.join(packageRoot, 'dist', `expo-export-${platform}`); for (const file of cleanupFiles) { - if (file && fs.existsSync(file)) fs.unlinkSync(file); + if (file) { + assertPathInside(file, artifact.outputDir, 'Artifact file'); + if (fs.existsSync(file)) fs.unlinkSync(file); + } } + assertPathInside(assetsPath, artifact.outputDir, 'Artifact assets'); if (fs.existsSync(assetsPath)) { fs.rmSync(assetsPath, { recursive: true, force: true }); } + assertPathInside(expoExportDirectory, generatedRoot, 'Expo export'); if (fs.existsSync(expoExportDirectory)) { fs.rmSync(expoExportDirectory, { recursive: true, force: true }); } @@ -464,3 +540,7 @@ export default async function upload( } } } + +export default function upload(platform: string, options: UploadOptions) { + return runUpload(platform, options); +} diff --git a/src/CLI/serverUrl.ts b/src/CLI/serverUrl.ts new file mode 100644 index 0000000..885c201 --- /dev/null +++ b/src/CLI/serverUrl.ts @@ -0,0 +1,35 @@ +export const DEFAULT_SERVER_URL = 'https://api.bundledrop.app'; + +export const normalizeServerUrl = (value?: string): string => { + const serverUrl = value || DEFAULT_SERVER_URL; + let parsed: URL; + try { + parsed = new URL(serverUrl); + } catch { + throw new Error('Bundle Drop serverUrl must be an HTTP(S) URL without embedded credentials.'); + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + throw new Error('Bundle Drop serverUrl must be an HTTP(S) URL without embedded credentials.'); + } + return serverUrl.replace(/\/+$/, ''); +}; + +export const assertMatchingServerOrigin = ( + requestServerUrl: string, + authenticatedServerUrl: string | undefined, +): void => { + if (!authenticatedServerUrl) { + throw new Error( + 'The stored CLI login is not bound to a server. Run `bundle-drop login` again or pass --token explicitly.', + ); + } + + const requestOrigin = new URL(normalizeServerUrl(requestServerUrl)).origin; + const authenticatedOrigin = new URL(normalizeServerUrl(authenticatedServerUrl)).origin; + if (requestOrigin !== authenticatedOrigin) { + throw new Error( + `The stored CLI login belongs to ${authenticatedOrigin}, but this project targets ${requestOrigin}. ` + + 'Run `bundle-drop login` for this server or pass --token explicitly.', + ); + } +}; diff --git a/src/bundleInfo.ts b/src/bundleInfo.ts index c2d5500..3be65b3 100644 --- a/src/bundleInfo.ts +++ b/src/bundleInfo.ts @@ -39,9 +39,13 @@ export async function readBundleInfo(): Promise { } } +export async function writeBundleInfoDurably(info: BundleInfo): Promise { + await RNFS.writeFile(BUNDLE_INFO_PATH, JSON.stringify(info, null, 2), 'utf8'); +} + export async function writeBundleInfo(info: BundleInfo): Promise { try { - await RNFS.writeFile(BUNDLE_INFO_PATH, JSON.stringify(info, null, 2), 'utf8'); + await writeBundleInfoDurably(info); } catch (e) { console.warn('⚠️ Failed to write bundle-info.json', e); } @@ -51,3 +55,13 @@ export async function updateBundleInfo(partial: Partial): Promise { + try { + if (await RNFS.exists(BUNDLE_INFO_PATH)) { + await RNFS.unlink(BUNDLE_INFO_PATH); + } + } catch (e) { + console.warn('⚠️ Failed to delete bundle-info.json', e); + } +} diff --git a/src/context.ts b/src/context.ts index 1197aab..67952d5 100644 --- a/src/context.ts +++ b/src/context.ts @@ -34,6 +34,20 @@ export const runtimeVersion = export const defaultChannel = config.defaultChannel || 'develop'; +const configuredMaxCrashCount = config.rollback?.maxCrashCount ?? 3; +if ( + !Number.isSafeInteger(configuredMaxCrashCount) || + configuredMaxCrashCount < 0 || + configuredMaxCrashCount > 2_147_483_647 +) { + throw new Error('[BundleDrop] rollback.maxCrashCount must be a non-negative 32-bit integer.'); +} + +const configuredHealthyAfterSec = config.rollback?.healthyAfterSec ?? 0; +if (!Number.isFinite(configuredHealthyAfterSec) || configuredHealthyAfterSec < 0) { + throw new Error('[BundleDrop] rollback.healthyAfterSec must be a finite non-negative number.'); +} + export const BUNDLE_DROP_ROOT = isIOS ? `${RNFS.LibraryDirectoryPath}/bundle-drop` : `${RNFS.DocumentDirectoryPath}/bundle-drop`; @@ -76,8 +90,8 @@ export const bundleDropConfig: BundleDropConfig = { org: { slug: config.org.slug }, project: { name: config.project.name, slug: config.project.slug }, rollback: { - maxCrashCount: config.rollback?.maxCrashCount ?? 3, + maxCrashCount: configuredMaxCrashCount, healthCheckMode: config.rollback?.healthCheckMode === 'manual' ? 'manual' : 'auto', - healthyAfterSec: config.rollback?.healthyAfterSec ?? 0, + healthyAfterSec: configuredHealthyAfterSec, }, }; diff --git a/src/fs/bundlePointer.ts b/src/fs/bundlePointer.ts index 1d19666..ece3b75 100644 --- a/src/fs/bundlePointer.ts +++ b/src/fs/bundlePointer.ts @@ -1,124 +1,22 @@ import RNFS from '../native/fs'; import { BUNDLE_DROP_ROOT } from '../context'; -import { atomicWriteJson, ensureDir } from './fsUtils'; - -export type BundlePointer = { - hash: string; - bundlePath: string; - updatedAt: string; -}; const CURRENT_POINTER_PATH = `${BUNDLE_DROP_ROOT}/current.json`; -const PREVIOUS_POINTER_PATH = `${BUNDLE_DROP_ROOT}/previous.json`; const BUNDLE_HASH_PATTERN = /^[a-f0-9]{64}$/; -const bundlePathForHash = (hash: string) => `${BUNDLE_DROP_ROOT}/bundles/${hash}/main.jsbundle`; - -async function readPointer(path: string): Promise { +async function readPointerHash(path: string): Promise { try { if (!(await RNFS.exists(path))) return null; const raw = await RNFS.readFile(path, 'utf8'); const parsed = JSON.parse(raw); if (!BUNDLE_HASH_PATTERN.test(parsed?.hash ?? '')) return null; - return { - hash: parsed.hash, - bundlePath: bundlePathForHash(parsed.hash), - updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '', - }; + return parsed.hash; } catch { return null; } } -export async function readCurrentBundlePointer(): Promise { - return readPointer(CURRENT_POINTER_PATH); -} - -export async function readPreviousBundlePointer(): Promise { - return readPointer(PREVIOUS_POINTER_PATH); -} - -export async function writeCurrentBundlePointer(pointer: BundlePointer): Promise { - assertCanonicalPointerHash(pointer.hash); - const root = BUNDLE_DROP_ROOT; - await ensureDir(root); - await atomicWriteJson(CURRENT_POINTER_PATH, pointerJson(pointer)); -} - -export async function writePreviousBundlePointer(pointer: BundlePointer): Promise { - assertCanonicalPointerHash(pointer.hash); - const root = BUNDLE_DROP_ROOT; - await ensureDir(root); - await atomicWriteJson(PREVIOUS_POINTER_PATH, pointerJson(pointer)); -} - -export async function restorePreviousBundlePointer(pointer: BundlePointer | null): Promise { - if (pointer) { - await writePreviousBundlePointer({ ...pointer, updatedAt: new Date().toISOString() }); - return; - } - - await deletePreviousBundlePointer(); -} - -export async function setCurrentBundlePointer( - _bundlePath: string, - hash: string, - options?: { setPrevious?: boolean }, -) { - assertCanonicalPointerHash(hash); - const root = BUNDLE_DROP_ROOT; - await ensureDir(root); - - const current = await readCurrentBundlePointer(); - if (options?.setPrevious !== false && current?.hash && current?.bundlePath) { - await writePreviousBundlePointer({ ...current, updatedAt: new Date().toISOString() }); - } - - await writeCurrentBundlePointer({ - hash, - bundlePath: bundlePathForHash(hash), - updatedAt: new Date().toISOString(), - }); -} - -function pointerJson(pointer: BundlePointer) { - return { - hash: pointer.hash, - updatedAt: pointer.updatedAt, - }; -} - -function assertCanonicalPointerHash(hash: string) { - if (!BUNDLE_HASH_PATTERN.test(hash)) { - throw new Error('Bundle pointer hash must be a canonical 64-character lowercase SHA-256 hash'); - } -} - -export async function rollbackToPreviousPointer(): Promise { - const previous = await readPreviousBundlePointer(); - if (!previous) return null; - await writeCurrentBundlePointer({ ...previous, updatedAt: new Date().toISOString() }); - return previous; -} - -export async function deleteCurrentBundlePointer(): Promise { - if (await RNFS.exists(CURRENT_POINTER_PATH)) { - await RNFS.unlink(CURRENT_POINTER_PATH); - } -} - -export async function deletePreviousBundlePointer(): Promise { - if (await RNFS.exists(PREVIOUS_POINTER_PATH)) { - await RNFS.unlink(PREVIOUS_POINTER_PATH); - } -} - -export async function clearCurrentBundlePointer(): Promise { - try { - await deleteCurrentBundlePointer(); - } catch { - // Best-effort cleanup for callers that do not need strict rollback semantics. - } +export async function readCurrentBundleHash(): Promise { + return readPointerHash(CURRENT_POINTER_PATH); } diff --git a/src/install/bundleInstallShared.ts b/src/install/bundleInstallShared.ts index d951cf3..f67852a 100644 --- a/src/install/bundleInstallShared.ts +++ b/src/install/bundleInstallShared.ts @@ -2,7 +2,7 @@ import RNFS from '../native/fs'; import { BUNDLE_DROP_ROOT } from '../context'; import { BundleInfo } from '../bundleInfo'; -import { readCurrentBundlePointer } from '../fs/bundlePointer'; +import { readCurrentBundleHash } from '../fs/bundlePointer'; import { ensureDir } from '../fs/fsUtils'; import { InstallPhaseError } from '../errors'; import { @@ -90,8 +90,8 @@ export const finalizeInstall = async ( try { existingManifest = await verifyBundleDir(bundleDir, hash, platform); } catch (e) { - const currentPointer = await readCurrentBundlePointer(); - if (currentPointer?.hash === hash) { + const currentHash = await readCurrentBundleHash(); + if (currentHash === hash) { throw new Error('Active bundle folder failed verification'); } } @@ -102,8 +102,8 @@ export const finalizeInstall = async ( return { bundlePath, metadataFromZip }; } - const currentPointer = await readCurrentBundlePointer(); - if (currentPointer?.hash === hash) { + const currentHash = await readCurrentBundleHash(); + if (currentHash === hash) { throw new Error('Active bundle folder failed verification'); } await RNFS.unlink(bundleDir); diff --git a/src/manager/downloadAndInstall.ts b/src/manager/downloadAndInstall.ts index 1e2fd7c..b6eb8a5 100644 --- a/src/manager/downloadAndInstall.ts +++ b/src/manager/downloadAndInstall.ts @@ -1,32 +1,17 @@ import { config, defaultChannel, platform as devicePlatform } from '../context'; -import { BundleInfo, readBundleInfo, writeBundleInfo } from '../bundleInfo'; -import { - deleteCurrentBundlePointer, - readCurrentBundlePointer, - readPreviousBundlePointer, - restorePreviousBundlePointer, - setCurrentBundlePointer, - writeCurrentBundlePointer, - type BundlePointer, -} from '../fs/bundlePointer'; +import { BundleInfo, readBundleInfo, writeBundleInfoDurably } from '../bundleInfo'; import { installFromZip } from '../install/installFromZip'; import { tryInstallPatchTransport } from '../patch-engine/patchTransport'; -import { getDownloadedBundlePathNative } from '../native/bundleDropNative'; import { authorizeRuntimeDeliveryUpdate, checkForUpdate } from './updateCheck'; import { BundleDropError, isInstallPhaseError } from '../errors'; -import { isBundleHashFailed, markCandidateActivated } from './rollbackState'; +import { + activateStartupCandidate, + isBundleHashFailed, + rollbackStartupBundle, +} from './rollbackState'; import type { OtaPatchSet, UpdateCheckResponse } from '../api/types'; import { isArtifactCapabilityRejected } from '../runtime-delivery/artifactCapability'; -async function restoreCurrentPointer(pointer: BundlePointer | null): Promise { - if (pointer) { - await writeCurrentBundlePointer({ ...pointer, updatedAt: new Date().toISOString() }); - return; - } - - await deleteCurrentBundlePointer(); -} - export type DownloadUpdateResult = | { status: 'staged'; bundlePath: string; hash: string } | { status: 'upToDate'; reason?: string; skippedFailedBundle?: boolean; skippedHash?: string } @@ -275,11 +260,7 @@ async function downloadAndStageUpdate( statusCb?.('✅ Update downloaded. Will apply on next launch or when you call applyUpdate().'); // Persist installed metadata for future skip logic. - const [previousInfo, previousCurrentPointer, previousRollbackPointer] = await Promise.all([ - readBundleInfo(), - readCurrentBundlePointer(), - readPreviousBundlePointer(), - ]); + const previousInfo = await readBundleInfo(); const installedInfo: BundleInfo = { bundleVersion: serverBundleVersion ?? metadataFromZip.bundleVersion, version: serverVersion ?? metadataFromZip.version, @@ -292,20 +273,38 @@ async function downloadAndStageUpdate( lastInstalledReportedHash: previousInfo?.lastInstalledReportedHash, installedReportedHashes: previousInfo?.installedReportedHashes, }; - await setCurrentBundlePointer(bundlePath, installedHash); + let candidateActivated = false; try { - const resolvedBundlePath = await getDownloadedBundlePathNative(); - if (resolvedBundlePath !== bundlePath) { + const activation = await activateStartupCandidate(installedHash); + candidateActivated = activation !== null; + if (!activation) { + throw new BundleDropError({ + message: 'Installed bundle requires native startup recovery support', + code: 'INSTALL_FAILED', + step: 'install', + context: { channelName, platform, hash }, + }); + } + if (activation.hash !== installedHash || activation.bundlePath !== bundlePath) { throw new BundleDropError({ message: 'Installed bundle was not accepted by the native resolver', code: 'INSTALL_FAILED', step: 'install', - context: { channelName, platform, hash, expectedBundlePath: bundlePath, resolvedBundlePath }, + context: { + channelName, + platform, + hash, + expectedBundlePath: bundlePath, + resolvedBundlePath: activation.bundlePath, + resolvedHash: activation.hash, + }, }); } + await writeBundleInfoDurably(installedInfo); } catch (nativeError) { - await restoreCurrentPointer(previousCurrentPointer); - await restorePreviousBundlePointer(previousRollbackPointer); + if (candidateActivated) { + await rollbackStartupBundle(false).catch(() => false); + } if (nativeError instanceof BundleDropError) { throw nativeError; } @@ -317,9 +316,6 @@ async function downloadAndStageUpdate( cause: nativeError, }); } - await writeBundleInfo(installedInfo); - await markCandidateActivated(installedHash); - return { status: 'staged', bundlePath, hash: installedHash }; } catch (err) { const wrapped = diff --git a/src/manager/reporting.ts b/src/manager/reporting.ts index 202590c..ef0c52f 100644 --- a/src/manager/reporting.ts +++ b/src/manager/reporting.ts @@ -5,7 +5,15 @@ import { getDownloadedBundlePathNative } from '../native/bundleDropNative'; import { getOrCreateInstallId } from '../fs/installId'; import { getCurrentUserProperties } from '../fs/userProperties'; import { getBundleDropRuntimeConfig } from '../runtime/initState'; -import type { FailedBundleRecord } from './rollbackState'; + +export type FailedBundleRecord = { + reason: 'crash_loop'; + failedAt: number; + crashCount?: number; + channelName?: string; + runtimeVersion?: string; + previousHash?: string; +}; const MAX_REPORTED_INSTALL_HASHES = 50; const installedReportInFlightHashes = new Set(); @@ -69,27 +77,26 @@ export async function reportInstalledIfReady(state?: { hasBundle?: boolean; info } } -export async function reportLocalRollback(hash: string, record: FailedBundleRecord): Promise { - try { - const [installId, userProperties] = await Promise.all([ - getOrCreateInstallId(), - getCurrentUserProperties(), - ]); - const appEnvironment = getBundleDropRuntimeConfig()?.environment ?? null; +export async function reportLocalRollback( + hash: string, + record: FailedBundleRecord, +): Promise { + const [installId, userProperties] = await Promise.all([ + getOrCreateInstallId(), + getCurrentUserProperties(), + ]); + const appEnvironment = getBundleDropRuntimeConfig()?.environment ?? null; - await postLocalRollbackReport(config.project.slug, hash, { - reason: record.reason, - previousHash: record.previousHash ?? null, - channelName: record.channelName ?? null, - platform, - installId, - runtimeVersion: record.runtimeVersion ?? runtimeVersion ?? null, - environment: appEnvironment, - userProperties: Object.keys(userProperties).length > 0 ? userProperties : undefined, - crashCount: record.crashCount ?? null, - failedAt: record.failedAt ? new Date(record.failedAt * 1000).toISOString() : null, - }); - } catch (e) { - console.warn('⚠️ Failed to report local rollback:', e?.toString?.() || e); - } + await postLocalRollbackReport(config.project.slug, hash, { + reason: record.reason, + previousHash: record.previousHash ?? null, + channelName: record.channelName ?? null, + platform, + installId, + runtimeVersion: record.runtimeVersion ?? runtimeVersion ?? null, + environment: appEnvironment, + userProperties: Object.keys(userProperties).length > 0 ? userProperties : undefined, + crashCount: record.crashCount ?? null, + failedAt: record.failedAt ? new Date(record.failedAt * 1000).toISOString() : null, + }); } diff --git a/src/manager/rollbackState.ts b/src/manager/rollbackState.ts index e031c2f..40e76b8 100644 --- a/src/manager/rollbackState.ts +++ b/src/manager/rollbackState.ts @@ -1,18 +1,21 @@ -import RNFS from '../native/fs'; - -import { BUNDLE_DROP_ROOT, bundleDropConfig, platform } from '../context'; +import type { BundleInfo } from '../bundleInfo'; +import { BUNDLE_DROP_ROOT, bundleDropConfig } from '../context'; import { atomicWriteJson, ensureDir } from '../fs/fsUtils'; +import RNFS from '../native/fs'; import { - deleteCurrentBundlePointer, - deletePreviousBundlePointer, - readCurrentBundlePointer, - readPreviousBundlePointer, - writeCurrentBundlePointer, - type BundlePointer, -} from '../fs/bundlePointer'; -import { readBundleInfo, updateBundleInfo } from '../bundleInfo'; -import { reportLocalRollback } from './reporting'; -import { BUNDLE_MANIFEST, type BundleManifest } from '../manifest/bundleManifest'; + acknowledgeStartupRecoveryNative, + activateStartupCandidateNative, + getStartupRecoveryAttemptNative, + getStartupRecoveryStateNative, + markStartupHealthyNative, + rollbackStartupBundleNative, + setStartupRecoveryRevokedHashesNative, + type StartupRecoveryEvent, + type StartupRecoveryState, + type StartupCandidateActivation, + type StartupRollbackResult, +} from '../native/bundleDropNative'; +import { reportLocalRollback, type FailedBundleRecord } from './reporting'; export type RollbackPolicy = { maxCrashCount?: number; @@ -20,310 +23,188 @@ export type RollbackPolicy = { healthyAfterSec?: number; }; -export type FailedBundleReason = 'crash_loop'; +export type { FailedBundleRecord } from './reporting'; +export type { StartupRecoveryState } from '../native/bundleDropNative'; -export type FailedBundleRecord = { - reason: FailedBundleReason; - failedAt: number; - crashCount?: number; +type RecoveryTelemetryContext = { + failedHash: string; channelName?: string; runtimeVersion?: string; - previousHash?: string; }; -export type RollbackState = { - activeHash?: string; - lastGoodHash?: string; - candidateHash?: string; - candidateActivatedAt?: number; - candidateCommitted?: boolean; - crashCount?: number; - lastLaunchAt?: number; - failedBundles?: Record; +type RecoveryTelemetryContextState = { + schemaVersion: 1; + events: Record; }; -type RollbackDecision = - | { shouldRollback: false } - | { shouldRollback: true; reason: FailedBundleReason }; - -const STATE_PATH = `${BUNDLE_DROP_ROOT}/state.json`; -const MAX_FAILED_BUNDLES = 20; +export const STARTUP_RECOVERY_TELEMETRY_CONTEXT_PATH = + `${BUNDLE_DROP_ROOT}/recovery-telemetry-context.json`; -function nowSec() { - return Math.floor(Date.now() / 1000); -} +let telemetryContextMutation: Promise = Promise.resolve(); -async function readState(): Promise { +async function readRecoveryTelemetryContexts(): Promise { try { - if (!(await RNFS.exists(STATE_PATH))) return null; - const raw = await RNFS.readFile(STATE_PATH, 'utf8'); - return JSON.parse(raw) as RollbackState; - } catch { - return null; + if (!await RNFS.exists(STARTUP_RECOVERY_TELEMETRY_CONTEXT_PATH)) { + return { schemaVersion: 1, events: {} }; + } + const parsed = JSON.parse( + await RNFS.readFile(STARTUP_RECOVERY_TELEMETRY_CONTEXT_PATH, 'utf8'), + ) as RecoveryTelemetryContextState; + if (parsed?.schemaVersion !== 1 || !parsed.events || typeof parsed.events !== 'object') { + throw new Error('unsupported recovery telemetry context'); + } + return parsed; + } catch (error) { + console.warn('⚠️ Ignoring malformed BundleDrop recovery telemetry context:', error); + return { schemaVersion: 1, events: {} }; } } -export { readState as readRollbackState }; - -async function writeState(state: RollbackState): Promise { - await ensureDir(BUNDLE_DROP_ROOT); - await atomicWriteJson(STATE_PATH, state); +async function mutateRecoveryTelemetryContexts( + mutate: (state: RecoveryTelemetryContextState) => boolean, +): Promise { + let result: RecoveryTelemetryContextState = { schemaVersion: 1, events: {} }; + const mutation = telemetryContextMutation.then(async () => { + result = await readRecoveryTelemetryContexts(); + if (!mutate(result)) return; + await ensureDir(BUNDLE_DROP_ROOT); + await atomicWriteJson(STARTUP_RECOVERY_TELEMETRY_CONTEXT_PATH, result); + }); + telemetryContextMutation = mutation.then(() => undefined, () => undefined); + await mutation; + return result; +} + +async function prepareRecoveryTelemetryContexts( + events: StartupRecoveryEvent[], + failedBundleInfo?: BundleInfo | null, +): Promise { + const pendingEventIds = new Set(events.map(event => event.id)); + return mutateRecoveryTelemetryContexts(state => { + let changed = false; + for (const eventId of Object.keys(state.events)) { + if (!pendingEventIds.has(eventId)) { + delete state.events[eventId]; + changed = true; + } + } + for (const event of events) { + if ( + !state.events[event.id] && + failedBundleInfo?.hash === event.failedHash + ) { + state.events[event.id] = { + failedHash: event.failedHash, + channelName: failedBundleInfo.channelName, + runtimeVersion: failedBundleInfo.runtimeVersion, + }; + changed = true; + } + } + return changed; + }); } -async function updateState(partial: Partial): Promise { - const existing = (await readState()) || {}; - const next = { ...existing, ...partial }; - await writeState(next); - return next; +async function removeRecoveryTelemetryContext(eventId: string): Promise { + await mutateRecoveryTelemetryContexts(state => { + if (!state.events[eventId]) return false; + delete state.events[eventId]; + return true; + }); } -function pruneFailedBundles( - failedBundles: Record, -): Record { - return Object.fromEntries( - Object.entries(failedBundles) - .sort(([, left], [, right]) => right.failedAt - left.failedAt) - .slice(0, MAX_FAILED_BUNDLES), - ); +export function getRollbackPolicy(): Required { + return bundleDropConfig.rollback; } -export async function markCandidateActivated(hash: string): Promise { - const previous = await readPreviousBundlePointer(); - const previousHash = previous?.hash; - const now = nowSec(); - await updateState({ - activeHash: hash, - candidateHash: hash, - candidateActivatedAt: now, - candidateCommitted: false, - crashCount: 0, - lastLaunchAt: now, - lastGoodHash: previousHash ?? undefined, - }); +export async function activateStartupCandidate( + hash: string, +): Promise { + return activateStartupCandidateNative(hash, getRollbackPolicy()); } -export async function reportActiveBundleHealthy( - cached?: { currentPointer?: BundlePointer | null }, - expectedHash?: string, -): Promise { - const current = cached?.currentPointer !== undefined ? cached.currentPointer : await readCurrentBundlePointer(); - const hash = current?.hash; - if (!hash) return false; - if (expectedHash && hash !== expectedHash) return false; - - const state = (await readState()) || {}; - const isCandidate = state.candidateHash === hash && state.candidateCommitted !== true; - if (!isCandidate) return false; +export async function reportActiveBundleHealthy(): Promise { + const attempt = getStartupRecoveryAttemptNative(); + if (!attempt) return false; + return markStartupHealthyNative(attempt); +} - await updateState({ - activeHash: hash, - candidateHash: hash, - candidateCommitted: true, - crashCount: 0, - lastGoodHash: hash, - }); - return true; +export async function readStartupRecoveryState(): Promise { + return getStartupRecoveryStateNative(); } -export async function commitActiveBundle( - cached?: { currentPointer?: BundlePointer | null }, -): Promise { - await reportActiveBundleHealthy(cached); +export async function getFailedBundleHashes( + cachedState?: StartupRecoveryState | null, +): Promise { + const state = cachedState === undefined ? await readStartupRecoveryState() : cachedState; + return [...(state?.quarantinedHashes || [])]; } export async function isBundleHashFailed(hash?: string | null): Promise { if (!hash) return false; - const state = await readState(); - return !!state?.failedBundles?.[hash]; + return (await getFailedBundleHashes()).includes(hash); } -export async function getFailedBundleHashes(): Promise { - const state = await readState(); - return Object.entries(state?.failedBundles || {}) - .sort(([, left], [, right]) => right.failedAt - left.failedAt) - .slice(0, MAX_FAILED_BUNDLES) - .map(([hash]) => hash); +export async function syncVerifiedRevokedHashes(hashes: string[]): Promise { + return setStartupRecoveryRevokedHashesNative(hashes); } -async function buildFailedBundleRecord( - hash: string, - reason: FailedBundleReason, - state: RollbackState, -): Promise { - const [bundleInfo, previous] = await Promise.all([ - readBundleInfo(), - readPreviousBundlePointer(), - ]); - const record: FailedBundleRecord = { - reason, - failedAt: nowSec(), - crashCount: state.crashCount, - channelName: bundleInfo?.channelName, - runtimeVersion: bundleInfo?.runtimeVersion, - previousHash: previous?.hash, - }; - return record; +export async function rollbackStartupBundle( + forceEmbedded: boolean, +): Promise { + return rollbackStartupBundleNative(forceEmbedded); } -async function recordFailedBundle( - hash: string, - record: FailedBundleRecord, -): Promise { - const state = (await readState()) || {}; - const failedBundles = pruneFailedBundles({ - ...(state.failedBundles || {}), - [hash]: record, - }); - await updateState({ failedBundles }); -} - -export async function evaluateRollbackOnLaunch( - policy: Required, - cached?: { currentPointer?: BundlePointer | null; rollbackState?: RollbackState | null }, -): Promise { - const current = cached?.currentPointer !== undefined ? cached.currentPointer : await readCurrentBundlePointer(); - const activeHash = current?.hash; - if (!activeHash) return { shouldRollback: false }; - - const now = nowSec(); - const persistedState = (await readState()) || {}; - const state = - cached?.rollbackState !== undefined - ? { - ...persistedState, - ...cached.rollbackState, - failedBundles: persistedState.failedBundles ?? cached.rollbackState?.failedBundles, - } - : persistedState; - const next: RollbackState = { - ...state, - activeHash, - lastLaunchAt: now, +function recoveryRecord( + event: StartupRecoveryEvent, + context?: RecoveryTelemetryContext, +): FailedBundleRecord { + return { + reason: event.reason, + failedAt: event.failedAt, + crashCount: event.crashCount, + channelName: context?.channelName, + runtimeVersion: context?.runtimeVersion, + previousHash: event.recoveredHash, }; - - const isCandidate = state.candidateHash === activeHash && state.candidateCommitted !== true; - if (isCandidate) { - const crashCount = (state.crashCount ?? 0) + 1; - next.crashCount = crashCount; - - if ( - policy.maxCrashCount > 0 && - crashCount >= policy.maxCrashCount - ) { - await writeState(next); - return { shouldRollback: true, reason: 'crash_loop' }; - } - - } - - await writeState(next); - return { shouldRollback: false }; -} - -export async function rollbackToPreviousIfNeeded( - policy: Required, - cached?: { currentPointer?: BundlePointer | null; rollbackState?: RollbackState | null }, -): Promise<{ rolledBack: boolean; reason?: FailedBundleReason }> { - const decision = await evaluateRollbackOnLaunch(policy, cached); - if (!decision.shouldRollback) return { rolledBack: false }; - - const current = cached?.currentPointer !== undefined ? cached.currentPointer : await readCurrentBundlePointer(); - const failedHash = current?.hash; - const state = (await readState()) || {}; - if (!failedHash) return { rolledBack: false }; - - const failedRecord = await buildFailedBundleRecord(failedHash, decision.reason, state); - await rollbackToPreviousOrNative(); - await recordFailedBundle(failedHash, failedRecord); - await reportLocalRollback(failedHash, failedRecord).catch(() => undefined); - return { rolledBack: true, reason: decision.reason }; -} - -export function getRollbackPolicy(): Required { - return bundleDropConfig.rollback; } -export async function rollbackToPreviousOrNative( - options: { forceNative?: boolean } = {}, -): Promise<{ rolledBack: boolean; toNative?: boolean }> { - const [current, previous, state] = await Promise.all([ - readCurrentBundlePointer(), - readPreviousBundlePointer(), - readState(), - ]); - const previousIsFailed = previous ? !!state?.failedBundles?.[previous.hash] : false; - - if (!options.forceNative && previous && previous.hash !== current?.hash && !previousIsFailed) { - await writeCurrentBundlePointer({ ...previous, updatedAt: new Date().toISOString() }); - const metadata = await readBundleMetadata(previous.bundlePath); - await updateBundleInfo({ - hash: previous.hash, - bundleVersion: metadata?.bundleVersion, - version: metadata?.version, - runtimeVersion: metadata?.runtimeVersion, - pendingApply: false, - installedAt: new Date().toISOString(), - lastInstalledReportedHash: previous.hash, - }); - - await updateState({ - activeHash: previous.hash, - candidateHash: previous.hash, - candidateCommitted: true, - crashCount: 0, - lastGoodHash: previous.hash, - }); - - return { rolledBack: true }; - } - - // No previous OTA bundle exists; fall back to native bundle by clearing the active pointer. - await deleteCurrentBundlePointer(); - await deletePreviousBundlePointer(); - await updateBundleInfo({ - hash: undefined, - bundleVersion: undefined, - version: undefined, - runtimeVersion: undefined, - pendingApply: false, - installedAt: new Date().toISOString(), - }); - await updateState({ - activeHash: undefined, - candidateHash: undefined, - candidateCommitted: true, - crashCount: 0, - }); - - return { rolledBack: true, toNative: true }; -} +/** + * Flush native recovery events without making JS part of the recovery decision. + * Native keeps each event durable until its backend report succeeds and JS + * acknowledges that exact event id. + */ +export async function reconcileStartupRecovery( + cachedState?: StartupRecoveryState | null, + failedBundleInfo?: BundleInfo | null, +): Promise { + const state = cachedState === undefined ? await readStartupRecoveryState() : cachedState; + if (!state) return null; + const telemetryContexts = await prepareRecoveryTelemetryContexts( + state.pendingRecoveryEvents, + failedBundleInfo, + ); -async function readJsonIfExists(path: string): Promise { - try { - if (!(await RNFS.exists(path))) return null; - const raw = await RNFS.readFile(path, 'utf8'); - return JSON.parse(raw) as T; - } catch { - return null; + for (const event of state.pendingRecoveryEvents) { + try { + const telemetryContext = telemetryContexts.events[event.id]; + await reportLocalRollback( + event.failedHash, + recoveryRecord( + event, + telemetryContext?.failedHash === event.failedHash ? telemetryContext : undefined, + ), + ); + if (await acknowledgeStartupRecoveryNative(event.id)) { + await removeRecoveryTelemetryContext(event.id); + } + } catch (error) { + console.warn( + `⚠️ Failed to report BundleDrop startup recovery event ${event.id}:`, + error?.toString?.() || error, + ); + } } -} - -async function readBundleMetadata(bundlePath: string): Promise<{ - bundleVersion?: number; - version?: string; - runtimeVersion?: string; -} | null> { - const dir = bundlePath.substring(0, bundlePath.lastIndexOf('/')); - const manifest = await readJsonIfExists(`${dir}/${BUNDLE_MANIFEST}`); - const metadataFile = platform === 'android' ? 'metadata-android.json' : 'metadata-ios.json'; - const parsed = await readJsonIfExists>(`${dir}/${metadataFile}`); - if (!manifest && !parsed) { - return null; - } - return { - bundleVersion: parsed?.bundleVersion as number | undefined, - version: manifest?.version ?? parsed?.version as string | undefined, - runtimeVersion: manifest?.runtimeVersion ?? parsed?.runtimeVersion as string | undefined, - }; + return state; } diff --git a/src/manager/updateCheck.ts b/src/manager/updateCheck.ts index 08e49dd..5860fed 100644 --- a/src/manager/updateCheck.ts +++ b/src/manager/updateCheck.ts @@ -15,12 +15,15 @@ import type { OtaResolveResponse, } from '../api/types'; import { defaultChannel } from '../context'; -import { readCurrentBundlePointer } from '../fs/bundlePointer'; +import { readCurrentBundleHash } from '../fs/bundlePointer'; import { getOrCreateInstallId } from '../fs/installId'; import { getCurrentUserProperties } from '../fs/userProperties'; import { getBundleDropRuntimeConfig } from '../runtime/initState'; import { getFailedBundleHashes, isBundleHashFailed } from './rollbackState'; -import { getDownloadedBundlePathNative } from '../native/bundleDropNative'; +import { + getDownloadedBundlePathNative, + getStartupRecoverySelectedHashNative, +} from '../native/bundleDropNative'; import { advertisedPatchAlgorithms } from '../patch-engine/patchOperations'; import { fetchRuntimeDeliveryManifest, @@ -99,8 +102,9 @@ export async function getAvailableChannels(): Promise { } async function readResolveContext(channelName: string): Promise { - const [currentPtr, nativeBundlePath, userProperties, installId, rejectedHashes] = await Promise.all([ - readCurrentBundlePointer(), + const selectedHash = getStartupRecoverySelectedHashNative(); + const [pointerHash, nativeBundlePath, userProperties, installId, rejectedHashes] = await Promise.all([ + readCurrentBundleHash(), getDownloadedBundlePathNative(), getCurrentUserProperties(), getOrCreateInstallId(), @@ -111,7 +115,12 @@ async function readResolveContext(channelName: string): Promise false); return { channelName, - currentHash: nativeBundlePath && currentPtr?.hash ? currentPtr.hash : null, + // Only adapters that predate startupRecoverySelectedHash infer the running + // identity from passive eligibility. New adapters distinguish embedded + // selection from an OTA that became ineligible after this runtime started. + currentHash: selectedHash === undefined + ? nativeBundlePath && pointerHash ? pointerHash : null + : selectedHash, rejectedHashes, installId, patchAlgorithms: advertisedPatchAlgorithms(supportsXdelta), diff --git a/src/manager/updateState.ts b/src/manager/updateState.ts index 1a10f7e..4bf9d5d 100644 --- a/src/manager/updateState.ts +++ b/src/manager/updateState.ts @@ -1,5 +1,14 @@ import { getDownloadedBundlePathNative, restartReactNativeNative } from '../native/bundleDropNative'; -import { BundleInfo, readBundleInfo, updateBundleInfo } from '../bundleInfo'; +import { + BundleInfo, + deleteBundleInfo, + readBundleInfo, + updateBundleInfo, + writeBundleInfoDurably, +} from '../bundleInfo'; +import RNFS from '../native/fs'; +import { platform } from '../context'; +import { readJsonFile, verifyBundleDir } from '../install/bundleVerification'; import { reportInstalledIfReady } from './reporting'; import { BundleDropError } from '../errors'; import { isBundleHashFailed } from './rollbackState'; @@ -25,24 +34,86 @@ export const getUpdateState = async (cached?: { }; }; +async function readRecoveredBundleInfo( + bundlePath: string, + hash: string, + previousInfo: BundleInfo | null, +): Promise { + const bundleDir = bundlePath.slice(0, bundlePath.lastIndexOf('/')); + const manifest = await verifyBundleDir(bundleDir, hash, platform); + const metadataPath = `${bundleDir}/${ + platform === 'android' ? 'metadata-android.json' : 'metadata-ios.json' + }`; + const metadata = await RNFS.exists(metadataPath) + ? await readJsonFile>(metadataPath) + : null; + const bundleVersion = typeof metadata?.bundleVersion === 'number' + ? metadata.bundleVersion + : undefined; + const metadataVersion = typeof metadata?.version === 'string' ? metadata.version : undefined; + const metadataRuntimeVersion = typeof metadata?.runtimeVersion === 'string' + ? metadata.runtimeVersion + : undefined; + const alreadyReported = previousInfo?.installedReportedHashes?.includes(hash) === true; + + return { + hash, + bundleVersion, + version: manifest?.version ?? metadataVersion, + runtimeVersion: manifest?.runtimeVersion ?? metadataRuntimeVersion, + platform, + installedAt: new Date().toISOString(), + pendingApply: false, + lastInstalledReportedHash: alreadyReported ? hash : undefined, + installedReportedHashes: previousInfo?.installedReportedHashes, + }; +} + export const reconcileAppliedBundleOnLaunch = async (cached?: { bundleInfo?: BundleInfo | null; bundlePath?: string | null; -}) => { - const state = await getUpdateState(cached); - if (!state.hasBundle) return; + currentHash?: string | null; +}): Promise => { + const bundlePath = cached?.bundlePath !== undefined + ? cached.bundlePath + : await getDownloadedBundlePathNative(); + const state = await getUpdateState({ ...cached, bundlePath }); + if (!state.hasBundle) { + if (state.info) { + await deleteBundleInfo(); + } + return null; + } + + const currentHash = cached?.currentHash; + if (currentHash && state.info?.hash !== currentHash) { + const recoveredInfo = await readRecoveredBundleInfo( + bundlePath as string, + currentHash, + state.info, + ); + await writeBundleInfoDurably(recoveredInfo); + reportInstalledIfReady({ hasBundle: true, info: recoveredInfo }).catch(() => {}); + return recoveredInfo; + } if (!state.info?.pendingApply) { reportInstalledIfReady(state).catch(() => {}); - return; + return state.info || null; } - await updateBundleInfo({ pendingApply: false, installedAt: new Date().toISOString() }); + const appliedInfo = { + ...state.info, + pendingApply: false, + installedAt: new Date().toISOString(), + }; + await updateBundleInfo(appliedInfo); // Fire-and-forget: don't block the startup path for server reporting reportInstalledIfReady({ hasBundle: true, - info: state.info ? { ...state.info, pendingApply: false } : state.info, + info: appliedInfo, }).catch(() => {}); + return appliedInfo; }; export const applyUpdate = async ( diff --git a/src/metro.ts b/src/metro.ts index 30bdaad..7b6ab63 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -5,7 +5,7 @@ import { resolveExpoMetroRuntimeVersion } from './expo'; import type { ExpoMetroRuntimeVersion } from './expo'; import type { ExpoBuildIdentityReceipt } from './expo/buildReceipt'; import { - readGeneratedRuntimeDeliveryBootstrap, + readRuntimeDeliveryBootstrap, type RuntimeDeliveryConfig, } from './runtime-delivery/bootstrapConfig'; @@ -51,7 +51,7 @@ const resolveRuntimeDelivery = ( projectRoot: string, baseConfig: BaseBundleDropConfig, ): RuntimeDeliveryConfig | undefined => { - const generated = readGeneratedRuntimeDeliveryBootstrap({ + const bootstrap = readRuntimeDeliveryBootstrap({ projectRoot, expectedIdentity: { serverUrl: baseConfig.serverUrl!, @@ -59,7 +59,7 @@ const resolveRuntimeDelivery = ( projectSlug: baseConfig.project!.slug!, }, }); - return generated?.runtimeDelivery; + return bootstrap?.runtimeDelivery; }; const writeGeneratedRuntimeConfig = (params: { @@ -81,6 +81,7 @@ const writeGeneratedRuntimeConfig = (params: { generatedFields.push(` runtimeDelivery: ${JSON.stringify(params.runtimeDelivery)},`); } const content = [ + '/* eslint-disable */', "'use strict';", '', "const baseConfig = require('../../bundle.drop.config.js');", @@ -112,7 +113,7 @@ const mergeMetroAlias = ( /** * Preserves a bare React Native Metro config and resolves Bundle Drop through - * the project-owned, package-validated generated bootstrap. + * the project-owned, package-validated runtime-delivery lockfile. */ export function withBundleDrop( config: T, diff --git a/src/native/bundleDropNative.ts b/src/native/bundleDropNative.ts index a361c87..1d4a390 100644 --- a/src/native/bundleDropNative.ts +++ b/src/native/bundleDropNative.ts @@ -1,6 +1,293 @@ import { NativeModules } from 'react-native'; -const { BundleDrop, BundleDropExpoIdentity } = NativeModules; +const STARTUP_RECOVERY_PROTOCOL_VERSION = 1; +const SHA256_HASH_PATTERN = /^[a-f0-9]{64}$/; +const STARTUP_RECOVERY_CAPABILITY_WARNING = + '[BundleDrop] Automatic startup recovery requires a newer BundleDrop native build. ' + + 'Recovery is disabled for this binary; rebuild the app before shipping another OTA update.'; + +export type StartupRecoveryHealthCheckMode = 'auto' | 'manual'; + +export type StartupRecoveryAttempt = { + hash: string; + attemptId: string; +}; + +export type StartupRecoverySnapshotAttempt = StartupRecoveryAttempt & { + status: 'launching'; + unacknowledgedLaunchCount: number; +}; + +export type StartupRecoveryEvent = { + id: string; + failedHash: string; + recoveryTarget: 'previous' | 'embedded'; + recoveredHash?: string; + crashCount: number; + reason: 'crash_loop'; + failedAt: number; +}; + +export type StartupRecoveryState = { + protocolVersion: typeof STARTUP_RECOVERY_PROTOCOL_VERSION; + revision: number; + phase: 'idle' | 'armed' | 'launching' | 'stable' | 'recovered'; + candidateHash?: string; + stableHash?: string; + activeAttempt?: StartupRecoverySnapshotAttempt; + policy?: { + maxCrashCount: number; + healthCheckMode: StartupRecoveryHealthCheckMode; + healthyAfterSec: number; + }; + quarantinedHashes: string[]; + pendingRecoveryEvents: StartupRecoveryEvent[]; +}; + +export type StartupCandidateActivation = { + hash: string; + bundlePath: string; +}; + +export type StartupRollbackResult = { + rolledBack: boolean; + toEmbedded: boolean; + hash?: string; +}; + +type BundleDropNativeModule = { + getDownloadedBundlePath?: () => Promise; + restartReactNative?: () => void; + setOtaEnabled?: (enabled: boolean) => Promise; + startupRecoveryProtocolVersion?: unknown; + startupRecoverySelectedHash?: unknown; + startupRecoveryAttemptHash?: unknown; + startupRecoveryAttemptId?: unknown; + activateStartupCandidate?: ( + hash: string, + maxCrashCount: number, + healthCheckMode: StartupRecoveryHealthCheckMode, + healthyAfterSec: number, + ) => Promise; + markStartupHealthy?: (hash: string, attemptId: string) => Promise; + getStartupRecoveryState?: () => Promise; + setStartupRecoveryRevokedHashes?: (hashes: string[]) => Promise; + acknowledgeStartupRecovery?: (eventId: string) => Promise; + rollbackStartupBundle?: (forceEmbedded: boolean) => Promise; +}; + +type BundleDropExpoIdentityModule = { + otaStartupEnabled?: unknown; +}; + +const BundleDrop = NativeModules.BundleDrop as BundleDropNativeModule | undefined; +const BundleDropExpoIdentity = NativeModules.BundleDropExpoIdentity as + | BundleDropExpoIdentityModule + | undefined; + +// These constants describe the bundle selected by native code for this specific +// React runtime. Capture them once so later native state changes cannot rewrite +// the running identity or the attempt acknowledged by reportHealthy(). +const startupRecoverySelectedHash = readStartupRecoverySelectedHash(BundleDrop); +const startupRecoveryAttempt = readStartupRecoveryAttempt(BundleDrop); +let hasWarnedAboutStartupRecoveryCapability = false; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null; +} + +function canonicalHash(value: unknown): string | null { + return typeof value === 'string' && SHA256_HASH_PATTERN.test(value) ? value : null; +} + +function readStartupRecoverySelectedHash( + nativeModule: { startupRecoverySelectedHash?: unknown } | undefined, +): string | null | undefined { + if (!nativeModule || !Object.prototype.hasOwnProperty.call(nativeModule, 'startupRecoverySelectedHash')) { + return undefined; + } + if (nativeModule.startupRecoverySelectedHash == null) return null; + return canonicalHash(nativeModule.startupRecoverySelectedHash); +} + +function nonNegativeInteger(value: unknown): number | null { + return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null; +} + +function readStartupRecoveryAttempt( + nativeModule: { + startupRecoveryAttemptHash?: unknown; + startupRecoveryAttemptId?: unknown; + } | undefined, +): StartupRecoveryAttempt | null { + const hash = canonicalHash(nativeModule?.startupRecoveryAttemptHash); + const attemptId = nonEmptyString(nativeModule?.startupRecoveryAttemptId); + return hash && attemptId ? { hash, attemptId } : null; +} + +function hasStartupRecoveryMethods(nativeModule: BundleDropNativeModule | undefined): boolean { + return nativeModule?.startupRecoveryProtocolVersion === STARTUP_RECOVERY_PROTOCOL_VERSION && + typeof nativeModule.activateStartupCandidate === 'function' && + typeof nativeModule.markStartupHealthy === 'function' && + typeof nativeModule.getStartupRecoveryState === 'function' && + typeof nativeModule.setStartupRecoveryRevokedHashes === 'function' && + typeof nativeModule.acknowledgeStartupRecovery === 'function' && + typeof nativeModule.rollbackStartupBundle === 'function'; +} + +function warnAboutMissingStartupRecovery(): void { + if (hasWarnedAboutStartupRecoveryCapability) return; + hasWarnedAboutStartupRecoveryCapability = true; + console.warn(STARTUP_RECOVERY_CAPABILITY_WARNING); +} + +function requireStartupRecoveryCapability(): BundleDropNativeModule | null { + if (!hasStartupRecoveryMethods(BundleDrop)) { + warnAboutMissingStartupRecovery(); + return null; + } + return BundleDrop!; +} + +function normalizeRecoveryEvent(value: unknown): StartupRecoveryEvent | null { + if (!isRecord(value)) return null; + const id = nonEmptyString(value.id); + const failedHash = canonicalHash(value.failedHash); + const hasRecoveredHash = value.recoveredHash != null; + const recoveredHash = hasRecoveredHash ? canonicalHash(value.recoveredHash) : undefined; + const crashCount = nonNegativeInteger(value.crashCount); + const failedAt = nonNegativeInteger(value.failedAt); + const recoveryTarget = value.recoveryTarget; + if ( + !id || + !failedHash || + (hasRecoveredHash && !recoveredHash) || + crashCount === null || + failedAt === null || + value.reason !== 'crash_loop' || + (recoveryTarget !== 'previous' && recoveryTarget !== 'embedded') + ) { + return null; + } + if (recoveryTarget === 'previous' && !recoveredHash) return null; + return { + id, + failedHash, + recoveryTarget, + ...(recoveredHash ? { recoveredHash } : {}), + crashCount, + reason: 'crash_loop', + failedAt, + }; +} + +function normalizeStartupRecoveryState(value: unknown): StartupRecoveryState | null { + if (!isRecord(value) || value.protocolVersion !== STARTUP_RECOVERY_PROTOCOL_VERSION) { + return null; + } + const revision = nonNegativeInteger(value.revision); + if (revision === null) return null; + + const phase = value.phase; + if (!['idle', 'armed', 'launching', 'stable', 'recovered'].includes(String(phase))) { + return null; + } + + const hasCandidateHash = value.candidateHash != null; + const candidateHash = hasCandidateHash ? canonicalHash(value.candidateHash) : undefined; + const hasStableHash = value.stableHash != null; + const stableHash = hasStableHash ? canonicalHash(value.stableHash) : undefined; + if ((hasCandidateHash && !candidateHash) || (hasStableHash && !stableHash)) return null; + + const baseAttempt = value.activeAttempt == null + ? null + : readStartupRecoveryAttempt({ + startupRecoveryAttemptHash: isRecord(value.activeAttempt) + ? value.activeAttempt.hash + : undefined, + startupRecoveryAttemptId: isRecord(value.activeAttempt) + ? value.activeAttempt.attemptId + : undefined, + }); + const activeAttemptCount = isRecord(value.activeAttempt) + ? nonNegativeInteger(value.activeAttempt.unacknowledgedLaunchCount) + : null; + const activeAttempt = baseAttempt && isRecord(value.activeAttempt) && + value.activeAttempt.status === 'launching' && activeAttemptCount !== null + ? { + ...baseAttempt, + status: 'launching' as const, + unacknowledgedLaunchCount: activeAttemptCount, + } + : undefined; + if (value.activeAttempt != null && !activeAttempt) return null; + + let policy: StartupRecoveryState['policy']; + if (value.policy != null) { + if (!isRecord(value.policy)) return null; + const maxCrashCount = nonNegativeInteger(value.policy.maxCrashCount); + const healthyAfterSec = typeof value.policy.healthyAfterSec === 'number' && + Number.isFinite(value.policy.healthyAfterSec) && value.policy.healthyAfterSec >= 0 + ? value.policy.healthyAfterSec + : null; + const healthCheckMode = value.policy.healthCheckMode; + if ( + maxCrashCount === null || + healthyAfterSec === null || + (healthCheckMode !== 'auto' && healthCheckMode !== 'manual') + ) { + return null; + } + policy = { maxCrashCount, healthCheckMode, healthyAfterSec }; + } + const quarantinedHashes = Array.isArray(value.quarantinedHashes) + ? Array.from(new Set(value.quarantinedHashes.map(canonicalHash).filter(Boolean) as string[])) + : null; + const pendingRecoveryEvents = Array.isArray(value.pendingRecoveryEvents) + ? value.pendingRecoveryEvents.map(normalizeRecoveryEvent).filter(Boolean) as StartupRecoveryEvent[] + : null; + if (!quarantinedHashes || !pendingRecoveryEvents) return null; + + const uniqueEvents = Array.from( + new Map(pendingRecoveryEvents.map(event => [event.id, event])).values(), + ); + return { + protocolVersion: STARTUP_RECOVERY_PROTOCOL_VERSION, + revision, + phase: phase as StartupRecoveryState['phase'], + ...(candidateHash ? { candidateHash } : {}), + ...(stableHash ? { stableHash } : {}), + ...(activeAttempt ? { activeAttempt } : {}), + ...(policy ? { policy } : {}), + quarantinedHashes, + pendingRecoveryEvents: uniqueEvents, + }; +} + +function normalizeCandidateActivation(value: unknown): StartupCandidateActivation | null { + if (!isRecord(value)) return null; + const hash = canonicalHash(value.hash); + const bundlePath = nonEmptyString(value.bundlePath); + return hash && bundlePath ? { hash, bundlePath } : null; +} + +function normalizeRollbackResult(value: unknown): StartupRollbackResult | null { + if (!isRecord(value) || typeof value.rolledBack !== 'boolean' || typeof value.toEmbedded !== 'boolean') { + return null; + } + const hasHash = value.hash != null; + const hash = hasHash ? canonicalHash(value.hash) : undefined; + if (hasHash && !hash) return null; + return { + rolledBack: value.rolledBack, + toEmbedded: value.toEmbedded, + ...(hash ? { hash } : {}), + }; +} export function isBundleDropNativeAvailable(): boolean { return Boolean(BundleDrop); @@ -11,6 +298,97 @@ export function isExpoOtaStartupEnabledNative(): boolean { return nativeValue === true || nativeValue === 1; } +export function isStartupRecoveryAvailableNative(): boolean { + return hasStartupRecoveryMethods(BundleDrop); +} + +export function warnIfStartupRecoveryUnavailableNative(): void { + if (!isStartupRecoveryAvailableNative()) { + warnAboutMissingStartupRecovery(); + } +} + +export function getStartupRecoveryAttemptNative(): StartupRecoveryAttempt | null { + return startupRecoveryAttempt ? { ...startupRecoveryAttempt } : null; +} + +/** + * Hash selected for this exact React runtime. `undefined` means the installed + * native adapter predates this constant; `null` means native selected embedded. + */ +export function getStartupRecoverySelectedHashNative(): string | null | undefined { + return startupRecoverySelectedHash; +} + +export async function activateStartupCandidateNative( + hash: string, + policy: { + maxCrashCount: number; + healthCheckMode: StartupRecoveryHealthCheckMode; + healthyAfterSec: number; + }, +): Promise { + if ( + !Number.isSafeInteger(policy.maxCrashCount) || + policy.maxCrashCount < 0 || + policy.maxCrashCount > 2_147_483_647 + ) { + throw new Error('maxCrashCount must be a non-negative 32-bit integer'); + } + if (!Number.isFinite(policy.healthyAfterSec) || policy.healthyAfterSec < 0) { + throw new Error('healthyAfterSec must be a finite non-negative number'); + } + const nativeModule = requireStartupRecoveryCapability(); + if (!nativeModule) return null; + const result = await nativeModule.activateStartupCandidate!( + hash, + policy.maxCrashCount, + policy.healthCheckMode, + policy.healthyAfterSec, + ); + return normalizeCandidateActivation(result); +} + +export async function markStartupHealthyNative( + attempt: StartupRecoveryAttempt, +): Promise { + const nativeModule = requireStartupRecoveryCapability(); + if (!nativeModule) return false; + return nativeModule.markStartupHealthy!(attempt.hash, attempt.attemptId); +} + +export async function getStartupRecoveryStateNative(): Promise { + const nativeModule = requireStartupRecoveryCapability(); + if (!nativeModule) return null; + const state = normalizeStartupRecoveryState(await nativeModule.getStartupRecoveryState!()); + if (!state) { + console.warn('[BundleDrop] Ignoring malformed native startup recovery state.'); + } + return state; +} + +export async function setStartupRecoveryRevokedHashesNative( + hashes: string[], +): Promise { + const nativeModule = requireStartupRecoveryCapability(); + if (!nativeModule) return false; + return nativeModule.setStartupRecoveryRevokedHashes!(Array.from(new Set(hashes))); +} + +export async function acknowledgeStartupRecoveryNative(eventId: string): Promise { + const nativeModule = requireStartupRecoveryCapability(); + if (!nativeModule) return false; + return nativeModule.acknowledgeStartupRecovery!(eventId); +} + +export async function rollbackStartupBundleNative( + forceEmbedded: boolean, +): Promise { + const nativeModule = requireStartupRecoveryCapability(); + if (!nativeModule) return null; + return normalizeRollbackResult(await nativeModule.rollbackStartupBundle!(forceEmbedded)); +} + export async function getDownloadedBundlePathNative(): Promise { if (!BundleDrop?.getDownloadedBundlePath) { console.warn('BundleDrop.getDownloadedBundlePath is not defined'); @@ -25,7 +403,7 @@ export async function getDownloadedBundlePathNative(): Promise { } } -export function restartReactNativeNative() { +export function restartReactNativeNative(): void { BundleDrop?.restartReactNative?.(); } diff --git a/src/patch-engine/installFromPatchSet.ts b/src/patch-engine/installFromPatchSet.ts index 8030c90..4925c60 100644 --- a/src/patch-engine/installFromPatchSet.ts +++ b/src/patch-engine/installFromPatchSet.ts @@ -2,7 +2,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 { readCurrentBundleHash } from '../fs/bundlePointer'; import { InstallPhaseError, isInstallPhaseError } from '../errors'; import { BUNDLE_MANIFEST, @@ -90,8 +90,8 @@ export async function installFromPatchSet(params: InstallFromPatchSetParams): Pr assertCanonicalBundleHash(baseHash, 'base hash'); assertCanonicalBundleHash(targetHash, 'target hash'); - const currentPointer = await readCurrentBundlePointer(); - if (currentPointer?.hash !== baseHash) { + const currentHash = await readCurrentBundleHash(); + if (currentHash !== baseHash) { throw new InstallPhaseError('install', new Error('Patch base hash does not match current bundle')); } diff --git a/src/runtime-delivery/bootstrapConfig.ts b/src/runtime-delivery/bootstrapConfig.ts index 3a70ed6..662472e 100644 --- a/src/runtime-delivery/bootstrapConfig.ts +++ b/src/runtime-delivery/bootstrapConfig.ts @@ -1,5 +1,6 @@ import fs from 'fs-extra'; import path from 'path'; +import { isDeepStrictEqual } from 'util'; import { inspectProjectFile, removeProjectFile, @@ -8,10 +9,16 @@ import { export const RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION = 1; export const RUNTIME_DELIVERY_BOOTSTRAP_PATH = path.join( + '.bundle-drop', + 'runtime-delivery.lock.json', +); +export const LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH = path.join( '.bundle-drop', 'runtime-delivery.generated.json', ); export const RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER = + '!.bundle-drop/runtime-delivery.lock.json'; +export const LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER = '!.bundle-drop/runtime-delivery.generated.json'; const RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_BLOCK = [ @@ -42,12 +49,19 @@ export type RuntimeDeliveryProjectIdentity = { orgId?: string; }; -export type GeneratedRuntimeDeliveryBootstrap = { +export type RuntimeDeliveryBootstrapLockfile = { schemaVersion: typeof RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION; project: RuntimeDeliveryProjectIdentity; runtimeDelivery: RuntimeDeliveryConfig; }; +export type RuntimeDeliveryBootstrapSource = 'lock' | 'legacy' | 'matching-dual'; + +export type RuntimeDeliveryBootstrapReadResult = { + bootstrap: RuntimeDeliveryBootstrapLockfile; + source: RuntimeDeliveryBootstrapSource; +}; + const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -127,10 +141,10 @@ export function normalizeRuntimeDeliveryBootstrap( }; } -export function createGeneratedRuntimeDeliveryBootstrap(params: { +export function createRuntimeDeliveryBootstrapLockfile(params: { identity: RuntimeDeliveryProjectIdentity; runtimeDelivery: unknown; -}): GeneratedRuntimeDeliveryBootstrap | undefined { +}): RuntimeDeliveryBootstrapLockfile | undefined { const runtimeDelivery = normalizeRuntimeDeliveryBootstrap(params.runtimeDelivery); const serverUrl = normalizeHttpUrl(params.identity.serverUrl); const orgSlug = params.identity.orgSlug.trim(); @@ -152,10 +166,10 @@ export function createGeneratedRuntimeDeliveryBootstrap(params: { }; } -export function parseGeneratedRuntimeDeliveryBootstrap( +export function parseRuntimeDeliveryBootstrapLockfile( value: unknown, expectedIdentity?: RuntimeDeliveryProjectIdentity, -): GeneratedRuntimeDeliveryBootstrap { +): RuntimeDeliveryBootstrapLockfile { if (!isRecord(value) || value.schemaVersion !== RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION) { throw new Error( `Runtime delivery bootstrap must use schemaVersion ${RUNTIME_DELIVERY_BOOTSTRAP_SCHEMA_VERSION}.`, @@ -177,7 +191,7 @@ export function parseGeneratedRuntimeDeliveryBootstrap( throw new Error('Runtime delivery bootstrap contains an invalid stable project identity.'); } - const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ + const bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity: { serverUrl: typeof value.project.serverUrl === 'string' ? value.project.serverUrl : '', orgSlug: typeof value.project.orgSlug === 'string' ? value.project.orgSlug : '', @@ -219,17 +233,33 @@ export function runtimeDeliveryBootstrapPath(projectRoot: string): string { return path.join(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH); } +export function legacyRuntimeDeliveryBootstrapPath(projectRoot: string): string { + return path.join(projectRoot, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH); +} + +const RUNTIME_DELIVERY_MANAGED_GITIGNORE_LINES = new Set([ + '# Bundle Drop: commit the public trust bootstrap; ignore generated runtime artifacts.', + '!.bundle-drop/', + '.bundle-drop/*', + RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER, + LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER, +]); + export function addRuntimeDeliveryBootstrapGitignoreRules(content: string): string { - if (content.includes(RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_MARKER)) return content; - const prefix = content.trimEnd(); + const prefix = content + .split('\n') + .filter(line => !RUNTIME_DELIVERY_MANAGED_GITIGNORE_LINES.has(line.trim())) + .join('\n') + .trimEnd(); return `${prefix}${prefix ? '\n\n' : ''}${RUNTIME_DELIVERY_BOOTSTRAP_GITIGNORE_BLOCK}\n`; } -export function readGeneratedRuntimeDeliveryBootstrap(params: { +function readRuntimeDeliveryBootstrapFile(params: { projectRoot: string; + relativePath: string; expectedIdentity?: RuntimeDeliveryProjectIdentity; -}): GeneratedRuntimeDeliveryBootstrap | null { - const bootstrapPath = runtimeDeliveryBootstrapPath(params.projectRoot); +}): RuntimeDeliveryBootstrapLockfile | null { + const bootstrapPath = path.join(params.projectRoot, params.relativePath); if (!fs.existsSync(bootstrapPath)) return null; let value: unknown; @@ -238,29 +268,69 @@ export function readGeneratedRuntimeDeliveryBootstrap(params: { } catch { throw new Error(`Runtime delivery bootstrap is not valid JSON: ${bootstrapPath}`); } - return parseGeneratedRuntimeDeliveryBootstrap(value, params.expectedIdentity); + return parseRuntimeDeliveryBootstrapLockfile(value, params.expectedIdentity); +} + +export function readRuntimeDeliveryLockfile(params: { + projectRoot: string; + expectedIdentity?: RuntimeDeliveryProjectIdentity; +}): RuntimeDeliveryBootstrapLockfile | null { + return readRuntimeDeliveryBootstrapFile({ + ...params, + relativePath: RUNTIME_DELIVERY_BOOTSTRAP_PATH, + }); +} + +export function inspectRuntimeDeliveryBootstrap(params: { + projectRoot: string; + expectedIdentity?: RuntimeDeliveryProjectIdentity; +}): RuntimeDeliveryBootstrapReadResult | null { + const current = readRuntimeDeliveryLockfile(params); + const legacy = readRuntimeDeliveryBootstrapFile({ + ...params, + relativePath: LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH, + }); + + if (current && legacy) { + if (!isDeepStrictEqual(current, legacy)) { + throw new Error( + 'Runtime delivery lockfile and legacy bootstrap differ. Run `bundle-drop sync` to repair them.', + ); + } + return { bootstrap: current, source: 'matching-dual' }; + } + if (current) return { bootstrap: current, source: 'lock' }; + if (legacy) return { bootstrap: legacy, source: 'legacy' }; + return null; +} + +export function readRuntimeDeliveryBootstrap(params: { + projectRoot: string; + expectedIdentity?: RuntimeDeliveryProjectIdentity; +}): RuntimeDeliveryBootstrapLockfile | null { + return inspectRuntimeDeliveryBootstrap(params)?.bootstrap ?? null; } -export function serializeGeneratedRuntimeDeliveryBootstrap( - bootstrap: GeneratedRuntimeDeliveryBootstrap, +export function serializeRuntimeDeliveryBootstrapLockfile( + bootstrap: RuntimeDeliveryBootstrapLockfile, ): string { return `${JSON.stringify(bootstrap, null, 2)}\n`; } -export async function writeGeneratedRuntimeDeliveryBootstrap(params: { +export async function writeRuntimeDeliveryBootstrapLockfile(params: { projectRoot: string; - bootstrap: GeneratedRuntimeDeliveryBootstrap; + bootstrap: RuntimeDeliveryBootstrapLockfile; }): Promise { const bootstrapPath = runtimeDeliveryBootstrapPath(params.projectRoot); writeProjectFileAtomically( params.projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH, - serializeGeneratedRuntimeDeliveryBootstrap(params.bootstrap), + serializeRuntimeDeliveryBootstrapLockfile(params.bootstrap), ); return bootstrapPath; } -export async function removeGeneratedRuntimeDeliveryBootstrap( +export async function removeRuntimeDeliveryBootstrapLockfile( projectRoot: string, ): Promise { const bootstrapPath = runtimeDeliveryBootstrapPath(projectRoot); @@ -269,6 +339,25 @@ export async function removeGeneratedRuntimeDeliveryBootstrap( return bootstrapPath; } +export async function removeLegacyRuntimeDeliveryBootstrap( + projectRoot: string, +): Promise { + const bootstrapPath = legacyRuntimeDeliveryBootstrapPath(projectRoot); + if (!inspectProjectFile(projectRoot, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH).exists) return null; + removeProjectFile(projectRoot, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH); + return bootstrapPath; +} + +export async function removeAllRuntimeDeliveryBootstraps( + projectRoot: string, +): Promise { + const removed = await Promise.all([ + removeRuntimeDeliveryBootstrapLockfile(projectRoot), + removeLegacyRuntimeDeliveryBootstrap(projectRoot), + ]); + return removed.filter((file): file is string => Boolean(file)); +} + export async function ensureRuntimeDeliveryBootstrapGitignore( projectRoot: string, ): Promise { diff --git a/src/runtime-delivery/manifestState.ts b/src/runtime-delivery/manifestState.ts index 883e213..370fe29 100644 --- a/src/runtime-delivery/manifestState.ts +++ b/src/runtime-delivery/manifestState.ts @@ -13,6 +13,10 @@ export type VerifiedLaneState = { verifiedAt: string; }; +export type PersistVerifiedRuntimeRevocations = ( + revokedHashes: string[], +) => Promise; + type RuntimeDeliveryState = { schemaVersion: 1; lanes: Record; @@ -74,6 +78,27 @@ function laneStateKey(identity: RuntimeDeliveryLaneIdentity): string { .join('/'); } +function belongsToRuntime( + laneKey: string, + identity: RuntimeDeliveryLaneIdentity, +): boolean { + const parts = laneKey.split('/'); + return parts.length === 4 && + parts[0] === encodeURIComponent(identity.projectSlug) && + parts[2] === encodeURIComponent(identity.platform) && + parts[3] === encodeURIComponent(identity.runtimeVersion); +} + +function verifiedRuntimeRevokedHashes( + state: RuntimeDeliveryState, + identity: RuntimeDeliveryLaneIdentity, +): string[] { + const revokedHashes = Object.entries(state.lanes) + .filter(([key]) => belongsToRuntime(key, identity)) + .flatMap(([, lane]) => lane.revokedHashes); + return Array.from(new Set(revokedHashes)).sort(); +} + async function readState(): Promise { if (!await RNFS.exists(STATE_PATH)) return { schemaVersion: 1, lanes: {} }; let raw: string; @@ -92,9 +117,17 @@ export async function readVerifiedLaneState( return state.lanes[laneStateKey(identity)] || null; } +export async function readVerifiedRuntimeRevokedHashes( + identity: RuntimeDeliveryLaneIdentity, +): Promise { + const state = await readState(); + return verifiedRuntimeRevokedHashes(state, identity); +} + export async function recordVerifiedLaneManifest( manifest: RuntimeDeliveryLaneManifest, payloadSha256: string, + persistRuntimeRevocations?: PersistVerifiedRuntimeRevocations, ): Promise { const mutation = stateMutation.then(async () => { const identity: RuntimeDeliveryLaneIdentity = manifest; @@ -117,6 +150,9 @@ export async function recordVerifiedLaneManifest( revokedHashes: [...manifest.revokedHashes], verifiedAt: new Date().toISOString(), }; + if (persistRuntimeRevocations) { + await persistRuntimeRevocations(verifiedRuntimeRevokedHashes(state, identity)); + } await atomicWriteJson(STATE_PATH, state); }); stateMutation = mutation.then(() => undefined, () => undefined); diff --git a/src/runtime-delivery/manifestVerifier.ts b/src/runtime-delivery/manifestVerifier.ts index 2af7aab..7f9f8ed 100644 --- a/src/runtime-delivery/manifestVerifier.ts +++ b/src/runtime-delivery/manifestVerifier.ts @@ -1,6 +1,10 @@ import RNFS from '../native/fs'; +import { syncVerifiedRevokedHashes } from '../manager/rollbackState'; import { decodeBase64UrlBytes, decodeBase64UrlUtf8, utf8ByteLength } from './encoding'; -import { readVerifiedLaneState, recordVerifiedLaneManifest } from './manifestState'; +import { + readVerifiedLaneState, + recordVerifiedLaneManifest, +} from './manifestState'; import { RUNTIME_DELIVERY_ROLLOUT_ALGORITHM, RUNTIME_DELIVERY_MANIFEST_JWS_TYPE, @@ -377,7 +381,16 @@ export async function verifyRuntimeDeliveryManifest( 'Runtime manifest generation equivocation detected', ); } - await recordVerifiedLaneManifest(manifest, payloadSha256); + await recordVerifiedLaneManifest( + manifest, + payloadSha256, + async runtimeRevokedHashes => { + const persisted = await syncVerifiedRevokedHashes(runtimeRevokedHashes); + if (!persisted) { + throw new Error('Native startup recovery rejected the verified revocation set'); + } + }, + ); return manifest; } diff --git a/src/runtime/service.ts b/src/runtime/service.ts index dcd9c66..bee0a73 100644 --- a/src/runtime/service.ts +++ b/src/runtime/service.ts @@ -12,8 +12,9 @@ import { isExpoOtaStartupEnabledNative, restartReactNativeNative, setOtaEnabledNative, + warnIfStartupRecoveryUnavailableNative, } from '../native/bundleDropNative'; -import { readCurrentBundlePointer } from '../fs/bundlePointer'; +import { readCurrentBundleHash } from '../fs/bundlePointer'; import type { DownloadUpdateResult } from '../manager/downloadAndInstall'; import { downloadUpdate as downloadUpdateInternal, @@ -25,11 +26,10 @@ import { checkForUpdate as checkForUpdateInternal, } from '../manager/updateCheck'; import { - getRollbackPolicy, - readRollbackState, + readStartupRecoveryState, + reconcileStartupRecovery, reportActiveBundleHealthy, - rollbackToPreviousIfNeeded, - rollbackToPreviousOrNative, + rollbackStartupBundle, } from '../manager/rollbackState'; import type { ApplyUpdateResult } from '../manager/updateState'; import { @@ -80,14 +80,11 @@ type ApplyAndRefreshResult = { result: ApplyUpdateResult; status?: string }; type CheckLatestResult = { response: UpdateCheckResponse | null; status?: string }; type LocalStartupState = { bundlePath: string | null; - currentPointer: Awaited>; - rollbackState: Awaited>; }; let updateFlowActive = false; let startupPromise: Promise | null = null; let localStartupPromise: Promise | null = null; -let healthTimer: ReturnType | null = null; let runtimeRestartRequested = false; let activeBundleInfoForObservability: BundleInfo | null = null; let nativeOtaEnabledPromise: Promise = Promise.resolve(); @@ -223,67 +220,26 @@ function getApplyStatus(result: ApplyUpdateResult): string | undefined { : 'ℹ️ Bundle already applied'; } -function clearHealthTimer() { - if (!healthTimer) return; - clearTimeout(healthTimer); - healthTimer = null; -} - function requestRuntimeRestart() { runtimeRestartRequested = true; restartReactNativeNative(); } async function applyAuthoritativeRollback(reason?: string): Promise { - if (isCurrentRevokedRollback(reason)) { - await rollbackToPreviousOrNative({ forceNative: true }); - } else { - await rollbackToPreviousOrNative(); + const result = await rollbackStartupBundle(isCurrentRevokedRollback(reason)); + if (result?.rolledBack) { + requestRuntimeRestart(); } - requestRuntimeRestart(); } -async function markActiveCandidateHealthy(expectedHash?: string): Promise { - if (runtimeRestartRequested) { - return false; - } - - const state = await getUpdateStateInternal(); - if (runtimeRestartRequested) { - return false; - } - - if (state.pendingApply) { - return false; - } - - const markedHealthy = await reportActiveBundleHealthy(undefined, expectedHash); +async function markActiveCandidateHealthy(): Promise { + const markedHealthy = await reportActiveBundleHealthy(); if (markedHealthy) { await refreshState(); } return markedHealthy; } -function scheduleCandidateHealthMark( - currentPointer: Awaited>, - rollbackState: Awaited>, -) { - clearHealthTimer(); - - const hash = currentPointer?.hash; - const rollbackPolicy = getRollbackPolicy(); - if (!hash || rollbackPolicy.healthCheckMode === 'manual') return; - if (rollbackState?.candidateHash !== hash || rollbackState.candidateCommitted === true) return; - - const delayMs = Math.max(0, rollbackPolicy.healthyAfterSec || 0) * 1000; - healthTimer = setTimeout(() => { - healthTimer = null; - markActiveCandidateHealthy(hash).catch(error => { - console.warn('⚠️ Failed to mark BundleDrop candidate healthy:', error); - }); - }, delayMs); -} - async function refreshState(cached?: { bundleInfo?: BundleInfo | null; bundlePath?: string | null; @@ -328,23 +284,29 @@ async function waitForLocalStartupIfNeeded(): Promise { } async function runLocalStartupFlow(): Promise { - const [bundleInfo, bundlePath, currentPointer, rollbackState] = await Promise.all([ + const [bundleInfo, bundlePath, currentHash, recoveryState] = await Promise.all([ readBundleInfo(), getDownloadedBundlePathNative(), - readCurrentBundlePointer(), - readRollbackState(), + readCurrentBundleHash(), + readStartupRecoveryState(), ]); - await reconcileAppliedBundleOnLaunch({ bundleInfo, bundlePath }); - await refreshState({ bundleInfo, bundlePath }); - const state = await getUpdateStateInternal({ bundlePath }); + void reconcileStartupRecovery(recoveryState, bundleInfo).catch(error => { + console.warn('⚠️ Failed to reconcile BundleDrop startup recovery telemetry:', error); + }); + const reconciledBundleInfo = await reconcileAppliedBundleOnLaunch({ + bundleInfo, + bundlePath, + currentHash, + }); + await refreshState({ bundleInfo: reconciledBundleInfo, bundlePath }); + const state = await getUpdateStateInternal({ + bundleInfo: reconciledBundleInfo, + bundlePath, + }); activeBundleInfoForObservability = state.hasBundle ? state.info || null : null; - return { - bundlePath, - currentPointer, - rollbackState, - }; + return { bundlePath }; } async function runStartupFlow() { @@ -357,22 +319,7 @@ async function runStartupFlow() { await nativeOtaEnabledPromise; return runLocalStartupFlow(); })(); - const { bundlePath, currentPointer, rollbackState } = await localStartupPromise; - - const rollbackPolicy = getRollbackPolicy(); - const rollbackResult = await rollbackToPreviousIfNeeded(rollbackPolicy, { - currentPointer, - rollbackState, - }); - - if (rollbackResult.rolledBack) { - clearHealthTimer(); - emitStatus('↩️ Rolled back to previous bundle'); - requestRuntimeRestart(); - return; - } - - scheduleCandidateHealthMark(currentPointer, await readRollbackState()); + const { bundlePath } = await localStartupPromise; const pendingState = await refreshState({ bundlePath }); @@ -512,6 +459,9 @@ export function initBundleDrop(options: BundleDropInitOptions): void { if (!alreadyInitialized) { runtimeRestartRequested = false; activeBundleInfoForObservability = null; + if (config.enabled) { + warnIfStartupRecoveryUnavailableNative(); + } } // Persist for the next cold start and for native path reads in this process. @@ -657,11 +607,9 @@ export async function reportHealthy(): Promise { if (!runtime) { return; } - await waitForStartupIfNeeded(); - if (runtimeRestartRequested || updateFlowActive) { + if (runtimeRestartRequested) { return; } - clearHealthTimer(); await markActiveCandidateHealthy(); } @@ -870,9 +818,9 @@ export async function getObservabilityContext(): Promise { return { source: 'ota', - dist: info.hash ?? 'embedded', + dist: info.hash, tags: { - bundle_drop_hash: info.hash ?? null, + bundle_drop_hash: info.hash, bundle_drop_channel: info.channelName ?? null, bundle_drop_version: info.bundleVersion != null ? `${info.bundleVersion}` : null, bundle_drop_runtime_version: info.runtimeVersion ?? null, @@ -983,7 +931,6 @@ export async function installBundle( } export function resetBundleDropRuntimeServiceForTests() { - clearHealthTimer(); updateFlowActive = false; startupPromise = null; localStartupPromise = null; diff --git a/src/scripts/bundle.ts b/src/scripts/bundle.ts index 943179d..3ed04b2 100644 --- a/src/scripts/bundle.ts +++ b/src/scripts/bundle.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; +import { spawnSync, type SpawnSyncOptions } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { BUNDLE_MANIFEST } from '../manifest/bundleManifest'; import { buildCanonicalArtifact } from './canonicalArtifact'; import { findProjectRoot } from './projectRoot'; +import { resolveModuleFrom, type ModuleResolver } from './resolveModule'; export { findProjectRoot }; @@ -14,8 +15,45 @@ const SENTRY_HERMES_OTA_DOCS_URL = 'https://bundledrop.app/docs/observability#sentry-and-hermes-ota-builds'; const SENTRY_DEBUG_ID_MARKERS = ['//# debugId=', 'sentry-dbid-']; -const getPackageRoot = () => - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE || path.resolve(__dirname, '..', '..'); +type SpawnProcess = typeof spawnSync; + +type BundleScriptOptions = { + platform?: string; + cwd?: string; + sourcemap?: boolean; + packageRoot?: string; + spawnProcess?: SpawnProcess; + resolveModule?: ModuleResolver; +}; + +const runProcess = ( + spawnProcess: SpawnProcess, + executable: string, + args: string[], + options: SpawnSyncOptions, +) => { + const result = spawnProcess(executable, args, { + ...options, + shell: false, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = typeof result.stderr === 'string' ? result.stderr.trim() : ''; + throw new Error(detail || `${path.basename(executable)} exited with status ${result.status}`); + } + return result; +}; + +const assertGeneratedPath = (targetPath: string, outputDir: string): void => { + const relative = path.relative(path.resolve(outputDir), path.resolve(targetPath)); + const escapesOutput = + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + if (escapesOutput) { + throw new Error(`Generated artifact path escaped the package output directory: ${targetPath}`); + } +}; const readTextIfExists = (filePath: string): string | null => { if (!fs.existsSync(filePath)) return null; @@ -62,9 +100,13 @@ const readIosProjectSettings = (projectRoot: string): string | null => { return contents.length ? contents.join('\n') : null; }; -const readHermesHelp = (hermescPath: string): string => { +const readHermesHelp = (hermescPath: string, spawnProcess: SpawnProcess): string => { try { - return String(execSync(`"${hermescPath}" -help`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); + const result = runProcess(spawnProcess, hermescPath, ['-help'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return String(result.stdout || ''); } catch { return ''; } @@ -73,8 +115,12 @@ const readHermesHelp = (hermescPath: string): string => { const hermesHelpIncludesFlag = (help: string, flag: string): boolean => new RegExp(`(?:^|\\n)\\s*${flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|=)`).test(help); -const buildHermesFlags = (hermescPath: string, generateSourceMap: boolean): string[] => { - const help = readHermesHelp(hermescPath); +const buildHermesFlags = ( + hermescPath: string, + generateSourceMap: boolean, + spawnProcess: SpawnProcess, +): string[] => { + const help = readHermesHelp(hermescPath, spawnProcess); const flags = ['-emit-binary']; if (hermesHelpIncludesFlag(help, '-O')) { @@ -176,8 +222,10 @@ const shouldCompileHermesBytecode = ( return false; }; -export function runBundleScript(options?: { platform?: string; cwd?: string; sourcemap?: boolean }) { - const packageRoot = getPackageRoot(); +export function runBundleScript(options: BundleScriptOptions = {}) { + const packageRoot = options.packageRoot || path.resolve(__dirname, '..', '..'); + const spawnProcess = options.spawnProcess || spawnSync; + const resolveModule = options.resolveModule || resolveModuleFrom; const platform = options?.platform || process.argv[2] || 'ios'; if (!['ios', 'android'].includes(platform)) { console.error('❌ Please provide platform: ios or android'); @@ -222,8 +270,10 @@ export function runBundleScript(options?: { platform?: string; cwd?: string; sou const hermesEnabled = shouldCompileHermesBytecode(cfg, platform, projectRoot); [bundlePath, zipPath, metadataPath, manifestPath, sourceMapPath].forEach(file => { + assertGeneratedPath(file, outputDir); if (fs.existsSync(file)) fs.unlinkSync(file); }); + assertGeneratedPath(assetsDir, outputDir); if (fs.existsSync(assetsDir)) fs.rmSync(assetsDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); @@ -231,16 +281,38 @@ export function runBundleScript(options?: { platform?: string; cwd?: string; sou console.log(`📦 Bundling React Native code for platform: ${platform}...`); - const sourcemapFlag = generateSourceMap ? ` \\\n --sourcemap-output "${sourceMapPath}"` : ''; + const reactNativePackageJson = resolveModule('react-native/package.json', [ + projectRoot, + packageRoot, + __dirname, + ]); + const reactNativeCli = path.join(path.dirname(reactNativePackageJson), 'cli.js'); + if (!fs.existsSync(reactNativeCli)) { + throw new Error(`React Native CLI entrypoint is missing: ${reactNativeCli}`); + } + const reactNativeArgs = [ + reactNativeCli, + 'bundle', + '--platform', + platform, + '--dev', + 'false', + '--entry-file', + 'index.js', + '--bundle-output', + bundlePath, + '--assets-dest', + assetsDir, + '--reset-cache', + ]; + if (generateSourceMap) { + reactNativeArgs.push('--sourcemap-output', sourceMapPath); + } - execSync( - `npx react-native bundle \ - --platform ${platform} \ - --dev false \ - --entry-file index.js \ - --bundle-output "${bundlePath}" \ - --assets-dest "${assetsDir}" \ - --reset-cache${sourcemapFlag}`, + runProcess( + spawnProcess, + process.execPath, + reactNativeArgs, { stdio: 'inherit', env: { @@ -270,8 +342,17 @@ export function runBundleScript(options?: { platform?: string; cwd?: string; sou const hbcPath = bundlePath + '.hbc'; try { console.log(`🔥 Compiling to Hermes bytecode (${platform})...`); - const hermesFlags = buildHermesFlags(hermescPath, generateSourceMap).join(' '); - execSync(`"${hermescPath}" ${hermesFlags} -out "${hbcPath}" "${bundlePath}"`, { stdio: ['ignore', 'ignore', 'ignore'] }); + const hermesFlags = buildHermesFlags( + hermescPath, + generateSourceMap, + spawnProcess, + ); + runProcess( + spawnProcess, + hermescPath, + [...hermesFlags, '-out', hbcPath, bundlePath], + { stdio: ['ignore', 'ignore', 'ignore'] }, + ); promoteHermesBytecode(hbcPath, bundlePath); console.log('✅ Hermes bytecode compiled'); } catch (e) { @@ -287,8 +368,10 @@ export function runBundleScript(options?: { platform?: string; cwd?: string; sou if (fs.existsSync(composeScript)) { try { const composedPath = sourceMapPath + '.composed'; - execSync( - `node "${composeScript}" "${sourceMapPath}" "${hermesMapPath}" -o "${composedPath}"`, + runProcess( + spawnProcess, + process.execPath, + [composeScript, sourceMapPath, hermesMapPath, '-o', composedPath], { stdio: ['ignore', 'ignore', 'ignore'] }, ); fs.renameSync(composedPath, sourceMapPath); diff --git a/src/scripts/download-bundle.ts b/src/scripts/download-bundle.ts index dcdc739..719b7e7 100644 --- a/src/scripts/download-bundle.ts +++ b/src/scripts/download-bundle.ts @@ -4,11 +4,13 @@ import axios from 'axios'; import * as fs from 'fs'; import * as path from 'path'; -const getPackageRoot = () => - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE || path.resolve(__dirname, '..', '..'); - -export async function runDownloadBundle(options?: { argv?: string[]; cwd?: string }) { - const outputDir = path.join(getPackageRoot(), 'dist'); +export async function runDownloadBundle(options?: { + argv?: string[]; + cwd?: string; + packageRoot?: string; +}) { + const packageRoot = options?.packageRoot || path.resolve(__dirname, '..', '..'); + const outputDir = path.join(packageRoot, 'dist'); const argv = options?.argv || process.argv; const platform = argv[2]; diff --git a/src/scripts/resolveModule.ts b/src/scripts/resolveModule.ts new file mode 100644 index 0000000..3ddfe54 --- /dev/null +++ b/src/scripts/resolveModule.ts @@ -0,0 +1,7 @@ +export type ModuleResolver = ( + moduleId: string, + searchPaths: string[], +) => string; + +export const resolveModuleFrom: ModuleResolver = (moduleId, searchPaths) => + require.resolve(moduleId, { paths: searchPaths }); 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 f2db806..bc5e156 100644 --- a/src/tests/CLI/scripts/aipowered/init-project-config.test.ts +++ b/src/tests/CLI/scripts/aipowered/init-project-config.test.ts @@ -889,14 +889,14 @@ describe('initProjectConfigAi', () => { projectRoot: root, changes: expect.arrayContaining([ expect.objectContaining({ - file: '.bundle-drop/runtime-delivery.generated.json', + file: '.bundle-drop/runtime-delivery.lock.json', original: null, updated: '{"schemaVersion":1}\n', }), expect.objectContaining({ file: '.gitignore', original: 'node_modules\n', - updated: expect.stringContaining('!.bundle-drop/runtime-delivery.generated.json'), + updated: expect.stringContaining('!.bundle-drop/runtime-delivery.lock.json'), }), ]), }); @@ -908,12 +908,15 @@ describe('initProjectConfigAi', () => { cwdSpy.mockReturnValue(root); fs.ensureDirSync(path.join(root, '.bundle-drop')); fs.writeFileSync( - path.join(root, '.bundle-drop/runtime-delivery.generated.json'), + path.join(root, '.bundle-drop/runtime-delivery.lock.json'), '{"schemaVersion":1}\n', ); fs.writeFileSync( path.join(root, '.gitignore'), - '!.bundle-drop/runtime-delivery.generated.json\n', + '# Bundle Drop: commit the public trust bootstrap; ignore generated runtime artifacts.\n' + + '!.bundle-drop/\n' + + '.bundle-drop/*\n' + + '!.bundle-drop/runtime-delivery.lock.json\n', ); mockInspectProject.mockResolvedValue({ projectRoot: root, diff --git a/src/tests/CLI/scripts/doctor.test.ts b/src/tests/CLI/scripts/doctor.test.ts index 3457573..dcfc52b 100644 --- a/src/tests/CLI/scripts/doctor.test.ts +++ b/src/tests/CLI/scripts/doctor.test.ts @@ -762,7 +762,7 @@ describe('CLI/scripts/doctor', () => { const projectRoot = createBareProject(); fs.mkdirSync(path.join(projectRoot, '.bundle-drop'), { recursive: true }); fs.writeFileSync( - path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'), + path.join(projectRoot, '.bundle-drop/runtime-delivery.lock.json'), JSON.stringify({ schemaVersion: 1, project: { @@ -793,9 +793,39 @@ describe('CLI/scripts/doctor', () => { status: 'pass', })); + const lockPath = path.join(projectRoot, '.bundle-drop/runtime-delivery.lock.json'); + const legacyPath = path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'); + const validBootstrap = fs.readFileSync(lockPath, 'utf8'); + fs.renameSync(lockPath, legacyPath); + result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'warning', + message: expect.stringContaining('legacy runtime-delivery.generated.json'), + })); + + fs.writeFileSync(lockPath, validBootstrap); + result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'warning', + message: expect.stringContaining('matches the legacy bootstrap'), + })); + + const conflictingLegacy = JSON.parse(validBootstrap); + conflictingLegacy.runtimeDelivery.manifestBaseUrl = 'https://other.example.com'; + fs.writeFileSync(legacyPath, JSON.stringify(conflictingLegacy)); + result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); + expect(result.checks).toContainEqual(expect.objectContaining({ + name: 'Runtime delivery bootstrap', + status: 'error', + message: expect.stringContaining('lockfile and legacy bootstrap differ'), + })); + fs.rmSync(legacyPath); + const malformedBootstrapPath = path.join( projectRoot, - '.bundle-drop/runtime-delivery.generated.json', + '.bundle-drop/runtime-delivery.lock.json', ); const malformedBootstrap = JSON.parse(fs.readFileSync(malformedBootstrapPath, 'utf8')); delete malformedBootstrap.project.orgId; @@ -822,7 +852,7 @@ describe('CLI/scripts/doctor', () => { 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'); + const bootstrapPath = path.join(projectRoot, '.bundle-drop/runtime-delivery.lock.json'); fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); fs.writeFileSync( bootstrapPath, @@ -848,7 +878,12 @@ describe('CLI/scripts/doctor', () => { }), ); execFileSync('git', ['init', '-q'], { cwd: projectRoot }); - fs.writeFileSync(path.join(projectRoot, '.gitignore'), '.bundle-drop/\n'); + fs.writeFileSync( + path.join(projectRoot, '.gitignore'), + '# !.bundle-drop/runtime-delivery.lock.json\n' + + '!.bundle-drop/runtime-delivery.lock.json\n' + + '.bundle-drop/\n', + ); let result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); expect(result.checks).toContainEqual(expect.objectContaining({ @@ -859,7 +894,7 @@ describe('CLI/scripts/doctor', () => { fs.writeFileSync( path.join(projectRoot, '.gitignore'), - '.bundle-drop/*\n!.bundle-drop/runtime-delivery.generated.json\n', + '.bundle-drop/*\n!.bundle-drop/runtime-delivery.lock.json\n', ); result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); expect(result.checks).toContainEqual(expect.objectContaining({ @@ -868,7 +903,7 @@ describe('CLI/scripts/doctor', () => { message: expect.stringContaining('not committed yet'), })); - execFileSync('git', ['add', '.gitignore', '.bundle-drop/runtime-delivery.generated.json'], { + execFileSync('git', ['add', '.gitignore', '.bundle-drop/runtime-delivery.lock.json'], { cwd: projectRoot, }); result = await inspectProject({ cwd: projectRoot, projectType: 'bare' }); diff --git a/src/tests/CLI/scripts/expo/configure-expo.test.ts b/src/tests/CLI/scripts/expo/configure-expo.test.ts index eb34531..fe25784 100644 --- a/src/tests/CLI/scripts/expo/configure-expo.test.ts +++ b/src/tests/CLI/scripts/expo/configure-expo.test.ts @@ -114,7 +114,7 @@ describe('CLI/scripts/expo/configure-expo', () => { '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', + '!.bundle-drop/runtime-delivery.lock.json\n', ); expect(fs.existsSync(path.join(projectRoot, '.fingerprintignore'))).toBe(false); expect(planExpoProjectConfiguration({ projectRoot, migrateExpoUpdates: true })).toEqual([]); @@ -263,7 +263,7 @@ describe('CLI/scripts/expo/configure-expo', () => { })); expect(gitignore).toEqual(expect.objectContaining({ original: null, - updated: expect.stringContaining('!.bundle-drop/runtime-delivery.generated.json'), + updated: expect.stringContaining('!.bundle-drop/runtime-delivery.lock.json'), })); }); @@ -524,7 +524,7 @@ describe('CLI/scripts/expo/configure-expo', () => { expect(() => applyExpoConfigurationChanges({ projectRoot, changes })).not.toThrow(); expect(fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8')).toContain( - '!.bundle-drop/runtime-delivery.generated.json', + '!.bundle-drop/runtime-delivery.lock.json', ); }); diff --git a/src/tests/CLI/scripts/init-config.test.ts b/src/tests/CLI/scripts/init-config.test.ts index 1652790..17c634a 100644 --- a/src/tests/CLI/scripts/init-config.test.ts +++ b/src/tests/CLI/scripts/init-config.test.ts @@ -4,6 +4,7 @@ import path from 'path'; import { mockAxiosNodeGet } from '../../mocks/modules/axiosNode'; import { queuePromptResponse } from '../../mocks/modules/prompts'; import { createTempProjectDir, removeTempDir } from '../../utils/tempDir'; +import * as runtimeDeliveryBootstrapConfig from '../../../runtime-delivery/bootstrapConfig'; jest.mock('axios', () => require('../../mocks/modules/axiosNode')); jest.mock('prompts', () => require('../../mocks/modules/prompts')); @@ -150,7 +151,7 @@ describe('CLI/scripts/init-config', () => { }); const result = await initConfig({ - serverUrl: 'https://ignored.example.com', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -158,15 +159,194 @@ describe('CLI/scripts/init-config', () => { expect(fs.readFileSync(configPath, 'utf8')).toBe(original); expect(result?.bootstrapPath).toBe( - path.join(fs.realpathSync(tempDir), '.bundle-drop/runtime-delivery.generated.json'), + path.join(fs.realpathSync(tempDir), '.bundle-drop/runtime-delivery.lock.json'), ); expect(fs.readFileSync(path.join(tempDir, '.gitignore'), 'utf8')).toContain( - '!.bundle-drop/runtime-delivery.generated.json', + '!.bundle-drop/runtime-delivery.lock.json', ); expect(mockAxiosNodeGet).toHaveBeenCalledWith( 'https://api.example.com/projects/demo-app/credentials?orgSlug=alpha-org', expect.any(Object), ); + + const lockPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'); + const firstLockfile = fs.readFileSync(lockPath, 'utf8'); + const firstGitignore = fs.readFileSync(path.join(tempDir, '.gitignore'), 'utf8'); + await initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + expect(fs.readFileSync(lockPath, 'utf8')).toBe(firstLockfile); + expect(fs.readFileSync(path.join(tempDir, '.gitignore'), 'utf8')).toBe(firstGitignore); + }); + + it('repairs shadowed bootstrap ignore rules during sync', async () => { + fs.writeFileSync( + path.join(tempDir, 'bundle.drop.config.js'), + `module.exports = { + serverUrl: 'https://api.example.com', + org: { slug: 'alpha-org' }, + project: { name: 'Demo', slug: 'demo-app', apiKey: 'existing-key' }, +};\n`, + 'utf8', + ); + fs.writeFileSync( + path.join(tempDir, '.gitignore'), + '# !.bundle-drop/runtime-delivery.lock.json\n' + + '!.bundle-drop/runtime-delivery.lock.json\n' + + '.bundle-drop/\n', + 'utf8', + ); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap('v2'), + }), + }); + + await initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + const gitignore = fs.readFileSync(path.join(tempDir, '.gitignore'), 'utf8'); + expect(gitignore).toContain('# !.bundle-drop/runtime-delivery.lock.json'); + expect(gitignore.match(/^!\.bundle-drop\/runtime-delivery\.lock\.json$/gm)).toHaveLength(1); + expect(gitignore).toMatch( + /# Bundle Drop: commit the public trust bootstrap; ignore generated runtime artifacts\.\n!\.bundle-drop\/\n\.bundle-drop\/\*\n!\.bundle-drop\/runtime-delivery\.lock\.json\n$/, + ); + }); + + it('rejects an auth-token origin mismatch before fetching project credentials', 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', + ); + + await expect( + initConfig({ + serverUrl: 'https://api-staging.example.com', + organizations: [], + projects: [], + authToken: 'staging-token', + }), + ).rejects.toThrow(/stored CLI login belongs to/); + + expect(mockAxiosNodeGet).not.toHaveBeenCalled(); + expect(fs.readFileSync(configPath, 'utf8')).toContain( + "serverUrl: 'https://api.example.com'", + ); + }); + + it('migrates a valid legacy bootstrap only after validating the new lockfile', async () => { + fs.writeFileSync( + path.join(tempDir, 'bundle.drop.config.js'), + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + ); + const legacyPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); + fs.writeFileSync(legacyPath, JSON.stringify({ + schemaVersion: 1, + project: { + serverUrl: 'https://api.example.com', + orgSlug: 'alpha-org', + projectSlug: 'demo-app', + projectId: 'project-1', + orgId: 'org-1', + }, + runtimeDelivery: normalizeRuntimeDeliveryBootstrap(runtimeDeliveryBootstrap('v2')), + })); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap('v2'), + }), + }); + + await initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + }); + + expect(fs.existsSync(path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'))).toBe(true); + expect(fs.existsSync(legacyPath)).toBe(false); + }); + + it('preserves a valid legacy bootstrap when the lockfile write is rejected', async () => { + fs.writeFileSync( + path.join(tempDir, 'bundle.drop.config.js'), + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + ); + const bootstrapDirectory = path.join(tempDir, '.bundle-drop'); + const legacyPath = path.join(bootstrapDirectory, 'runtime-delivery.generated.json'); + const outsideRoot = createTempProjectDir(); + fs.mkdirSync(bootstrapDirectory, { recursive: true }); + fs.writeFileSync(legacyPath, '{"legacy":"preserved"}\n'); + const outsideSentinel = path.join(outsideRoot, 'sentinel.json'); + fs.writeFileSync(outsideSentinel, '{"outside":"safe"}\n'); + fs.symlinkSync(outsideSentinel, path.join(bootstrapDirectory, 'runtime-delivery.lock.json')); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap('v2'), + }), + }); + + try { + await expect(initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + })).rejects.toThrow('symlinked or non-regular'); + expect(fs.readFileSync(legacyPath, 'utf8')).toBe('{"legacy":"preserved"}\n'); + expect(fs.readFileSync(outsideSentinel, 'utf8')).toBe('{"outside":"safe"}\n'); + } finally { + removeTempDir(outsideRoot); + } + }); + + it('preserves the legacy bootstrap when lockfile read-back validation fails', async () => { + fs.writeFileSync( + path.join(tempDir, 'bundle.drop.config.js'), + "module.exports = { serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' } };\n", + ); + const legacyPath = path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'); + fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); + fs.writeFileSync(legacyPath, '{"legacy":"preserved"}\n'); + mockAxiosNodeGet.mockResolvedValue({ + data: projectCredentials({ + runtimeDeliveryMode: 'v2', + runtimeDelivery: runtimeDeliveryBootstrap('v2'), + }), + }); + const readBack = jest + .spyOn(runtimeDeliveryBootstrapConfig, 'readRuntimeDeliveryLockfile') + .mockReturnValue(null); + + try { + await expect(initConfig({ + serverUrl: 'https://api.example.com', + organizations: [], + projects: [], + authToken: 'jwt-token', + })).rejects.toThrow('lockfile validation failed after writing'); + expect(fs.readFileSync(legacyPath, 'utf8')).toBe('{"legacy":"preserved"}\n'); + } finally { + readBack.mockRestore(); + } }); it('accepts the neutral credentials response and recreates deleted generated state', async () => { @@ -188,7 +368,7 @@ describe('CLI/scripts/init-config', () => { }); const result = await initConfig({ - serverUrl: 'https://ignored.example.com', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -196,10 +376,10 @@ describe('CLI/scripts/init-config', () => { expect(result).toEqual(expect.objectContaining({ runtimeDeliveryAvailable: true })); expect(fs.existsSync( - path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'), + path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'), )).toBe(true); expect(fs.readFileSync(path.join(tempDir, '.gitignore'), 'utf8')).toContain( - '!.bundle-drop/runtime-delivery.generated.json', + '!.bundle-drop/runtime-delivery.lock.json', ); }); @@ -210,7 +390,7 @@ describe('CLI/scripts/init-config', () => { "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'); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'); fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); mockAxiosNodeGet.mockResolvedValue({ @@ -218,7 +398,7 @@ describe('CLI/scripts/init-config', () => { }); const result = await initConfig({ - serverUrl: 'https://ignored.example.com', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -238,7 +418,7 @@ describe('CLI/scripts/init-config', () => { "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'); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'); fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); mockAxiosNodeGet.mockResolvedValue({ @@ -267,7 +447,7 @@ describe('CLI/scripts/init-config', () => { "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'); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'); fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); mockAxiosNodeGet.mockResolvedValue({ @@ -275,7 +455,7 @@ describe('CLI/scripts/init-config', () => { }); const result = await initConfig({ - serverUrl: 'https://ignored.example.com', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -297,13 +477,13 @@ describe('CLI/scripts/init-config', () => { "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'); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.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', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -321,13 +501,13 @@ describe('CLI/scripts/init-config', () => { "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'); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.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', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -338,7 +518,7 @@ describe('CLI/scripts/init-config', () => { data: projectCredentials({ runtimeDeliveryMode: undefined }), }); await expect(initConfig({ - serverUrl: 'https://ignored.example.com', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -397,13 +577,13 @@ describe('CLI/scripts/init-config', () => { "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'); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.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', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -442,7 +622,7 @@ describe('CLI/scripts/init-config', () => { "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'); + const bootstrapPath = path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'); fs.mkdirSync(path.dirname(bootstrapPath), { recursive: true }); fs.writeFileSync(bootstrapPath, '{"lastGood":true}\n', 'utf8'); mockAxiosNodeGet.mockResolvedValue({ @@ -454,7 +634,7 @@ describe('CLI/scripts/init-config', () => { }); await expect(initConfig({ - serverUrl: 'https://ignored.example.com', + serverUrl: 'https://api.example.com', organizations: [], projects: [], authToken: 'jwt-token', @@ -552,7 +732,7 @@ describe('CLI/scripts/init-config', () => { 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'), + path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'), 'utf8', ); expect(bootstrap).not.toContain('"mode"'); @@ -596,7 +776,7 @@ describe('CLI/scripts/init-config', () => { 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); + expect(fs.existsSync(path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'))).toBe(false); }); it('fails closed for malformed runtime-delivery bootstrap subshapes', () => { @@ -718,7 +898,7 @@ describe('CLI/scripts/init-config', () => { expect.any(Object), ); expect(fs.readFileSync( - path.join(tempDir, '.bundle-drop/runtime-delivery.generated.json'), + path.join(tempDir, '.bundle-drop/runtime-delivery.lock.json'), 'utf8', )).toContain('"projectId": "project-beta-shared"'); }); diff --git a/src/tests/CLI/scripts/native/write-runtime-identity.test.ts b/src/tests/CLI/scripts/native/write-runtime-identity.test.ts new file mode 100644 index 0000000..f7687f4 --- /dev/null +++ b/src/tests/CLI/scripts/native/write-runtime-identity.test.ts @@ -0,0 +1,106 @@ +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +import { + parseNativeRuntimeIdentityArguments, + resolveNativeRuntimeIdentity, + writeNativeRuntimeIdentity, +} from '../../../../CLI/scripts/native/write-runtime-identity'; + +describe('native runtime identity writer', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-drop-native-identity-')); + }); + + afterEach(() => { + fs.removeSync(projectRoot); + }); + + const writeConfig = (runtimeVersion: string) => { + fs.writeFileSync( + path.join(projectRoot, 'bundle.drop.config.js'), + `module.exports = { runtimeVersion: ${runtimeVersion} };\n`, + ); + }; + + it('resolves platform literals and writes deterministic native identity JSON', () => { + writeConfig("{ ios: 'ios-runtime', android: 'android-runtime' }"); + const outputPath = path.join(projectRoot, 'generated', 'bundle-drop', 'build-identity.json'); + + expect(writeNativeRuntimeIdentity({ projectRoot, platform: 'android', outputPath })).toEqual({ + schemaVersion: 1, + platform: 'android', + source: 'bundle-drop', + runtimeVersion: 'android-runtime', + }); + expect(fs.readFileSync(outputPath, 'utf8')).toBe( + '{"schemaVersion":1,"platform":"android","source":"bundle-drop","runtimeVersion":"android-runtime"}\n', + ); + expect(fs.readdirSync(path.dirname(outputPath))).toEqual(['build-identity.json']); + }); + + it('reports Expo authority without inventing a literal', () => { + writeConfig("{ source: 'expo' }"); + expect(writeNativeRuntimeIdentity({ projectRoot, platform: 'ios' })).toEqual({ + schemaVersion: 1, + platform: 'ios', + source: 'expo', + }); + }); + + it('fails closed for missing platform literals', () => { + writeConfig("{ ios: 'ios-only' }"); + expect(() => resolveNativeRuntimeIdentity(projectRoot, 'android')).toThrow( + 'runtimeVersion.android', + ); + }); + + it('parses the executable arguments and rejects malformed input', () => { + expect(parseNativeRuntimeIdentityArguments([ + '--project-root', projectRoot, + '--platform', 'ios', + '--output', '/tmp/identity.json', + ])).toEqual({ + projectRoot, + platform: 'ios', + outputPath: '/tmp/identity.json', + }); + expect(() => parseNativeRuntimeIdentityArguments(['--platform', 'windows'])).toThrow( + 'Usage: write-runtime-identity', + ); + expect(() => parseNativeRuntimeIdentityArguments(['--unknown', 'value'])).toThrow( + 'Usage: write-runtime-identity', + ); + expect(() => parseNativeRuntimeIdentityArguments(['--platform'])).toThrow( + 'Usage: write-runtime-identity', + ); + expect(() => parseNativeRuntimeIdentityArguments([ + '--project-root', projectRoot, + '--platform', 'android', + ])).not.toThrow(); + expect(() => parseNativeRuntimeIdentityArguments([ + '--project-root', projectRoot, + '--project-root', projectRoot, + '--platform', 'ios', + ])).toThrow('Duplicate argument --project-root'); + }); + + it('removes a temporary file when the atomic rename fails', () => { + writeConfig("{ ios: 'ios-runtime', android: 'android-runtime' }"); + const outputPath = path.join(projectRoot, 'generated', 'identity.json'); + const rename = jest.spyOn(fs, 'renameSync').mockImplementationOnce(() => { + throw new Error('rename failed'); + }); + + expect(() => writeNativeRuntimeIdentity({ + projectRoot, + platform: 'android', + outputPath, + })).toThrow('rename failed'); + expect(fs.readdirSync(path.dirname(outputPath))).toEqual([]); + rename.mockRestore(); + }); +}); diff --git a/src/tests/CLI/scripts/upload-cli.test.ts b/src/tests/CLI/scripts/upload-cli.test.ts index 869f77d..6b0c3e1 100644 --- a/src/tests/CLI/scripts/upload-cli.test.ts +++ b/src/tests/CLI/scripts/upload-cli.test.ts @@ -14,6 +14,7 @@ type MockFormDataInstance = { const formInstances: MockFormDataInstance[] = []; const mockExecSync = jest.fn(); +const spawnCalls: Array<{ executable: string; args: string[]; options: unknown }> = []; const mockResolveExpoUploadIdentity = jest.fn(); const mockExportProjectArtifact = jest.fn(); const mockDetectProjectType = jest.fn(); @@ -29,7 +30,18 @@ const mockLog = { jest.mock('axios', () => require('../../mocks/modules/axiosNode')); jest.mock('child_process', () => ({ - execSync: (...args: unknown[]) => mockExecSync(...args), + spawnSync: (executable: string, args: string[], options: unknown) => { + spawnCalls.push({ executable, args, options }); + const commandName = executable === process.execPath ? 'node' : executable; + const command = [ + commandName, + ...args.map(arg => arg.includes(path.sep) ? `"${arg}"` : arg), + ].join(' '); + const { shell: _shell, ...legacyOptions } = options as Record; + const output = mockExecSync(command, legacyOptions); + if (output && typeof output === 'object' && 'status' in output) return output; + return { status: 0, stdout: output ?? '', stderr: '' }; + }, })); jest.mock('form-data', () => ({ __esModule: true, @@ -67,7 +79,7 @@ jest.mock('../../../expo', () => ({ jest.requireActual('../../../expo/expoUpdatesOwnership').assertExpoUpdatesDoesNotOwnStartup(...args), })); -import upload from '../../../CLI/scripts/upload-cli'; +import uploadWithDefaultDependencies, { runUpload } from '../../../CLI/scripts/upload-cli'; describe('CLI/scripts/upload-cli', () => { const originalCwd = process.cwd(); @@ -82,6 +94,18 @@ describe('CLI/scripts/upload-cli', () => { let consoleWarnSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; + const upload = ( + platform: string, + options: Parameters[1], + ) => runUpload(platform, options, { + packageRoot: tempPackageRoot, + spawnProcess: require('child_process').spawnSync, + resolveModule: moduleId => { + if (moduleId !== 'ts-node/dist/bin.js') throw new Error(`Unexpected module: ${moduleId}`); + return path.join(tempPackageRoot, 'test-tools', 'ts-node.js'); + }, + }); + const writeConfig = (content: string) => { const configPath = path.join(tempProjectDir, 'bundle.drop.config.js'); fs.writeFileSync(configPath, content, 'utf8'); @@ -95,7 +119,11 @@ describe('CLI/scripts/upload-cli', () => { const writeAuth = (token = 'jwt-token') => { const authDir = path.join(tempHome, '.bundle-drop'); fs.mkdirSync(authDir, { recursive: true }); - fs.writeFileSync(path.join(authDir, 'auth.json'), JSON.stringify({ token }), 'utf8'); + fs.writeFileSync( + path.join(authDir, 'auth.json'), + JSON.stringify({ token, serverUrl: 'https://api.example.com' }), + 'utf8', + ); }; const prepareDist = (platform: 'ios' | 'android', runtimeVersion = '2.0.0') => { @@ -124,8 +152,13 @@ describe('CLI/scripts/upload-cli', () => { platform: 'ios' | 'android', runtimeVersion: string, ) => { - const outputDir = path.join(distDir, `expo-artifacts-${platform}`); - const expoExportDirectory = path.join(distDir, `expo-export-${platform}`); + const artifactRoot = path.join( + fs.realpathSync(tempProjectDir), + '.bundle-drop', + 'artifacts', + ); + const outputDir = path.join(artifactRoot, `expo-artifacts-${platform}`); + const expoExportDirectory = path.join(artifactRoot, `expo-export-${platform}`); const bundlePath = path.join(outputDir, 'main.jsbundle'); const sourceMapPath = path.join(outputDir, 'main.jsbundle.map'); const metadataPath = path.join(outputDir, `metadata-${platform}.json`); @@ -171,9 +204,9 @@ describe('CLI/scripts/upload-cli', () => { consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); process.chdir(tempProjectDir); - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE = tempPackageRoot; mockAxiosNodePost.mockReset(); mockExecSync.mockReset(); + spawnCalls.length = 0; mockResolveExpoUploadIdentity.mockReset(); mockExportProjectArtifact.mockReset().mockResolvedValue(undefined); mockDetectProjectType.mockReset().mockImplementation(() => { @@ -200,7 +233,6 @@ describe('CLI/scripts/upload-cli', () => { removeTempDir(tempProjectDir); removeTempDir(tempHome); removeTempDir(tempPackageRoot); - delete process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE; process.exitCode = originalExitCode; fs.rmSync(distDir, { recursive: true, force: true }); }); @@ -340,6 +372,49 @@ describe('CLI/scripts/upload-cli', () => { }); }); + it('passes a platform containing shell metacharacters as one inert argument', async () => { + const platform = 'android";touch bundle-drop-upload-pwned;# $()'; + const sentinelPath = path.join(tempProjectDir, 'bundle-drop-upload-pwned'); + const compiledScriptPath = path.join(tempPackageRoot, 'lib', 'scripts', 'bundle.js'); + fs.mkdirSync(path.dirname(compiledScriptPath), { recursive: true }); + fs.writeFileSync(compiledScriptPath, '// compiled bundle script', 'utf8'); + writeConfig(`module.exports = { + serverUrl: 'https://api.example.com', + org: { slug: 'alpha-org' }, + project: { slug: 'demo-app' }, + runtimeVersion: { android: '1.0.0', ios: '1.0.0' }, +};`); + fs.mkdirSync(path.join(distDir, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(distDir, 'main.jsbundle'), 'bundle-data', 'utf8'); + fs.writeFileSync(path.join(distDir, `bundle-${platform}.zip`), 'zip-data', 'utf8'); + fs.writeFileSync( + path.join(distDir, `metadata-${platform}.json`), + JSON.stringify({ runtimeVersion: '2.0.0' }), + 'utf8', + ); + fs.writeFileSync( + path.join(distDir, 'bundle-manifest.json'), + JSON.stringify({ manifestVersion: 1, runtimeVersion: '2.0.0' }), + 'utf8', + ); + mockAxiosNodePost.mockResolvedValue({ data: {} }); + + await upload(platform, { + version: '1.2.3', + channel: 'General', + token: 'explicit-token', + }); + + expect(spawnCalls[0]).toEqual( + expect.objectContaining({ + executable: process.execPath, + args: [compiledScriptPath, platform], + options: expect.objectContaining({ shell: false }), + }), + ); + expect(fs.existsSync(sentinelPath)).toBe(false); + }); + it('uses the plist version, token override, and manifest runtimeVersion for iOS uploads', async () => { fs.mkdirSync(path.join(tempProjectDir, 'ios'), { recursive: true }); fs.writeFileSync( @@ -505,6 +580,24 @@ describe('CLI/scripts/upload-cli', () => { } }); + it('constructs default production dependencies in the public upload entrypoint', async () => { + const exitSpy = mockProcessExit(); + try { + await expect( + uploadWithDefaultDependencies('android', { + version: '1.2.3', + channel: 'General', + token: 'override-token', + }), + ).rejects.toMatchObject({ code: 1 }); + expect(mockLog.error).toHaveBeenCalledWith( + expect.stringContaining('bundle.drop.config.js not found'), + ); + } finally { + exitSpy.mockRestore(); + } + }); + it('clears stale artifact-dir output before early validation failures', async () => { const exitSpy = mockProcessExit(); const artifactDir = path.join(tempPackageRoot, 'ci-artifacts'); @@ -668,6 +761,87 @@ describe('CLI/scripts/upload-cli', () => { } }); + it('rejects malformed and cross-origin stored credentials before any request', async () => { + const exitSpy = mockProcessExit(); + writeConfig(`module.exports = { + serverUrl: 'https://api.example.com', + org: { slug: 'alpha-org' }, + project: { slug: 'demo-app' }, +};`); + const authDir = path.join(tempHome, '.bundle-drop'); + const authPath = path.join(authDir, 'auth.json'); + fs.mkdirSync(authDir, { recursive: true }); + + try { + fs.writeFileSync(authPath, '{ malformed', 'utf8'); + await expect( + upload('android', { version: '1.2.3', channel: 'General' }), + ).rejects.toMatchObject({ code: 1 }); + expect(mockLog.error).toHaveBeenLastCalledWith( + '❌ Failed to read CLI auth session. Run `bundle-drop login` again or pass --token.', + ); + + fs.writeFileSync( + authPath, + JSON.stringify({ serverUrl: 'https://api.example.com' }), + 'utf8', + ); + await expect( + upload('android', { version: '1.2.3', channel: 'General' }), + ).rejects.toMatchObject({ code: 1 }); + expect(mockLog.error).toHaveBeenLastCalledWith( + '❌ CLI auth session is missing a token. Run `bundle-drop login` again or pass --token.', + ); + + fs.writeFileSync( + authPath, + JSON.stringify({ + token: 'staging-token', + serverUrl: 'https://api-staging.example.com', + }), + 'utf8', + ); + await expect( + upload('android', { version: '1.2.3', channel: 'General' }), + ).rejects.toMatchObject({ code: 1 }); + expect(mockLog.error).toHaveBeenLastCalledWith( + expect.stringContaining('stored CLI login belongs to'), + ); + expect(mockAxiosNodePost).not.toHaveBeenCalled(); + expect(spawnCalls).toHaveLength(0); + } finally { + exitSpy.mockRestore(); + } + }); + + it('keeps an explicit token usable for the selected project server', async () => { + writeConfig(`module.exports = { + serverUrl: 'https://api.example.com', + org: { slug: 'alpha-org' }, + project: { slug: 'demo-app' }, + runtimeVersion: { android: '1.0.0', ios: '1.0.0' }, +};`); + const authDir = path.join(tempHome, '.bundle-drop'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'auth.json'), + JSON.stringify({ token: 'wrong-origin-token', serverUrl: 'https://other.example.com' }), + 'utf8', + ); + prepareDist('android', '2.0.0'); + mockAxiosNodePost.mockResolvedValue({ data: {} }); + + await upload('android', { + version: '1.2.3', + channel: 'General', + token: 'explicit-token', + }); + + expect(mockAxiosNodePost.mock.calls[0][2].headers.Authorization).toBe( + 'Bearer explicit-token', + ); + }); + it('fails on missing or invalid version sources for Android and iOS', async () => { const exitSpy = mockProcessExit(); @@ -804,6 +978,39 @@ describe('CLI/scripts/upload-cli', () => { } }); + it.each([ + { + result: { status: null, error: new Error('spawn failed') }, + label: 'spawn error', + }, + { + result: { status: 7, stderr: 'bundle rejected' }, + label: 'nonzero child status', + }, + ])('fails when bundling returns a $label', async ({ result }) => { + const exitSpy = mockProcessExit(); + try { + writeConfig(`module.exports = { + serverUrl: 'https://api.example.com', + org: { slug: 'alpha-org' }, + project: { slug: 'demo-app' }, +};`); + mockExecSync.mockReturnValueOnce(result); + + await expect( + upload('android', { + version: '1.2.3', + channel: 'General', + token: 'override-token', + }), + ).rejects.toMatchObject({ code: 1 }); + expect(mockLog.error).toHaveBeenCalledWith('❌ Bundling failed'); + expect(mockAxiosNodePost).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } + }); + it('fails when bundle-manifest.json is missing after bundling', async () => { const exitSpy = mockProcessExit(); @@ -1173,6 +1380,52 @@ describe('CLI/scripts/upload-cli', () => { rmSpy.mockRestore(); }); + it('refuses to remove an Expo artifact outside its generated directory', async () => { + mockDetectProjectType.mockReturnValue('expo'); + writeConfig(`module.exports = { + projectType: 'expo', + serverUrl: 'https://api.example.com', + org: { slug: 'alpha-org' }, + project: { slug: 'demo-app' }, + runtimeVersion: { source: 'expo' }, +};`); + const outsideRoot = path.join(tempHome, 'must-survive'); + const outputDir = path.join(outsideRoot, 'artifact'); + const artifact = { + outputDir, + bundlePath: path.join(outputDir, 'main.jsbundle'), + metadataPath: path.join(outputDir, 'metadata-ios.json'), + manifestPath: path.join(outputDir, 'bundle-manifest.json'), + zipPath: path.join(outputDir, 'bundle-ios.zip'), + expoExportDirectory: path.join(outsideRoot, 'export'), + }; + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(artifact.expoExportDirectory, { recursive: true }); + fs.writeFileSync(artifact.bundlePath, 'bundle', 'utf8'); + fs.writeFileSync(artifact.metadataPath, '{}', 'utf8'); + fs.writeFileSync( + artifact.manifestPath, + JSON.stringify({ manifestVersion: 1, runtimeVersion: '1.0.0' }), + 'utf8', + ); + fs.writeFileSync(artifact.zipPath, 'zip', 'utf8'); + mockResolveExpoUploadIdentity.mockResolvedValue({ + platform: 'ios', + runtimeVersion: '1.0.0', + appVersion: '1.0.0', + }); + mockExportProjectArtifact.mockResolvedValue(artifact); + mockAxiosNodePost.mockResolvedValue({ data: {} }); + + await upload('ios', { channel: 'General', token: 'explicit-token' }); + + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining('escaped its generated output directory'), + ); + expect(fs.existsSync(artifact.zipPath)).toBe(true); + expect(fs.existsSync(artifact.expoExportDirectory)).toBe(true); + }); + it('does not copy artifacts or add paths to result when flags are omitted', async () => { writeConfig(`module.exports = { serverUrl: 'https://api.example.com', diff --git a/src/tests/CLI/serverUrl.test.ts b/src/tests/CLI/serverUrl.test.ts new file mode 100644 index 0000000..c21acd0 --- /dev/null +++ b/src/tests/CLI/serverUrl.test.ts @@ -0,0 +1,50 @@ +import { + assertMatchingServerOrigin, + DEFAULT_SERVER_URL, + normalizeServerUrl, +} from '../../CLI/serverUrl'; + +describe('CLI/serverUrl', () => { + it('uses the production API by default and removes trailing slashes', () => { + expect(normalizeServerUrl()).toBe(DEFAULT_SERVER_URL); + expect(normalizeServerUrl('https://api.example.com///')).toBe( + 'https://api.example.com', + ); + }); + + it.each([ + 'ftp://api.example.com', + 'file:///tmp/api', + 'https://username:password@api.example.com', + 'not-a-url', + ])('rejects unsafe server URL %s', serverUrl => { + expect(() => normalizeServerUrl(serverUrl)).toThrow( + 'Bundle Drop serverUrl must be an HTTP(S) URL without embedded credentials.', + ); + }); + + it('accepts matching normalized origins', () => { + expect(() => + assertMatchingServerOrigin( + 'https://api.example.com/v1/', + 'https://api.example.com/login', + ), + ).not.toThrow(); + }); + + it.each([ + ['https://api.example.com', 'http://api.example.com'], + ['https://api.example.com', 'https://other.example.com'], + ['https://api.example.com', 'https://api.example.com:8443'], + ])('rejects a stored login from a different origin', (requestUrl, loginUrl) => { + expect(() => assertMatchingServerOrigin(requestUrl, loginUrl)).toThrow( + /stored CLI login belongs to/, + ); + }); + + it('rejects legacy stored credentials without a server binding', () => { + expect(() => assertMatchingServerOrigin('https://api.example.com', undefined)).toThrow( + /not bound to a server/, + ); + }); +}); diff --git a/src/tests/bundleInfo.test.ts b/src/tests/bundleInfo.test.ts index 690b59c..05448dd 100644 --- a/src/tests/bundleInfo.test.ts +++ b/src/tests/bundleInfo.test.ts @@ -1,5 +1,11 @@ -import { readBundleInfo, updateBundleInfo, writeBundleInfo } from '../bundleInfo'; -import { getMockFile, mockWriteFile, resetNativeFsMocks, setMockFile } from './mocks/native/fs'; +import { deleteBundleInfo, readBundleInfo, updateBundleInfo, writeBundleInfo } from '../bundleInfo'; +import { + getMockFile, + mockUnlink, + mockWriteFile, + resetNativeFsMocks, + setMockFile, +} from './mocks/native/fs'; jest.mock('../native/fs', () => require('./mocks/native/fs')); @@ -74,4 +80,25 @@ describe('bundleInfo', () => { consoleSpy.mockRestore(); } }); + + it('deletes persisted bundle info and tolerates missing files and delete failures', async () => { + setMockFile(BUNDLE_INFO_PATH, JSON.stringify({ hash: 'hash-1' })); + await deleteBundleInfo(); + expect(getMockFile(BUNDLE_INFO_PATH)).toBeUndefined(); + + await expect(deleteBundleInfo()).resolves.toBeUndefined(); + + setMockFile(BUNDLE_INFO_PATH, JSON.stringify({ hash: 'hash-2' })); + mockUnlink.mockRejectedValueOnce(new Error('disk unavailable')); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await expect(deleteBundleInfo()).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + '⚠️ Failed to delete bundle-info.json', + expect.any(Error), + ); + } finally { + warnSpy.mockRestore(); + } + }); }); diff --git a/src/tests/context.test.ts b/src/tests/context.test.ts index 39c8089..a016338 100644 --- a/src/tests/context.test.ts +++ b/src/tests/context.test.ts @@ -117,6 +117,23 @@ describe('context', () => { expect(context.bundleDropConfig.runtimeVersion).toBeUndefined(); }); + it.each([ + ['negative maxCrashCount', { maxCrashCount: -1 }], + ['fractional maxCrashCount', { maxCrashCount: 1.5 }], + ['oversized maxCrashCount', { maxCrashCount: 2_147_483_648 }], + ['negative healthyAfterSec', { healthyAfterSec: -1 }], + ['non-finite healthyAfterSec', { healthyAfterSec: Number.POSITIVE_INFINITY }], + ])('rejects an invalid rollback policy: %s', (_label, rollback) => { + expect(() => loadContextModule(({ bundleDropConfig }) => { + Object.assign(bundleDropConfig, { + serverUrl: 'https://bundledrop.app', + org: { slug: 'alpha-org' }, + project: { name: 'Bundle Drop', slug: 'app' }, + rollback, + }); + })).toThrow(/rollback\.(maxCrashCount|healthyAfterSec)/); + }); + it('derives a remote nativeVersion policy from the installed Expo binary', () => { const context = loadContextModule(({ Platform, bundleDropConfig, NativeModules }) => { Platform.OS = 'android'; diff --git a/src/tests/fs/bundlePointer.test.ts b/src/tests/fs/bundlePointer.test.ts index 07b819e..29cd650 100644 --- a/src/tests/fs/bundlePointer.test.ts +++ b/src/tests/fs/bundlePointer.test.ts @@ -1,176 +1,33 @@ -import { - clearCurrentBundlePointer, - deletePreviousBundlePointer, - readCurrentBundlePointer, - readPreviousBundlePointer, - rollbackToPreviousPointer, - setCurrentBundlePointer, -} from '../../fs/bundlePointer'; -import { getMockFile, setMockFile } from '../mocks/native/fs'; +import { readCurrentBundleHash } from '../../fs/bundlePointer'; +import { resetNativeFsMocks, setMockFile } from '../mocks/native/fs'; jest.mock('../../context', () => require('../mocks/context')); jest.mock('../../native/fs', () => require('../mocks/native/fs')); const CURRENT_POINTER_PATH = '/mock/doc/bundle-drop/current.json'; -const PREVIOUS_POINTER_PATH = '/mock/doc/bundle-drop/previous.json'; const HASH_1 = '1'.repeat(64); -const HASH_2 = '2'.repeat(64); -const HASH_3 = '3'.repeat(64); -const HASH_4 = '4'.repeat(64); -const bundlePath = (hash: string) => `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`; -describe('fs/bundlePointer', () => { - it('writes the current bundle pointer when none exists', async () => { - await setCurrentBundlePointer('/old/container/bundles/stale/main.jsbundle', HASH_1); - - expect(await readCurrentBundlePointer()).toEqual( - expect.objectContaining({ - hash: HASH_1, - bundlePath: bundlePath(HASH_1), - }) - ); - expect(JSON.parse(getMockFile(CURRENT_POINTER_PATH) ?? '{}')).toEqual({ - hash: HASH_1, - updatedAt: expect.any(String), - }); - expect(await readPreviousBundlePointer()).toBeNull(); +describe('fs/bundlePointer passive reads', () => { + beforeEach(() => { + resetNativeFsMocks(); }); - it('moves the old current pointer into previous before writing the new one', async () => { - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: HASH_1, - bundlePath: '/old/container/bundles/hash-old/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - - await setCurrentBundlePointer('/new/container/bundles/hash-new/main.jsbundle', HASH_2); - - expect(await readCurrentBundlePointer()).toEqual( - expect.objectContaining({ - hash: HASH_2, - bundlePath: bundlePath(HASH_2), - }) - ); - expect(await readPreviousBundlePointer()).toEqual( - expect.objectContaining({ - hash: HASH_1, - bundlePath: bundlePath(HASH_1), - }) - ); - expect(JSON.parse(getMockFile(PREVIOUS_POINTER_PATH) ?? '{}')).toEqual({ + it('reads only the canonical hash from the native-managed current pointer', async () => { + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: HASH_1, - updatedAt: expect.any(String), - }); - }); - - it('skips writing the previous pointer when requested', async () => { - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: HASH_1, - bundlePath: '/bundles/hash-old/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - - await setCurrentBundlePointer('/bundles/hash-new/main.jsbundle', HASH_2, { - setPrevious: false, - }); - - expect(await readCurrentBundlePointer()).toEqual( - expect.objectContaining({ - hash: HASH_2, - }) - ); - expect(getMockFile(PREVIOUS_POINTER_PATH)).toBeUndefined(); - }); - - it('rolls back to the previous pointer when available', async () => { - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: HASH_3, - bundlePath: '/bundles/hash-prev/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - - await expect(rollbackToPreviousPointer()).resolves.toEqual( - expect.objectContaining({ - hash: HASH_3, - }) - ); - - expect(await readCurrentBundlePointer()).toEqual( - expect.objectContaining({ - hash: HASH_3, - bundlePath: bundlePath(HASH_3), - }) - ); - }); - - it('returns null when rolling back without a previous pointer', async () => { - await expect(rollbackToPreviousPointer()).resolves.toBeNull(); - await expect(readCurrentBundlePointer()).resolves.toBeNull(); + bundlePath: '/stale/container/current.jsbundle', + updatedAt: '2026-03-01T00:00:00.000Z', + })); + await expect(readCurrentBundleHash()).resolves.toBe(HASH_1); }); - it('clears the current pointer and ignores missing files', async () => { - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: HASH_4, - bundlePath: '/bundles/hash-current/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - - await clearCurrentBundlePointer(); - await clearCurrentBundlePointer(); - - expect(getMockFile(CURRENT_POINTER_PATH)).toBeUndefined(); - }); + it('returns null for missing, malformed, and non-canonical pointers', async () => { + await expect(readCurrentBundleHash()).resolves.toBeNull(); - it('deletes the previous pointer and ignores missing files', async () => { - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: HASH_4, - bundlePath: '/bundles/hash-previous/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - - await deletePreviousBundlePointer(); - await deletePreviousBundlePointer(); - - expect(getMockFile(PREVIOUS_POINTER_PATH)).toBeUndefined(); - }); - - it('returns null for invalid or malformed pointers', async () => { - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: 'hash-only', - }) - ); - setMockFile(PREVIOUS_POINTER_PATH, '{invalid json'); - - await expect(readCurrentBundlePointer()).resolves.toBeNull(); - await expect(readPreviousBundlePointer()).resolves.toBeNull(); - }); - - it('returns null when a pointer has no canonical hash', async () => { - setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ bundlePath: '/stale/main.jsbundle' })); - - await expect(readCurrentBundlePointer()).resolves.toBeNull(); - }); + setMockFile(CURRENT_POINTER_PATH, '{invalid json'); + await expect(readCurrentBundleHash()).resolves.toBeNull(); - it('rejects writes with non-canonical hashes', async () => { - await expect(setCurrentBundlePointer('/bundles/hash/main.jsbundle', 'hash-short')).rejects.toThrow( - 'Bundle pointer hash must be a canonical 64-character lowercase SHA-256 hash', - ); + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({})); + await expect(readCurrentBundleHash()).resolves.toBeNull(); }); }); diff --git a/src/tests/manager/downloadAndInstall.test.ts b/src/tests/manager/downloadAndInstall.test.ts index 5e6c8d3..cd4ad32 100644 --- a/src/tests/manager/downloadAndInstall.test.ts +++ b/src/tests/manager/downloadAndInstall.test.ts @@ -9,7 +9,13 @@ import { } 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'; +import { + mockActivateStartupCandidateNative, + mockGetDownloadedBundlePathNative, + mockGetStartupRecoveryStateNative, + mockRollbackStartupBundleNative, + resetBundleDropNativeMocks, +} from '../mocks/native/bundleDropNative'; jest.mock('../../context', () => require('../mocks/context')); jest.mock('../../native/fs', () => require('../mocks/native/fs')); @@ -37,6 +43,20 @@ describe('manager/downloadAndInstall', () => { ? `/mock/doc/bundle-drop/bundles/${pointer.hash}/main.jsbundle` : null; }); + mockActivateStartupCandidateNative.mockImplementation(async hash => { + const current = readMockJson(CURRENT_POINTER_PATH) as { hash?: string } | null; + if (current?.hash) { + setMockFile(PREVIOUS_POINTER_PATH, JSON.stringify(current)); + } + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ + hash, + updatedAt: '2026-08-28T00:00:00.000Z', + })); + return { + hash, + bundlePath: `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`, + }; + }); mockCheckForUpdate.mockReset(); mockInstallFromZip.mockReset(); mockInstallFromPatchSet.mockReset(); @@ -174,12 +194,9 @@ describe('manager/downloadAndInstall', () => { hash: '1111111111111111111111111111111111111111111111111111111111111111', }) ); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '1111111111111111111111111111111111111111111111111111111111111111', - candidateHash: '1111111111111111111111111111111111111111111111111111111111111111', - candidateCommitted: false, - }) + expect(mockActivateStartupCandidateNative).toHaveBeenCalledWith( + '1111111111111111111111111111111111111111111111111111111111111111', + { maxCrashCount: 2, healthCheckMode: 'auto', healthyAfterSec: 0 }, ); }); @@ -394,11 +411,14 @@ describe('manager/downloadAndInstall', () => { } }); - it('clears the candidate pointer before throwing when native rejects without a previous pointer', async () => { + it('asks native to revert activation when resolver validation fails without a previous pointer', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); try { - mockGetDownloadedBundlePathNative.mockResolvedValueOnce(null); + mockActivateStartupCandidateNative.mockResolvedValueOnce({ + hash: '2222222222222222222222222222222222222222222222222222222222222222', + bundlePath: '/wrong/path/main.jsbundle', + }); mockCheckForUpdate.mockResolvedValue({ action: 'INSTALL', upToDate: false, @@ -424,7 +444,7 @@ describe('manager/downloadAndInstall', () => { step: 'install', }), ); - expect(getMockFile(CURRENT_POINTER_PATH)).toBeUndefined(); + expect(mockRollbackStartupBundleNative).toHaveBeenCalledWith(false); expect(getMockFile(BUNDLE_INFO_PATH)).toBeUndefined(); expect(getMockFile(STATE_PATH)).toBeUndefined(); } finally { @@ -432,7 +452,7 @@ describe('manager/downloadAndInstall', () => { } }); - it('restores the previous verified pointer before throwing when native rejects the installed pointer', async () => { + it('asks native to restore the ledger when its resolver rejects the installed pointer', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); const previousHash = '1111111111111111111111111111111111111111111111111111111111111111'; const rejectedHash = '2222222222222222222222222222222222222222222222222222222222222222'; @@ -445,7 +465,7 @@ describe('manager/downloadAndInstall', () => { updatedAt: '2026-03-01T00:00:00.000Z', }), ); - mockGetDownloadedBundlePathNative.mockRejectedValueOnce(new Error('native rejected candidate')); + mockActivateStartupCandidateNative.mockRejectedValueOnce(new Error('native rejected candidate')); mockCheckForUpdate.mockResolvedValue({ action: 'INSTALL', upToDate: false, @@ -472,12 +492,7 @@ describe('manager/downloadAndInstall', () => { cause: expect.objectContaining({ message: 'native rejected candidate' }), }), ); - expect(readMockJson(CURRENT_POINTER_PATH)).toEqual( - expect.objectContaining({ - hash: previousHash, - }), - ); - expect(readMockJson(PREVIOUS_POINTER_PATH)).toBeNull(); + expect(mockRollbackStartupBundleNative).not.toHaveBeenCalled(); expect(getMockFile(BUNDLE_INFO_PATH)).toBeUndefined(); expect(getMockFile(STATE_PATH)).toBeUndefined(); } finally { @@ -485,7 +500,7 @@ describe('manager/downloadAndInstall', () => { } }); - it('restores both current and previous pointers when native rejects after writing candidate pointer', async () => { + it('does not use JS pointer restoration when native rejects after activation', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); const currentHash = '1111111111111111111111111111111111111111111111111111111111111111'; const rollbackHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -500,7 +515,7 @@ describe('manager/downloadAndInstall', () => { hash: rollbackHash, updatedAt: '2026-02-01T00:00:00.000Z', })); - mockGetDownloadedBundlePathNative.mockRejectedValueOnce(new Error('native rejected candidate')); + mockActivateStartupCandidateNative.mockRejectedValueOnce(new Error('native rejected candidate')); mockCheckForUpdate.mockResolvedValue({ action: 'INSTALL', upToDate: false, @@ -526,8 +541,7 @@ describe('manager/downloadAndInstall', () => { step: 'install', }), ); - expect(readMockJson(CURRENT_POINTER_PATH)).toEqual(expect.objectContaining({ hash: currentHash })); - expect(readMockJson(PREVIOUS_POINTER_PATH)).toEqual(expect.objectContaining({ hash: rollbackHash })); + expect(mockRollbackStartupBundleNative).not.toHaveBeenCalled(); expect(getMockFile(BUNDLE_INFO_PATH)).toBeUndefined(); expect(getMockFile(STATE_PATH)).toBeUndefined(); } finally { @@ -535,7 +549,7 @@ describe('manager/downloadAndInstall', () => { } }); - it('restores the previous verified pointer when native resolves a different bundle path', async () => { + it('asks native to restore the previous ledger target when it resolves a different path', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); const previousHash = '1111111111111111111111111111111111111111111111111111111111111111'; const targetHash = '2222222222222222222222222222222222222222222222222222222222222222'; @@ -548,9 +562,10 @@ describe('manager/downloadAndInstall', () => { updatedAt: '2026-03-01T00:00:00.000Z', }), ); - mockGetDownloadedBundlePathNative.mockResolvedValueOnce( - `/mock/doc/bundle-drop/bundles/${previousHash}/main.jsbundle`, - ); + mockActivateStartupCandidateNative.mockResolvedValueOnce({ + hash: targetHash, + bundlePath: `/mock/doc/bundle-drop/bundles/${previousHash}/main.jsbundle`, + }); mockCheckForUpdate.mockResolvedValue({ action: 'INSTALL', upToDate: false, @@ -580,11 +595,7 @@ describe('manager/downloadAndInstall', () => { }), }), ); - expect(readMockJson(CURRENT_POINTER_PATH)).toEqual( - expect.objectContaining({ - hash: previousHash, - }), - ); + expect(mockRollbackStartupBundleNative).toHaveBeenCalledWith(false); expect(getMockFile(BUNDLE_INFO_PATH)).toBeUndefined(); expect(getMockFile(STATE_PATH)).toBeUndefined(); } finally { @@ -890,17 +901,13 @@ describe('manager/downloadAndInstall', () => { }); it('refuses to install a locally failed bundle hash', async () => { - setMockFile( - STATE_PATH, - JSON.stringify({ - failedBundles: { - 'hash-failed': { - reason: 'crash_loop', - failedAt: 1000, - }, - }, - }), - ); + mockGetStartupRecoveryStateNative.mockResolvedValue({ + protocolVersion: 1, + revision: 4, + phase: 'idle', + quarantinedHashes: ['hash-failed'], + pendingRecoveryEvents: [], + }); const statusSpy = jest.fn(); await expect( @@ -922,6 +929,55 @@ describe('manager/downloadAndInstall', () => { ); }); + it('fails closed when native startup recovery cannot activate the candidate', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const hash = '5'.repeat(64); + mockActivateStartupCandidateNative.mockResolvedValueOnce(null); + mockCheckForUpdate.mockResolvedValue({ + action: 'INSTALL', + upToDate: false, + channelName: 'General', + hash, + downloadUrl: 'https://cdn.example.com/no-recovery.zip', + }); + mockInstallFromZip.mockResolvedValue({ + bundlePath: `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`, + metadataFromZip: {}, + }); + + try { + await expect(downloadUpdate()).rejects.toMatchObject({ + code: 'INSTALL_FAILED', + step: 'install', + }); + expect(mockRollbackStartupBundleNative).not.toHaveBeenCalled(); + } finally { + consoleSpy.mockRestore(); + } + }); + + it('wraps native quarantine read failures as unknown install failures', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + mockGetStartupRecoveryStateNative.mockRejectedValueOnce(new Error('native state unavailable')); + mockCheckForUpdate.mockResolvedValue({ + action: 'INSTALL', + upToDate: false, + channelName: 'General', + hash: '6'.repeat(64), + downloadUrl: 'https://cdn.example.com/state-failure.zip', + }); + + try { + await expect(downloadUpdate()).rejects.toMatchObject({ + code: 'UNKNOWN', + step: 'install', + }); + expect(mockInstallFromZip).not.toHaveBeenCalled(); + } finally { + consoleSpy.mockRestore(); + } + }); + it('rejects install decisions without a server-selected bundleHash before downloading', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); @@ -954,12 +1010,13 @@ describe('manager/downloadAndInstall', () => { } }); - it('wraps unexpected persistence errors as UNKNOWN failures', async () => { + it('rolls back native activation when bundle metadata persistence fails', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); const statusSpy = jest.fn(); const writeBundleInfoSpy = jest - .spyOn(bundleInfoModule, 'writeBundleInfo') + .spyOn(bundleInfoModule, 'writeBundleInfoDurably') .mockRejectedValueOnce(new Error('disk full')); + mockRollbackStartupBundleNative.mockRejectedValueOnce(new Error('rollback unavailable')); try { mockCheckForUpdate.mockResolvedValue({ @@ -978,11 +1035,12 @@ describe('manager/downloadAndInstall', () => { await expect(downloadUpdate(undefined, statusSpy)).rejects.toEqual( expect.objectContaining>({ - code: 'UNKNOWN', + code: 'INSTALL_FAILED', step: 'install', }), ); - expect(statusSpy).toHaveBeenCalledWith('❌ OTA update failed (UNKNOWN/install)'); + expect(statusSpy).toHaveBeenCalledWith('❌ OTA update failed (INSTALL_FAILED/install)'); + expect(mockRollbackStartupBundleNative).toHaveBeenCalledWith(false); expect(consoleSpy).toHaveBeenCalled(); } finally { writeBundleInfoSpy.mockRestore(); diff --git a/src/tests/manager/reporting.test.ts b/src/tests/manager/reporting.test.ts index 8843050..79d550c 100644 --- a/src/tests/manager/reporting.test.ts +++ b/src/tests/manager/reporting.test.ts @@ -355,7 +355,7 @@ describe('manager/reporting', () => { }); }); - it('reports local rollback health telemetry without blocking on failures', async () => { + it('reports local rollback health telemetry and propagates delivery failures', async () => { initializeBundleDropRuntime({ environment: 'production', }); @@ -394,18 +394,12 @@ describe('manager/reporting', () => { failedAt: '1970-01-01T00:16:40.000Z', }); - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); mockReportLocalRollback.mockRejectedValueOnce(null); await expect( reportLocalRollback('hash-bad', { reason: 'crash_loop', failedAt: 1_000, }), - ).resolves.toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith( - '⚠️ Failed to report local rollback:', - null, - ); - consoleSpy.mockRestore(); + ).rejects.toBeNull(); }); }); diff --git a/src/tests/manager/rollbackState.test.ts b/src/tests/manager/rollbackState.test.ts index 134b369..c74df86 100644 --- a/src/tests/manager/rollbackState.test.ts +++ b/src/tests/manager/rollbackState.test.ts @@ -1,1081 +1,361 @@ -import { - commitActiveBundle, - evaluateRollbackOnLaunch, - getRollbackPolicy, - isBundleHashFailed, - markCandidateActivated, - readRollbackState, - reportActiveBundleHealthy, - rollbackToPreviousIfNeeded, - rollbackToPreviousOrNative, -} from '../../manager/rollbackState'; -import { reportLocalRollback } from '../../manager/reporting'; -import { resetContextMocks, setMockConfig, setMockPlatform } from '../mocks/context'; -import { - getMockFile, - mockReadFile, - mockUnlink, - mockWriteFile, - readMockJson, - resetNativeFsMocks, - setMockFile, -} from '../mocks/native/fs'; - -jest.mock('../../context', () => require('../mocks/context')); jest.mock('../../native/fs', () => require('../mocks/native/fs')); -jest.mock('../../manager/reporting', () => ({ - reportLocalRollback: jest.fn(async () => undefined), -})); - -const CURRENT_POINTER_PATH = '/mock/doc/bundle-drop/current.json'; -const PREVIOUS_POINTER_PATH = '/mock/doc/bundle-drop/previous.json'; -const STATE_PATH = '/mock/doc/bundle-drop/state.json'; -const BUNDLE_INFO_PATH = '/mock/doc/bundle-info.json'; - -const DEFAULT_POLICY = { maxCrashCount: 3, healthCheckMode: 'auto' as const, healthyAfterSec: 0 }; - -describe('manager/rollbackState', () => { - beforeEach(() => { - resetContextMocks(); - resetNativeFsMocks(); - (reportLocalRollback as jest.Mock).mockReset(); - (reportLocalRollback as jest.Mock).mockResolvedValue(undefined); - }); - - it('marks a newly activated candidate and tracks the previous hash', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000_000); - - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '1111111111111111111111111111111111111111111111111111111111111111', - bundlePath: '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - - await markCandidateActivated('2222222222222222222222222222222222222222222222222222222222222222'); - - expect(readMockJson(STATE_PATH)).toEqual({ - activeHash: '2222222222222222222222222222222222222222222222222222222222222222', - candidateHash: '2222222222222222222222222222222222222222222222222222222222222222', - candidateActivatedAt: 1000, - candidateCommitted: false, - crashCount: 0, - lastLaunchAt: 1000, - lastGoodHash: '1111111111111111111111111111111111111111111111111111111111111111', - }); - - nowSpy.mockRestore(); - }); - - it('returns null when rollback state is missing or malformed', async () => { - await expect(readRollbackState()).resolves.toBeNull(); - - setMockFile(STATE_PATH, '{invalid json'); - await expect(readRollbackState()).resolves.toBeNull(); - }); - - it('commits the active bundle as the last good hash', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(2_000_000); - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - }), - ); - - await commitActiveBundle(); - - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: true, - crashCount: 0, - lastGoodHash: '3333333333333333333333333333333333333333333333333333333333333333', - }) - ); - - nowSpy.mockRestore(); +type RollbackStateModule = typeof import('../../manager/rollbackState'); + +import { resetNativeFsMocks } from '../mocks/native/fs'; + +const ACTIVE_HASH = 'a'.repeat(64); +const FAILED_HASH = 'b'.repeat(64); +const STABLE_HASH = 'c'.repeat(64); +const CANDIDATE_HASH = 'd'.repeat(64); + +const RECOVERY_STATE = { + protocolVersion: 1 as const, + revision: 7, + phase: 'launching' as const, + activeAttempt: { + hash: ACTIVE_HASH, + attemptId: 'attempt-7', + status: 'launching' as const, + unacknowledgedLaunchCount: 1, + }, + quarantinedHashes: [FAILED_HASH], + pendingRecoveryEvents: [ + { + id: 'event-1', + failedHash: FAILED_HASH, + recoveryTarget: 'previous' as const, + recoveredHash: STABLE_HASH, + crashCount: 3, + reason: 'crash_loop' as const, + failedAt: 1_700_000_000, + }, + ], +}; + +function loadRollbackState(options?: { + attempt?: { hash: string; attemptId: string } | null; + markHealthyResult?: boolean; + recoveryState?: typeof RECOVERY_STATE | null; + reportError?: unknown; +}) { + jest.resetModules(); + + const activateStartupCandidateNative = jest.fn(async (hash: string) => ({ + hash, + bundlePath: `/bundles/${hash}/main.jsbundle`, + })); + const getStartupRecoveryAttemptNative = jest.fn(() => + options?.attempt === undefined + ? { hash: ACTIVE_HASH, attemptId: 'attempt-7' } + : options.attempt, + ); + const markStartupHealthyNative = jest.fn(async () => options?.markHealthyResult ?? true); + const getStartupRecoveryStateNative = jest.fn(async () => + options && 'recoveryState' in options ? options.recoveryState ?? null : RECOVERY_STATE, + ); + const setStartupRecoveryRevokedHashesNative = jest.fn(async () => true); + const rollbackStartupBundleNative = jest.fn(async (forceEmbedded: boolean) => ({ + rolledBack: true, + toEmbedded: forceEmbedded, + ...(forceEmbedded ? {} : { hash: STABLE_HASH }), + })); + const acknowledgeStartupRecoveryNative = jest.fn(async () => true); + const reportLocalRollback = jest.fn(async () => { + if (options && 'reportError' in options) throw options.reportError; }); - it('commits cached pointers and skips empty current pointers', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(3_000_000); - - await commitActiveBundle({ currentPointer: null }); - expect(readMockJson(STATE_PATH)).toBeNull(); - - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '4444444444444444444444444444444444444444444444444444444444444444', - candidateCommitted: false, - }), - ); - await commitActiveBundle({ - currentPointer: { - hash: '4444444444444444444444444444444444444444444444444444444444444444', - bundlePath: '/mock/doc/bundle-drop/bundles/4444444444444444444444444444444444444444444444444444444444444444/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', + jest.doMock('../../context', () => ({ + BUNDLE_DROP_ROOT: '/mock/doc/bundle-drop', + bundleDropConfig: { + rollback: { + maxCrashCount: 2, + healthCheckMode: 'manual', + healthyAfterSec: 9, }, - }); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '4444444444444444444444444444444444444444444444444444444444444444', - candidateHash: '4444444444444444444444444444444444444444444444444444444444444444', - candidateCommitted: true, - lastGoodHash: '4444444444444444444444444444444444444444444444444444444444444444', - }), - ); - - nowSpy.mockRestore(); - }); - - it('does not mark non-candidates healthy and ignores mismatched expected hashes', async () => { - await expect(reportActiveBundleHealthy({ currentPointer: null })).resolves.toBe(false); - await expect(isBundleHashFailed(null)).resolves.toBe(false); - - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - }), - ); - - await expect( - reportActiveBundleHealthy( - { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - }, - 'other-hash', - ), - ).resolves.toBe(false); - - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: 'other-hash', - candidateCommitted: false, - }), - ); - await expect( - reportActiveBundleHealthy({ - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - }), - ).resolves.toBe(false); - - resetNativeFsMocks(); - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - await expect(reportActiveBundleHealthy()).resolves.toBe(false); + }, + })); + jest.doMock('../../native/bundleDropNative', () => ({ + acknowledgeStartupRecoveryNative, + activateStartupCandidateNative, + getStartupRecoveryAttemptNative, + getStartupRecoveryStateNative, + markStartupHealthyNative, + rollbackStartupBundleNative, + setStartupRecoveryRevokedHashesNative, + })); + jest.doMock('../../manager/reporting', () => ({ + reportLocalRollback, + })); + + return { + module: require('../../manager/rollbackState') as RollbackStateModule, + mocks: { + acknowledgeStartupRecoveryNative, + activateStartupCandidateNative, + getStartupRecoveryStateNative, + markStartupHealthyNative, + reportLocalRollback, + rollbackStartupBundleNative, + setStartupRecoveryRevokedHashesNative, + }, + }; +} + +describe('manager/rollbackState native recovery coordination', () => { + beforeEach(resetNativeFsMocks); + + afterEach(() => { + jest.resetModules(); + jest.unmock('../../context'); + jest.unmock('../../native/bundleDropNative'); + jest.unmock('../../manager/reporting'); + jest.restoreAllMocks(); }); - it('requests rollback once failed candidate launches reach the crash limit', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(10_000_000); - - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - candidateActivatedAt: 10000, - }) - ); + it('activates candidates with the public rollback configuration', async () => { + const { module, mocks } = loadRollbackState(); - await expect( - evaluateRollbackOnLaunch({ - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }) - ).resolves.toEqual({ - shouldRollback: true, - reason: 'crash_loop', + await expect(module.activateStartupCandidate(CANDIDATE_HASH)).resolves.toEqual({ + hash: CANDIDATE_HASH, + bundlePath: `/bundles/${CANDIDATE_HASH}/main.jsbundle`, }); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 0, - candidateActivatedAt: 9500, - }) - ); - - await expect( - evaluateRollbackOnLaunch({ - maxCrashCount: 5, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }) - ).resolves.toEqual({ shouldRollback: false }); - - nowSpy.mockRestore(); - }); - - it('returns no rollback when there is no active pointer or the crash limit is not reached', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(40_000_000); - - await expect( - evaluateRollbackOnLaunch(DEFAULT_POLICY, { - currentPointer: null, - }), - ).resolves.toEqual({ shouldRollback: false }); - expect(readMockJson(STATE_PATH)).toBeNull(); - - await expect( - evaluateRollbackOnLaunch(DEFAULT_POLICY, { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - rollbackState: { - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }, - }), - ).resolves.toEqual({ shouldRollback: false }); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '3333333333333333333333333333333333333333333333333333333333333333', - crashCount: 2, - lastLaunchAt: 40000, - }), - ); - - nowSpy.mockRestore(); - }); - - it('returns no rollback for safe launches and exposes the configured rollback policy', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(50_000_000); - - setMockConfig({ - rollback: { - maxCrashCount: 7, - healthCheckMode: 'manual', - healthyAfterSec: 12, - }, - }); - expect(getRollbackPolicy()).toEqual({ - maxCrashCount: 7, + expect(mocks.activateStartupCandidateNative).toHaveBeenCalledWith(CANDIDATE_HASH, { + maxCrashCount: 2, healthCheckMode: 'manual', - healthyAfterSec: 12, + healthyAfterSec: 9, }); - - await expect( - rollbackToPreviousIfNeeded(DEFAULT_POLICY, { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - rollbackState: { - candidateHash: 'other-hash', - candidateCommitted: false, - }, - }), - ).resolves.toEqual({ rolledBack: false }); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '3333333333333333333333333333333333333333333333333333333333333333', - lastLaunchAt: 50000, - }), - ); - - nowSpy.mockRestore(); }); - it('reads persisted rollback state and increments the candidate launch count', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(60_000_000); - - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - candidateActivatedAt: 59990, - }), - ); - - await expect( - evaluateRollbackOnLaunch(DEFAULT_POLICY, { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - }), - ).resolves.toEqual({ shouldRollback: false }); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '3333333333333333333333333333333333333333333333333333333333333333', - crashCount: 1, - lastLaunchAt: 60000, - }), - ); + it('reports health only for the launch attempt captured by native', async () => { + const { module, mocks } = loadRollbackState({ markHealthyResult: false }); - nowSpy.mockRestore(); + await expect(module.reportActiveBundleHealthy()).resolves.toBe(false); + expect(mocks.markStartupHealthyNative).toHaveBeenCalledWith({ + hash: ACTIVE_HASH, + attemptId: 'attempt-7', + }); }); - it('rolls back to the previous OTA bundle and restores metadata', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(20_000_000); + it('does not report health when native did not capture an OTA launch attempt', async () => { + const { module, mocks } = loadRollbackState({ attempt: null }); - setMockFile( - BUNDLE_INFO_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - lastInstalledReportedHash: '3333333333333333333333333333333333333333333333333333333333333333', - }), - ); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '1111111111111111111111111111111111111111111111111111111111111111', - bundlePath: '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - setMockFile( - '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/metadata-android.json', - JSON.stringify({ - bundleVersion: 3, - version: 'metadata-version', - runtimeVersion: 'metadata-runtime', - }) - ); - setMockFile( - '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/bundle-manifest.json', - JSON.stringify({ - manifestVersion: 1, - bundleHash: '1111111111111111111111111111111111111111111111111111111111111111', - version: '1.0.3', - runtimeVersion: '1.0.0', - }), - ); - - await expect(rollbackToPreviousOrNative()).resolves.toEqual({ rolledBack: true }); - - expect(readMockJson(CURRENT_POINTER_PATH)).toEqual( - expect.objectContaining({ - hash: '1111111111111111111111111111111111111111111111111111111111111111', - }) - ); - expect(readMockJson(BUNDLE_INFO_PATH)).toEqual( - expect.objectContaining({ - hash: '1111111111111111111111111111111111111111111111111111111111111111', - bundleVersion: 3, - version: '1.0.3', - runtimeVersion: '1.0.0', - pendingApply: false, - lastInstalledReportedHash: '1111111111111111111111111111111111111111111111111111111111111111', - }) - ); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '1111111111111111111111111111111111111111111111111111111111111111', - candidateHash: '1111111111111111111111111111111111111111111111111111111111111111', - candidateCommitted: true, - lastGoodHash: '1111111111111111111111111111111111111111111111111111111111111111', - }) - ); - - nowSpy.mockRestore(); + await expect(module.reportActiveBundleHealthy()).resolves.toBe(false); + expect(mocks.markStartupHealthyNative).not.toHaveBeenCalled(); }); - it('rolls back through the previous-pointer flow when launch evaluation demands it', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(25_000_000); + it('uses native quarantine and native rollback/revocation commands', async () => { + const { module, mocks } = loadRollbackState(); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '1111111111111111111111111111111111111111111111111111111111111111', - bundlePath: '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - setMockFile( - '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/metadata-android.json', - JSON.stringify({ - bundleVersion: 9, - version: '2.0.0', - runtimeVersion: '2.0.0', - }), - ); - - await expect( - rollbackToPreviousIfNeeded( - { - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }, - { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - rollbackState: { - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - candidateActivatedAt: 25000, - }, - }, - ), - ).resolves.toEqual({ + await expect(module.getFailedBundleHashes()).resolves.toEqual([FAILED_HASH]); + await expect(module.isBundleHashFailed(FAILED_HASH)).resolves.toBe(true); + await expect(module.syncVerifiedRevokedHashes([CANDIDATE_HASH])).resolves.toBe(true); + await expect(module.rollbackStartupBundle(true)).resolves.toEqual({ rolledBack: true, - reason: 'crash_loop', + toEmbedded: true, }); - expect(await isBundleHashFailed('3333333333333333333333333333333333333333333333333333333333333333')).toBe(true); - expect(reportLocalRollback).toHaveBeenCalledWith( - '3333333333333333333333333333333333333333333333333333333333333333', - expect.objectContaining({ - reason: 'crash_loop', - previousHash: '1111111111111111111111111111111111111111111111111111111111111111', - }), - ); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - activeHash: '1111111111111111111111111111111111111111111111111111111111111111', - candidateHash: '1111111111111111111111111111111111111111111111111111111111111111', - candidateCommitted: true, - lastGoodHash: '1111111111111111111111111111111111111111111111111111111111111111', - failedBundles: expect.objectContaining({ - '3333333333333333333333333333333333333333333333333333333333333333': expect.objectContaining({ - reason: 'crash_loop', - previousHash: '1111111111111111111111111111111111111111111111111111111111111111', - }), - }), - }), - ); - nowSpy.mockRestore(); + expect(mocks.setStartupRecoveryRevokedHashesNative).toHaveBeenCalledWith([CANDIDATE_HASH]); + expect(mocks.rollbackStartupBundleNative).toHaveBeenCalledWith(true); }); - it('waits for local rollback telemetry before resolving rollback', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(26_000_000); - let finishReport!: () => void; - const reportPromise = new Promise(resolve => { - finishReport = resolve; - }); - (reportLocalRollback as jest.Mock).mockReturnValueOnce(reportPromise); + it('accepts cached recovery state and handles missing hashes and snapshots', async () => { + const { module, mocks } = loadRollbackState({ recoveryState: null }); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '1111111111111111111111111111111111111111111111111111111111111111', - bundlePath: '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); + await expect(module.getFailedBundleHashes(RECOVERY_STATE)).resolves.toEqual([FAILED_HASH]); + await expect(module.getFailedBundleHashes(null)).resolves.toEqual([]); + await expect(module.isBundleHashFailed()).resolves.toBe(false); + await expect(module.reconcileStartupRecovery()).resolves.toBeNull(); - const rollbackPromise = rollbackToPreviousIfNeeded( - { - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }, - { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - rollbackState: { - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }, - }, - ); + expect(mocks.getStartupRecoveryStateNative).toHaveBeenCalledTimes(1); + }); + + it('acknowledges a durable recovery event only after telemetry succeeds', async () => { + const { module, mocks } = loadRollbackState(); - await Promise.resolve(); - let resolved = false; - rollbackPromise.then(() => { - resolved = true; + await module.reconcileStartupRecovery(RECOVERY_STATE, { + hash: FAILED_HASH, + channelName: 'General', + runtimeVersion: '1.0.0', }); - await Promise.resolve(); - expect(resolved).toBe(false); - finishReport(); - await expect(rollbackPromise).resolves.toEqual({ - rolledBack: true, + expect(mocks.reportLocalRollback).toHaveBeenCalledWith(FAILED_HASH, { reason: 'crash_loop', + failedAt: 1_700_000_000, + crashCount: 3, + channelName: 'General', + runtimeVersion: '1.0.0', + previousHash: STABLE_HASH, }); - - nowSpy.mockRestore(); - }); - - it('keeps only the newest failed bundle quarantine records', async () => { - const failedBundles = Object.fromEntries( - Array.from({ length: 20 }, (_, index) => [ - `old-${index}`, - { - reason: 'crash_loop', - failedAt: index + 1, - }, - ]), - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - failedBundles, - }), - ); - setMockFile( - BUNDLE_INFO_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - channelName: 'General', - runtimeVersion: '1.0.0', - }), - ); - - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(70_000_000); - await rollbackToPreviousIfNeeded( - { maxCrashCount: 2, healthCheckMode: 'auto', healthyAfterSec: 0 }, - { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - rollbackState: { - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }, - }, - ); - - const state = readMockJson<{ failedBundles: Record }>(STATE_PATH); - expect(Object.keys(state?.failedBundles || {})).toHaveLength(20); - expect(state?.failedBundles).toHaveProperty('3333333333333333333333333333333333333333333333333333333333333333'); - expect(state?.failedBundles).not.toHaveProperty('old-0'); - nowSpy.mockRestore(); + expect(mocks.acknowledgeStartupRecoveryNative).toHaveBeenCalledWith('event-1'); }); - it('records failed bundles when rollback evaluation reads the current pointer itself', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(80_000_000); + it('acknowledges recovery even when no failed-bundle metadata was captured', async () => { + const { module, mocks } = loadRollbackState(); - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }), - ); + await module.reconcileStartupRecovery(RECOVERY_STATE); - await expect( - rollbackToPreviousIfNeeded({ - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }), - ).resolves.toEqual({ - rolledBack: true, + expect(mocks.reportLocalRollback).toHaveBeenCalledWith(FAILED_HASH, { reason: 'crash_loop', + failedAt: 1_700_000_000, + crashCount: 3, + channelName: undefined, + runtimeVersion: undefined, + previousHash: STABLE_HASH, }); - - await expect(isBundleHashFailed('3333333333333333333333333333333333333333333333333333333333333333')).resolves.toBe(true); - nowSpy.mockRestore(); + expect(mocks.acknowledgeStartupRecoveryNative).toHaveBeenCalledWith('event-1'); }); - it('does not roll back when the active pointer disappears after launch evaluation', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(90_000_000); - const readFileImplementation = mockReadFile.getMockImplementation(); - let currentPointerReads = 0; - - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }), - ); - mockReadFile.mockImplementation(async (path: string) => { - if (path === CURRENT_POINTER_PATH) { - currentPointerReads += 1; - if (currentPointerReads > 1) { - throw new Error('ENOENT'); - } - } - const content = getMockFile(path); - if (content === undefined) { - throw new Error(`ENOENT: ${path}`); - } - return content; - }); - - try { - await expect( - rollbackToPreviousIfNeeded({ - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }), - ).resolves.toEqual({ rolledBack: false }); - expect(reportLocalRollback).not.toHaveBeenCalled(); - } finally { - if (readFileImplementation) { - mockReadFile.mockImplementation(readFileImplementation); - } - nowSpy.mockRestore(); - } - }); + it('leaves recovery telemetry pending when reporting fails', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const { module, mocks } = loadRollbackState({ reportError: new Error('offline') }); - it('does not quarantine the candidate when the local rollback write fails', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(92_000_000); - const writeFileImplementation = mockWriteFile.getMockImplementation(); + await expect(module.reconcileStartupRecovery(RECOVERY_STATE)).resolves.toEqual(RECOVERY_STATE); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '1111111111111111111111111111111111111111111111111111111111111111', - bundlePath: '/mock/doc/bundle-drop/bundles/1111111111111111111111111111111111111111111111111111111111111111/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }), + expect(mocks.acknowledgeStartupRecoveryNative).not.toHaveBeenCalled(); + expect( + require('../mocks/native/fs').readMockJson( + '/mock/doc/bundle-drop/recovery-telemetry-context.json', + ), + ).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + '⚠️ Failed to report BundleDrop startup recovery event event-1:', + 'Error: offline', ); - mockWriteFile.mockImplementation(async (path: string) => { - if (path.includes('current.json')) { - throw new Error('disk full'); - } - }); - - try { - await expect( - rollbackToPreviousIfNeeded( - { - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }, - { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - rollbackState: { - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }, - }, - ), - ).rejects.toThrow('disk full'); - await expect(isBundleHashFailed('3333333333333333333333333333333333333333333333333333333333333333')).resolves.toBe(false); - expect(reportLocalRollback).not.toHaveBeenCalled(); - } finally { - if (writeFileImplementation) { - mockWriteFile.mockImplementation(writeFileImplementation); - } - nowSpy.mockRestore(); - } }); - it('does not quarantine the candidate when native fallback pointer clearing fails', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(93_000_000); - const unlinkImplementation = mockUnlink.getMockImplementation(); + it('reuses failed-bundle context after recovery metadata replaces bundle-info', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const { module, mocks } = loadRollbackState(); + mocks.reportLocalRollback.mockRejectedValueOnce(new Error('offline')); - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }), - ); - mockUnlink.mockImplementation(async (path: string) => { - if (path === CURRENT_POINTER_PATH) { - throw new Error('permission denied'); - } - await unlinkImplementation?.(path); + await module.reconcileStartupRecovery(RECOVERY_STATE, { + hash: FAILED_HASH, + channelName: 'Failed channel', + runtimeVersion: 'failed-runtime', }); - - try { - await expect( - rollbackToPreviousIfNeeded({ - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }), - ).rejects.toThrow('permission denied'); - await expect(isBundleHashFailed('3333333333333333333333333333333333333333333333333333333333333333')).resolves.toBe(false); - expect(reportLocalRollback).not.toHaveBeenCalled(); - expect(getMockFile(CURRENT_POINTER_PATH)).toBeDefined(); - } finally { - if (unlinkImplementation) { - mockUnlink.mockImplementation(unlinkImplementation); - } - nowSpy.mockRestore(); - } - }); - - it('swallows local rollback telemetry failures', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(95_000_000); - (reportLocalRollback as jest.Mock).mockRejectedValueOnce(new Error('network down')); - - await expect( - rollbackToPreviousIfNeeded( - { - maxCrashCount: 2, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }, - { - currentPointer: { - hash: '3333333333333333333333333333333333333333333333333333333333333333', - bundlePath: '/mock/doc/bundle-drop/bundles/3333333333333333333333333333333333333333333333333333333333333333/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }, - rollbackState: { - candidateHash: '3333333333333333333333333333333333333333333333333333333333333333', - candidateCommitted: false, - crashCount: 1, - }, - }, + expect( + require('../mocks/native/fs').readMockJson( + '/mock/doc/bundle-drop/recovery-telemetry-context.json', ), - ).resolves.toEqual({ - rolledBack: true, - reason: 'crash_loop', + ).toEqual({ + schemaVersion: 1, + events: { + 'event-1': { + failedHash: FAILED_HASH, + channelName: 'Failed channel', + runtimeVersion: 'failed-runtime', + }, + }, }); - await Promise.resolve(); - - nowSpy.mockRestore(); - }); - - it('falls back to the native bundle when there is no previous OTA pointer', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(30_000_000); - - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '5555555555555555555555555555555555555555555555555555555555555555', - bundlePath: '/mock/doc/bundle-drop/bundles/5555555555555555555555555555555555555555555555555555555555555555/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - setMockFile( - BUNDLE_INFO_PATH, - JSON.stringify({ - hash: '5555555555555555555555555555555555555555555555555555555555555555', - bundleVersion: 5, - pendingApply: true, - }) - ); - - await expect(rollbackToPreviousOrNative()).resolves.toEqual({ - rolledBack: true, - toNative: true, + await module.reconcileStartupRecovery(RECOVERY_STATE, { + hash: STABLE_HASH, + channelName: 'Recovered channel', + runtimeVersion: 'recovered-runtime', }); - expect(readMockJson(CURRENT_POINTER_PATH)).toBeNull(); - expect(readMockJson(BUNDLE_INFO_PATH)).toEqual( - expect.objectContaining({ - pendingApply: false, - }) - ); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - candidateCommitted: true, - crashCount: 0, - }) - ); - - 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'); + expect(mocks.reportLocalRollback).toHaveBeenLastCalledWith(FAILED_HASH, { + reason: 'crash_loop', + failedAt: 1_700_000_000, + crashCount: 3, + channelName: 'Failed channel', + runtimeVersion: 'failed-runtime', + previousHash: STABLE_HASH, + }); + expect(mocks.acknowledgeStartupRecoveryNative).toHaveBeenCalledWith('event-1'); + expect( + require('../mocks/native/fs').readMockJson( + '/mock/doc/bundle-drop/recovery-telemetry-context.json', + ), + ).toEqual({ schemaVersion: 1, events: {} }); + warnSpy.mockRestore(); }); - it('falls back to native when the previous pointer matches the active bundle', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(32_000_000); - - const activePointer = { - hash: '5555555555555555555555555555555555555555555555555555555555555555', - bundlePath: '/mock/doc/bundle-drop/bundles/5555555555555555555555555555555555555555555555555555555555555555/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }; - setMockFile(CURRENT_POINTER_PATH, JSON.stringify(activePointer)); - setMockFile(PREVIOUS_POINTER_PATH, JSON.stringify(activePointer)); - setMockFile( - BUNDLE_INFO_PATH, - JSON.stringify({ - hash: '5555555555555555555555555555555555555555555555555555555555555555', - bundleVersion: 5, - pendingApply: false, - }) + it('repairs a malformed telemetry context before reporting recovery', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const { module, mocks } = loadRollbackState(); + require('../mocks/native/fs').setMockFile( + '/mock/doc/bundle-drop/recovery-telemetry-context.json', + JSON.stringify({ schemaVersion: 2, events: {} }), ); - await expect(rollbackToPreviousOrNative()).resolves.toEqual({ - rolledBack: true, - toNative: true, + await module.reconcileStartupRecovery(RECOVERY_STATE, { + hash: FAILED_HASH, + channelName: 'General', + runtimeVersion: '1.0.0', }); - expect(readMockJson(CURRENT_POINTER_PATH)).toBeNull(); - expect(readMockJson(BUNDLE_INFO_PATH)).toEqual( - expect.objectContaining({ - pendingApply: false, - }) - ); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toEqual( - expect.objectContaining({ - hash: '5555555555555555555555555555555555555555555555555555555555555555', - }) + expect(warnSpy).toHaveBeenCalledWith( + '⚠️ Ignoring malformed BundleDrop recovery telemetry context:', + expect.any(Error), ); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - candidateCommitted: true, - crashCount: 0, - }) + expect(mocks.reportLocalRollback).toHaveBeenCalledWith( + FAILED_HASH, + expect.objectContaining({ channelName: 'General', runtimeVersion: '1.0.0' }), ); - expect(readMockJson(STATE_PATH)).not.toEqual(expect.objectContaining({ activeHash: '5555555555555555555555555555555555555555555555555555555555555555' })); - expect(readMockJson(STATE_PATH)).not.toEqual(expect.objectContaining({ candidateHash: '5555555555555555555555555555555555555555555555555555555555555555' })); - - nowSpy.mockRestore(); }); - it('falls back to native and clears previous when the previous bundle previously failed', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(33_000_000); - - setMockFile( - CURRENT_POINTER_PATH, - JSON.stringify({ - hash: '5555555555555555555555555555555555555555555555555555555555555555', - bundlePath: '/mock/doc/bundle-drop/bundles/5555555555555555555555555555555555555555555555555555555555555555/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '6666666666666666666666666666666666666666666666666666666666666666', - bundlePath: '/mock/doc/bundle-drop/bundles/6666666666666666666666666666666666666666666666666666666666666666/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }) - ); - setMockFile( - STATE_PATH, - JSON.stringify({ - failedBundles: { - '6666666666666666666666666666666666666666666666666666666666666666': { - reason: 'crash_loop', - failedAt: 32000, + it('prunes telemetry contexts whose native events were already acknowledged', async () => { + const { module } = loadRollbackState({ + recoveryState: { + ...RECOVERY_STATE, + pendingRecoveryEvents: [], + }, + }); + require('../mocks/native/fs').setMockFile( + '/mock/doc/bundle-drop/recovery-telemetry-context.json', + JSON.stringify({ + schemaVersion: 1, + events: { + stale: { + failedHash: CANDIDATE_HASH, + channelName: 'Old channel', }, }, - }) + }), ); - await expect(rollbackToPreviousOrNative()).resolves.toEqual({ - rolledBack: true, - toNative: true, - }); - - expect(readMockJson(CURRENT_POINTER_PATH)).toBeNull(); - expect(readMockJson(PREVIOUS_POINTER_PATH)).toBeNull(); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toEqual( - expect.objectContaining({ - hash: '6666666666666666666666666666666666666666666666666666666666666666', - }) - ); - expect(readMockJson(STATE_PATH)).toEqual( - expect.objectContaining({ - failedBundles: expect.objectContaining({ - '6666666666666666666666666666666666666666666666666666666666666666': expect.any(Object), - }), - candidateCommitted: true, - crashCount: 0, - }) - ); - expect(readMockJson(STATE_PATH)).not.toEqual(expect.objectContaining({ activeHash: '6666666666666666666666666666666666666666666666666666666666666666' })); - expect(readMockJson(STATE_PATH)).not.toEqual(expect.objectContaining({ candidateHash: '6666666666666666666666666666666666666666666666666666666666666666' })); + await module.reconcileStartupRecovery(); - nowSpy.mockRestore(); + expect( + require('../mocks/native/fs').readMockJson( + '/mock/doc/bundle-drop/recovery-telemetry-context.json', + ), + ).toEqual({ schemaVersion: 1, events: {} }); }); - it('reads iOS metadata and tolerates missing or malformed rollback metadata', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(35_000_000); - - setMockPlatform('ios'); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '7777777777777777777777777777777777777777777777777777777777777777', - bundlePath: '/mock/lib/bundle-drop/bundles/7777777777777777777777777777777777777777777777777777777777777777/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - setMockFile( - '/mock/lib/bundle-drop/bundles/7777777777777777777777777777777777777777777777777777777777777777/metadata-ios.json', - JSON.stringify({ - bundleVersion: 4, - version: '1.0.4', - runtimeVersion: '4.0.0', - }), - ); - - await expect(rollbackToPreviousOrNative()).resolves.toEqual({ rolledBack: true }); - expect(readMockJson(BUNDLE_INFO_PATH)).toEqual( - expect.objectContaining({ - hash: '7777777777777777777777777777777777777777777777777777777777777777', - bundleVersion: 4, - version: '1.0.4', - runtimeVersion: '4.0.0', - }), - ); - - resetNativeFsMocks(); - setMockPlatform('android'); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '8888888888888888888888888888888888888888888888888888888888888888', - bundlePath: '/mock/doc/bundle-drop/bundles/8888888888888888888888888888888888888888888888888888888888888888/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); - setMockFile( - '/mock/doc/bundle-drop/bundles/8888888888888888888888888888888888888888888888888888888888888888/metadata-android.json', - '{bad json', - ); + it('continues serializing telemetry mutations after a failed context write', async () => { + const { module, mocks } = loadRollbackState(); + const nativeFs = require('../mocks/native/fs'); + nativeFs.mockWriteFile + .mockRejectedValueOnce(new Error('temporary write failed')) + .mockRejectedValueOnce(new Error('fallback write failed')); + + await expect(module.reconcileStartupRecovery(RECOVERY_STATE, { + hash: FAILED_HASH, + channelName: 'General', + runtimeVersion: '1.0.0', + })).rejects.toThrow('fallback write failed'); + + await expect(module.reconcileStartupRecovery(RECOVERY_STATE, { + hash: FAILED_HASH, + channelName: 'General', + runtimeVersion: '1.0.0', + })).resolves.toEqual(RECOVERY_STATE); + expect(mocks.acknowledgeStartupRecoveryNative).toHaveBeenCalledWith('event-1'); + }); - await expect(rollbackToPreviousOrNative()).resolves.toEqual({ rolledBack: true }); - expect(readMockJson(BUNDLE_INFO_PATH)).toEqual( - expect.objectContaining({ - hash: '8888888888888888888888888888888888888888888888888888888888888888', - pendingApply: false, - }), - ); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toHaveProperty('bundleVersion'); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toHaveProperty('version'); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toHaveProperty('runtimeVersion'); + it('keeps telemetry pending when the failure has no printable error value', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const { module, mocks } = loadRollbackState({ reportError: null }); - resetNativeFsMocks(); - setMockFile( - PREVIOUS_POINTER_PATH, - JSON.stringify({ - hash: '9999999999999999999999999999999999999999999999999999999999999999', - bundlePath: '/mock/doc/bundle-drop/bundles/9999999999999999999999999999999999999999999999999999999999999999/main.jsbundle', - updatedAt: '2026-03-01T00:00:00.000Z', - }), - ); + await expect(module.reconcileStartupRecovery()).resolves.toEqual(RECOVERY_STATE); - await expect(rollbackToPreviousOrNative()).resolves.toEqual({ rolledBack: true }); - expect(readMockJson(BUNDLE_INFO_PATH)).toEqual( - expect.objectContaining({ - hash: '9999999999999999999999999999999999999999999999999999999999999999', - pendingApply: false, - }), + expect(mocks.acknowledgeStartupRecoveryNative).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '⚠️ Failed to report BundleDrop startup recovery event event-1:', + null, ); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toHaveProperty('bundleVersion'); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toHaveProperty('version'); - expect(readMockJson(BUNDLE_INFO_PATH)).not.toHaveProperty('runtimeVersion'); - - nowSpy.mockRestore(); }); }); diff --git a/src/tests/manager/updateCheck.test.ts b/src/tests/manager/updateCheck.test.ts index 5515f65..1f4702c 100644 --- a/src/tests/manager/updateCheck.test.ts +++ b/src/tests/manager/updateCheck.test.ts @@ -25,7 +25,12 @@ import { resetNativeFsMocks, setMockFile, } from '../mocks/native/fs'; -import { mockGetDownloadedBundlePathNative, resetBundleDropNativeMocks } from '../mocks/native/bundleDropNative'; +import { + mockGetDownloadedBundlePathNative, + mockGetStartupRecoveryStateNative, + mockGetStartupRecoverySelectedHashNative, + resetBundleDropNativeMocks, +} from '../mocks/native/bundleDropNative'; import { initializeBundleDropRuntime, resetBundleDropRuntimeForTests } from '../../runtime/initState'; import * as manifestStateModule from '../../runtime-delivery/manifestState'; @@ -37,7 +42,6 @@ jest.mock('../../api/clientApi', () => require('../mocks/api/clientApi')); const BUNDLE_INFO_PATH = '/mock/doc/bundle-info.json'; const CURRENT_POINTER_PATH = '/mock/doc/bundle-drop/current.json'; 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'; @@ -726,21 +730,13 @@ describe('manager/updateCheck', () => { }, }) ); - setMockFile( - STATE_PATH, - JSON.stringify({ - failedBundles: { - 'failed-newer': { - reason: 'crash_loop', - failedAt: 3000, - }, - 'failed-older': { - reason: 'crash_loop', - failedAt: 1000, - }, - }, - }), - ); + mockGetStartupRecoveryStateNative.mockResolvedValue({ + protocolVersion: 1, + revision: 2, + phase: 'idle', + quarantinedHashes: ['failed-newer', 'failed-older'], + pendingRecoveryEvents: [], + }); setMockFile(INSTALL_ID_PATH, 'install-123'); mockGetDownloadedBundlePathNative.mockResolvedValue( '/mock/doc/bundle-drop/bundles/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/main.jsbundle', @@ -803,7 +799,7 @@ describe('manager/updateCheck', () => { expect(statusSpy).toHaveBeenLastCalledWith('⬇️ Update available'); }); - it('does not report a raw current pointer hash when native rejects the bundle path', async () => { + it('uses passive eligibility as the resolve-context fallback for older native adapters', async () => { setMockFile( CURRENT_POINTER_PATH, JSON.stringify({ @@ -829,6 +825,60 @@ describe('manager/updateCheck', () => { ); }); + it('reports the passively eligible pointer hash when the selected-hash constant is missing', async () => { + const pointerHash = v2Hash('a'); + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: pointerHash })); + mockGetStartupRecoverySelectedHashNative.mockReturnValue(undefined); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${pointerHash}/main.jsbundle`, + ); + mockPostOtaResolve.mockResolvedValue({ + data: { action: 'NOOP', reason: 'UP_TO_DATE' }, + } as never); + + await checkForUpdate('General'); + + expect(mockPostOtaResolve).toHaveBeenCalledWith( + 'bundle-drop-app', + expect.objectContaining({ currentHash: pointerHash }), + ); + }); + + it('keeps the runtime-selected hash after passive eligibility becomes null', async () => { + const selectedHash = v2Hash('a'); + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: selectedHash })); + mockGetStartupRecoverySelectedHashNative.mockReturnValue(selectedHash); + mockGetDownloadedBundlePathNative.mockResolvedValue(null); + mockPostOtaResolve.mockResolvedValue({ + data: { action: 'NOOP', reason: 'UP_TO_DATE' }, + } as never); + + await checkForUpdate('General'); + + expect(mockPostOtaResolve).toHaveBeenCalledWith( + 'bundle-drop-app', + expect.objectContaining({ currentHash: selectedHash }), + ); + }); + + it('does not infer an OTA hash when new native explicitly selected embedded', async () => { + setMockFile(CURRENT_POINTER_PATH, JSON.stringify({ hash: v2Hash('a') })); + mockGetStartupRecoverySelectedHashNative.mockReturnValue(null); + mockGetDownloadedBundlePathNative.mockResolvedValue( + `/mock/doc/bundle-drop/bundles/${v2Hash('a')}/main.jsbundle`, + ); + mockPostOtaResolve.mockResolvedValue({ + data: { action: 'NOOP', reason: 'UP_TO_DATE' }, + } as never); + + await checkForUpdate('General'); + + expect(mockPostOtaResolve).toHaveBeenCalledWith( + 'bundle-drop-app', + expect.objectContaining({ currentHash: null }), + ); + }); + it('marks incompatible binaries when the server reports no compatible bundle', async () => { mockPostOtaResolve.mockResolvedValue({ data: { @@ -854,17 +904,13 @@ describe('manager/updateCheck', () => { }); it('converts locally failed install targets into a no-op decision', async () => { - setMockFile( - STATE_PATH, - JSON.stringify({ - failedBundles: { - 'failed-hash': { - reason: 'crash_loop', - failedAt: 1000, - }, - }, - }), - ); + mockGetStartupRecoveryStateNative.mockResolvedValue({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + quarantinedHashes: ['failed-hash'], + pendingRecoveryEvents: [], + }); mockPostOtaResolve.mockResolvedValue({ data: { action: 'INSTALL', diff --git a/src/tests/manager/updateState.test.ts b/src/tests/manager/updateState.test.ts index b18613a..3e48537 100644 --- a/src/tests/manager/updateState.test.ts +++ b/src/tests/manager/updateState.test.ts @@ -7,6 +7,9 @@ const loadUpdateStateModule = (overrides?: { updateBundleInfoError?: Error; reportError?: Error; failedHash?: string; + recoveredManifest?: Record | null; + recoveredMetadata?: Record | null; + platform?: 'ios' | 'android'; }) => { jest.resetModules(); @@ -21,6 +24,8 @@ const loadUpdateStateModule = (overrides?: { throw overrides.updateBundleInfoError; } }); + const writeBundleInfoDurably = jest.fn(async (_info: Record) => undefined); + const deleteBundleInfo = jest.fn(async () => undefined); const reportInstalledIfReady = jest.fn(async (_state?: unknown) => { if (overrides?.reportError) { throw overrides.reportError; @@ -31,10 +36,15 @@ const loadUpdateStateModule = (overrides?: { ); const restartReactNativeNative = jest.fn(); const isBundleHashFailed = jest.fn(async (hash?: string | null) => !!hash && hash === overrides?.failedHash); + const verifyBundleDir = jest.fn(async () => overrides?.recoveredManifest ?? null); + const readJsonFile = jest.fn(async () => overrides?.recoveredMetadata ?? {}); + const fsExists = jest.fn(async () => overrides?.recoveredMetadata != null); jest.doMock('../../bundleInfo', () => ({ readBundleInfo, updateBundleInfo, + writeBundleInfoDurably, + deleteBundleInfo, })); jest.doMock('../../manager/reporting', () => ({ reportInstalledIfReady, @@ -46,6 +56,17 @@ const loadUpdateStateModule = (overrides?: { jest.doMock('../../manager/rollbackState', () => ({ isBundleHashFailed, })); + jest.doMock('../../install/bundleVerification', () => ({ + verifyBundleDir, + readJsonFile, + })); + jest.doMock('../../native/fs', () => ({ + __esModule: true, + default: { exists: fsExists }, + })); + jest.doMock('../../context', () => ({ + platform: overrides?.platform ?? 'android', + })); const module = require('../../manager/updateState') as UpdateStateModule; return { @@ -53,10 +74,14 @@ const loadUpdateStateModule = (overrides?: { mocks: { readBundleInfo, updateBundleInfo, + writeBundleInfoDurably, + deleteBundleInfo, reportInstalledIfReady, getDownloadedBundlePathNative, restartReactNativeNative, isBundleHashFailed, + verifyBundleDir, + readJsonFile, }, }; }; @@ -68,6 +93,9 @@ describe('manager/updateState', () => { jest.unmock('../../manager/reporting'); jest.unmock('../../native/bundleDropNative'); jest.unmock('../../manager/rollbackState'); + jest.unmock('../../install/bundleVerification'); + jest.unmock('../../native/fs'); + jest.unmock('../../context'); }); it('returns cached update state without re-reading dependencies', async () => { @@ -124,11 +152,11 @@ describe('manager/updateState', () => { ); expect(mocks.reportInstalledIfReady).toHaveBeenCalledWith({ hasBundle: true, - info: { + info: expect.objectContaining({ hash: 'hash-1', pendingApply: false, channelName: 'General', - }, + }), }); }); @@ -145,6 +173,7 @@ describe('manager/updateState', () => { expect(mocks.updateBundleInfo).not.toHaveBeenCalled(); expect(mocks.reportInstalledIfReady).not.toHaveBeenCalled(); + expect(mocks.deleteBundleInfo).toHaveBeenCalledTimes(1); const noPending = loadUpdateStateModule({ bundlePath: '/bundles/hash-1/main.jsbundle', @@ -175,7 +204,10 @@ describe('manager/updateState', () => { reportError: new Error('report failed'), }); - await expect(noPendingReportFailure.module.reconcileAppliedBundleOnLaunch()).resolves.toBeUndefined(); + await expect(noPendingReportFailure.module.reconcileAppliedBundleOnLaunch()).resolves.toEqual({ + hash: 'hash-2', + pendingApply: false, + }); await Promise.resolve(); expect(noPendingReportFailure.mocks.updateBundleInfo).not.toHaveBeenCalled(); expect(noPendingReportFailure.mocks.reportInstalledIfReady).toHaveBeenCalledTimes(1); @@ -192,7 +224,10 @@ describe('manager/updateState', () => { reportError: new Error('report failed'), }); - await expect(module.reconcileAppliedBundleOnLaunch()).resolves.toBeUndefined(); + await expect(module.reconcileAppliedBundleOnLaunch()).resolves.toMatchObject({ + hash: 'hash-4', + pendingApply: false, + }); await Promise.resolve(); expect(mocks.updateBundleInfo).toHaveBeenCalledWith( @@ -203,6 +238,114 @@ describe('manager/updateState', () => { expect(mocks.reportInstalledIfReady).toHaveBeenCalledTimes(1); }); + it('reconstructs recovered metadata from the executing verified bundle', async () => { + const { module, mocks } = loadUpdateStateModule({ + bundlePath: '/bundles/stable-hash/main.jsbundle', + bundleInfo: { + hash: 'failed-hash', + channelName: 'Failed candidate channel', + runtimeVersion: 'failed-runtime', + version: 'failed-version', + bundleVersion: 99, + pendingApply: true, + installedReportedHashes: ['stable-hash'], + }, + recoveredManifest: { + bundleHash: 'stable-hash', + runtimeVersion: 'stable-runtime', + version: '2.1.0', + }, + recoveredMetadata: { + bundleVersion: 21, + runtimeVersion: 'metadata-runtime', + version: 'metadata-version', + }, + }); + + await expect(module.reconcileAppliedBundleOnLaunch({ + bundleInfo: { + hash: 'failed-hash', + channelName: 'Failed candidate channel', + runtimeVersion: 'failed-runtime', + pendingApply: true, + installedReportedHashes: ['stable-hash'], + }, + bundlePath: '/bundles/stable-hash/main.jsbundle', + currentHash: 'stable-hash', + })).resolves.toEqual({ + hash: 'stable-hash', + bundleVersion: 21, + version: '2.1.0', + runtimeVersion: 'stable-runtime', + platform: 'android', + installedAt: expect.any(String), + pendingApply: false, + lastInstalledReportedHash: 'stable-hash', + installedReportedHashes: ['stable-hash'], + }); + + expect(mocks.verifyBundleDir).toHaveBeenCalledWith( + '/bundles/stable-hash', + 'stable-hash', + 'android', + ); + expect(mocks.writeBundleInfoDurably).toHaveBeenCalledWith( + expect.not.objectContaining({ channelName: 'Failed candidate channel' }), + ); + }); + + it('reconstructs an iOS recovery without optional metadata and ignores report failures', async () => { + const { module, mocks } = loadUpdateStateModule({ + platform: 'ios', + bundlePath: '/bundles/stable-hash/main.jsbundle', + bundleInfo: { + hash: 'failed-hash', + pendingApply: true, + }, + recoveredManifest: { + bundleHash: 'stable-hash', + runtimeVersion: 'stable-runtime', + version: '2.2.0', + }, + recoveredMetadata: null, + reportError: new Error('offline'), + }); + + await expect(module.reconcileAppliedBundleOnLaunch({ + bundleInfo: { hash: 'failed-hash', pendingApply: true }, + bundlePath: '/bundles/stable-hash/main.jsbundle', + currentHash: 'stable-hash', + })).resolves.toEqual({ + hash: 'stable-hash', + bundleVersion: undefined, + version: '2.2.0', + runtimeVersion: 'stable-runtime', + platform: 'ios', + installedAt: expect.any(String), + pendingApply: false, + lastInstalledReportedHash: undefined, + installedReportedHashes: undefined, + }); + await Promise.resolve(); + + expect(mocks.readJsonFile).not.toHaveBeenCalled(); + expect(mocks.reportInstalledIfReady).toHaveBeenCalledTimes(1); + }); + + it('returns null metadata when a downloaded bundle has no stored bundle info', async () => { + const { module, mocks } = loadUpdateStateModule({ + bundlePath: '/bundles/untracked/main.jsbundle', + bundleInfo: null, + }); + + await expect(module.reconcileAppliedBundleOnLaunch()).resolves.toBeNull(); + expect(mocks.reportInstalledIfReady).toHaveBeenCalledWith({ + hasBundle: true, + info: null, + pendingApply: false, + }); + }); + it('returns noBundle and alreadyApplied when apply preconditions fail', async () => { const noBundle = loadUpdateStateModule({ bundlePath: null, diff --git a/src/tests/metro.test.ts b/src/tests/metro.test.ts index 1bbb3ab..44317a3 100644 --- a/src/tests/metro.test.ts +++ b/src/tests/metro.test.ts @@ -12,6 +12,10 @@ jest.mock('../expo', () => ({ import { withBundleDrop, withBundleDropExpo } from '../metro'; import type { ExpoBuildIdentity } from '../expo'; +import { + LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH, + RUNTIME_DELIVERY_BOOTSTRAP_PATH, +} from '../runtime-delivery/bootstrapConfig'; const identity = (platform: 'ios' | 'android'): ExpoBuildIdentity => { const withoutHash: Omit = { @@ -47,7 +51,7 @@ describe('withBundleDropExpo', () => { "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'), { + fs.writeJsonSync(path.join(root, RUNTIME_DELIVERY_BOOTSTRAP_PATH), { schemaVersion: 1, project: { serverUrl: 'https://api.example.com', @@ -110,6 +114,7 @@ describe('withBundleDropExpo', () => { ); const generatedPath = path.join(root, '.bundle-drop/generated/bundle.drop.config.js'); + expect(fs.readFileSync(generatedPath, 'utf8').split('\n')[0]).toBe('/* eslint-disable */'); expect(result).toEqual({ transformer: { minifierPath: 'custom' }, resolver: { @@ -215,7 +220,7 @@ describe('withBundleDropExpo', () => { 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 bootstrapPath = path.join(root, RUNTIME_DELIVERY_BOOTSTRAP_PATH); const bootstrap = fs.readJsonSync(bootstrapPath); bootstrap.project.projectSlug = 'other-app'; fs.writeJsonSync(bootstrapPath, bootstrap); @@ -224,7 +229,7 @@ describe('withBundleDropExpo', () => { 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.removeSync(path.join(root, RUNTIME_DELIVERY_BOOTSTRAP_PATH)); 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", @@ -247,7 +252,7 @@ describe('withBundleDropExpo', () => { 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.removeSync(path.join(invalidRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH)); fs.writeFileSync( path.join(invalidRoot, 'bundle.drop.config.js'), "module.exports = { serverUrl: 'https://api.example.com' };\n", @@ -256,4 +261,21 @@ describe('withBundleDropExpo', () => { 'must define serverUrl, org.slug, and project.slug', ); }); + + it('accepts a legacy bootstrap without rewriting project files', () => { + const root = fixture(); + const lockPath = path.join(root, RUNTIME_DELIVERY_BOOTSTRAP_PATH); + const legacyPath = path.join(root, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH); + fs.moveSync(lockPath, legacyPath); + + withBundleDrop({}, { projectRoot: root }); + + expect(fs.existsSync(lockPath)).toBe(false); + expect(fs.existsSync(legacyPath)).toBe(true); + expect(readGeneratedConfig(root)).toEqual(expect.objectContaining({ + runtimeDelivery: expect.objectContaining({ + manifestBaseUrl: 'https://manifests.example.com', + }), + })); + }); }); diff --git a/src/tests/mocks/modules/react-native.ts b/src/tests/mocks/modules/react-native.ts index ce3e6ae..e4158c5 100644 --- a/src/tests/mocks/modules/react-native.ts +++ b/src/tests/mocks/modules/react-native.ts @@ -18,6 +18,9 @@ export const NativeModules = { otaStartupEnabled: true, }, BundleDrop: { + startupRecoveryProtocolVersion: 1, + startupRecoveryAttemptHash: null, + startupRecoveryAttemptId: null, DocumentDirectoryPath: '/mock/doc', LibraryDirectoryPath: '/mock/lib', fsExists: jest.fn(async (_path: string) => false), @@ -53,6 +56,25 @@ export const NativeModules = { _timeoutMs: number, ) => undefined), getDownloadedBundlePath: jest.fn(async () => null), + activateStartupCandidate: jest.fn(async (hash: string) => ({ + hash, + bundlePath: `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`, + })), + markStartupHealthy: jest.fn(async () => true), + getStartupRecoveryState: jest.fn(async () => ({ + protocolVersion: 1, + revision: 0, + phase: 'idle', + quarantinedHashes: [], + pendingRecoveryEvents: [], + })), + setStartupRecoveryRevokedHashes: jest.fn(async () => true), + acknowledgeStartupRecovery: jest.fn(async () => true), + rollbackStartupBundle: jest.fn(async (forceEmbedded: boolean) => ({ + rolledBack: true, + toEmbedded: forceEmbedded, + ...(forceEmbedded ? {} : { hash: 'c'.repeat(64) }), + })), getImageManifestSync: jest.fn(() => null), getImageManifest: jest.fn(async () => null), restartReactNative: jest.fn(), diff --git a/src/tests/mocks/native/bundleDropNative.ts b/src/tests/mocks/native/bundleDropNative.ts index 5fe92f7..9cd3bf7 100644 --- a/src/tests/mocks/native/bundleDropNative.ts +++ b/src/tests/mocks/native/bundleDropNative.ts @@ -1,10 +1,47 @@ export const mockGetDownloadedBundlePathNative = jest.fn, []>(); export const mockRestartReactNativeNative = jest.fn(); +export const mockActivateStartupCandidateNative = jest.fn, [string, unknown]>(); +export const mockMarkStartupHealthyNative = jest.fn, [unknown]>(); +export const mockGetStartupRecoveryStateNative = jest.fn, []>(); +export const mockSetStartupRecoveryRevokedHashesNative = jest.fn, [string[]]>(); +export const mockAcknowledgeStartupRecoveryNative = jest.fn, [string]>(); +export const mockRollbackStartupBundleNative = jest.fn, [boolean]>(); +export const mockGetStartupRecoveryAttemptNative = jest.fn(); +export const mockGetStartupRecoverySelectedHashNative = jest.fn(); export const resetBundleDropNativeMocks = () => { mockGetDownloadedBundlePathNative.mockReset(); mockGetDownloadedBundlePathNative.mockResolvedValue(null); mockRestartReactNativeNative.mockReset(); + mockActivateStartupCandidateNative.mockReset(); + mockActivateStartupCandidateNative.mockImplementation(async hash => ({ + hash, + bundlePath: `/mock/doc/bundle-drop/bundles/${hash}/main.jsbundle`, + })); + mockMarkStartupHealthyNative.mockReset(); + mockMarkStartupHealthyNative.mockResolvedValue(true); + mockGetStartupRecoveryStateNative.mockReset(); + mockGetStartupRecoveryStateNative.mockResolvedValue({ + protocolVersion: 1, + revision: 0, + phase: 'idle', + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + mockSetStartupRecoveryRevokedHashesNative.mockReset(); + mockSetStartupRecoveryRevokedHashesNative.mockResolvedValue(true); + mockAcknowledgeStartupRecoveryNative.mockReset(); + mockAcknowledgeStartupRecoveryNative.mockResolvedValue(true); + mockRollbackStartupBundleNative.mockReset(); + mockRollbackStartupBundleNative.mockResolvedValue({ + rolledBack: true, + toEmbedded: false, + hash: 'c'.repeat(64), + }); + mockGetStartupRecoveryAttemptNative.mockReset(); + mockGetStartupRecoveryAttemptNative.mockReturnValue(null); + mockGetStartupRecoverySelectedHashNative.mockReset(); + mockGetStartupRecoverySelectedHashNative.mockReturnValue(undefined); }; resetBundleDropNativeMocks(); @@ -12,3 +49,15 @@ resetBundleDropNativeMocks(); export const getDownloadedBundlePathNative = () => mockGetDownloadedBundlePathNative(); export const restartReactNativeNative = () => mockRestartReactNativeNative(); +export const activateStartupCandidateNative = (hash: string, policy: unknown) => + mockActivateStartupCandidateNative(hash, policy); +export const markStartupHealthyNative = (attempt: unknown) => mockMarkStartupHealthyNative(attempt); +export const getStartupRecoveryStateNative = () => mockGetStartupRecoveryStateNative(); +export const setStartupRecoveryRevokedHashesNative = (hashes: string[]) => + mockSetStartupRecoveryRevokedHashesNative(hashes); +export const acknowledgeStartupRecoveryNative = (eventId: string) => + mockAcknowledgeStartupRecoveryNative(eventId); +export const rollbackStartupBundleNative = (forceEmbedded: boolean) => + mockRollbackStartupBundleNative(forceEmbedded); +export const getStartupRecoveryAttemptNative = () => mockGetStartupRecoveryAttemptNative(); +export const getStartupRecoverySelectedHashNative = () => mockGetStartupRecoverySelectedHashNative(); diff --git a/src/tests/native/bundleDropNative.test.ts b/src/tests/native/bundleDropNative.test.ts index eb5e51c..e32442a 100644 --- a/src/tests/native/bundleDropNative.test.ts +++ b/src/tests/native/bundleDropNative.test.ts @@ -1,5 +1,14 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + type NativeModule = typeof import('../../native/bundleDropNative'); +const ACTIVE_HASH = 'a'.repeat(64); +const FAILED_HASH = 'b'.repeat(64); +const STABLE_HASH = 'c'.repeat(64); +const CANDIDATE_HASH = 'd'.repeat(64); +const LATER_HASH = 'e'.repeat(64); + const loadBundleDropNativeModule = ( configure?: (deps: { NativeModules: any }) => void ) => { @@ -142,4 +151,461 @@ describe('native/bundleDropNative', () => { debugSpy.mockRestore(); delete (globalThis as { __DEV__?: boolean }).__DEV__; }); + + it('captures the native launch attempt once for the current JS runtime', () => { + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.startupRecoveryAttemptHash = ACTIVE_HASH; + NativeModules.BundleDrop.startupRecoveryAttemptId = 'attempt-1'; + }); + const reactNative = require('react-native') as typeof import('react-native'); + + reactNative.NativeModules.BundleDrop.startupRecoveryAttemptHash = LATER_HASH; + reactNative.NativeModules.BundleDrop.startupRecoveryAttemptId = 'attempt-2'; + + expect(nativeModule.getStartupRecoveryAttemptNative()).toEqual({ + hash: ACTIVE_HASH, + attemptId: 'attempt-1', + }); + + reactNative.NativeModules.BundleDrop.startupRecoveryAttemptHash = null; + reactNative.NativeModules.BundleDrop.startupRecoveryAttemptId = null; + }); + + it('captures and validates the hash selected for the current JS runtime', () => { + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.startupRecoverySelectedHash = ACTIVE_HASH; + }); + const reactNative = require('react-native') as typeof import('react-native'); + + reactNative.NativeModules.BundleDrop.startupRecoverySelectedHash = LATER_HASH; + expect(nativeModule.getStartupRecoverySelectedHashNative()).toBe(ACTIVE_HASH); + + delete reactNative.NativeModules.BundleDrop.startupRecoverySelectedHash; + const missing = loadBundleDropNativeModule(); + expect(missing.getStartupRecoverySelectedHashNative()).toBeUndefined(); + + const embedded = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.startupRecoverySelectedHash = null; + }); + expect(embedded.getStartupRecoverySelectedHashNative()).toBeNull(); + + const malformed = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.startupRecoverySelectedHash = 'not-a-hash'; + }); + expect(malformed.getStartupRecoverySelectedHashNative()).toBeNull(); + + delete reactNative.NativeModules.BundleDrop.startupRecoverySelectedHash; + }); + + it('warns once and does not fall back when the native recovery protocol is missing', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.startupRecoveryProtocolVersion = 0; + }); + + expect(nativeModule.isStartupRecoveryAvailableNative()).toBe(false); + nativeModule.warnIfStartupRecoveryUnavailableNative(); + await expect(nativeModule.activateStartupCandidateNative(CANDIDATE_HASH, { + maxCrashCount: 3, + healthCheckMode: 'auto', + healthyAfterSec: 0, + })).resolves.toBeNull(); + await expect(nativeModule.markStartupHealthyNative({ + hash: ACTIVE_HASH, + attemptId: 'attempt', + })).resolves.toBe(false); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + await expect(nativeModule.setStartupRecoveryRevokedHashesNative([FAILED_HASH])).resolves.toBe(false); + await expect(nativeModule.acknowledgeStartupRecoveryNative('event')).resolves.toBe(false); + await expect(nativeModule.rollbackStartupBundleNative(false)).resolves.toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + + const reactNative = require('react-native') as typeof import('react-native'); + reactNative.NativeModules.BundleDrop.startupRecoveryProtocolVersion = 1; + }); + + it('normalizes recovery snapshots and propagates native boolean results', async () => { + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.markStartupHealthy = jest.fn(async () => false); + NativeModules.BundleDrop.setStartupRecoveryRevokedHashes = jest.fn(async () => false); + NativeModules.BundleDrop.acknowledgeStartupRecovery = jest.fn(async () => false); + NativeModules.BundleDrop.getStartupRecoveryState = jest.fn(async () => ({ + protocolVersion: 1, + revision: 4, + phase: 'launching', + activeAttempt: { + hash: ACTIVE_HASH, + attemptId: 'attempt-4', + status: 'launching', + unacknowledgedLaunchCount: 1, + }, + quarantinedHashes: [FAILED_HASH, FAILED_HASH, 'invalid'], + pendingRecoveryEvents: [ + { + id: 'event-1', + failedHash: FAILED_HASH, + recoveryTarget: 'previous', + recoveredHash: STABLE_HASH, + crashCount: 3, + reason: 'crash_loop', + failedAt: 1_700_000_000, + }, + { id: '', failedHash: 'invalid' }, + ], + })); + }); + + await expect(nativeModule.markStartupHealthyNative({ + hash: ACTIVE_HASH, + attemptId: 'attempt-4', + })).resolves.toBe(false); + nativeModule.warnIfStartupRecoveryUnavailableNative(); + await expect( + nativeModule.setStartupRecoveryRevokedHashesNative([FAILED_HASH, FAILED_HASH]), + ).resolves.toBe(false); + await expect(nativeModule.acknowledgeStartupRecoveryNative('event-1')).resolves.toBe(false); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toEqual({ + protocolVersion: 1, + revision: 4, + phase: 'launching', + activeAttempt: { + hash: ACTIVE_HASH, + attemptId: 'attempt-4', + status: 'launching', + unacknowledgedLaunchCount: 1, + }, + quarantinedHashes: [FAILED_HASH], + pendingRecoveryEvents: [ + { + id: 'event-1', + failedHash: FAILED_HASH, + recoveryTarget: 'previous', + recoveredHash: STABLE_HASH, + crashCount: 3, + reason: 'crash_loop', + failedAt: 1_700_000_000, + }, + ], + }); + }); + + it('normalizes the shared startup recovery v1 contract fixture', async () => { + const contract = JSON.parse( + readFileSync(join(process.cwd(), 'test-fixtures/startup-recovery-contract-v1.json'), 'utf8'), + ); + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.getStartupRecoveryState = jest.fn(async () => contract); + }); + + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toEqual({ + protocolVersion: 1, + revision: 7, + phase: 'launching', + candidateHash: 'a'.repeat(64), + stableHash: 'c'.repeat(64), + activeAttempt: { + hash: 'a'.repeat(64), + attemptId: 'attempt-contract-v1', + status: 'launching', + unacknowledgedLaunchCount: 2, + }, + policy: { + maxCrashCount: 3, + healthCheckMode: 'manual', + healthyAfterSec: 4.5, + }, + quarantinedHashes: ['b'.repeat(64)], + pendingRecoveryEvents: [{ + id: 'event-contract-v1', + failedHash: 'a'.repeat(64), + recoveryTarget: 'previous', + recoveredHash: 'c'.repeat(64), + crashCount: 3, + reason: 'crash_loop', + failedAt: 1_700_000_000, + }], + }); + }); + + it('rejects malformed recovery snapshots and filters malformed events', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const getState = jest.fn, []>(); + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.getStartupRecoveryState = getState; + }); + + getState.mockResolvedValueOnce(null); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ protocolVersion: 0 }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: -1, + phase: 'idle', + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'unknown', + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + candidateHash: 'invalid', + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + policy: 'invalid', + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + policy: { maxCrashCount: -1, healthCheckMode: 'auto', healthyAfterSec: 0 }, + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + policy: { maxCrashCount: 1, healthCheckMode: 'auto', healthyAfterSec: -1 }, + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + quarantinedHashes: 'invalid', + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + quarantinedHashes: [], + pendingRecoveryEvents: 'invalid', + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'armed', + policy: { + maxCrashCount: 1, + healthCheckMode: 'unknown', + healthyAfterSec: 0, + }, + quarantinedHashes: [], + pendingRecoveryEvents: [], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 1, + phase: 'idle', + activeAttempt: 'invalid', + quarantinedHashes: 'invalid', + pendingRecoveryEvents: 'invalid', + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toBeNull(); + + getState.mockResolvedValueOnce({ + protocolVersion: 1, + revision: 2, + phase: 'idle', + activeAttempt: null, + quarantinedHashes: [], + pendingRecoveryEvents: [ + null, + { + id: 'missing-failed-hash', + failedHash: '', + recoveryTarget: 'embedded', + crashCount: 1, + reason: 'crash_loop', + failedAt: 1, + }, + { + id: 'invalid-crash-count', + failedHash: FAILED_HASH, + recoveryTarget: 'embedded', + crashCount: -1, + reason: 'crash_loop', + failedAt: 1, + }, + { + id: 'invalid-failed-at', + failedHash: FAILED_HASH, + recoveryTarget: 'embedded', + crashCount: 1, + reason: 'crash_loop', + failedAt: -1, + }, + { + id: 'invalid-reason', + failedHash: FAILED_HASH, + recoveryTarget: 'embedded', + crashCount: 1, + reason: 'other', + failedAt: 1, + }, + { + id: 'invalid-target', + failedHash: FAILED_HASH, + recoveryTarget: 'other', + crashCount: 1, + reason: 'crash_loop', + failedAt: 1, + }, + { + id: 'missing-previous-target', + failedHash: FAILED_HASH, + recoveryTarget: 'previous', + crashCount: 1, + reason: 'crash_loop', + failedAt: 1, + }, + { + id: 'invalid-recovered-hash', + failedHash: FAILED_HASH, + recoveryTarget: 'embedded', + recoveredHash: '', + crashCount: 1, + reason: 'crash_loop', + failedAt: 1, + }, + { + id: 'embedded-event', + failedHash: FAILED_HASH, + recoveryTarget: 'embedded', + crashCount: 1, + reason: 'crash_loop', + failedAt: 1, + }, + ], + }); + await expect(nativeModule.getStartupRecoveryStateNative()).resolves.toEqual({ + protocolVersion: 1, + revision: 2, + phase: 'idle', + quarantinedHashes: [], + pendingRecoveryEvents: [{ + id: 'embedded-event', + failedHash: FAILED_HASH, + recoveryTarget: 'embedded', + crashCount: 1, + reason: 'crash_loop', + failedAt: 1, + }], + }); + + expect(warnSpy).toHaveBeenCalledTimes(12); + warnSpy.mockRestore(); + }); + + it('returns null for missing launch attempts and malformed transaction results', async () => { + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.startupRecoveryAttemptHash = null; + NativeModules.BundleDrop.startupRecoveryAttemptId = null; + NativeModules.BundleDrop.activateStartupCandidate = jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ hash: CANDIDATE_HASH }); + NativeModules.BundleDrop.rollbackStartupBundle = jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ rolledBack: true, toEmbedded: false }) + .mockResolvedValueOnce({ rolledBack: true, toEmbedded: false, hash: '' }); + }); + const policy = { + maxCrashCount: 3, + healthCheckMode: 'manual' as const, + healthyAfterSec: 4, + }; + + expect(nativeModule.getStartupRecoveryAttemptNative()).toBeNull(); + await expect(nativeModule.activateStartupCandidateNative(CANDIDATE_HASH, policy)) + .resolves.toBeNull(); + await expect(nativeModule.activateStartupCandidateNative(CANDIDATE_HASH, policy)) + .resolves.toBeNull(); + await expect(nativeModule.rollbackStartupBundleNative(false)).resolves.toBeNull(); + await expect(nativeModule.rollbackStartupBundleNative(false)).resolves.toEqual({ + rolledBack: true, + toEmbedded: false, + }); + await expect(nativeModule.rollbackStartupBundleNative(false)).resolves.toBeNull(); + }); + + it('normalizes activation and rollback transaction results', async () => { + const nativeModule = loadBundleDropNativeModule(({ NativeModules }) => { + NativeModules.BundleDrop.activateStartupCandidate = jest.fn(async () => ({ + hash: CANDIDATE_HASH, + bundlePath: `/bundles/${CANDIDATE_HASH}/main.jsbundle`, + })); + NativeModules.BundleDrop.rollbackStartupBundle = jest.fn(async () => ({ + rolledBack: true, + toEmbedded: false, + hash: STABLE_HASH, + })); + }); + + await expect(nativeModule.activateStartupCandidateNative(CANDIDATE_HASH, { + maxCrashCount: 3, + healthCheckMode: 'auto', + healthyAfterSec: 0, + })).resolves.toEqual({ + hash: CANDIDATE_HASH, + bundlePath: `/bundles/${CANDIDATE_HASH}/main.jsbundle`, + }); + await expect(nativeModule.rollbackStartupBundleNative(false)).resolves.toEqual({ + rolledBack: true, + toEmbedded: false, + hash: STABLE_HASH, + }); + }); + + it.each([ + [{ maxCrashCount: -1, healthCheckMode: 'auto' as const, healthyAfterSec: 0 }, 'maxCrashCount'], + [{ maxCrashCount: 1.5, healthCheckMode: 'auto' as const, healthyAfterSec: 0 }, 'maxCrashCount'], + [{ maxCrashCount: 2_147_483_648, healthCheckMode: 'auto' as const, healthyAfterSec: 0 }, 'maxCrashCount'], + [{ maxCrashCount: 1, healthCheckMode: 'auto' as const, healthyAfterSec: -1 }, 'healthyAfterSec'], + [{ maxCrashCount: 1, healthCheckMode: 'auto' as const, healthyAfterSec: Number.NaN }, 'healthyAfterSec'], + ])('rejects invalid startup policy before calling native: %j', async (policy, field) => { + const nativeModule = loadBundleDropNativeModule(); + await expect(nativeModule.activateStartupCandidateNative(CANDIDATE_HASH, policy)) + .rejects.toThrow(field); + expect(require('react-native').NativeModules.BundleDrop.activateStartupCandidate) + .not.toHaveBeenCalled(); + }); }); diff --git a/src/tests/native/expoNativeIsolation.test.ts b/src/tests/native/expoNativeIsolation.test.ts index 0e1206b..a2e5f01 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.5.0'); + expect(packageManifest.nativeVersion).toBe('0.6.0'); expect(barePodspec).toContain('native_version = package["nativeVersion"] || package["version"]'); expect(expoPodspec).toContain('native_version = package["nativeVersion"] || package["version"]'); expect(expoAndroidBuild).toContain( @@ -34,6 +34,34 @@ describe('Expo native target isolation', () => { expect(reactNativeConfig).toContain("sourceDir: 'android'"); }); + it('embeds the literal bare runtime identity in native application builds', () => { + const barePodspec = readPackageFile('BundleDrop.podspec'); + const bareAndroidBuild = readPackageFile('android/build.gradle'); + + expect(barePodspec).toContain('write-runtime-identity.js'); + expect(barePodspec).toContain('"--platform", "ios"'); + expect(barePodspec).toContain('"bundle-drop-build-identity.json"'); + expect(barePodspec).toContain('Digest::SHA256.hexdigest(project_root)[0, 16]'); + expect(barePodspec).toContain('"runtime-identity"'); + expect(barePodspec).toContain('s.resources = native_runtime_identity_resource'); + expect(barePodspec).toContain('s.script_phase = {'); + expect(barePodspec).toContain(':execution_position => :before_compile'); + expect(barePodspec).toContain('Regenerate Bundle Drop runtime identity'); + expect(barePodspec).toContain('"${NODE_BINARY:-node}"'); + expect(barePodspec).not.toContain(':output_files'); + expect(barePodspec).not.toContain('return nil unless File.file?(config_path)'); + expect(barePodspec).toContain('"source" => "unconfigured"'); + expect(barePodspec).toContain('FileUtils.mkdir_p(File.dirname(output_path))'); + expect(barePodspec).toContain('return nil if identity["source"] == "expo"'); + expect(bareAndroidBuild).toContain('BundleDropNativeIdentityCommandExecutor'); + expect(bareAndroidBuild).toContain('generateBundleDropNativeRuntimeIdentity'); + expect(bareAndroidBuild).toContain('bundle-drop/build-identity.json'); + expect(bareAndroidBuild).toContain('android.sourceSets.main.assets.srcDir'); + expect(bareAndroidBuild).toContain('rootProject.findProject(":bundledrop-expo") == null'); + expect(bareAndroidBuild).toContain('abstract ExecOperations getExecOperations()'); + expect(bareAndroidBuild).not.toMatch(/\bproject\.exec\s*\{/); + }); + it('keeps the iOS adapter in an isolated pod that depends on the unchanged core pod', () => { const moduleConfig = JSON.parse(readPackageFile('expo-module.config.json')); const expoPodspec = readPackageFile('BundleDropExpo.podspec'); @@ -49,6 +77,7 @@ describe('Expo native target isolation', () => { expect(expoPodspec).toContain('s.dependency "ExpoModulesCore"'); expect(expoPodspec).toContain('s.dependency "BundleDrop"'); expect(expoPodspec).not.toContain('"ios/**/*.{h,m,mm,swift}"'); + expect(expoPodspec).not.toContain('Regenerate Bundle Drop runtime identity'); expect(adapter).toContain('import BundleDrop'); expect(adapter).toContain('BundleDropLocatorCore.bundleURL()'); expect(adapter).toContain('EXAppDefines.APP_DEBUG'); diff --git a/src/tests/runtime-delivery/bootstrapConfig.test.ts b/src/tests/runtime-delivery/bootstrapConfig.test.ts index 2f62f5b..c3e8607 100644 --- a/src/tests/runtime-delivery/bootstrapConfig.test.ts +++ b/src/tests/runtime-delivery/bootstrapConfig.test.ts @@ -3,12 +3,16 @@ import path from 'path'; import { addRuntimeDeliveryBootstrapGitignoreRules, - createGeneratedRuntimeDeliveryBootstrap, + createRuntimeDeliveryBootstrapLockfile, ensureRuntimeDeliveryBootstrapGitignore, - parseGeneratedRuntimeDeliveryBootstrap, - readGeneratedRuntimeDeliveryBootstrap, - removeGeneratedRuntimeDeliveryBootstrap, - writeGeneratedRuntimeDeliveryBootstrap, + inspectRuntimeDeliveryBootstrap, + LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH, + parseRuntimeDeliveryBootstrapLockfile, + readRuntimeDeliveryBootstrap, + removeAllRuntimeDeliveryBootstraps, + removeRuntimeDeliveryBootstrapLockfile, + RUNTIME_DELIVERY_BOOTSTRAP_PATH, + writeRuntimeDeliveryBootstrapLockfile, } from '../../runtime-delivery/bootstrapConfig'; import { createTempProjectDir, removeTempDir } from '../utils/tempDir'; @@ -42,7 +46,7 @@ describe('runtime-delivery/bootstrapConfig', () => { it('creates, atomically writes, and identity-validates a neutral bootstrap', async () => { const projectRoot = createTempProjectDir(); roots.push(projectRoot); - const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery }); + const bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery }); expect(bootstrap).toEqual(expect.objectContaining({ schemaVersion: 1, project: { @@ -56,13 +60,13 @@ describe('runtime-delivery/bootstrapConfig', () => { })); expect(bootstrap?.runtimeDelivery).not.toHaveProperty('mode'); - await writeGeneratedRuntimeDeliveryBootstrap({ projectRoot, bootstrap: bootstrap! }); - expect(readGeneratedRuntimeDeliveryBootstrap({ + await writeRuntimeDeliveryBootstrapLockfile({ projectRoot, bootstrap: bootstrap! }); + expect(readRuntimeDeliveryBootstrap({ projectRoot, expectedIdentity: identity, })).toEqual(bootstrap); expect(fs.readdirSync(path.join(projectRoot, '.bundle-drop'))).toEqual([ - 'runtime-delivery.generated.json', + 'runtime-delivery.lock.json', ]); }); @@ -77,17 +81,51 @@ describe('runtime-delivery/bootstrapConfig', () => { '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', + '!.bundle-drop/runtime-delivery.lock.json\n', ); expect(addRuntimeDeliveryBootstrapGitignoreRules(updated)).toBe(updated); }); + it('repairs the legacy gitignore marker without duplicating the managed block', () => { + const legacy = + '# 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(legacy)).toBe( + '# Bundle Drop: commit the public trust bootstrap; ignore generated runtime artifacts.\n' + + '!.bundle-drop/\n.bundle-drop/*\n' + + '!.bundle-drop/runtime-delivery.lock.json\n', + ); + }); + + it('repairs commented and shadowed rules with one canonical final managed block', () => { + const shadowed = + 'node_modules/\n' + + '# !.bundle-drop/runtime-delivery.lock.json\n' + + '!.bundle-drop/runtime-delivery.lock.json\n' + + '.bundle-drop/\n'; + + const repaired = addRuntimeDeliveryBootstrapGitignoreRules(shadowed); + + expect(repaired).toBe( + 'node_modules/\n' + + '# !.bundle-drop/runtime-delivery.lock.json\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.lock.json\n', + ); + expect(repaired.match(/^!\.bundle-drop\/runtime-delivery\.lock\.json$/gm)).toHaveLength(1); + expect(addRuntimeDeliveryBootstrapGitignoreRules(repaired)).toBe(repaired); + }); + it('rejects shadow promotion, private key material, and copied project identity', () => { - expect(createGeneratedRuntimeDeliveryBootstrap({ + expect(createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery: { ...runtimeDelivery, mode: 'shadow' }, })).toBeUndefined(); - expect(createGeneratedRuntimeDeliveryBootstrap({ + expect(createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery: { ...runtimeDelivery, @@ -95,8 +133,8 @@ describe('runtime-delivery/bootstrapConfig', () => { }, })).toBeUndefined(); - const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery })!; - expect(() => parseGeneratedRuntimeDeliveryBootstrap(bootstrap, { + const bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery })!; + expect(() => parseRuntimeDeliveryBootstrapLockfile(bootstrap, { ...identity, projectSlug: 'other-app', })).toThrow('belongs to a different'); @@ -108,7 +146,7 @@ describe('runtime-delivery/bootstrapConfig', () => { projectId: 'project-id-1', orgId: 'org-id-1', }; - const bootstrap = createGeneratedRuntimeDeliveryBootstrap({ + const bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity: stableIdentity, runtimeDelivery, })!; @@ -120,15 +158,15 @@ describe('runtime-delivery/bootstrapConfig', () => { projectId: 'project-id-1', orgId: 'org-id-1', }); - expect(parseGeneratedRuntimeDeliveryBootstrap(bootstrap, stableIdentity)).toEqual(bootstrap); - expect(() => parseGeneratedRuntimeDeliveryBootstrap(bootstrap, { + expect(parseRuntimeDeliveryBootstrapLockfile(bootstrap, stableIdentity)).toEqual(bootstrap); + expect(() => parseRuntimeDeliveryBootstrapLockfile(bootstrap, { ...stableIdentity, projectId: 'other-project-id', })).toThrow('belongs to a different'); - const legacy = createGeneratedRuntimeDeliveryBootstrap({ identity, runtimeDelivery })!; - expect(parseGeneratedRuntimeDeliveryBootstrap(legacy, identity)).toEqual(legacy); - expect(createGeneratedRuntimeDeliveryBootstrap({ + const legacy = createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery })!; + expect(parseRuntimeDeliveryBootstrapLockfile(legacy, identity)).toEqual(legacy); + expect(createRuntimeDeliveryBootstrapLockfile({ identity: { ...identity, projectId: 'project-id-only' }, runtimeDelivery, })).toBeUndefined(); @@ -137,53 +175,53 @@ describe('runtime-delivery/bootstrapConfig', () => { it('fails closed for unsupported schemas and malformed JSON', () => { const projectRoot = createTempProjectDir(); roots.push(projectRoot); - expect(readGeneratedRuntimeDeliveryBootstrap({ projectRoot })).toBeNull(); - expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + expect(readRuntimeDeliveryBootstrap({ projectRoot })).toBeNull(); + expect(() => parseRuntimeDeliveryBootstrapLockfile({ schemaVersion: 2, project: identity, runtimeDelivery, })).toThrow('schemaVersion 1'); - expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + expect(() => parseRuntimeDeliveryBootstrapLockfile({ schemaVersion: 1, runtimeDelivery, })).toThrow('missing its project identity'); - expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + expect(() => parseRuntimeDeliveryBootstrapLockfile({ schemaVersion: 1, project: { serverUrl: 7, orgSlug: null, projectSlug: [] }, runtimeDelivery, })).toThrow('invalid trust configuration'); - expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + expect(() => parseRuntimeDeliveryBootstrapLockfile({ schemaVersion: 1, project: { ...identity, projectId: 'project-id-1' }, runtimeDelivery, })).toThrow('invalid stable project identity'); - expect(() => parseGeneratedRuntimeDeliveryBootstrap({ + expect(() => parseRuntimeDeliveryBootstrapLockfile({ 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); + const valid = createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery })!; + expect(parseRuntimeDeliveryBootstrapLockfile(valid)).toEqual(valid); fs.ensureDirSync(path.join(projectRoot, '.bundle-drop')); fs.writeFileSync( - path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'), + path.join(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH), '{not-json', ); - expect(() => readGeneratedRuntimeDeliveryBootstrap({ projectRoot })).toThrow('not valid JSON'); + expect(() => readRuntimeDeliveryBootstrap({ 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 bootstrap = createRuntimeDeliveryBootstrapLockfile({ 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 })) + await expect(writeRuntimeDeliveryBootstrapLockfile({ projectRoot, bootstrap })) .rejects.toThrow('symlinked or non-directory'); expect(fs.readFileSync(sentinel, 'utf8')).toBe('outside-safe'); }); @@ -191,13 +229,13 @@ describe('runtime-delivery/bootstrapConfig', () => { 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 bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery })!; + await writeRuntimeDeliveryBootstrapLockfile({ projectRoot, bootstrap }); - const bootstrapPath = await removeGeneratedRuntimeDeliveryBootstrap(projectRoot); + const bootstrapPath = await removeRuntimeDeliveryBootstrapLockfile(projectRoot); expect(bootstrapPath).not.toBeNull(); expect(fs.existsSync(bootstrapPath!)).toBe(false); - await expect(removeGeneratedRuntimeDeliveryBootstrap(projectRoot)).resolves.toBeNull(); + await expect(removeRuntimeDeliveryBootstrapLockfile(projectRoot)).resolves.toBeNull(); }); it('refuses to remove a symlinked bootstrap target', async () => { @@ -209,11 +247,50 @@ describe('runtime-delivery/bootstrapConfig', () => { fs.writeFileSync(sentinel, 'outside-safe'); fs.symlinkSync( sentinel, - path.join(projectRoot, '.bundle-drop/runtime-delivery.generated.json'), + path.join(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH), ); - await expect(removeGeneratedRuntimeDeliveryBootstrap(projectRoot)) + await expect(removeRuntimeDeliveryBootstrapLockfile(projectRoot)) .rejects.toThrow('symlinked or non-regular'); expect(fs.readFileSync(sentinel, 'utf8')).toBe('outside-safe'); }); + + it('reads legacy-only and matching dual bootstraps but rejects disagreement', async () => { + const projectRoot = createTempProjectDir(); + roots.push(projectRoot); + const bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery })!; + fs.ensureDirSync(path.join(projectRoot, '.bundle-drop')); + fs.writeJsonSync(path.join(projectRoot, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH), bootstrap); + + expect(inspectRuntimeDeliveryBootstrap({ projectRoot, expectedIdentity: identity })).toEqual({ + bootstrap, + source: 'legacy', + }); + + await writeRuntimeDeliveryBootstrapLockfile({ projectRoot, bootstrap }); + expect(inspectRuntimeDeliveryBootstrap({ projectRoot, expectedIdentity: identity })).toEqual({ + bootstrap, + source: 'matching-dual', + }); + + const different = createRuntimeDeliveryBootstrapLockfile({ + identity, + runtimeDelivery: { ...runtimeDelivery, manifestBaseUrl: 'https://other.example.com' }, + })!; + fs.writeJsonSync(path.join(projectRoot, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH), different); + expect(() => inspectRuntimeDeliveryBootstrap({ projectRoot, expectedIdentity: identity })) + .toThrow('lockfile and legacy bootstrap differ'); + }); + + it('removes current and legacy bootstrap files when delivery is disabled', async () => { + const projectRoot = createTempProjectDir(); + roots.push(projectRoot); + const bootstrap = createRuntimeDeliveryBootstrapLockfile({ identity, runtimeDelivery })!; + await writeRuntimeDeliveryBootstrapLockfile({ projectRoot, bootstrap }); + fs.writeJsonSync(path.join(projectRoot, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH), bootstrap); + + await expect(removeAllRuntimeDeliveryBootstraps(projectRoot)).resolves.toHaveLength(2); + expect(fs.existsSync(path.join(projectRoot, RUNTIME_DELIVERY_BOOTSTRAP_PATH))).toBe(false); + expect(fs.existsSync(path.join(projectRoot, LEGACY_RUNTIME_DELIVERY_BOOTSTRAP_PATH))).toBe(false); + }); }); diff --git a/src/tests/runtime-delivery/manifestState.test.ts b/src/tests/runtime-delivery/manifestState.test.ts index 6e838c6..276d9de 100644 --- a/src/tests/runtime-delivery/manifestState.test.ts +++ b/src/tests/runtime-delivery/manifestState.test.ts @@ -3,6 +3,7 @@ jest.mock('../../native/fs', () => require('../mocks/native/fs')); import { readVerifiedLaneState, + readVerifiedRuntimeRevokedHashes, recordVerifiedLaneManifest, } from '../../runtime-delivery/manifestState'; import type { RuntimeDeliveryLaneManifest } from '../../runtime-delivery/types'; @@ -126,4 +127,105 @@ describe('runtime-delivery/manifestState', () => { .rejects.toThrow('equivocation'); await expect(recordVerifiedLaneManifest(manifest(3), '4'.repeat(64))).resolves.toBeUndefined(); }); + + it('unions revocations across channels for the same project, platform, and runtime', async () => { + const otherChannelManifest = { + ...manifest(1), + channelName: 'Production', + revokedHashes: ['b'.repeat(64)], + }; + const otherRuntimeManifest = { + ...manifest(1), + runtimeVersion: '2.0.0', + revokedHashes: ['c'.repeat(64)], + }; + + await recordVerifiedLaneManifest(manifest(1), '1'.repeat(64)); + await recordVerifiedLaneManifest(otherChannelManifest, '2'.repeat(64)); + await recordVerifiedLaneManifest(otherRuntimeManifest, '3'.repeat(64)); + + await expect(readVerifiedRuntimeRevokedHashes(identity)).resolves.toEqual([ + 'a'.repeat(64), + 'b'.repeat(64), + ]); + }); + + it('persists the prospective runtime revocation set before committing lane state', async () => { + let laneVisibleDuringNativePersistence: unknown = 'not-checked'; + const persistRuntimeRevocations = jest.fn(async (hashes: string[]) => { + laneVisibleDuringNativePersistence = await readVerifiedLaneState(identity); + expect(hashes).toEqual(['a'.repeat(64)]); + }); + + await recordVerifiedLaneManifest( + manifest(1), + '1'.repeat(64), + persistRuntimeRevocations, + ); + + expect(laneVisibleDuringNativePersistence).toBeNull(); + await expect(readVerifiedLaneState(identity)).resolves.toEqual(expect.objectContaining({ + highestGeneration: 1, + })); + }); + + it('does not commit a lane generation when native revocation persistence fails', async () => { + const persistRuntimeRevocations = jest.fn(async () => { + throw new Error('interrupted native persistence'); + }); + + await expect(recordVerifiedLaneManifest( + manifest(1), + '1'.repeat(64), + persistRuntimeRevocations, + )).rejects.toThrow('interrupted native persistence'); + await expect(readVerifiedLaneState(identity)).resolves.toBeNull(); + + await expect(recordVerifiedLaneManifest(manifest(1), '1'.repeat(64))) + .resolves.toBeUndefined(); + }); + + it('serializes native revocation persistence and supplies the full prospective union', async () => { + let releaseFirst!: () => void; + let markFirstStarted!: () => void; + const firstStarted = new Promise(resolve => { + markFirstStarted = resolve; + }); + const firstRelease = new Promise(resolve => { + releaseFirst = resolve; + }); + const calls: string[][] = []; + const otherChannel = { + ...manifest(1), + channelName: 'Production', + revokedHashes: ['b'.repeat(64)], + }; + + const first = recordVerifiedLaneManifest( + manifest(1), + '1'.repeat(64), + async hashes => { + calls.push(hashes); + markFirstStarted(); + await firstRelease; + }, + ); + const second = recordVerifiedLaneManifest( + otherChannel, + '2'.repeat(64), + async hashes => { + calls.push(hashes); + }, + ); + + await firstStarted; + expect(calls).toEqual([['a'.repeat(64)]]); + releaseFirst(); + await Promise.all([first, second]); + + expect(calls).toEqual([ + ['a'.repeat(64)], + ['a'.repeat(64), 'b'.repeat(64)], + ]); + }); }); diff --git a/src/tests/runtime-delivery/manifestVerifier.test.ts b/src/tests/runtime-delivery/manifestVerifier.test.ts index f6fa823..4f21e40 100644 --- a/src/tests/runtime-delivery/manifestVerifier.test.ts +++ b/src/tests/runtime-delivery/manifestVerifier.test.ts @@ -2,6 +2,7 @@ jest.mock('../../context', () => require('../mocks/context')); jest.mock('../../native/fs', () => require('../mocks/native/fs')); import { verifyRuntimeDeliveryManifest } from '../../runtime-delivery/manifestVerifier'; +import { NativeModules } from 'react-native'; import type { RuntimeDeliveryJws } from '../../runtime-delivery/types'; import { mockVerifyEs256Signature, readMockJson, resetNativeFsMocks } from '../mocks/native/fs'; @@ -33,7 +34,10 @@ const encodedPayload = (payload: unknown) => Buffer.from(JSON.stringify(payload)).toString('base64url'); describe('runtime-delivery/manifestVerifier', () => { - beforeEach(resetNativeFsMocks); + beforeEach(() => { + resetNativeFsMocks(); + NativeModules.BundleDrop.setStartupRecoveryRevokedHashes.mockClear(); + }); it('verifies the shared cross-repository ES256 golden vector', async () => { await expect(verifyRuntimeDeliveryManifest( @@ -46,6 +50,67 @@ describe('runtime-delivery/manifestVerifier', () => { channelName: 'Production / β', candidateSetComplete: true, })); + expect(NativeModules.BundleDrop.setStartupRecoveryRevokedHashes).toHaveBeenCalledWith([]); + }); + + it('accepts repeated verification when native accepts an unchanged revocation set', async () => { + await verifyRuntimeDeliveryManifest(serialize(), IDENTITY, { 'test-key-2026-08': KEY }); + + await expect( + verifyRuntimeDeliveryManifest(serialize(), IDENTITY, { 'test-key-2026-08': KEY }), + ).resolves.toEqual(expect.objectContaining({ revokedHashes: [] })); + expect(NativeModules.BundleDrop.setStartupRecoveryRevokedHashes).toHaveBeenNthCalledWith(1, []); + expect(NativeModules.BundleDrop.setStartupRecoveryRevokedHashes).toHaveBeenNthCalledWith(2, []); + }); + + it('keeps revocations from other verified channels in the same runtime', async () => { + const verifySignature = mockVerifyEs256Signature.getMockImplementation(); + mockVerifyEs256Signature.mockResolvedValue(true); + const firstPayload = basePayload(); + firstPayload.revokedHashes = ['a'.repeat(64)]; + const secondPayload = { + ...basePayload(), + channelName: 'Beta', + revokedHashes: ['b'.repeat(64)], + }; + + try { + await verifyRuntimeDeliveryManifest( + serialize({ payload: encodedPayload(firstPayload) }), + IDENTITY, + { 'test-key-2026-08': KEY }, + ); + await verifyRuntimeDeliveryManifest( + serialize({ payload: encodedPayload(secondPayload) }), + { ...IDENTITY, channelName: 'Beta' }, + { 'test-key-2026-08': KEY }, + ); + + expect(NativeModules.BundleDrop.setStartupRecoveryRevokedHashes).toHaveBeenNthCalledWith( + 1, + ['a'.repeat(64)], + ); + expect(NativeModules.BundleDrop.setStartupRecoveryRevokedHashes).toHaveBeenNthCalledWith( + 2, + ['a'.repeat(64), 'b'.repeat(64)], + ); + } finally { + if (verifySignature) { + mockVerifyEs256Signature.mockImplementation(verifySignature); + } + } + }); + + it('does not commit the verified generation when native rejects revocation persistence', async () => { + NativeModules.BundleDrop.setStartupRecoveryRevokedHashes.mockResolvedValueOnce(false); + + await expect(verifyRuntimeDeliveryManifest( + serialize(), + IDENTITY, + { 'test-key-2026-08': KEY }, + )).rejects.toThrow('rejected the verified revocation set'); + + expect(readMockJson('/mock/doc/bundle-drop/runtime-delivery-state.json')).toBeNull(); }); it('rejects unknown keys, tampered payloads, malformed signatures, and wrong lanes', async () => { diff --git a/src/tests/runtime/service.test.ts b/src/tests/runtime/service.test.ts index ee80586..075175a 100644 --- a/src/tests/runtime/service.test.ts +++ b/src/tests/runtime/service.test.ts @@ -38,8 +38,8 @@ const loadRuntimeServiceModule = (overrides?: { bundlesError?: Error; installResult?: { status: 'staged' | 'upToDate' | 'disabled' | 'incompatible' | 'rollback'; reason?: string }; waitForCheck?: Promise; - rollbackPolicy?: { maxCrashCount: number; healthCheckMode: 'auto' | 'manual'; healthyAfterSec: number }; - rollbackState?: { candidateHash?: string; candidateCommitted?: boolean } | null; + waitForRecoveryTelemetry?: Promise; + recoveryTelemetryError?: Error; reportHealthyError?: Error; setOtaEnabledPromise?: Promise; nativeModuleAvailable?: boolean; @@ -114,38 +114,47 @@ const loadRuntimeServiceModule = (overrides?: { const getUpdateState = jest.fn(async () => overrides?.pendingState ?? { hasBundle: false, info: null, pendingApply: false } ); - const reconcileAppliedBundleOnLaunch = jest.fn(async () => undefined); + const reconcileAppliedBundleOnLaunch = jest.fn, [any?]>( + async () => ({ hash: 'hash-1', pendingApply: false }), + ); const reportActiveBundleHealthy = jest.fn(async () => { if (overrides?.reportHealthyError) { throw overrides.reportHealthyError; } return true; }); - const getRollbackPolicy = jest.fn(() => - overrides?.rollbackPolicy ?? { maxCrashCount: 3, healthCheckMode: 'auto', healthyAfterSec: 0 } - ); - const readRollbackState = jest.fn(async () => - overrides?.rollbackState === undefined - ? null - : overrides.rollbackState - ); - const rollbackToPreviousIfNeeded = jest.fn(async () => overrides?.rollbackResult ?? { rolledBack: false }); - const rollbackToPreviousOrNative = jest.fn(async () => ({ rolledBack: true, toNative: false })); + const readStartupRecoveryState = jest.fn(async () => ({ + protocolVersion: 1, + revision: 0, + phase: 'idle' as const, + quarantinedHashes: [], + pendingRecoveryEvents: [], + })); + const reconcileStartupRecovery = jest.fn(async (state: unknown) => { + if (overrides?.recoveryTelemetryError) { + throw overrides.recoveryTelemetryError; + } + await overrides?.waitForRecoveryTelemetry; + return state; + }); + const rollbackStartupBundle = jest.fn(async (forceEmbedded: boolean) => ({ + rolledBack: overrides?.rollbackResult?.rolledBack ?? true, + toEmbedded: forceEmbedded, + ...(forceEmbedded ? {} : { hash: 'c'.repeat(64) }), + })); const readBundleInfo = jest.fn(async () => { if (overrides?.readBundleInfoError) { throw overrides.readBundleInfoError; } return { hash: 'hash-1', pendingApply: true }; }); - const readCurrentBundlePointer = jest.fn(async () => ({ - hash: 'hash-1', - bundlePath: '/bundles/hash-1/main.jsbundle', - })); + const readCurrentBundleHash = jest.fn(async () => 'hash-1'); const getDownloadedBundlePathNative = jest.fn(async () => '/bundles/hash-1/main.jsbundle'); const restartReactNativeNative = jest.fn(); const setOtaEnabledNative = jest.fn(async () => { await overrides?.setOtaEnabledPromise; }); + const warnIfStartupRecoveryUnavailableNative = jest.fn(); jest.doMock('../../manager/downloadAndInstall', () => ({ downloadUpdate, @@ -162,11 +171,10 @@ const loadRuntimeServiceModule = (overrides?: { reconcileAppliedBundleOnLaunch, })); jest.doMock('../../manager/rollbackState', () => ({ - getRollbackPolicy, - readRollbackState, + readStartupRecoveryState, + reconcileStartupRecovery, reportActiveBundleHealthy, - rollbackToPreviousIfNeeded, - rollbackToPreviousOrNative, + rollbackStartupBundle, })); jest.doMock('../../bundleInfo', () => ({ readBundleInfo, @@ -188,7 +196,7 @@ const loadRuntimeServiceModule = (overrides?: { defaultChannel: 'General', })); jest.doMock('../../fs/bundlePointer', () => ({ - readCurrentBundlePointer, + readCurrentBundleHash, })); jest.doMock('../../native/bundleDropNative', () => ({ getDownloadedBundlePathNative, @@ -196,6 +204,7 @@ const loadRuntimeServiceModule = (overrides?: { isExpoOtaStartupEnabledNative: () => overrides?.expoOtaStartupEnabled ?? true, restartReactNativeNative, setOtaEnabledNative, + warnIfStartupRecoveryUnavailableNative, })); const service = require('../../runtime/service') as RuntimeServiceModule & { @@ -214,15 +223,15 @@ const loadRuntimeServiceModule = (overrides?: { getUpdateState, reconcileAppliedBundleOnLaunch, reportActiveBundleHealthy, - getRollbackPolicy, - readRollbackState, - rollbackToPreviousIfNeeded, - rollbackToPreviousOrNative, + readStartupRecoveryState, + reconcileStartupRecovery, + rollbackStartupBundle, readBundleInfo, - readCurrentBundlePointer, + readCurrentBundleHash, getDownloadedBundlePathNative, restartReactNativeNative, setOtaEnabledNative, + warnIfStartupRecoveryUnavailableNative, }, }; }; @@ -435,93 +444,65 @@ describe('runtime/service', () => { ).toThrow('different runtime config'); }); - it('marks pending candidates healthy on the configured timer or explicit reportHealthy call', async () => { - jest.useFakeTimers(); - try { - const automatic = loadRuntimeServiceModule({ - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - rollbackPolicy: { - maxCrashCount: 3, - healthCheckMode: 'auto', - healthyAfterSec: 0, - }, - }); + it('preserves explicit reportHealthy while native owns automatic health timing', async () => { + const { service, mocks } = loadRuntimeServiceModule(); - automatic.service.initBundleDrop({ environment: 'production' }); - await automatic.service.waitForBundleDropStartupForTests(); - expect(automatic.mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); + expect(mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); - await automatic.service.reportHealthy(); - expect(automatic.mocks.reportActiveBundleHealthy).toHaveBeenCalledWith(undefined, undefined); + await service.reportHealthy(); + expect(mocks.reportActiveBundleHealthy).toHaveBeenCalledWith(); + }); - jest.runOnlyPendingTimers(); - await Promise.resolve(); + it('reports healthy without waiting for startup network work to finish', async () => { + const unresolvedCheck = new Promise(() => undefined); + const { service, mocks } = loadRuntimeServiceModule({ + waitForCheck: unresolvedCheck, + }); - expect(automatic.mocks.reportActiveBundleHealthy).toHaveBeenCalledTimes(1); + service.initBundleDrop({ environment: 'production', policy: 'immediate' }); + await service.reportHealthy(); - const failedTimer = loadRuntimeServiceModule({ - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - reportHealthyError: new Error('disk failed'), - }); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - failedTimer.service.initBundleDrop({ environment: 'production' }); - await failedTimer.service.waitForBundleDropStartupForTests(); - await jest.runOnlyPendingTimersAsync(); - expect(warnSpy).toHaveBeenCalledWith( - '⚠️ Failed to mark BundleDrop candidate healthy:', - expect.any(Error), - ); - warnSpy.mockRestore(); + expect(mocks.reportActiveBundleHealthy).toHaveBeenCalledWith(); + }); - const manual = loadRuntimeServiceModule({ - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - rollbackPolicy: { - maxCrashCount: 3, - healthCheckMode: 'manual', - healthyAfterSec: 0, - }, - }); + it('does not block local startup on pending recovery telemetry delivery', async () => { + const pendingTelemetry = new Promise(() => undefined); + const { service, mocks } = loadRuntimeServiceModule({ + waitForRecoveryTelemetry: pendingTelemetry, + }); - manual.service.initBundleDrop({ environment: 'production' }); - await manual.service.waitForBundleDropStartupForTests(); - jest.runOnlyPendingTimers(); - await Promise.resolve(); - expect(manual.mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); - await manual.service.reportHealthy(); - expect(manual.mocks.reportActiveBundleHealthy).toHaveBeenCalledTimes(1); + expect(mocks.reconcileStartupRecovery).toHaveBeenCalledTimes(1); + expect(service.getBundleDropSnapshot().isBusy).toBe(false); + }); - const staged = loadRuntimeServiceModule({ - pendingState: { - hasBundle: true, - info: { hash: 'hash-1', pendingApply: true }, - pendingApply: true, - }, - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - rollbackPolicy: { - maxCrashCount: 3, - healthCheckMode: 'manual', - healthyAfterSec: 0, - }, - }); + it('warns without failing startup when recovery telemetry reconciliation rejects', async () => { + const telemetryError = new Error('telemetry unavailable'); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const { service } = loadRuntimeServiceModule({ + recoveryTelemetryError: telemetryError, + }); - staged.service.initBundleDrop({ environment: 'production' }); - await staged.service.waitForBundleDropStartupForTests(); - await staged.service.reportHealthy(); - expect(staged.mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); + try { + service.initBundleDrop({ environment: 'production' }); + await service.waitForBundleDropStartupForTests(); + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalledWith( + '⚠️ Failed to reconcile BundleDrop startup recovery telemetry:', + telemetryError, + ); } finally { - jest.useRealTimers(); + warnSpy.mockRestore(); } }); it('does not report a candidate healthy after apply has requested a restart', async () => { const { service, mocks } = loadRuntimeServiceModule({ - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - rollbackPolicy: { - maxCrashCount: 3, - healthCheckMode: 'manual', - healthyAfterSec: 0, - }, }); service.initBundleDrop({ environment: 'production' }); @@ -537,46 +518,8 @@ describe('runtime/service', () => { expect(mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); }); - it('does not report healthy when apply requests restart during an in-flight health check', async () => { - const { service, mocks } = loadRuntimeServiceModule({ - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - rollbackPolicy: { - maxCrashCount: 3, - healthCheckMode: 'manual', - healthyAfterSec: 0, - }, - }); - let resolveHealthState!: () => void; - const healthStateReady = new Promise(resolve => { - resolveHealthState = resolve; - }); - - service.initBundleDrop({ environment: 'production' }); - await service.waitForBundleDropStartupForTests(); - - mocks.getUpdateState.mockImplementationOnce(async () => { - await healthStateReady; - return { hasBundle: true, info: { hash: 'hash-1', pendingApply: false }, pendingApply: false }; - }); - - const reportPromise = service.reportHealthy(); - await Promise.resolve(); - await service.applyDownloadedUpdate(); - resolveHealthState(); - await reportPromise; - - expect(mocks.applyUpdate).toHaveBeenCalledWith(expect.any(Function), expect.any(Function)); - expect(mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); - }); - it('does not clear a pending restart guard on duplicate init', async () => { const { service, mocks } = loadRuntimeServiceModule({ - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - rollbackPolicy: { - maxCrashCount: 3, - healthCheckMode: 'manual', - healthyAfterSec: 0, - }, }); service.initBundleDrop({ environment: 'production' }); @@ -589,30 +532,6 @@ describe('runtime/service', () => { expect(mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); }); - it('ignores scheduled auto health marks after apply has requested a restart', async () => { - jest.useFakeTimers(); - try { - const { service, mocks } = loadRuntimeServiceModule({ - rollbackState: { candidateHash: 'hash-1', candidateCommitted: false }, - rollbackPolicy: { - maxCrashCount: 3, - healthCheckMode: 'auto', - healthyAfterSec: 5, - }, - }); - - service.initBundleDrop({ environment: 'production' }); - await service.waitForBundleDropStartupForTests(); - await service.applyDownloadedUpdate(); - await jest.advanceTimersByTimeAsync(5000); - - expect(mocks.applyUpdate).toHaveBeenCalledWith(expect.any(Function), expect.any(Function)); - expect(mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); - } finally { - jest.useRealTimers(); - } - }); - it('keeps a single active flow, exposes subscribers, and resets test state helpers', async () => { const { service } = loadRuntimeServiceModule(); const listener = jest.fn(); @@ -673,19 +592,16 @@ describe('runtime/service', () => { }); }); - it('restarts immediately when rollback is required on launch', async () => { - const { service, mocks } = loadRuntimeServiceModule({ - rollbackResult: { rolledBack: true, reason: 'crash_loop' }, - }); - const statusSpy = jest.fn(); + it('reconciles native startup recovery without making a JS rollback decision', async () => { + const { service, mocks } = loadRuntimeServiceModule(); - service.initBundleDrop({ environment: 'production', onStatusUpdate: statusSpy }); + service.initBundleDrop({ environment: 'production' }); await service.waitForBundleDropStartupForTests(); - expect(mocks.rollbackToPreviousIfNeeded).toHaveBeenCalled(); - expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); - expect(statusSpy).toHaveBeenCalledWith('↩️ Rolled back to previous bundle'); - expect(mocks.reportActiveBundleHealthy).not.toHaveBeenCalled(); + expect(mocks.readStartupRecoveryState).toHaveBeenCalledTimes(1); + expect(mocks.reconcileStartupRecovery).toHaveBeenCalledTimes(1); + expect(mocks.rollbackStartupBundle).not.toHaveBeenCalled(); + expect(mocks.restartReactNativeNative).not.toHaveBeenCalled(); expect(mocks.checkForUpdate).not.toHaveBeenCalled(); }); @@ -799,8 +715,8 @@ 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.rollbackStartupBundle).toHaveBeenCalledTimes(1); + expect(rollback.mocks.rollbackStartupBundle).toHaveBeenCalledWith(true); expect(rollback.mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); const incompatible = loadRuntimeServiceModule({ @@ -858,7 +774,7 @@ describe('runtime/service', () => { service.initBundleDrop({ environment: 'production', checkOnly: true }); await service.waitForBundleDropStartupForTests(); - expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + expect(mocks.rollbackStartupBundle).toHaveBeenCalledWith(true); }); it('preserves previous-or-native behavior for non-revocation rollback reasons', async () => { @@ -869,7 +785,7 @@ describe('runtime/service', () => { service.initBundleDrop({ environment: 'production', checkOnly: true }); await service.waitForBundleDropStartupForTests(); - expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith(); + expect(mocks.rollbackStartupBundle).toHaveBeenCalledWith(false); }); it.each(['immediate', 'on-next-launch'] as const)( @@ -896,7 +812,7 @@ describe('runtime/service', () => { await service.waitForBundleDropStartupForTests(); expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1); - expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + expect(mocks.rollbackStartupBundle).toHaveBeenCalledWith(true); expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); expect(mocks.applyUpdate).not.toHaveBeenCalled(); }, @@ -918,7 +834,7 @@ describe('runtime/service', () => { service.initBundleDrop({ environment: 'production', policy: 'immediate' }); await service.waitForBundleDropStartupForTests(); - expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith(); + expect(mocks.rollbackStartupBundle).toHaveBeenCalledWith(false); expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); expect(mocks.applyUpdate).not.toHaveBeenCalled(); }); @@ -1316,16 +1232,14 @@ describe('runtime/service', () => { }, status: '↩️ Rollback requested: CURRENT_REVOKED_NO_SAFE_TARGET', }); - expect(rollbackDownload.mocks.rollbackToPreviousOrNative).not.toHaveBeenCalled(); + expect(rollbackDownload.mocks.rollbackStartupBundle).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.rollbackStartupBundle).toHaveBeenCalledWith(true); expect(rollbackDownload.mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); const noBundleApply = loadRuntimeServiceModule({ @@ -1418,14 +1332,14 @@ describe('runtime/service', () => { response: { action: 'ROLLBACK' }, status: '↩️ Rollback requested', }); - expect(mocks.rollbackToPreviousOrNative).not.toHaveBeenCalled(); + expect(mocks.rollbackStartupBundle).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.rollbackStartupBundle).toHaveBeenCalledWith(false); expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); }); @@ -1535,7 +1449,7 @@ describe('runtime/service', () => { status: '↩️ Rollback requested: CURRENT_REVOKED_NO_SAFE_TARGET', }); - expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + expect(mocks.rollbackStartupBundle).toHaveBeenCalledWith(true); expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); expect(mocks.downloadUpdate).not.toHaveBeenCalled(); expect(mocks.installBundle).not.toHaveBeenCalled(); @@ -1570,7 +1484,7 @@ describe('runtime/service', () => { }); expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1); - expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledWith({ forceNative: true }); + expect(mocks.rollbackStartupBundle).toHaveBeenCalledWith(true); expect(mocks.restartReactNativeNative).toHaveBeenCalledTimes(1); expect(mocks.installBundle).not.toHaveBeenCalled(); }); @@ -2140,7 +2054,7 @@ describe('runtime/service', () => { service.initBundleDrop({ environment: 'production', checkOnly: true }); await service.waitForBundleDropStartupForTests(); - expect(mocks.rollbackToPreviousOrNative).toHaveBeenCalledTimes(1); + expect(mocks.rollbackStartupBundle).toHaveBeenCalledTimes(1); await expect(service.getObservabilityContext()).resolves.toEqual({ source: 'ota', diff --git a/src/tests/scripts/bundle.test.ts b/src/tests/scripts/bundle.test.ts index 1995d8c..b56cd9f 100644 --- a/src/tests/scripts/bundle.test.ts +++ b/src/tests/scripts/bundle.test.ts @@ -12,9 +12,29 @@ type MockZipInstance = { const zipInstances: MockZipInstance[] = []; const mockExecSync = jest.fn(); +const spawnCalls: Array<{ executable: string; args: string[]; options: unknown }> = []; + +const quotePath = (value: string) => `"${value}"`; +const legacyCommand = (executable: string, args: string[]) => { + if (executable === process.execPath) { + if (args[0]?.replace(/\\/g, '/').endsWith('/react-native/cli.js')) { + return ['npx', 'react-native', ...args.slice(1).map(arg => + arg.includes(path.sep) ? quotePath(arg) : arg, + )].join(' '); + } + return ['node', ...args.map(arg => arg.startsWith('-') ? arg : quotePath(arg))].join(' '); + } + return [quotePath(executable), ...args.map(arg => arg.startsWith('-') ? arg : quotePath(arg))] + .join(' '); +}; jest.mock('child_process', () => ({ - execSync: (...args: unknown[]) => mockExecSync(...args), + spawnSync: (executable: string, args: string[], options: unknown) => { + spawnCalls.push({ executable, args, options }); + const output = mockExecSync(legacyCommand(executable, args), options); + if (output && typeof output === 'object' && 'status' in output) return output; + return { status: 0, stdout: output ?? '', stderr: '' }; + }, })); jest.mock('adm-zip', () => jest.fn().mockImplementation(() => { @@ -27,7 +47,10 @@ jest.mock('adm-zip', () => }), ); -import { findProjectRoot, runBundleScript } from '../../scripts/bundle'; +import { + findProjectRoot, + runBundleScript as runBundleScriptImplementation, +} from '../../scripts/bundle'; describe('scripts/bundle', () => { const originalArgv = [...process.argv]; @@ -39,6 +62,10 @@ describe('scripts/bundle', () => { let consoleWarnSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; + const runBundleScript = ( + options: Parameters[0] = {}, + ) => runBundleScriptImplementation({ ...options, packageRoot: tempPackageRoot }); + const osBin = process.platform === 'darwin' ? 'osx-bin' @@ -54,6 +81,7 @@ describe('scripts/bundle', () => { consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); zipInstances.length = 0; + spawnCalls.length = 0; mockExecSync.mockReset().mockImplementation((command: string) => { if (command.includes('react-native bundle')) { fs.mkdirSync(path.join(distDir, 'assets', 'drawable-mdpi'), { recursive: true }); @@ -87,7 +115,6 @@ describe('scripts/bundle', () => { } } }); - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE = tempPackageRoot; process.env.BUNDLE_DROP_APP_VERSION = '1.2.3'; process.argv = [...originalArgv]; fs.rmSync(distDir, { recursive: true, force: true }); @@ -103,7 +130,6 @@ describe('scripts/bundle', () => { process.argv = [...originalArgv]; removeTempDir(tempProjectDir); fs.rmSync(tempPackageRoot, { recursive: true, force: true }); - delete process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE; delete process.env.BUNDLE_DROP_APP_VERSION; fs.rmSync(distDir, { recursive: true, force: true }); }); @@ -584,7 +610,7 @@ describe('scripts/bundle', () => { expect(fs.existsSync(path.join(distDir, 'assets', 'stale', 'old.txt'))).toBe(false); }); - it('falls back to the package root derived from the script directory when no env override is set', () => { + it('derives the package root from the script directory when none is injected', () => { const originalResolve = path.resolve.bind(path); fs.writeFileSync( @@ -594,8 +620,6 @@ describe('scripts/bundle', () => { };`, 'utf8', ); - delete process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE; - const resolveSpy = jest.spyOn(path, 'resolve').mockImplementation((...args: string[]) => { if ( args.length === 3 && @@ -610,7 +634,7 @@ describe('scripts/bundle', () => { }); try { - const result = runBundleScript({ + const result = runBundleScriptImplementation({ platform: 'ios', cwd: tempProjectDir, }); @@ -1071,7 +1095,7 @@ describe('scripts/bundle', () => { expect(result.sourceMapPath).toBeUndefined(); }); - it('quotes file paths in the react-native bundle command', () => { + it('passes file paths as discrete arguments with shell execution disabled', () => { fs.writeFileSync( path.join(tempProjectDir, 'bundle.drop.config.js'), `module.exports = { runtimeVersion: { ios: '1.0.0' } };`, @@ -1087,6 +1111,95 @@ describe('scripts/bundle', () => { const cmd = bundleCall[0] as string; expect(cmd).toMatch(/--bundle-output "[^"]+main\.jsbundle"/); expect(cmd).toMatch(/--assets-dest "[^"]+assets"/); + + const spawnCall = spawnCalls.find(call => + call.executable === process.execPath && + call.args[0]?.replace(/\\/g, '/').endsWith('/react-native/cli.js') && + call.args.includes('bundle'), + ); + expect(spawnCall).toEqual( + expect.objectContaining({ + executable: process.execPath, + args: expect.arrayContaining([ + expect.stringMatching(/react-native[\\/]cli\.js$/), + 'bundle', + '--bundle-output', + path.join(distDir, 'main.jsbundle'), + '--assets-dest', + path.join(distDir, 'assets'), + ]), + options: expect.objectContaining({ shell: false }), + }), + ); + }); + + it('runs the resolved React Native JavaScript CLI without a platform shell shim', () => { + fs.writeFileSync( + path.join(tempProjectDir, 'bundle.drop.config.js'), + `module.exports = { runtimeVersion: { android: '1.0.0' } };`, + 'utf8', + ); + const cliPath = path.join(tempProjectDir, 'node_modules', 'react-native', 'cli.js'); + const packageJsonPath = path.join(path.dirname(cliPath), 'package.json'); + fs.mkdirSync(path.dirname(cliPath), { recursive: true }); + fs.writeFileSync(cliPath, '', 'utf8'); + fs.writeFileSync(packageJsonPath, '{}', 'utf8'); + const resolveModule = jest.fn(() => packageJsonPath); + + runBundleScriptImplementation({ + platform: 'android', + cwd: tempProjectDir, + packageRoot: tempPackageRoot, + resolveModule, + }); + + expect(resolveModule).toHaveBeenCalledWith('react-native/package.json', [ + tempProjectDir, + tempPackageRoot, + expect.any(String), + ]); + expect(spawnCalls[0]).toEqual(expect.objectContaining({ + executable: process.execPath, + args: expect.arrayContaining([cliPath, 'bundle', '--platform', 'android']), + options: expect.objectContaining({ shell: false }), + })); + }); + + it('does not interpret shell metacharacters in generated artifact paths', () => { + fs.writeFileSync( + path.join(tempProjectDir, 'bundle.drop.config.js'), + `module.exports = { runtimeVersion: { ios: '1.0.0' } };`, + 'utf8', + ); + const packageRoot = path.join( + tempPackageRoot, + 'output";touch bundle-drop-pwned;# $()', + ); + const sentinelPath = path.join(tempProjectDir, 'bundle-drop-pwned'); + const calls: Array<{ executable: string; args: string[]; options: unknown }> = []; + const safeSpawn = ((executable: string, args: string[], options: unknown) => { + calls.push({ executable, args, options }); + const bundleOutputIndex = args.indexOf('--bundle-output'); + const assetsOutputIndex = args.indexOf('--assets-dest'); + if (bundleOutputIndex >= 0) { + fs.mkdirSync(args[assetsOutputIndex + 1], { recursive: true }); + fs.writeFileSync(args[bundleOutputIndex + 1], 'plain-bundle', 'utf8'); + } + return { status: 0, stdout: '', stderr: '' }; + }) as typeof import('child_process').spawnSync; + + process.chdir(tempProjectDir); + const result = runBundleScriptImplementation({ + platform: 'ios', + cwd: tempProjectDir, + packageRoot, + spawnProcess: safeSpawn, + }); + + expect(result.bundlePath).toBe(path.join(packageRoot, 'dist', 'main.jsbundle')); + expect(calls[0].options).toEqual(expect.objectContaining({ shell: false })); + expect(calls[0].args).toContain(result.bundlePath); + expect(fs.existsSync(sentinelPath)).toBe(false); }); it('falls back to the plain JS bundle when the Hermes compiler writes no bytecode output', () => { @@ -1140,4 +1253,85 @@ describe('scripts/bundle', () => { /Bundle output missing after bundling/, ); }); + + it('propagates a Metro spawn error', () => { + fs.writeFileSync( + path.join(tempProjectDir, 'bundle.drop.config.js'), + `module.exports = { runtimeVersion: { ios: '1.0.0' } };`, + 'utf8', + ); + const spawnError = new Error('could not start Metro'); + const failingSpawn = jest.fn().mockReturnValue({ + status: null, + error: spawnError, + }) as unknown as typeof import('child_process').spawnSync; + + expect(() => + runBundleScriptImplementation({ + platform: 'ios', + cwd: tempProjectDir, + packageRoot: tempPackageRoot, + spawnProcess: failingSpawn, + }), + ).toThrow(spawnError); + }); + + it('reports stderr when Metro exits unsuccessfully', () => { + fs.writeFileSync( + path.join(tempProjectDir, 'bundle.drop.config.js'), + `module.exports = { runtimeVersion: { ios: '1.0.0' } };`, + 'utf8', + ); + const failingSpawn = jest.fn().mockReturnValue({ + status: 9, + stdout: '', + stderr: 'Metro rejected the arguments', + }) as unknown as typeof import('child_process').spawnSync; + + expect(() => + runBundleScriptImplementation({ + platform: 'ios', + cwd: tempProjectDir, + packageRoot: tempPackageRoot, + spawnProcess: failingSpawn, + }), + ).toThrow('Metro rejected the arguments'); + }); + + it('handles an iOS project without a native ios directory', () => { + fs.writeFileSync( + path.join(tempProjectDir, 'bundle.drop.config.js'), + `module.exports = { runtimeVersion: { ios: '1.0.0' } };`, + 'utf8', + ); + fs.rmSync(path.join(tempProjectDir, 'ios'), { recursive: true, force: true }); + + expect(runBundleScript({ platform: 'ios', cwd: tempProjectDir }).runtimeVersion).toBe( + '1.0.0', + ); + }); + + it('refuses a generated cleanup path that escapes dist', () => { + fs.writeFileSync( + path.join(tempProjectDir, 'bundle.drop.config.js'), + `module.exports = { runtimeVersion: { ios: '1.0.0' } };`, + 'utf8', + ); + const originalResolve = path.resolve.bind(path); + const bundlePath = path.join(distDir, 'main.jsbundle'); + const resolveSpy = jest.spyOn(path, 'resolve').mockImplementation((...args: string[]) => { + if (args.length === 1 && args[0] === bundlePath) { + return path.join(tempPackageRoot, 'outside-main.jsbundle'); + } + return originalResolve(...args); + }); + + try { + expect(() => runBundleScript({ platform: 'ios', cwd: tempProjectDir })).toThrow( + /escaped the package output directory/, + ); + } finally { + resolveSpy.mockRestore(); + } + }); }); diff --git a/src/tests/scripts/download-bundle.test.ts b/src/tests/scripts/download-bundle.test.ts index 19dab25..ed9f898 100644 --- a/src/tests/scripts/download-bundle.test.ts +++ b/src/tests/scripts/download-bundle.test.ts @@ -8,7 +8,7 @@ import { mockProcessExit } from '../utils/processExit'; jest.mock('axios', () => require('../mocks/modules/axiosNode')); -import { runDownloadBundle } from '../../scripts/download-bundle'; +import { runDownloadBundle as runDownloadBundleImplementation } from '../../scripts/download-bundle'; describe('scripts/download-bundle', () => { let tempProjectDir = ''; @@ -19,6 +19,10 @@ describe('scripts/download-bundle', () => { let consoleLogSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; + const runDownloadBundle = ( + options: Parameters[0] = {}, + ) => runDownloadBundleImplementation({ ...options, packageRoot: tempPackageRoot }); + beforeEach(() => { tempProjectDir = createTempProjectDir(); tempPackageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-drop-package-root-')); @@ -29,7 +33,6 @@ describe('scripts/download-bundle', () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); mockAxiosNodePost.mockReset(); mockAxiosNodeGet.mockReset(); - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE = tempPackageRoot; fs.rmSync(distDir, { recursive: true, force: true }); }); @@ -40,7 +43,6 @@ describe('scripts/download-bundle', () => { fs.rmSync(tempPackageRoot, { recursive: true, force: true }); process.argv = originalArgv; process.chdir(originalCwd); - delete process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE; fs.rmSync(distDir, { recursive: true, force: true }); }); @@ -262,7 +264,6 @@ describe('scripts/download-bundle', () => { const defaultDistDir = path.join(defaultPackageRoot, 'dist'); try { - delete process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE; fs.writeFileSync( path.join(tempProjectDir, 'bundle.drop.config.js'), `module.exports = { @@ -276,7 +277,7 @@ describe('scripts/download-bundle', () => { process.chdir(tempProjectDir); mockAxiosNodePost.mockRejectedValue({ raw: 'failure payload' }); - await expect(runDownloadBundle()).rejects.toMatchObject({ code: 1 }); + await expect(runDownloadBundleImplementation()).rejects.toMatchObject({ code: 1 }); expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Download failed:', { raw: 'failure payload' }); expect(fs.existsSync(defaultDistDir)).toBe(true); } finally { diff --git a/src/tests/scripts/exportProject.test.ts b/src/tests/scripts/exportProject.test.ts index 986e649..a9399a9 100644 --- a/src/tests/scripts/exportProject.test.ts +++ b/src/tests/scripts/exportProject.test.ts @@ -51,13 +51,11 @@ const androidIdentity: ExpoBuildIdentity = { describe('exportProjectArtifact', () => { const roots: string[] = []; - const originalPackageRoot = process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE; const fixture = (packageJson: Record = {}) => { const root = createTempProjectDir(); roots.push(root); fs.writeJsonSync(path.join(root, 'package.json'), packageJson); - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE = root; return root; }; @@ -90,8 +88,6 @@ describe('exportProjectArtifact', () => { }); afterEach(() => { - if (originalPackageRoot === undefined) delete process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE; - else process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE = originalPackageRoot; for (const root of roots.splice(0)) removeTempDir(root); }); @@ -197,8 +193,6 @@ describe('exportProjectArtifact', () => { const root = fixture(); const packageRoot = createTempProjectDir(); roots.push(packageRoot); - process.env.BUNDLE_DROP_PACKAGE_ROOT_OVERRIDE = packageRoot; - const artifact = await exportProjectArtifact({ projectRoot: root, platform: 'ios', diff --git a/test-fixtures/startup-recovery-contract-v1.json b/test-fixtures/startup-recovery-contract-v1.json new file mode 100644 index 0000000..52726b2 --- /dev/null +++ b/test-fixtures/startup-recovery-contract-v1.json @@ -0,0 +1,32 @@ +{ + "protocolVersion": 1, + "revision": 7, + "phase": "launching", + "candidateHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "stableHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "activeAttempt": { + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "attemptId": "attempt-contract-v1", + "status": "launching", + "unacknowledgedLaunchCount": 2 + }, + "quarantinedHashes": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "policy": { + "maxCrashCount": 3, + "healthCheckMode": "manual", + "healthyAfterSec": 4.5 + }, + "pendingRecoveryEvents": [ + { + "id": "event-contract-v1", + "failedHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "recoveryTarget": "previous", + "recoveredHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "crashCount": 3, + "reason": "crash_loop", + "failedAt": 1700000000 + } + ] +}