Files
shorebird-updater/library/src/cache/updater_state.rs
T
Tony 5d4e9c3396
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
ci / 🦀 Build ${{ matrix.crate }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / shorebird_code_push (push) Has been cancelled
Shorebird CI / shorebird_code_push_example (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled
refactor: improve comments for clarity and update privacy references
Signed-off-by: Tony <tonylu@tony-cloud.com>
2026-06-25 15:50:24 +08:00

788 lines
30 KiB
Rust

// This file deals with the cache / state management for the updater.
use std::path::{Path, PathBuf};
use anyhow::{ensure, Result};
#[cfg(test)]
use anyhow::{bail, Context};
use serde::{Deserialize, Serialize};
use crate::events::PatchEvent;
use crate::yaml::PatchVerificationMode;
use super::lifecycle::{PatchLifecycle, PatchState};
#[cfg(test)]
use super::signing;
use super::{disk_io, PatchInfo};
const STATE_FILE_NAME: &str = "state.json";
/// Files and directories under `cache_dir` that shorebird has ever
/// written. On a release-version change or unparseable state we wipe
/// these and keep going. `state.json` is intentionally absent — it's
/// rewritten in place with the preserved `client_id` (so removing it
/// would lose the only thing we want to carry forward).
///
/// `patches_state.json` is the legacy file from the prior `PatchManager`
/// implementation; carrying it forward would orphan ~few-KB of stale
/// state on every device upgrading through this PR.
// TODO(eseidel): Drop `patches_state.json` from this list two minor
// versions after the release that ships this PR. By that point the
// in-flight devices upgrading from a pre-PR build have all wiped it
// once on their first release-version change, and nothing on disk
// references it anymore.
const SHOREBIRD_OWNED_PATHS: &[&str] = &["patches", "pointers.json", "patches_state.json"];
/// Records the updater's "state of the world": which patches we have
/// downloaded or installed, which patch booted last, events that need to
/// be reported to the server, etc.
///
/// Per-patch state lives inside [`PatchLifecycle`] (one document per
/// patch number under `{cache}/patches/{N}/state.json`). UpdaterState
/// itself only owns the per-device `client_id` and the per-release event
/// queue; all other patch-related fields are pointers managed by the
/// lifecycle.
// TODO(eseidel): Split the per-release state from the per-device state
// so per-device state isn't reset on release-version change.
#[derive(Debug)]
pub struct UpdaterState {
/// Persistent app-storage root. Holds `state.json`, `pointers.json`,
/// and `patches/{N}/` (state.json + dlc.vmcode). The compressed
/// download bytes live separately under the OS-managed cache dir
/// (passed to `load_or_new_on_error`); the lifecycle owns both
/// roots and we don't store the download dir here directly.
cache_dir: PathBuf,
lifecycle: PatchLifecycle,
patch_public_key: Option<String>,
verification_mode: PatchVerificationMode,
serialized_state: SerializedState,
}
/// UpdaterState fields that are serialized to disk at `{cache}/state.json`.
///
/// Every per-release field on disk (this struct, `pointers.json`, and the
/// per-patch `state.json` files under `patches/`) is wiped when
/// `release_version` changes. The release version effectively names a
/// unique build of the engine + updater, since the updater ships
/// embedded in the engine — there's no "version range" of updater code
/// that's mutually compatible. On a release-version mismatch, anything
/// we read from disk could have been written by code we don't
/// recognize, so we discard it.
#[derive(Debug, Deserialize, Serialize)]
struct SerializedState {
/// Stable per-install ID. Survives release-version changes; only
/// reset when the app is uninstalled. Used for analytics.
/// Treat this as privacy-sensitive update-server metadata.
client_id: String,
/// The release version this cache corresponds to. Mismatch with the
/// app's reported release version triggers a wipe of all per-release
/// state.
release_version: String,
/// Events that have not yet been sent to the server. Format may
/// change between releases, so this is per-release state.
queued_events: Vec<PatchEvent>,
}
fn generate_client_id() -> String {
uuid::Uuid::new_v4().to_string()
}
fn is_file_not_found(error: &anyhow::Error) -> bool {
for cause in error.chain() {
if let Some(io_error) = cause.downcast_ref::<std::io::Error>() {
return io_error.kind() == std::io::ErrorKind::NotFound;
}
}
false
}
impl UpdaterState {
pub fn client_id(&self) -> String {
self.serialized_state.client_id.clone()
}
pub fn set_client_id_override(&mut self, client_id: &str) -> Result<()> {
ensure!(
!client_id.is_empty(),
"Device id override must not be empty."
);
ensure!(
client_id.len() <= 256,
"Device id override must be 256 bytes or fewer."
);
ensure!(
!client_id.chars().any(char::is_control),
"Device id override must not contain control characters."
);
self.serialized_state.client_id = client_id.to_string();
self.save()
}
}
impl UpdaterState {
fn new(
cache_dir: PathBuf,
download_dir: PathBuf,
release_version: String,
patch_public_key: Option<&str>,
verification_mode: PatchVerificationMode,
client_id: String,
) -> Self {
Self {
lifecycle: PatchLifecycle::load_or_default(cache_dir.clone(), download_dir),
cache_dir,
patch_public_key: patch_public_key.map(|s| s.to_owned()),
verification_mode,
serialized_state: SerializedState {
client_id,
release_version,
queued_events: Vec::new(),
},
}
}
fn load(
cache_dir: &Path,
download_dir: &Path,
patch_public_key: Option<&str>,
verification_mode: PatchVerificationMode,
) -> Result<Self> {
let path = cache_dir.join(STATE_FILE_NAME);
let serialized_state = disk_io::read(&path)?;
Ok(Self {
cache_dir: cache_dir.to_path_buf(),
lifecycle: PatchLifecycle::load_or_default(
cache_dir.to_path_buf(),
download_dir.to_path_buf(),
),
patch_public_key: patch_public_key.map(|s| s.to_owned()),
verification_mode,
serialized_state,
})
}
/// Initializes a new UpdaterState and saves it to disk. Wipes the
/// shorebird-managed files in `cache_dir` and the entire
/// `download_dir` — used when the release version changes or when
/// the on-disk state was unparseable.
///
/// We *don't* blanket-wipe `cache_dir` because the embedder may
/// configure it to be shared with non-shorebird files (the test
/// suite does this; production engines typically hand us a
/// dedicated subdir but the API doesn't enforce that). Instead we
/// enumerate the set of files we've ever written there. Add new
/// entries to `SHOREBIRD_OWNED_PATHS` when introducing new files
/// or directories under `cache_dir`.
fn create_new_and_save(
cache_dir: &Path,
download_dir: &Path,
release_version: &str,
patch_public_key: Option<&str>,
verification_mode: PatchVerificationMode,
client_id: String,
) -> Self {
for relative in SHOREBIRD_OWNED_PATHS {
let path = cache_dir.join(relative);
if !path.exists() {
continue;
}
let result = if path.is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
if let Err(e) = result {
shorebird_error!("Failed to wipe {:?} on reset: {:?}", path, e);
}
}
// The download dir is fully shorebird-owned — wipe it whole.
if download_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(download_dir) {
shorebird_error!("Failed to wipe download dir on reset: {:?}", e);
}
}
let mut state = Self::new(
cache_dir.to_owned(),
download_dir.to_owned(),
release_version.to_owned(),
patch_public_key,
verification_mode,
client_id,
);
if let Err(e) = state.save() {
shorebird_warn!("Error saving state {:?}, ignoring.", e);
}
state.lifecycle =
PatchLifecycle::load_or_default(cache_dir.to_path_buf(), download_dir.to_path_buf());
state
}
pub fn load_or_new_on_error(
cache_dir: &Path,
download_dir: &Path,
release_version: &str,
patch_public_key: Option<&str>,
verification_mode: PatchVerificationMode,
) -> Self {
match Self::load(cache_dir, download_dir, patch_public_key, verification_mode) {
Ok(loaded) => {
if loaded.serialized_state.release_version != release_version {
shorebird_info!(
"release_version changed {} -> {}, creating new state",
loaded.serialized_state.release_version,
release_version
);
return Self::create_new_and_save(
cache_dir,
download_dir,
release_version,
patch_public_key,
verification_mode,
loaded.client_id(),
);
}
loaded
}
Err(e) => {
if !is_file_not_found(&e) {
shorebird_info!("No existing state file found: {:#}, creating new state.", e);
}
Self::create_new_and_save(
cache_dir,
download_dir,
release_version,
patch_public_key,
verification_mode,
generate_client_id(),
)
}
}
}
/// Saves the top-level (non-patch) state to disk.
pub fn save(&self) -> Result<()> {
disk_io::write(
&self.serialized_state,
&self.cache_dir.join(STATE_FILE_NAME),
)
}
}
/// Patch lifecycle accessors — UpdaterState delegates to [`PatchLifecycle`].
impl UpdaterState {
/// Direct access to the lifecycle. Wrapping every transition in a
/// forwarding method on UpdaterState would be churn for no reader
/// benefit, so callers are expected to reach in for transitions
/// (`decide_start`, `record_download_*`, `mark_bad`, etc). The
/// boot-lifecycle / install / boot-failure helpers below are kept
/// as wrappers because they have invariants (e.g. patch number
/// argument validation, breadcrumb clearing) that a direct caller
/// would have to know about.
pub fn lifecycle(&self) -> &PatchLifecycle {
&self.lifecycle
}
/// See [`lifecycle`].
pub fn lifecycle_mut(&mut self) -> &mut PatchLifecycle {
&mut self.lifecycle
}
/// Records that we are attempting to boot the patch with `patch_number`.
pub fn record_boot_start_for_patch(&mut self, patch_number: usize) -> Result<()> {
self.lifecycle.record_boot_start(patch_number)
}
/// Records that patch `patch_number` failed to boot. Marks it
/// `Bad{BootCrash}` and recomputes `next_boot_patch`. Clears the
/// boot breadcrumb regardless of whether it matched.
pub fn record_boot_failure_for_patch(&mut self, patch_number: usize) -> Result<()> {
self.lifecycle.record_boot_failure(patch_number)
}
/// Records that the in-flight boot succeeded.
pub fn record_boot_success(&mut self) -> Result<()> {
self.lifecycle.record_boot_success()
}
pub fn currently_booting_patch(&self) -> Option<PatchInfo> {
self.lifecycle
.pointers()
.currently_booting_patch
.map(|n| self.patch_info(n))
}
pub fn boot_started_at(&self) -> Option<u64> {
self.lifecycle.pointers().boot_started_at
}
pub fn last_successfully_booted_patch(&self) -> Option<PatchInfo> {
self.lifecycle
.pointers()
.last_booted_patch
.map(|n| self.patch_info(n))
}
/// The patch this process is using. Backed by the session-scoped
/// global in `config.rs` — survives server-driven rollback (the
/// running process is still using the patch) and resets on every
/// fresh process start.
pub fn running_patch(&self) -> Option<PatchInfo> {
crate::config::running_patch_number().map(|n| self.patch_info(n))
}
pub fn set_running_patch(&mut self, patch_number: Option<usize>) {
crate::config::set_running_patch_number(patch_number);
}
pub fn next_boot_patch(&mut self) -> Option<PatchInfo> {
self.lifecycle
.pointers()
.next_boot_patch
.map(|n| self.patch_info(n))
}
/// Validates that `next_boot_patch` is bootable. On failure, marks
/// the patch `Bad{ValidationFailed}` and recomputes `next_boot_patch`.
pub fn validate_next_boot_patch(&mut self) -> Result<()> {
self.lifecycle
.validate_next_boot_patch(self.patch_public_key.as_deref(), self.verification_mode)
}
/// Moves the inflated artifact at `patch.path` into the lifecycle's
/// installed location, validates the signature in `InstallOnly`
/// mode, transitions the patch to `Installed`, and promotes it to
/// `next_boot_patch`.
///
/// Test-only entry point. The production update flow inflates
/// directly into the lifecycle's installed location and transitions
/// `Downloaded → Installed` via `lifecycle::record_install_complete`,
/// so no production caller goes through this function. Gated to
/// `#[cfg(test)]` so a future refactor can't accidentally
/// reintroduce the divergence — direct lifecycle calls are the
/// canonical path. Used by `test_utils::install_fake_patch` and
/// the tests below.
#[cfg(test)]
pub fn install_patch(
&mut self,
patch: &PatchInfo,
hash: &str,
signature: Option<&str>,
) -> Result<()> {
if !patch.path.exists() {
bail!("Patch file {} does not exist", patch.path.display());
}
// InstallOnly mode verifies the signature here; Strict mode
// verifies it again at boot time via validate_next_boot_patch.
if self.verification_mode == PatchVerificationMode::InstallOnly {
if let Some(public_key) = &self.patch_public_key {
let sig = signature.context("Patch signature is missing")?;
signing::check_signature(hash, sig, public_key)?;
}
}
let installed_path = self.lifecycle.installed_artifact_path(patch.number);
if let Some(parent) = installed_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&patch.path, &installed_path)?;
// Mirror `record_install_complete`'s cleanup of the now-stale
// compressed download bytes if any are sitting in the patch dir.
let download = self.lifecycle.download_artifact_path(patch.number);
if download.exists() {
if let Err(e) = std::fs::remove_file(&download) {
shorebird_error!(
"Failed to remove stale download for patch {}: {:?}",
patch.number,
e
);
}
}
let installed_size = std::fs::metadata(&installed_path)?.len();
self.lifecycle.write_state(
patch.number,
&PatchState::Installed {
signature: signature.map(String::from),
size: installed_size,
},
)?;
self.lifecycle.promote_to_next_boot(patch.number)
}
/// Removes the artifacts for `patch_number` and recomputes pointers.
/// Used today for server-driven rollbacks.
pub fn uninstall_patch(&mut self, patch_number: usize) -> Result<()> {
self.lifecycle.cleanup(patch_number)?;
self.lifecycle.recompute_next_boot()
}
/// True if `patch_number` is currently in `Bad` state — we tried it
/// and it failed, and shouldn't be retried within this release.
pub fn is_known_bad_patch(&self, patch_number: usize) -> bool {
matches!(
self.lifecycle.read_state(patch_number),
Some(PatchState::Bad { .. })
)
}
fn patch_info(&self, n: usize) -> PatchInfo {
PatchInfo {
path: self.lifecycle.installed_artifact_path(n),
number: n,
}
}
}
/// PatchEvent management.
impl UpdaterState {
pub fn queue_event(&mut self, event: PatchEvent) -> Result<()> {
self.serialized_state.queued_events.push(event);
self.save()
}
pub fn copy_events(&self, limit: usize) -> Vec<PatchEvent> {
self.serialized_state
.queued_events
.iter()
.take(limit)
.cloned()
.collect()
}
pub fn clear_events(&mut self) -> Result<()> {
self.serialized_state.queued_events.clear();
self.save()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::lifecycle::BadReason;
use tempfile::TempDir;
fn fake_artifact(tmp: &TempDir, number: usize) -> PatchInfo {
let path = tmp.path().join(format!("patch{}.full", number));
std::fs::write(&path, format!("patch_{}_bytes", number)).unwrap();
PatchInfo { number, path }
}
fn load(tmp: &TempDir, release_version: &str) -> UpdaterState {
UpdaterState::load_or_new_on_error(
tmp.path(),
&tmp.path().join("downloads"),
release_version,
None,
PatchVerificationMode::default(),
)
}
#[test]
fn release_version_change_wipes_patch_state() {
// Ports `patch_manager.rs::reset_tests::deletes_patches_dir_and_resets_patches_state`.
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
let p = fake_artifact(&tmp, 1);
state.install_patch(&p, "hash", None).unwrap();
state.save().unwrap();
assert_eq!(state.next_boot_patch().map(|p| p.number), Some(1));
let mut next = load(&tmp, "1.0.0+2");
assert!(next.next_boot_patch().is_none());
}
#[test]
fn release_version_change_wipes_legacy_patches_state_json() {
// Devices upgrading from the prior `PatchManager` will have a
// `patches_state.json` left behind in cache_dir from the old
// code. The new code never reads or writes it, but leaving it
// on disk would orphan a few KB on every release upgrade.
// Belongs to the SHOREBIRD_OWNED_PATHS wipe list.
let tmp = TempDir::new().unwrap();
let _state = load(&tmp, "1.0.0+1");
std::fs::write(
tmp.path().join("patches_state.json"),
br#"{"legacy":"junk"}"#,
)
.unwrap();
assert!(tmp.path().join("patches_state.json").exists());
let _next = load(&tmp, "1.0.0+2");
assert!(
!tmp.path().join("patches_state.json").exists(),
"legacy patches_state.json should be wiped on release-version change"
);
}
#[test]
fn release_version_change_wipes_download_dir() {
// The cache-rooted download dir is per-release just like the
// persistent patches/ tree. A release-version mismatch must
// wipe both — otherwise an in-flight partial download from
// the prior release could be confused for a current one.
let tmp = TempDir::new().unwrap();
let downloads_dir = tmp.path().join("downloads");
std::fs::create_dir_all(&downloads_dir).unwrap();
std::fs::write(downloads_dir.join("1"), b"stale prior-release bytes").unwrap();
std::fs::write(downloads_dir.join("orphan"), b"junk").unwrap();
// Release-version change triggers a full wipe.
let _next = load(&tmp, "1.0.0+2");
assert!(
!downloads_dir.join("1").exists(),
"stale prior-release download should be wiped"
);
assert!(
!downloads_dir.join("orphan").exists(),
"junk in downloads/ should be wiped"
);
}
#[test]
fn client_id_persists_across_release_changes() {
let tmp = TempDir::new().unwrap();
let original = load(&tmp, "1.0.0+1");
let original_client_id = original.client_id();
let next = load(&tmp, "1.0.0+2");
assert_eq!(next.client_id(), original_client_id);
}
#[test]
fn client_id_override_persists_across_release_changes() {
let tmp = TempDir::new().unwrap();
let mut original = load(&tmp, "1.0.0+1");
original
.set_client_id_override("developer-device-id")
.unwrap();
let next = load(&tmp, "1.0.0+2");
assert_eq!(next.client_id(), "developer-device-id");
}
#[test]
fn client_id_override_rejects_empty_value() {
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
assert!(state.set_client_id_override("").is_err());
}
#[test]
fn corrupt_state_file_creates_new_state() {
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
let p = fake_artifact(&tmp, 1);
state.install_patch(&p, "hash", None).unwrap();
state.save().unwrap();
std::fs::write(tmp.path().join(STATE_FILE_NAME), "garbage").unwrap();
let mut reloaded = load(&tmp, "1.0.0+2");
assert!(reloaded.next_boot_patch().is_none());
}
#[test]
fn install_patch_renames_into_lifecycle_dir_and_sets_next_boot() {
// Ports `patch_manager.rs::add_patch_tests::adds_patch_successfully`.
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
let p = fake_artifact(&tmp, 1);
state.install_patch(&p, "hash", None).unwrap();
let next = state.next_boot_patch().unwrap();
assert_eq!(next.number, 1);
assert!(next.path.exists());
assert!(!tmp.path().join("patch1.full").exists(), "source moved");
}
#[test]
fn install_patch_replaces_unbooted_predecessor() {
// Ports
// `patch_manager.rs::next_boot_patch_tests::adding_patch_deletes_unbooted_patch_not_last_booted`.
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
state
.install_patch(&fake_artifact(&tmp, 1), "h1", None)
.unwrap();
state
.install_patch(&fake_artifact(&tmp, 2), "h2", None)
.unwrap();
assert_eq!(state.next_boot_patch().map(|p| p.number), Some(2));
assert!(!state.lifecycle.installed_artifact_path(1).exists());
}
#[test]
fn install_patch_errors_when_file_missing() {
// Ports `patch_manager.rs::add_patch_tests::errs_if_file_path_does_not_exist`.
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
let bogus = PatchInfo {
number: 1,
path: tmp.path().join("nope"),
};
assert!(state.install_patch(&bogus, "h", None).is_err());
}
// The base64-encoded RSA key + matching signature were generated for
// signing.rs's tests; reused here to exercise the InstallOnly path
// without standing up our own keypair fixture.
const TEST_PUBLIC_KEY: &str = "MIIBCgKCAQEA2wdpEGbuvlPsb9i0qYrfMefJnEw1BHTi8SYZTKrXOvJWmEpPE1hWfbkvYzXu5a96gV1yocF3DMwn04VmRlKhC4AhsD0NL0UNhYhotbKG91Kwi1vAXpHhCdz5gQEBw0K1uB4Jz+zK6WK+31PryYpwLwbyXNqXoY8IAAUQ4STsHYV5w+BMSi8pepWMRd7DR9RHcbNOZlJvdBQ5NxvB4JN4dRMq8cC73ez1P9d7Dfwv3TWY+he9EmuXLT2UivZSlHIrGBa7MFfqyUe2ro0F7Te/B0si12itBbWIqycvqcXjeOPNn6WEpqN7IWjb9LUh162JyYaz5Lb/VeeJX8LKtElccwIDAQAB";
const TEST_HASH: &str = "404e5caa5b906f6d03c97657e8c4d604d759f9cfba1a8bba9d5b49a5ebc174f9";
const TEST_SIGNATURE: &str = "2ixSo5LpaWUSLg2GJEV+D+uyLeLjp0c3vNXnl0yb1iJjAdpn10BFlbcwCcjaJW9PNky2HU2hKOBe62PkFHOU8DDYOfxf2LGg/ToLGPHin85WrwFAceAUYDs7JpQr43dRTbrXcT8k5tuCQOTwXecGwuWcOFFvh0GbXFnyAmi7fLfN9CtTsG2GIOle/LyYLwoviTrXn/fZTZEYrqxD/wZ4QzoWOWLWNvrPbILhqWELkBLhdZeK0+nC2CIxFRYd3bUeOi1AGtPyHKBfdwuf4VO3+HbwJVaAEiD7HU2Bj+Zp1xeSdbznmYgBV86oizrLFd23D+lBfTlmDGgdfNE9J4Z2/g==";
fn load_with_verification(
tmp: &TempDir,
public_key: Option<&str>,
mode: PatchVerificationMode,
) -> UpdaterState {
UpdaterState::load_or_new_on_error(
tmp.path(),
&tmp.path().join("downloads"),
"1.0.0+1",
public_key,
mode,
)
}
#[test]
fn install_patch_install_only_accepts_valid_signature() {
// Ports `patch_manager.rs::add_patch_tests::install_only_succeeds_with_valid_signature`.
let tmp = TempDir::new().unwrap();
let mut state = load_with_verification(
&tmp,
Some(TEST_PUBLIC_KEY),
PatchVerificationMode::InstallOnly,
);
let p = fake_artifact(&tmp, 1);
state
.install_patch(&p, TEST_HASH, Some(TEST_SIGNATURE))
.unwrap();
assert_eq!(state.next_boot_patch().map(|p| p.number), Some(1));
}
#[test]
fn install_patch_install_only_rejects_missing_signature() {
// Ports
// `patch_manager.rs::add_patch_tests::install_only_errs_if_signature_is_missing_when_public_key_configured`.
let tmp = TempDir::new().unwrap();
let mut state = load_with_verification(
&tmp,
Some(TEST_PUBLIC_KEY),
PatchVerificationMode::InstallOnly,
);
let p = fake_artifact(&tmp, 1);
assert!(state.install_patch(&p, TEST_HASH, None).is_err());
// Failure leaves no Installed state.
assert!(state.next_boot_patch().is_none());
}
#[test]
fn install_patch_install_only_rejects_bad_signature() {
// Ports
// `patch_manager.rs::add_patch_tests::install_only_errs_if_signature_is_invalid`
// and (same code path)
// `patch_manager.rs::add_patch_tests::install_only_errs_if_public_key_is_invalid`.
let tmp = TempDir::new().unwrap();
let mut state = load_with_verification(
&tmp,
Some(TEST_PUBLIC_KEY),
PatchVerificationMode::InstallOnly,
);
let p = fake_artifact(&tmp, 1);
assert!(state
.install_patch(&p, TEST_HASH, Some("not_a_real_signature"))
.is_err());
}
#[test]
fn boot_lifecycle_tracks_state() {
// Ports
// `patch_manager.rs::last_successfully_booted_patch_tests::returns_value_from_patches_state`
// and the happy path of
// `patch_manager.rs::record_boot_success_for_patch_tests::succeeds_when_provided_next_boot_patch_number`.
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
state
.install_patch(&fake_artifact(&tmp, 1), "h", None)
.unwrap();
state.record_boot_start_for_patch(1).unwrap();
assert_eq!(state.currently_booting_patch().map(|p| p.number), Some(1));
state.record_boot_success().unwrap();
assert!(state.currently_booting_patch().is_none());
assert_eq!(
state.last_successfully_booted_patch().map(|p| p.number),
Some(1)
);
}
#[test]
fn record_boot_failure_marks_bad_and_clears_next_boot() {
// Ports
// `patch_manager.rs::next_boot_patch_tests::returns_none_patch_if_first_patch_failed_to_boot`
// and
// `patch_manager.rs::record_boot_failure_for_patch_tests::deletes_failed_patch_artifacts`.
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
state
.install_patch(&fake_artifact(&tmp, 1), "h", None)
.unwrap();
state.record_boot_start_for_patch(1).unwrap();
state.record_boot_failure_for_patch(1).unwrap();
assert!(state.is_known_bad_patch(1));
assert!(state.next_boot_patch().is_none());
}
#[test]
fn record_boot_failure_works_without_active_boot() {
// Matches the prior PatchManager semantics: the call doesn't
// require currently_booting_patch to be set; it just marks the
// patch bad and recomputes pointers.
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
state
.install_patch(&fake_artifact(&tmp, 1), "h", None)
.unwrap();
state.record_boot_failure_for_patch(1).unwrap();
assert!(state.is_known_bad_patch(1));
assert!(state.next_boot_patch().is_none());
}
#[test]
fn uninstall_patch_clears_artifacts_and_recomputes_pointers() {
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
state
.install_patch(&fake_artifact(&tmp, 1), "h", None)
.unwrap();
assert_eq!(state.next_boot_patch().map(|p| p.number), Some(1));
state.uninstall_patch(1).unwrap();
assert!(state.next_boot_patch().is_none());
assert!(!state.lifecycle.installed_artifact_path(1).exists());
}
#[test]
fn is_known_bad_patch_after_mark_bad() {
let tmp = TempDir::new().unwrap();
let mut state = load(&tmp, "1.0.0+1");
state
.install_patch(&fake_artifact(&tmp, 1), "h", None)
.unwrap();
state
.lifecycle
.mark_bad(1, BadReason::InstallHashMismatch)
.unwrap();
assert!(state.is_known_bad_patch(1));
}
#[test]
fn install_patch_install_only_skips_verification_when_no_public_key() {
// Ports
// `patch_manager.rs::add_patch_tests::install_only_succeeds_with_any_signature_if_no_public_key`.
// InstallOnly + no public_key configured → signature is never
// checked, so any value (including garbage) is accepted.
let tmp = TempDir::new().unwrap();
let mut state = load_with_verification(&tmp, None, PatchVerificationMode::InstallOnly);
let p = fake_artifact(&tmp, 1);
state
.install_patch(&p, "any-hash", Some("garbage-signature"))
.unwrap();
assert_eq!(state.next_boot_patch().map(|p| p.number), Some(1));
}
}