From c6ca9ebf3a8b7752f03aadcc84222197952a8bb2 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 15:54:08 -0700 Subject: [PATCH 01/21] refactor: introduce per-patch lifecycle state machine (types + persistence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of shorebirdtech/shorebird#3737 — replace the scattered storage of per-patch state across download_state.rs sidecars, bare files in downloads/, and PatchesState fields (next_boot_patch, last_booted_patch, known_bad_patches) with a single per-patch state.json driven by an explicit state machine. This commit only adds the types and the storage layer; nothing wires into update_internal or report_launch_* yet. Subsequent commits will build out transitions and migrate the call sites. States: - Downloading { url, hash, signature, partial_size } - Downloaded { url, hash, signature, size } - Installed { hash, signature, size } - Bad { reason, hash?, signature?, size? } // tombstone Operations: - mark_bad(n, reason) — sugar over write Bad{} + cleanup - cleanup(n) — state-aware: keeps Bad tombstone, else forgets dir Per-release pointers (next_boot, last_booted, currently_booting, boot_started_at) move to a separate pointers.json holding patch numbers; metadata lives once per patch in state.json instead of duplicated across pointers. Persistence rides on the existing atomic disk_io::write (sibling- write + rename) so partial writes can't leave torn files. --- library/src/cache/lifecycle.rs | 522 +++++++++++++++++++++++++++++++++ library/src/cache/mod.rs | 1 + 2 files changed, 523 insertions(+) create mode 100644 library/src/cache/lifecycle.rs diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs new file mode 100644 index 00000000..981221e1 --- /dev/null +++ b/library/src/cache/lifecycle.rs @@ -0,0 +1,522 @@ +//! Per-patch lifecycle state machine. +//! +//! Replaces the scattered storage of patch state across `download_state.rs` +//! sidecars, the bare files in `downloads/`, and the `next_boot_patch` / +//! `last_booted_patch` / `known_bad_patches` fields of `PatchesState`. +//! +//! On-disk layout (per release): +//! {root}/ +//! pointers.json # ReleasePointers +//! patches/ +//! {N}/ +//! state.json # PatchState +//! download # compressed bytes (Downloading/Downloaded only) +//! dlc.vmcode # installed artifact (Installed only) +//! +//! state.json is the source of truth for "what state is patch N in?" and +//! survives within a release as a tombstone for `Bad` patches even after +//! their artifact files are removed. Everything under `patches/` is wiped +//! on release-version change. +//! +//! Mutations are exposed as two operations on top of the raw read/write: +//! - `mark_bad(n, reason)` writes a Bad tombstone and deletes artifact +//! files (sugar over `write_state` + `cleanup`). +//! - `cleanup(n)` is state-aware: keeps the tombstone if the patch is +//! already Bad, otherwise removes the patch directory entirely. +//! +//! Callers never pick between "delete tombstone" and "preserve tombstone"; +//! the state on disk decides. See the design notes that led here in +//! shorebirdtech/shorebird#3737. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::cache::disk_io; + +const PATCHES_DIR: &str = "patches"; +const PATCH_STATE_FILE: &str = "state.json"; +const POINTERS_FILE: &str = "pointers.json"; + +/// Per-patch lifecycle state. Persisted at `{root}/patches/{N}/state.json`. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind")] +pub enum PatchState { + /// Compressed bytes are partially on disk. Resume sends + /// `Range: bytes={partial_size}-`. + Downloading { + url: String, + hash: String, + signature: Option, + partial_size: u64, + }, + /// Compressed bytes are fully on disk and the size matches what we + /// recorded after the download completed. Bytes are untrusted until + /// install validates them (inflate + check_hash). + Downloaded { + url: String, + hash: String, + signature: Option, + size: u64, + }, + /// `dlc.vmcode` is present; the patch is bootable. + Installed { + hash: String, + signature: Option, + size: u64, + }, + /// Tombstone. The patch will not be re-attempted within this release. + /// Optional fields preserve what we knew about the patch for diagnostics + /// and for the `PatchInstallFailure` event we queue. + Bad { + reason: BadReason, + hash: Option, + signature: Option, + size: Option, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum BadReason { + /// Boot started but never recorded success — process crashed during boot. + BootCrash, + /// `inflate` failed (zstd magic / decompression error). + InvalidPatchBytes, + /// Inflated bytes' hash didn't match the server-claimed hash. + InstallHashMismatch, + /// `validate_patch_is_bootable` failed at boot time (size mismatch + /// vs Installed.size, or signature failed in Strict mode). + ValidationFailed, +} + +/// Per-release pointers. Single document at `{root}/pointers.json`. +/// References patch numbers — the metadata for each lives in that patch's +/// `state.json`. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct ReleasePointers { + /// Boot target on next launch. Must reference a patch in `Installed`. + /// `None` means base release. + pub next_boot_patch: Option, + + /// Most recent patch that successfully booted on a prior run. Used as + /// a fallback target when `next_boot_patch` becomes invalid. + pub last_booted_patch: Option, + + /// Boot-in-progress breadcrumb. Set at `record_boot_start`, cleared + /// at `record_boot_success` / `record_boot_failure`. If still set on + /// next init, treat as a crashed boot. + pub currently_booting_patch: Option, + + /// Unix timestamp (seconds) when `currently_booting_patch` was set. + pub boot_started_at: Option, +} + +/// Per-release patch lifecycle and storage. Owns `{root}/patches/` and +/// `{root}/pointers.json`. +pub struct PatchLifecycle { + root: PathBuf, + pointers: ReleasePointers, +} + +impl PatchLifecycle { + /// Loads the lifecycle from `root`. Missing or unparseable + /// `pointers.json` falls back to defaults; per-patch state files are + /// read lazily. + pub fn load_or_default(root: PathBuf) -> Self { + let pointers_path = root.join(POINTERS_FILE); + let pointers = if pointers_path.exists() { + match disk_io::read(&pointers_path) { + Ok(p) => p, + Err(e) => { + shorebird_error!( + "Failed to read pointers from {:?}: {:?}; using defaults", + pointers_path, + e + ); + ReleasePointers::default() + } + } + } else { + ReleasePointers::default() + }; + Self { root, pointers } + } + + pub fn pointers(&self) -> &ReleasePointers { + &self.pointers + } + + /// Returns the on-disk state for patch `n`, or `None` if the patch has + /// no record on disk (i.e. is in the conceptual "Unknown" state). + pub fn read_state(&self, n: usize) -> Option { + let path = self.state_path(n); + if !path.exists() { + return None; + } + match disk_io::read(&path) { + Ok(state) => Some(state), + Err(e) => { + shorebird_error!("Failed to read state for patch {}: {:?}", n, e); + None + } + } + } + + /// Persists `state` for patch `n`. Creates the patch directory if + /// needed. Atomic via `disk_io::write`. + pub fn write_state(&self, n: usize, state: &PatchState) -> Result<()> { + disk_io::write(state, &self.state_path(n)) + } + + /// Persists the current pointers. + pub fn save_pointers(&self) -> Result<()> { + disk_io::write(&self.pointers, &self.pointers_path()) + } + + /// Transitions patch `n` to `Bad{reason}`, preserving any prior + /// hash/signature/size info as best-effort diagnostics. Then deletes + /// the patch's artifact files (state.json stays as the tombstone). + /// + /// Write-then-cleanup ordering means a crash between the two leaves a + /// tombstone with stale-but-unused artifact bytes — sweeping picks + /// them up on the next `cleanup` call. + pub fn mark_bad(&self, n: usize, reason: BadReason) -> Result<()> { + let (hash, signature, size) = match self.read_state(n) { + Some(PatchState::Downloading { + hash, + signature, + partial_size, + .. + }) => (Some(hash), signature, Some(partial_size)), + Some(PatchState::Downloaded { + hash, + signature, + size, + .. + }) => (Some(hash), signature, Some(size)), + Some(PatchState::Installed { + hash, + signature, + size, + }) => (Some(hash), signature, Some(size)), + Some(PatchState::Bad { + hash, + signature, + size, + .. + }) => (hash, signature, size), + None => (None, None, None), + }; + self.write_state( + n, + &PatchState::Bad { + reason, + hash, + signature, + size, + }, + )?; + self.cleanup(n) + } + + /// State-aware retirement. If patch `n` is in `Bad`, the tombstone is + /// preserved and only artifact files are removed. Otherwise the entire + /// patch directory is removed. Idempotent — safe to call on patches + /// that don't exist. + pub fn cleanup(&self, n: usize) -> Result<()> { + match self.read_state(n) { + Some(PatchState::Bad { .. }) => self.delete_artifact_files(n), + Some(_) | None => self.forget_dir(n), + } + } + + /// Removes everything under `{root}/patches/{N}/` except `state.json`. + fn delete_artifact_files(&self, n: usize) -> Result<()> { + let dir = self.patch_dir(n); + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return Ok(()), // Directory doesn't exist; nothing to do. + }; + for entry in entries.flatten() { + if entry.file_name() == PATCH_STATE_FILE { + continue; + } + let path = entry.path(); + if path.is_dir() { + if let Err(e) = std::fs::remove_dir_all(&path) { + shorebird_error!("Failed to remove {:?}: {:?}", path, e); + } + } else if let Err(e) = std::fs::remove_file(&path) { + shorebird_error!("Failed to remove {:?}: {:?}", path, e); + } + } + Ok(()) + } + + /// Removes `{root}/patches/{N}/` entirely, including `state.json`. + fn forget_dir(&self, n: usize) -> Result<()> { + let dir = self.patch_dir(n); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + Ok(()) + } + + fn patches_root(&self) -> PathBuf { + self.root.join(PATCHES_DIR) + } + + fn patch_dir(&self, n: usize) -> PathBuf { + self.patches_root().join(n.to_string()) + } + + fn state_path(&self, n: usize) -> PathBuf { + self.patch_dir(n).join(PATCH_STATE_FILE) + } + + fn pointers_path(&self) -> PathBuf { + self.root.join(POINTERS_FILE) + } +} + +/// Convenience accessor; returns the path a caller would write the +/// compressed download bytes to. Public so the network layer can stream +/// directly into it without knowing the on-disk layout details. +pub fn download_artifact_path(root: &Path, n: usize) -> PathBuf { + root.join(PATCHES_DIR).join(n.to_string()).join("download") +} + +/// Path to the installed (inflated) artifact for patch `n`. +pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf { + root.join(PATCHES_DIR) + .join(n.to_string()) + .join("dlc.vmcode") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn fixture() -> (TempDir, PatchLifecycle) { + let tmp = TempDir::new().unwrap(); + let lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + (tmp, lifecycle) + } + + #[test] + fn read_state_returns_none_when_patch_unknown() { + let (_tmp, lifecycle) = fixture(); + assert!(lifecycle.read_state(1).is_none()); + } + + #[test] + fn write_then_read_roundtrips() { + let (_tmp, lifecycle) = fixture(); + let state = PatchState::Downloaded { + url: "https://example.com/p1".into(), + hash: "abc".into(), + signature: Some("sig".into()), + size: 1234, + }; + lifecycle.write_state(1, &state).unwrap(); + assert_eq!(lifecycle.read_state(1), Some(state)); + } + + #[test] + fn read_state_is_none_for_corrupt_state_json() { + let (_tmp, lifecycle) = fixture(); + let path = lifecycle.state_path(1); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "not json").unwrap(); + // Corrupt JSON returns None — caller treats as Unknown and starts fresh. + assert!(lifecycle.read_state(1).is_none()); + } + + #[test] + fn mark_bad_preserves_metadata_from_installed() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Installed { + hash: "h".into(), + signature: Some("s".into()), + size: 999, + }, + ) + .unwrap(); + lifecycle.mark_bad(1, BadReason::BootCrash).unwrap(); + match lifecycle.read_state(1).unwrap() { + PatchState::Bad { + reason, + hash, + signature, + size, + } => { + assert_eq!(reason, BadReason::BootCrash); + assert_eq!(hash, Some("h".into())); + assert_eq!(signature, Some("s".into())); + assert_eq!(size, Some(999)); + } + other => panic!("expected Bad, got {other:?}"), + } + } + + #[test] + fn mark_bad_on_unknown_patch_records_no_metadata() { + let (_tmp, lifecycle) = fixture(); + lifecycle.mark_bad(1, BadReason::ValidationFailed).unwrap(); + match lifecycle.read_state(1).unwrap() { + PatchState::Bad { + reason, + hash, + signature, + size, + } => { + assert_eq!(reason, BadReason::ValidationFailed); + assert!(hash.is_none()); + assert!(signature.is_none()); + assert!(size.is_none()); + } + other => panic!("expected Bad, got {other:?}"), + } + } + + #[test] + fn mark_bad_deletes_artifact_files_but_keeps_tombstone() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloaded { + url: "u".into(), + hash: "h".into(), + signature: None, + size: 100, + }, + ) + .unwrap(); + // Drop fake artifact files alongside state.json. + let dir = lifecycle.patch_dir(1); + std::fs::write(dir.join("download"), b"compressed bytes").unwrap(); + std::fs::write(dir.join("dlc.vmcode"), b"installed bytes").unwrap(); + + lifecycle.mark_bad(1, BadReason::InvalidPatchBytes).unwrap(); + + assert!(lifecycle.state_path(1).exists(), "tombstone preserved"); + assert!(!dir.join("download").exists(), "artifact gone"); + assert!(!dir.join("dlc.vmcode").exists(), "artifact gone"); + } + + #[test] + fn cleanup_on_bad_patch_keeps_tombstone() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Bad { + reason: BadReason::BootCrash, + hash: Some("h".into()), + signature: None, + size: Some(50), + }, + ) + .unwrap(); + // Stale artifact bytes left around (e.g. from a crash between + // mark_bad's state write and its cleanup) should be swept up. + let dir = lifecycle.patch_dir(1); + std::fs::write(dir.join("download"), b"stale").unwrap(); + + lifecycle.cleanup(1).unwrap(); + + assert!(lifecycle.state_path(1).exists()); + assert!(!dir.join("download").exists()); + } + + #[test] + fn cleanup_on_non_bad_patch_forgets_entirely() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Installed { + hash: "h".into(), + signature: None, + size: 100, + }, + ) + .unwrap(); + std::fs::write(lifecycle.patch_dir(1).join("dlc.vmcode"), b"x").unwrap(); + + lifecycle.cleanup(1).unwrap(); + + assert!(!lifecycle.patch_dir(1).exists()); + assert!(lifecycle.read_state(1).is_none()); + } + + #[test] + fn cleanup_on_unknown_patch_is_noop() { + let (_tmp, lifecycle) = fixture(); + lifecycle.cleanup(99).unwrap(); // Should not error. + } + + #[test] + fn cleanup_is_idempotent() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Installed { + hash: "h".into(), + signature: None, + size: 1, + }, + ) + .unwrap(); + lifecycle.cleanup(1).unwrap(); + lifecycle.cleanup(1).unwrap(); // No-op the second time. + } + + #[test] + fn pointers_load_default_when_missing() { + let (_tmp, lifecycle) = fixture(); + assert_eq!(lifecycle.pointers(), &ReleasePointers::default()); + } + + #[test] + fn pointers_save_and_reload_roundtrip() { + let tmp = TempDir::new().unwrap(); + { + let mut lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + lifecycle.pointers = ReleasePointers { + next_boot_patch: Some(3), + last_booted_patch: Some(2), + currently_booting_patch: None, + boot_started_at: None, + }; + lifecycle.save_pointers().unwrap(); + } + let reloaded = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + assert_eq!(reloaded.pointers().next_boot_patch, Some(3)); + assert_eq!(reloaded.pointers().last_booted_patch, Some(2)); + } + + #[test] + fn pointers_load_default_on_corrupt_file() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join(POINTERS_FILE), "not json").unwrap(); + let lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + assert_eq!(lifecycle.pointers(), &ReleasePointers::default()); + } + + #[test] + fn artifact_path_helpers_match_state_directory() { + let (tmp, lifecycle) = fixture(); + let download = download_artifact_path(tmp.path(), 7); + let installed = installed_artifact_path(tmp.path(), 7); + assert_eq!(download.parent().unwrap(), lifecycle.patch_dir(7)); + assert_eq!(installed.parent().unwrap(), lifecycle.patch_dir(7)); + } +} diff --git a/library/src/cache/mod.rs b/library/src/cache/mod.rs index 4bf5e330..2792bd9e 100644 --- a/library/src/cache/mod.rs +++ b/library/src/cache/mod.rs @@ -1,4 +1,5 @@ mod disk_io; +pub mod lifecycle; mod patch_manager; mod signing; pub mod updater_state; From 6190b5076660cc21c02dc23be528adf62d01f3b1 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 16:39:52 -0700 Subject: [PATCH 02/21] refactor: add download/install/boot transitions to lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the lifecycle module with the methods update_internal and report_launch_* will need: - decide_start(n, url, hash) → DownloadAction (Fresh, Resume, Complete, Skip) - record_download_started / _complete - record_install_complete (transitions Downloaded → Installed, removes the now-unneeded compressed download file) - promote_to_next_boot (transitions a freshly Installed patch into pointers.next_boot, retiring any unbooted predecessor) - record_boot_start / _success / _failure - detect_boot_crash_on_init (handles the breadcrumb left when a prior process crashed during boot) - validate_next_boot_patch (size + signature checks; marks Bad{ValidationFailed} on failure) - recompute_next_boot Nothing wires into the existing updater.rs / report_launch_* yet — those changes follow in the cutover commit. --- library/src/cache/lifecycle.rs | 786 ++++++++++++++++++++++++++++++++- 1 file changed, 784 insertions(+), 2 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 981221e1..4e72a373 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -30,10 +30,11 @@ use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; -use crate::cache::disk_io; +use super::{disk_io, signing}; +use crate::yaml::PatchVerificationMode; const PATCHES_DIR: &str = "patches"; const PATCH_STATE_FILE: &str = "state.json"; @@ -294,6 +295,349 @@ pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf { .join("dlc.vmcode") } +/// What `update_internal` should do when starting work on a patch. +/// +/// Returned by [`PatchLifecycle::decide_start`] after inspecting the +/// patch's current on-disk state. The caller uses this to decide whether +/// to send a fresh GET, a Range GET, skip the network entirely, or bail. +#[derive(Debug, Clone, PartialEq)] +pub enum DownloadAction { + /// No usable prior bytes — start a fresh download. The caller should + /// `record_download_started(... partial_size: 0)` and issue a GET + /// without a Range header. + Fresh, + /// Partial bytes from a matching prior attempt are on disk. The + /// caller resumes from `offset` (the existing partial file size) + /// and issues a GET with `Range: bytes={offset}-`. + Resume { offset: u64 }, + /// Bytes for this exact url+hash are fully on disk. Skip the network + /// request entirely and proceed to install. + Complete, + /// The patch is in a terminal state and shouldn't be re-fetched. + Skip(SkipReason), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SkipReason { + /// Already installed; `should_install_patch` returns NoUpdate to + /// avoid downloading the patch we're already running. + AlreadyInstalled, + /// Tombstoned in this release. Subsequent attempts short-circuit. + KnownBad, +} + +impl PatchLifecycle { + /// Decide what to do when the server offers patch `n`. Reads the + /// on-disk state and matches it against the server's `url` + `hash`. + /// A mismatch on either field discards the prior state — the patch + /// was deleted and re-uploaded with the same number, or routed + /// through a different CDN URL, and the prior bytes can't be + /// trusted. + pub fn decide_start(&self, n: usize, url: &str, hash: &str) -> DownloadAction { + match self.read_state(n) { + None => DownloadAction::Fresh, + Some(PatchState::Downloading { + url: prior_url, + hash: prior_hash, + partial_size, + .. + }) if prior_url == url && prior_hash == hash => DownloadAction::Resume { + offset: partial_size, + }, + Some(PatchState::Downloading { .. }) => DownloadAction::Fresh, + Some(PatchState::Downloaded { + url: prior_url, + hash: prior_hash, + .. + }) if prior_url == url && prior_hash == hash => DownloadAction::Complete, + Some(PatchState::Downloaded { .. }) => DownloadAction::Fresh, + Some(PatchState::Installed { .. }) => { + DownloadAction::Skip(SkipReason::AlreadyInstalled) + } + Some(PatchState::Bad { .. }) => DownloadAction::Skip(SkipReason::KnownBad), + } + } + + /// Records that a download is starting (or restarting). `partial_size` + /// is what the caller will tell the server via Range — typically `0` + /// for a fresh download, or the existing file size for a resume. + pub fn record_download_started( + &self, + n: usize, + url: &str, + hash: &str, + signature: Option<&str>, + partial_size: u64, + ) -> Result<()> { + self.write_state( + n, + &PatchState::Downloading { + url: url.to_string(), + hash: hash.to_string(), + signature: signature.map(String::from), + partial_size, + }, + ) + } + + /// Transitions `n` from `Downloading` to `Downloaded` after the + /// download completes. `size` is the actual on-disk size of the + /// compressed bytes. + pub fn record_download_complete(&self, n: usize, size: u64) -> Result<()> { + let (url, hash, signature) = match self.read_state(n) { + Some(PatchState::Downloading { + url, + hash, + signature, + .. + }) => (url, hash, signature), + // Idempotent: a second "complete" call on an already-Downloaded + // patch is a no-op (e.g. process restarted just before this). + Some(PatchState::Downloaded { + url, + hash, + signature, + .. + }) => (url, hash, signature), + other => { + anyhow::bail!( + "record_download_complete called on patch {n} in unexpected state: {other:?}" + ); + } + }; + self.write_state( + n, + &PatchState::Downloaded { + url, + hash, + signature, + size, + }, + ) + } + + /// Records that this process is starting to boot patch `n`. The + /// breadcrumb in `pointers.currently_booting_patch` survives a process + /// crash, which is how we detect boot-time crashes on the next init + /// (see [`detect_boot_crash_on_init`]). + pub fn record_boot_start(&mut self, n: usize) -> Result<()> { + match self.read_state(n) { + Some(PatchState::Installed { .. }) => {} + other => { + bail!("record_boot_start({n}) expected Installed, got {other:?}"); + } + } + self.pointers.currently_booting_patch = Some(n); + self.pointers.boot_started_at = Some(crate::time::unix_timestamp()); + self.save_pointers() + } + + /// Records a successful boot. Promotes `currently_booting_patch` to + /// `last_booted_patch` and runs cleanup on older patches (per-patch + /// state-aware: Bad tombstones survive, others are forgotten). + pub fn record_boot_success(&mut self) -> Result<()> { + let n = self + .pointers + .currently_booting_patch + .context("record_boot_success without currently_booting_patch")?; + self.pointers.last_booted_patch = Some(n); + self.pointers.currently_booting_patch = None; + self.pointers.boot_started_at = None; + self.save_pointers()?; + self.cleanup_older_than(n); + Ok(()) + } + + /// Records a boot failure. Marks the in-flight patch + /// `Bad{BootCrash}` and recomputes `next_boot_patch` from the + /// remaining state. Returns the patch number that was marked bad. + pub fn record_boot_failure(&mut self) -> Result { + let n = self + .pointers + .currently_booting_patch + .context("record_boot_failure without currently_booting_patch")?; + self.pointers.currently_booting_patch = None; + self.pointers.boot_started_at = None; + self.save_pointers()?; + self.mark_bad(n, BadReason::BootCrash)?; + self.recompute_next_boot()?; + Ok(n) + } + + /// Called at init. If `currently_booting_patch` is still set from a + /// prior process, that boot crashed without recording success or + /// failure — transition the patch to `Bad{BootCrash}` and recompute + /// `next_boot_patch`. Returns the patch number that was recovered, if + /// any. + pub fn detect_boot_crash_on_init(&mut self) -> Result> { + let Some(n) = self.pointers.currently_booting_patch else { + return Ok(None); + }; + self.pointers.currently_booting_patch = None; + self.pointers.boot_started_at = None; + self.save_pointers()?; + self.mark_bad(n, BadReason::BootCrash)?; + self.recompute_next_boot()?; + Ok(Some(n)) + } + + /// Validates that `next_boot_patch` is bootable (its on-disk size + /// matches `Installed.size`, and in `Strict` mode its signature + /// verifies against `public_key`). On failure, marks the patch + /// `Bad{ValidationFailed}` and recomputes `next_boot_patch`. + pub fn validate_next_boot_patch( + &mut self, + public_key: Option<&str>, + mode: PatchVerificationMode, + ) -> Result<()> { + let Some(n) = self.pointers.next_boot_patch else { + return Ok(()); + }; + if let Err(e) = self.validate_installed_patch(n, public_key, mode) { + shorebird_error!("Patch {} failed validation: {:?}", n, e); + self.mark_bad(n, BadReason::ValidationFailed)?; + self.recompute_next_boot()?; + return Err(e); + } + Ok(()) + } + + /// Sets `next_boot_patch` to the best Installed candidate. Today the + /// only fallback target is `last_booted_patch` if that patch is + /// currently `Installed`; otherwise we boot the base release. + /// + /// We deliberately don't scan `patches/` for arbitrary Installed + /// patches — within a release there are at most a couple of patches + /// active at once, and the last successfully booted patch is the + /// only one we have evidence works on this device. + pub fn recompute_next_boot(&mut self) -> Result<()> { + let new_target = self + .pointers + .last_booted_patch + .filter(|&lb| matches!(self.read_state(lb), Some(PatchState::Installed { .. }))); + if self.pointers.next_boot_patch != new_target { + self.pointers.next_boot_patch = new_target; + self.save_pointers()?; + } + Ok(()) + } + + /// Sets `next_boot_patch` to a freshly Installed patch. Replaces any + /// prior `next_boot_patch` that was Installed-but-never-booted (those + /// are forgotten via [`cleanup`]); a Bad tombstone in that slot is + /// preserved. + pub fn promote_to_next_boot(&mut self, n: usize) -> Result<()> { + if !matches!(self.read_state(n), Some(PatchState::Installed { .. })) { + bail!("promote_to_next_boot({n}) requires Installed state"); + } + // If we're replacing an Installed-but-never-booted previous + // next_boot, retire it. cleanup handles tombstones correctly. + let last_booted = self.pointers.last_booted_patch; + if let Some(prev) = self.pointers.next_boot_patch { + if prev != n && Some(prev) != last_booted { + self.cleanup(prev)?; + } + } + self.pointers.next_boot_patch = Some(n); + self.save_pointers() + } + + /// Validates a specific Installed patch against its on-disk artifact. + fn validate_installed_patch( + &self, + n: usize, + public_key: Option<&str>, + mode: PatchVerificationMode, + ) -> Result<()> { + let (expected_size, signature) = match self.read_state(n) { + Some(PatchState::Installed { + size, signature, .. + }) => (size, signature), + other => bail!("Patch {n} is not Installed: {other:?}"), + }; + let path = installed_artifact_path(&self.root, n); + if !path.exists() { + bail!("Patch {n} artifact missing at {}", path.display()); + } + let actual_size = std::fs::metadata(&path)?.len(); + if actual_size != expected_size { + bail!( + "Patch {n} size {} on disk, expected {}", + actual_size, + expected_size + ); + } + if mode == PatchVerificationMode::Strict { + if let Some(public_key) = public_key { + let signature = signature.context("Patch signature is missing")?; + let actual_hash = signing::hash_file(&path)?; + signing::check_signature(&actual_hash, &signature, public_key)?; + } else { + shorebird_info!("No public key configured; skipping signature verification"); + } + } + Ok(()) + } + + /// Walks `patches/` and runs [`cleanup`] on every patch with number + /// < `n`. State-aware per-patch: Bad tombstones survive, everything + /// else is forgotten. Best-effort — read errors are logged and + /// skipped so a single bad entry can't block the cleanup of others. + fn cleanup_older_than(&self, n: usize) { + let entries = match std::fs::read_dir(self.patches_root()) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + let Ok(num) = name.parse::() else { + continue; + }; + if num < n { + if let Err(e) = self.cleanup(num) { + shorebird_error!("cleanup({}) failed: {:?}", num, e); + } + } + } + } + + /// Transitions `n` from `Downloaded` to `Installed`. `installed_size` + /// is the on-disk size of the inflated artifact (what + /// `validate_installed_patch` will check against on next boot). + /// Also removes the now-unneeded compressed `download` file. + pub fn record_install_complete(&self, n: usize, installed_size: u64) -> Result<()> { + let (hash, signature) = match self.read_state(n) { + Some(PatchState::Downloaded { + hash, signature, .. + }) => (hash, signature), + other => { + anyhow::bail!( + "record_install_complete called on patch {n} in unexpected state: {other:?}" + ); + } + }; + self.write_state( + n, + &PatchState::Installed { + hash, + signature, + size: installed_size, + }, + )?; + // The compressed bytes are no longer needed; the dlc.vmcode is + // the canonical artifact going forward. + let download = self.patch_dir(n).join("download"); + if download.exists() { + if let Err(e) = std::fs::remove_file(&download) { + shorebird_error!("Failed to remove download file for patch {}: {:?}", n, e); + } + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -519,4 +863,442 @@ mod tests { assert_eq!(download.parent().unwrap(), lifecycle.patch_dir(7)); assert_eq!(installed.parent().unwrap(), lifecycle.patch_dir(7)); } + + #[test] + fn decide_start_unknown_patch_is_fresh() { + let (_tmp, lifecycle) = fixture(); + assert_eq!( + lifecycle.decide_start(1, "https://example/p", "h"), + DownloadAction::Fresh + ); + } + + #[test] + fn decide_start_resumes_matching_downloading() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloading { + url: "https://example/p".into(), + hash: "h".into(), + signature: None, + partial_size: 250, + }, + ) + .unwrap(); + assert_eq!( + lifecycle.decide_start(1, "https://example/p", "h"), + DownloadAction::Resume { offset: 250 } + ); + } + + #[test] + fn decide_start_url_mismatch_starts_fresh() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloading { + url: "https://old.example/p".into(), + hash: "h".into(), + signature: None, + partial_size: 100, + }, + ) + .unwrap(); + assert_eq!( + lifecycle.decide_start(1, "https://new.example/p", "h"), + DownloadAction::Fresh + ); + } + + #[test] + fn decide_start_hash_mismatch_starts_fresh() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloaded { + url: "u".into(), + hash: "old".into(), + signature: None, + size: 1000, + }, + ) + .unwrap(); + assert_eq!(lifecycle.decide_start(1, "u", "new"), DownloadAction::Fresh); + } + + #[test] + fn decide_start_complete_skips_fetch() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloaded { + url: "u".into(), + hash: "h".into(), + signature: None, + size: 1000, + }, + ) + .unwrap(); + assert_eq!( + lifecycle.decide_start(1, "u", "h"), + DownloadAction::Complete + ); + } + + #[test] + fn decide_start_skips_installed() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Installed { + hash: "h".into(), + signature: None, + size: 1000, + }, + ) + .unwrap(); + assert_eq!( + lifecycle.decide_start(1, "u", "h"), + DownloadAction::Skip(SkipReason::AlreadyInstalled) + ); + } + + #[test] + fn decide_start_skips_bad() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Bad { + reason: BadReason::BootCrash, + hash: None, + signature: None, + size: None, + }, + ) + .unwrap(); + assert_eq!( + lifecycle.decide_start(1, "u", "h"), + DownloadAction::Skip(SkipReason::KnownBad) + ); + } + + #[test] + fn record_download_started_writes_downloading_state() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .record_download_started(1, "u", "h", Some("s"), 0) + .unwrap(); + assert_eq!( + lifecycle.read_state(1).unwrap(), + PatchState::Downloading { + url: "u".into(), + hash: "h".into(), + signature: Some("s".into()), + partial_size: 0, + } + ); + } + + #[test] + fn record_download_complete_transitions_downloading_to_downloaded() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .record_download_started(1, "u", "h", None, 0) + .unwrap(); + lifecycle.record_download_complete(1, 1234).unwrap(); + assert_eq!( + lifecycle.read_state(1).unwrap(), + PatchState::Downloaded { + url: "u".into(), + hash: "h".into(), + signature: None, + size: 1234, + } + ); + } + + #[test] + fn record_download_complete_is_idempotent_on_downloaded() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloaded { + url: "u".into(), + hash: "h".into(), + signature: None, + size: 1234, + }, + ) + .unwrap(); + // Second call doesn't error; size update reflects new value (e.g. + // a server that retried with a different chunked-encoding total). + lifecycle.record_download_complete(1, 5678).unwrap(); + match lifecycle.read_state(1).unwrap() { + PatchState::Downloaded { size, .. } => assert_eq!(size, 5678), + _ => panic!("expected Downloaded"), + } + } + + #[test] + fn record_download_complete_errors_on_invalid_state() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Installed { + hash: "h".into(), + signature: None, + size: 100, + }, + ) + .unwrap(); + assert!(lifecycle.record_download_complete(1, 1234).is_err()); + } + + #[test] + fn record_install_complete_transitions_to_installed_and_removes_download() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloaded { + url: "u".into(), + hash: "h".into(), + signature: Some("s".into()), + size: 1234, + }, + ) + .unwrap(); + let download_path = lifecycle.patch_dir(1).join("download"); + std::fs::write(&download_path, b"compressed").unwrap(); + + lifecycle.record_install_complete(1, 9999).unwrap(); + + assert_eq!( + lifecycle.read_state(1).unwrap(), + PatchState::Installed { + hash: "h".into(), + signature: Some("s".into()), + size: 9999, + } + ); + assert!( + !download_path.exists(), + "download file should be removed after install" + ); + } + + #[test] + fn record_install_complete_errors_on_invalid_state() { + let (_tmp, lifecycle) = fixture(); + // No prior state: not Downloaded. + assert!(lifecycle.record_install_complete(1, 1234).is_err()); + } + + fn install_patch(lifecycle: &PatchLifecycle, n: usize, size: u64) { + let path = installed_artifact_path(&lifecycle.root, n); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, vec![0u8; size as usize]).unwrap(); + lifecycle + .write_state( + n, + &PatchState::Installed { + hash: format!("hash{n}"), + signature: None, + size, + }, + ) + .unwrap(); + } + + #[test] + fn record_boot_start_requires_installed() { + let (_tmp, mut lifecycle) = fixture(); + assert!(lifecycle.record_boot_start(1).is_err()); + + install_patch(&lifecycle, 1, 100); + lifecycle.record_boot_start(1).unwrap(); + assert_eq!(lifecycle.pointers().currently_booting_patch, Some(1)); + assert!(lifecycle.pointers().boot_started_at.is_some()); + } + + #[test] + fn record_boot_success_promotes_and_cleans_older() { + let (_tmp, mut lifecycle) = fixture(); + install_patch(&lifecycle, 1, 100); + install_patch(&lifecycle, 2, 200); + install_patch(&lifecycle, 3, 300); + + lifecycle.record_boot_start(3).unwrap(); + lifecycle.record_boot_success().unwrap(); + + assert_eq!(lifecycle.pointers().last_booted_patch, Some(3)); + assert!(lifecycle.pointers().currently_booting_patch.is_none()); + // Older patches removed entirely. + assert!(!lifecycle.patch_dir(1).exists()); + assert!(!lifecycle.patch_dir(2).exists()); + // Booted patch survives. + assert!(lifecycle.patch_dir(3).exists()); + } + + #[test] + fn record_boot_success_keeps_bad_tombstones_for_older() { + let (_tmp, mut lifecycle) = fixture(); + install_patch(&lifecycle, 1, 100); + install_patch(&lifecycle, 2, 200); + install_patch(&lifecycle, 3, 300); + + // Patch 2 went bad some time ago. + lifecycle.mark_bad(2, BadReason::BootCrash).unwrap(); + + lifecycle.record_boot_start(3).unwrap(); + lifecycle.record_boot_success().unwrap(); + + assert!(!lifecycle.patch_dir(1).exists(), "1 forgotten"); + // Patch 2's tombstone survives older-than cleanup. + assert!(matches!( + lifecycle.read_state(2), + Some(PatchState::Bad { .. }) + )); + assert!(lifecycle.patch_dir(3).exists()); + } + + #[test] + fn record_boot_failure_marks_bad_and_recomputes_next_boot() { + let (_tmp, mut lifecycle) = fixture(); + install_patch(&lifecycle, 1, 100); + install_patch(&lifecycle, 2, 200); + // Pretend 1 was the last-booted, 2 is queued for next boot. + lifecycle.pointers.last_booted_patch = Some(1); + lifecycle.pointers.next_boot_patch = Some(2); + lifecycle.save_pointers().unwrap(); + + lifecycle.record_boot_start(2).unwrap(); + let bad_n = lifecycle.record_boot_failure().unwrap(); + + assert_eq!(bad_n, 2); + assert!(matches!( + lifecycle.read_state(2), + Some(PatchState::Bad { .. }) + )); + // Last-booted promoted as the new next-boot. + assert_eq!(lifecycle.pointers().next_boot_patch, Some(1)); + assert!(lifecycle.pointers().currently_booting_patch.is_none()); + } + + #[test] + fn record_boot_failure_clears_next_boot_when_last_booted_is_also_bad() { + let (_tmp, mut lifecycle) = fixture(); + install_patch(&lifecycle, 1, 100); + install_patch(&lifecycle, 2, 200); + lifecycle.mark_bad(1, BadReason::BootCrash).unwrap(); + lifecycle.pointers.last_booted_patch = Some(1); + lifecycle.pointers.next_boot_patch = Some(2); + lifecycle.save_pointers().unwrap(); + + lifecycle.record_boot_start(2).unwrap(); + lifecycle.record_boot_failure().unwrap(); + + // Both candidates are Bad; no fallback target → boot base. + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + + #[test] + fn detect_boot_crash_on_init_recovers_when_breadcrumb_set() { + let tmp = TempDir::new().unwrap(); + // First "process": records boot start, then "crashes" without + // recording success or failure. + { + let mut lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + install_patch(&lifecycle, 1, 100); + lifecycle.record_boot_start(1).unwrap(); + // Drop without record_boot_success/failure. + } + // Second "process": init detects the breadcrumb and marks Bad. + let mut lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + let recovered = lifecycle.detect_boot_crash_on_init().unwrap(); + assert_eq!(recovered, Some(1)); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { .. }) + )); + assert!(lifecycle.pointers().currently_booting_patch.is_none()); + } + + #[test] + fn detect_boot_crash_on_init_is_noop_when_no_breadcrumb() { + let (_tmp, mut lifecycle) = fixture(); + assert_eq!(lifecycle.detect_boot_crash_on_init().unwrap(), None); + } + + #[test] + fn validate_next_boot_patch_marks_bad_on_size_mismatch() { + let (_tmp, mut lifecycle) = fixture(); + // Install patch 1 with a state.json claiming size=100. + install_patch(&lifecycle, 1, 100); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + // Truncate the artifact so it no longer matches. + std::fs::write(installed_artifact_path(&lifecycle.root, 1), b"short").unwrap(); + + let result = lifecycle.validate_next_boot_patch(None, PatchVerificationMode::default()); + assert!(result.is_err()); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { + reason: BadReason::ValidationFailed, + .. + }) + )); + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + + #[test] + fn validate_next_boot_patch_is_noop_when_unset() { + let (_tmp, mut lifecycle) = fixture(); + assert!(lifecycle + .validate_next_boot_patch(None, PatchVerificationMode::default()) + .is_ok()); + } + + #[test] + fn promote_to_next_boot_replaces_unbooted_predecessor() { + let (_tmp, mut lifecycle) = fixture(); + install_patch(&lifecycle, 1, 100); + install_patch(&lifecycle, 2, 200); + lifecycle.promote_to_next_boot(1).unwrap(); + // Now install 2 and promote it; 1 was never booted (last_booted is + // None) and should be forgotten. + lifecycle.promote_to_next_boot(2).unwrap(); + assert_eq!(lifecycle.pointers().next_boot_patch, Some(2)); + assert!(!lifecycle.patch_dir(1).exists(), "unbooted 1 forgotten"); + } + + #[test] + fn promote_to_next_boot_preserves_last_booted_patch() { + let (_tmp, mut lifecycle) = fixture(); + install_patch(&lifecycle, 1, 100); + install_patch(&lifecycle, 2, 200); + lifecycle.pointers.last_booted_patch = Some(1); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + // Now install 2 and promote it; 1 is last_booted so survives. + lifecycle.promote_to_next_boot(2).unwrap(); + assert_eq!(lifecycle.pointers().next_boot_patch, Some(2)); + assert!(lifecycle.patch_dir(1).exists(), "last_booted 1 preserved"); + } + + #[test] + fn promote_to_next_boot_requires_installed() { + let (_tmp, mut lifecycle) = fixture(); + assert!(lifecycle.promote_to_next_boot(1).is_err()); + } } From 0b08f18dbff3d1a935af720cb7fae709483dd469 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 16:49:31 -0700 Subject: [PATCH 03/21] refactor: replace UpdaterState's PatchManager backend with PatchLifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the patch_manager dependency from updater_state.rs. UpdaterState now owns a PatchLifecycle directly, with all patch-related methods delegating to it: install_patch → write Installed state + promote to next_boot next_boot_patch → pointers.next_boot_patch + installed_artifact_path is_known_bad_patch → read_state matches Bad uninstall_patch → cleanup + recompute_next_boot record_boot_* → lifecycle's boot transitions validate_next_boot_patch → lifecycle's signature/size check UpdaterState's own state.json shrinks to {client_id, release_version, queued_events}. Per-release per-patch state lives entirely in the lifecycle (pointers.json + patches/{N}/state.json). Other notable behavior changes: - record_boot_failure_for_patch no longer requires currently_booting_patch to match. Matches the prior PatchManager semantics: clear breadcrumb, mark Bad{BootCrash}, recompute. - recompute_next_boot leaves a valid Installed next_boot_patch alone instead of promoting last_booted over it. Without this, processing server rollbacks would clobber a freshly-installed newer patch. Tests in updater.rs that asserted file paths (patches_state.json) update to the new layout (pointers.json). The MockManagePatches-backed unit tests in updater_state.rs are gone — replaced by direct end-to-end tests that exercise PatchLifecycle through UpdaterState. patch_manager.rs is still on disk and compiled (nothing references it from cache/mod.rs anymore) — deletion happens after the updater.rs cutover. --- library/src/cache/lifecycle.rs | 54 +-- library/src/cache/updater_state.rs | 622 +++++++++++++---------------- library/src/updater.rs | 55 +-- 3 files changed, 330 insertions(+), 401 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 4e72a373..8e8ef9f5 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -115,6 +115,7 @@ pub struct ReleasePointers { /// Per-release patch lifecycle and storage. Owns `{root}/patches/` and /// `{root}/pointers.json`. +#[derive(Debug)] pub struct PatchLifecycle { root: PathBuf, pointers: ReleasePointers, @@ -448,36 +449,32 @@ impl PatchLifecycle { Ok(()) } - /// Records a boot failure. Marks the in-flight patch - /// `Bad{BootCrash}` and recomputes `next_boot_patch` from the - /// remaining state. Returns the patch number that was marked bad. - pub fn record_boot_failure(&mut self) -> Result { - let n = self - .pointers - .currently_booting_patch - .context("record_boot_failure without currently_booting_patch")?; + /// Records that patch `n` failed to boot. Clears the boot + /// breadcrumb, marks the patch `Bad{BootCrash}`, and recomputes + /// `next_boot_patch`. + /// + /// The patch number is passed in (rather than read from + /// `currently_booting_patch`) to match the prior PatchManager API + /// shape — most call sites already have the number in hand. The + /// breadcrumb is cleared regardless of whether it matched. + pub fn record_boot_failure(&mut self, n: usize) -> Result<()> { self.pointers.currently_booting_patch = None; self.pointers.boot_started_at = None; self.save_pointers()?; self.mark_bad(n, BadReason::BootCrash)?; - self.recompute_next_boot()?; - Ok(n) + self.recompute_next_boot() } /// Called at init. If `currently_booting_patch` is still set from a /// prior process, that boot crashed without recording success or /// failure — transition the patch to `Bad{BootCrash}` and recompute - /// `next_boot_patch`. Returns the patch number that was recovered, if - /// any. + /// `next_boot_patch`. Returns the patch number that was recovered, + /// if any. pub fn detect_boot_crash_on_init(&mut self) -> Result> { let Some(n) = self.pointers.currently_booting_patch else { return Ok(None); }; - self.pointers.currently_booting_patch = None; - self.pointers.boot_started_at = None; - self.save_pointers()?; - self.mark_bad(n, BadReason::BootCrash)?; - self.recompute_next_boot()?; + self.record_boot_failure(n)?; Ok(Some(n)) } @@ -502,15 +499,26 @@ impl PatchLifecycle { Ok(()) } - /// Sets `next_boot_patch` to the best Installed candidate. Today the - /// only fallback target is `last_booted_patch` if that patch is - /// currently `Installed`; otherwise we boot the base release. + /// Ensures `next_boot_patch` points at a usable Installed patch. + /// If it already does, no-op. Otherwise (None, Bad, or Unknown) it + /// falls back to `last_booted_patch` if that patch is currently + /// Installed; otherwise None (boot the base release). + /// + /// Crucially, this does not stomp a valid `next_boot_patch` — + /// otherwise a check that processes server rollbacks would clobber + /// a freshly installed newer patch by promoting the older + /// `last_booted_patch` back into `next_boot_patch`. /// /// We deliberately don't scan `patches/` for arbitrary Installed /// patches — within a release there are at most a couple of patches /// active at once, and the last successfully booted patch is the /// only one we have evidence works on this device. pub fn recompute_next_boot(&mut self) -> Result<()> { + if let Some(n) = self.pointers.next_boot_patch { + if matches!(self.read_state(n), Some(PatchState::Installed { .. })) { + return Ok(()); + } + } let new_target = self .pointers .last_booted_patch @@ -1182,9 +1190,7 @@ mod tests { lifecycle.save_pointers().unwrap(); lifecycle.record_boot_start(2).unwrap(); - let bad_n = lifecycle.record_boot_failure().unwrap(); - - assert_eq!(bad_n, 2); + lifecycle.record_boot_failure(2).unwrap(); assert!(matches!( lifecycle.read_state(2), Some(PatchState::Bad { .. }) @@ -1205,7 +1211,7 @@ mod tests { lifecycle.save_pointers().unwrap(); lifecycle.record_boot_start(2).unwrap(); - lifecycle.record_boot_failure().unwrap(); + lifecycle.record_boot_failure(2).unwrap(); // Both candidates are Bad; no fallback target → boot base. assert_eq!(lifecycle.pointers().next_boot_patch, None); diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index e8ca3b04..ce6755f9 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -1,63 +1,51 @@ // This file deals with the cache / state management for the updater. -// This code is very confused and uses "patch number" sometimes -// and "slot index" others. The public interface should be -// consistent and use patch number everywhere. -// PatchInfo can probably go away. - use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use crate::events::PatchEvent; use crate::yaml::PatchVerificationMode; -use super::patch_manager::{ManagePatches, PatchManager}; -use super::{disk_io, PatchInfo}; +use super::lifecycle::{installed_artifact_path, PatchLifecycle, PatchState}; +use super::{disk_io, signing, PatchInfo}; -/// Where the updater state is stored on disk. const STATE_FILE_NAME: &str = "state.json"; -/// Records the updater's "state of the world" - which patches we know to be -/// good or bad, which patches we have downloaded, which patch we're currently -/// booted from, events that need to be reported to the server, etc. +/// 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. /// -// This struct is public, as callers can have a handle to it, but modifying -// anything inside should be done via the functions below. -// TODO(eseidel): Split the per-release state from the per-device state. -// That way per-release state is reset when the release version changes. -// but per-device state is not. +/// 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 { - // Per-device state: - /// Where this writes to disk. Don't serialize this field, as it can change - /// between runs of the app. cache_dir: PathBuf, - - patch_manager: Box, - + lifecycle: PatchLifecycle, + patch_public_key: Option, + verification_mode: PatchVerificationMode, serialized_state: SerializedState, } -/// UpdaterState fields that are serialized to disk. -/// -/// Written out to disk as a json file at STATE_FILE_NAME. +/// UpdaterState fields that are serialized to disk at `{cache}/state.json`. #[derive(Debug, Deserialize, Serialize)] struct SerializedState { - /// The client ID for this device. This is assigned on the first launch of this app and persists - /// between release versions. This is only reset when the app is uninstalled. - /// Shorebird uses these per-install ids in order to provide you, the customer, - /// install-count analytics for your apps. Storage or use of this, and any other, - /// information is covered in our privacy policy: https://shorebird.dev/privacy/ + /// Stable per-install ID. Survives release-version changes; only + /// reset when the app is uninstalled. Used for analytics. + /// client_id: String, - // Per-release state: - /// The release version this cache corresponds to. - /// If this does not match the release version we're booting from we will - /// clear the cache. + /// The release version this cache corresponds to. If this doesn't + /// match the release version we're booting from, the patch state + /// is wiped and rebuilt for the new release. release_version: String, - /// Events that have not yet been sent to the server. - /// Format could change between releases, so this is per-release state. + /// Events that have not yet been sent to the server. Format may + /// change between releases, so this is per-release state. queued_events: Vec, } @@ -74,16 +62,13 @@ fn is_file_not_found(error: &anyhow::Error) -> bool { false } -/// Serialized updater state impl UpdaterState { pub fn client_id(&self) -> String { self.serialized_state.client_id.clone() } } -/// Lifecycle methods for the updater state. impl UpdaterState { - /// Creates a new `UpdaterState`. fn new( cache_dir: PathBuf, release_version: String, @@ -92,12 +77,10 @@ impl UpdaterState { client_id: String, ) -> Self { Self { - cache_dir: cache_dir.clone(), - patch_manager: Box::new(PatchManager::new( - cache_dir.clone(), - patch_public_key, - verification_mode, - )), + lifecycle: PatchLifecycle::load_or_default(cache_dir.clone()), + cache_dir, + patch_public_key: patch_public_key.map(|s| s.to_owned()), + verification_mode, serialized_state: SerializedState { client_id, release_version, @@ -106,35 +89,34 @@ impl UpdaterState { } } - /// Loads UpdaterState from disk fn load( cache_dir: &Path, patch_public_key: Option<&str>, verification_mode: PatchVerificationMode, - ) -> anyhow::Result { + ) -> Result { let path = cache_dir.join(STATE_FILE_NAME); let serialized_state = disk_io::read(&path)?; - Ok(UpdaterState { + Ok(Self { cache_dir: cache_dir.to_path_buf(), - patch_manager: Box::new(PatchManager::new( - cache_dir.to_path_buf(), - patch_public_key, - verification_mode, - )), + lifecycle: PatchLifecycle::load_or_default(cache_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. + /// Initializes a new UpdaterState and saves it to disk. Wipes any + /// existing per-release patch state — used when the release version + /// changes or when the on-disk state was unparseable. fn create_new_and_save( - storage_dir: &Path, + cache_dir: &Path, release_version: &str, patch_public_key: Option<&str>, verification_mode: PatchVerificationMode, client_id: String, ) -> Self { let mut state = Self::new( - storage_dir.to_owned(), + cache_dir.to_owned(), release_version.to_owned(), patch_public_key, verification_mode, @@ -143,19 +125,29 @@ impl UpdaterState { if let Err(e) = state.save() { shorebird_warn!("Error saving state {:?}, ignoring.", e); } - // Ensure we clear any patch data if we're creating a new state. - let _ = state.patch_manager.reset(); + // Wipe per-release patch storage from any prior release. + let patches_root = cache_dir.join("patches"); + if patches_root.exists() { + if let Err(e) = std::fs::remove_dir_all(&patches_root) { + shorebird_error!("Failed to wipe patches dir on reset: {:?}", e); + } + } + let pointers_path = cache_dir.join("pointers.json"); + if pointers_path.exists() { + let _ = std::fs::remove_file(&pointers_path); + } + // Reload lifecycle from a clean slate. + state.lifecycle = PatchLifecycle::load_or_default(cache_dir.to_path_buf()); state } pub fn load_or_new_on_error( - storage_dir: &Path, + cache_dir: &Path, release_version: &str, patch_public_key: Option<&str>, verification_mode: PatchVerificationMode, ) -> Self { - let load_result = Self::load(storage_dir, patch_public_key, verification_mode); - match load_result { + match Self::load(cache_dir, patch_public_key, verification_mode) { Ok(loaded) => { if loaded.serialized_state.release_version != release_version { shorebird_info!( @@ -164,7 +156,7 @@ impl UpdaterState { release_version ); return Self::create_new_and_save( - storage_dir, + cache_dir, release_version, patch_public_key, verification_mode, @@ -178,7 +170,7 @@ impl UpdaterState { shorebird_info!("No existing state file found: {:#}, creating new state.", e); } Self::create_new_and_save( - storage_dir, + cache_dir, release_version, patch_public_key, verification_mode, @@ -188,116 +180,155 @@ impl UpdaterState { } } - /// Saves the updater state to disk. - pub fn save(&self) -> anyhow::Result<()> { - let path = Path::new(&self.cache_dir).join(STATE_FILE_NAME); - disk_io::write(&self.serialized_state, &path) + /// 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 management. All patch management is done via the patch manager. +/// Patch lifecycle accessors — UpdaterState delegates to [`PatchLifecycle`]. impl UpdaterState { - /// Records that we are attempting to boot the patch with patch_number. + pub fn lifecycle(&self) -> &PatchLifecycle { + &self.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.patch_manager.record_boot_start_for_patch(patch_number) + self.lifecycle.record_boot_start(patch_number) } - /// Records that the patch with patch_number failed to boot, uninstalls the patch. + /// 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.patch_manager - .record_boot_failure_for_patch(patch_number) + self.lifecycle.record_boot_failure(patch_number) } - /// Records that the patch with patch_number was successfully booted, marks the patch as "good". + /// Records that the in-flight boot succeeded. pub fn record_boot_success(&mut self) -> Result<()> { - self.patch_manager.record_boot_success() + self.lifecycle.record_boot_success() } - /// The patch that is currently in the process of booting. That is, we've recorded a boot start - /// but not yet a boot success or failure. pub fn currently_booting_patch(&self) -> Option { - self.patch_manager.currently_booting_patch() + self.lifecycle + .pointers() + .currently_booting_patch + .map(|n| self.patch_info(n)) } - /// Unix timestamp (seconds) when the current boot attempt started, if known. pub fn boot_started_at(&self) -> Option { - self.patch_manager.boot_started_at() + self.lifecycle.pointers().boot_started_at } - /// The last patch that was successfully booted (e.g., for which we record_boot_success was - /// called). - /// Will be None if: - /// - There was no good patch at time of boot. - /// - The updater has been initialized but no boot recorded yet. pub fn last_successfully_booted_patch(&self) -> Option { - self.patch_manager.last_successfully_booted_patch() + self.lifecycle + .pointers() + .last_booted_patch + .map(|n| self.patch_info(n)) } - /// The patch this process is using, set at `report_launch_start` and - /// kept until the next launch. `None` means the process is running the - /// base release. Survives server-driven rollbacks of that patch — the - /// running process is still using it. + /// 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 { - self.patch_manager.running_patch() + crate::config::running_patch_number().map(|n| self.patch_info(n)) } - /// Records which patch this process is using. Called from - /// `report_launch_start` with `Some(n)` when launching a patch, or - /// `None` when launching the base release. pub fn set_running_patch(&mut self, patch_number: Option) { - self.patch_manager.set_running_patch(patch_number); + crate::config::set_running_patch_number(patch_number); } - /// This is the patch that will be used for the next boot. - /// Will be None if: - /// - There has never been a patch selected. - /// - There was a patch selected but it was later marked as bad. pub fn next_boot_patch(&mut self) -> Option { - self.patch_manager.next_boot_patch() + self.lifecycle + .pointers() + .next_boot_patch + .map(|n| self.patch_info(n)) } - /// Performs integrity checks on the next boot patch. If the patch fails these checks, the patch - /// will be deleted and the next boot patch will be set to the last successfully booted patch or - /// the base release if there is no last successfully booted patch. - /// - /// Returns an error if the patch fails integrity checks. - pub fn validate_next_boot_patch(&mut self) -> anyhow::Result<()> { - self.patch_manager.validate_next_boot_patch() + /// 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) } - /// Copies the patch file at file_path to the manager's directory structure sets - /// this patch as the next patch to boot. + /// 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`. pub fn install_patch( &mut self, patch: &PatchInfo, hash: &str, signature: Option<&str>, - ) -> anyhow::Result<()> { - self.patch_manager - .add_patch(patch.number, &patch.path, hash, signature) + ) -> 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 = installed_artifact_path(&self.cache_dir, patch.number); + if let Some(parent) = installed_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::rename(&patch.path, &installed_path)?; + let installed_size = std::fs::metadata(&installed_path)?.len(); + self.lifecycle.write_state( + patch.number, + &PatchState::Installed { + hash: hash.to_string(), + signature: signature.map(String::from), + size: installed_size, + }, + )?; + self.lifecycle.promote_to_next_boot(patch.number) } - /// Removes the artifacts for patch `patch_number` from disk and updates state to ensure the - /// uninstalled patch is not booted in the future. + /// 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.patch_manager.remove_patch(patch_number) + self.lifecycle.cleanup(patch_number)?; + self.lifecycle.recompute_next_boot() } - /// Returns true if we have previously failed to boot from patch `patch_number`. + /// 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 { - self.patch_manager.is_known_bad_patch(patch_number) + matches!( + self.lifecycle.read_state(patch_number), + Some(PatchState::Bad { .. }) + ) + } + + fn patch_info(&self, n: usize) -> PatchInfo { + PatchInfo { + path: installed_artifact_path(&self.cache_dir, n), + number: n, + } } } -/// PatchEvent management +/// PatchEvent management. impl UpdaterState { - /// Adds an event to the queue to be sent to the server. pub fn queue_event(&mut self, event: PatchEvent) -> Result<()> { self.serialized_state.queued_events.push(event); self.save() } - /// Returns up to `limit` events from the reporting queue. pub fn copy_events(&self, limit: usize) -> Vec { self.serialized_state .queued_events @@ -307,7 +338,6 @@ impl UpdaterState { .collect() } - /// Removes all events from the reporting queue. pub fn clear_events(&mut self) -> Result<()> { self.serialized_state.queued_events.clear(); self.save() @@ -316,277 +346,167 @@ impl UpdaterState { #[cfg(test)] mod tests { - use tempfile::TempDir; - - use crate::cache::patch_manager::MockManagePatches; - - use mockall::predicate::eq; - use super::*; + use crate::cache::lifecycle::BadReason; + use tempfile::TempDir; - fn test_state(tmp_dir: &TempDir, patch_manager: MP) -> UpdaterState - where - MP: ManagePatches + 'static, - { - UpdaterState { - cache_dir: tmp_dir.path().to_path_buf(), - patch_manager: Box::new(patch_manager), - serialized_state: SerializedState { - client_id: "123".to_string(), - release_version: "1.0.0+1".to_string(), - queued_events: Vec::new(), - }, - } - } - - fn fake_patch(tmp_dir: &TempDir, number: usize) -> super::PatchInfo { - let path = tmp_dir.path().join(format!("patch_{}", number)); - std::fs::write(&path, "fake patch").unwrap(); + 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 } } - #[test] - fn release_version_changed_resets_patches() { - let tmp_dir = TempDir::new().unwrap(); - let mut patch_manager = PatchManager::manager_for_test(&tmp_dir); - let file_path = &tmp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, "patch file contents").unwrap(); - assert!(patch_manager.add_patch(1, file_path, "hash", None).is_ok()); - - let state = test_state(&tmp_dir, patch_manager); - let release_version = state.serialized_state.release_version.clone(); - assert!(state.save().is_ok()); - - let mut state = UpdaterState::load_or_new_on_error( - &state.cache_dir, - &release_version, + fn load(tmp: &TempDir, release_version: &str) -> UpdaterState { + UpdaterState::load_or_new_on_error( + tmp.path(), + release_version, None, PatchVerificationMode::default(), - ); - assert_eq!(state.next_boot_patch().unwrap().number, 1); - - let mut next_version_state = UpdaterState::load_or_new_on_error( - &state.cache_dir, - "1.0.0+2", - None, - PatchVerificationMode::default(), - ); - assert!(next_version_state.next_boot_patch().is_none()); + ) } #[test] - fn is_file_not_found_test() { - use anyhow::Context; - assert!(!super::is_file_not_found(&anyhow::anyhow!(""))); - let tmp_dir = TempDir::new().unwrap(); - let path = tmp_dir.path().join("does_not_exist"); - let result = std::fs::File::open(path).context("foo"); - assert!(result.is_err()); - assert!(super::is_file_not_found(&result.unwrap_err())); - } + fn release_version_change_wipes_patch_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)); - #[test] - fn creates_updater_state_with_client_id() { - let tmp_dir = TempDir::new().unwrap(); - let state = UpdaterState::load_or_new_on_error( - tmp_dir.path(), - "1.0.0+1", - None, - PatchVerificationMode::default(), - ); - let saved_state = UpdaterState::load_or_new_on_error( - tmp_dir.path(), - "1.0.0+1", - None, - PatchVerificationMode::default(), - ); - assert_eq!( - state.serialized_state.client_id, - saved_state.serialized_state.client_id - ); + let mut next = load(&tmp, "1.0.0+2"); + assert!(next.next_boot_patch().is_none()); } - // A new UpdaterState is created when the release version is changed, but - // the client_id should remain the same. #[test] - fn client_id_does_not_change_if_release_version_changes() { - let tmp_dir = TempDir::new().unwrap(); - - let state = test_state(&tmp_dir, PatchManager::manager_for_test(&tmp_dir)); - let original_loaded = UpdaterState::load_or_new_on_error( - &state.cache_dir, - &state.serialized_state.release_version, - None, - PatchVerificationMode::default(), - ); - - let new_loaded = UpdaterState::load_or_new_on_error( - &state.cache_dir, - "1.0.0+2", - None, - PatchVerificationMode::default(), - ); - - assert_eq!( - original_loaded.serialized_state.client_id, - new_loaded.serialized_state.client_id - ); + 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 does_not_save_cache_dir() { - let original_tmp_dir = TempDir::new().unwrap(); - let original_state = UpdaterState { - cache_dir: original_tmp_dir.path().to_path_buf(), - patch_manager: Box::new(PatchManager::manager_for_test(&original_tmp_dir)), - serialized_state: SerializedState { - client_id: "123".to_string(), - release_version: "1.0.0+1".to_string(), - queued_events: Vec::new(), - }, - }; - original_state.save().unwrap(); + 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(); - let new_tmp_dir = TempDir::new().unwrap(); - let original_state_path = original_tmp_dir.path().join(STATE_FILE_NAME); - let new_state_path = new_tmp_dir.path().join(STATE_FILE_NAME); - std::fs::rename(original_state_path, new_state_path).unwrap(); + std::fs::write(tmp.path().join(STATE_FILE_NAME), "garbage").unwrap(); - let new_state = - UpdaterState::load(new_tmp_dir.path(), None, PatchVerificationMode::default()).unwrap(); - assert_eq!(new_state.cache_dir, new_tmp_dir.path()); + let mut reloaded = load(&tmp, "1.0.0+2"); + assert!(reloaded.next_boot_patch().is_none()); } #[test] - fn record_boot_failure_for_patch_forwards_to_patch_manager() { - let patch_number = 1; - let tmp_dir = TempDir::new().unwrap(); - let mut mock_manage_patches = MockManagePatches::new(); - mock_manage_patches - .expect_record_boot_failure_for_patch() - .with(eq(patch_number)) - .returning(|_| Ok(())); - let mut state = test_state(&tmp_dir, mock_manage_patches); - assert!(state.record_boot_failure_for_patch(patch_number).is_ok()); + fn install_patch_renames_into_lifecycle_dir_and_sets_next_boot() { + 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 record_boot_success_for_patch_forwards_to_patch_manager() { - let tmp_dir = TempDir::new().unwrap(); - let mut mock_manage_patches = MockManagePatches::new(); - mock_manage_patches - .expect_record_boot_success() - .returning(|| Ok(())); - let mut state = test_state(&tmp_dir, mock_manage_patches); - - assert!(state.record_boot_success().is_ok()); - } - - #[test] - fn last_successfully_booted_patch_forwards_from_patch_manager() { - let tmp_dir = TempDir::new().unwrap(); - let patch = fake_patch(&tmp_dir, 1); - let mut mock_manage_patches = MockManagePatches::new(); - mock_manage_patches - .expect_last_successfully_booted_patch() - .return_const(Some(patch.clone())); - let state = test_state(&tmp_dir, mock_manage_patches); - assert_eq!(state.last_successfully_booted_patch(), Some(patch)); + fn install_patch_replaces_unbooted_predecessor() { + 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!(!installed_artifact_path(tmp.path(), 1).exists()); } #[test] - fn next_boot_patch_forwards_from_patch_manager() { - let patch_number = 1; - let tmp_dir = TempDir::new().unwrap(); - let patch = fake_patch(&tmp_dir, patch_number); - let mut mock_manage_patches = MockManagePatches::new(); - mock_manage_patches - .expect_next_boot_patch() - .return_const(Some(patch.clone())); - let mut state = test_state(&tmp_dir, mock_manage_patches); - assert_eq!(state.next_boot_patch(), Some(patch)); + fn install_patch_errors_when_file_missing() { + 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()); } #[test] - fn validate_next_boot_patch_forwards_to_patch_manager() { - let tmp_dir = TempDir::new().unwrap(); - let mut mock_manage_patches = MockManagePatches::new(); - mock_manage_patches - .expect_validate_next_boot_patch() - .returning(|| Ok(())); - let mut state = test_state(&tmp_dir, mock_manage_patches); - assert!(state.validate_next_boot_patch().is_ok()); + fn boot_lifecycle_tracks_state() { + 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 install_patch_forwards_to_patch_manager() { - let patch_number = 1; - let tmp_dir = TempDir::new().unwrap(); - let patch = fake_patch(&tmp_dir, patch_number); - let mut mock_manage_patches = MockManagePatches::new(); - let cloned_patch = patch.clone(); - mock_manage_patches - .expect_add_patch() - .withf(move |number, path, hash, signature| { - number == &cloned_patch.number - && path == cloned_patch.path - && hash == "hash" - && signature == &Some("signature") - }) - .returning(|_, __, ___, ____| Ok(())); - let mut state = test_state(&tmp_dir, mock_manage_patches); - - assert!(state - .install_patch(&patch, "hash", Some("signature")) - .is_ok()); + fn record_boot_failure_marks_bad_and_clears_next_boot() { + 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 is_known_bad_patch_returns_value_from_patch_manager() { - let tmp_dir = TempDir::new().unwrap(); - let mut mock_manage_patches = MockManagePatches::new(); - mock_manage_patches - .expect_is_known_bad_patch() - .with(eq(1)) - .return_const(true); - mock_manage_patches - .expect_is_known_bad_patch() - .with(eq(2)) - .return_const(false); - let state = test_state(&tmp_dir, mock_manage_patches); + 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.is_known_bad_patch(2)); + assert!(state.next_boot_patch().is_none()); } #[test] - fn load_or_new_on_error_clears_patch_state_on_error() -> Result<()> { - let tmp_dir = TempDir::new()?; - - // Create a new state, add a patch, and save it. - let mut state = UpdaterState::load_or_new_on_error( - tmp_dir.path(), - "1.0.0+1", - None, - PatchVerificationMode::default(), - ); - let patch = fake_patch(&tmp_dir, 1); - state.install_patch(&patch, "hash", None)?; - state.save()?; - assert_eq!(state.next_boot_patch().unwrap().number, 1); - - // Corrupt the state file. - let state_file = tmp_dir.path().join(STATE_FILE_NAME); - std::fs::write(&state_file, "corrupt json")?; - - // Ensure that, by corrupting the file, we've reset the patches state. - let mut state = UpdaterState::load_or_new_on_error( - tmp_dir.path(), - "1.0.0+2", - None, - PatchVerificationMode::default(), - ); + 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!(!installed_artifact_path(tmp.path(), 1).exists()); + } - Ok(()) + #[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)); } } diff --git a/library/src/updater.rs b/library/src/updater.rs index 908e570c..eda48156 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -2901,19 +2901,20 @@ mod state_recovery_tests { Ok(()) })?; - // Corrupt the patches_state.json file. - let patches_state_path = tmp_dir.path().join("patches_state.json"); + // Corrupt the pointers.json file. + let pointers_path = tmp_dir.path().join("pointers.json"); assert!( - patches_state_path.exists(), - "patches_state.json should exist before we corrupt it" + pointers_path.exists(), + "pointers.json should exist before we corrupt it" ); - std::fs::write(&patches_state_path, "{{{{not json at all")?; + std::fs::write(&pointers_path, "{{{{not json at all")?; // Reinitialize — should recover gracefully. init_for_testing(&tmp_dir, None); with_mut_state(|state| { - // Corrupt state means we lose knowledge of the patch. + // Corrupt pointers means we lose knowledge of which patch + // to boot. assert!( state.next_boot_patch().is_none(), "Expected no next_boot_patch after state corruption" @@ -2924,7 +2925,7 @@ mod state_recovery_tests { Ok(()) } - /// When patches_state.json is deleted but patch artifacts remain on disk, + /// When pointers.json is deleted but patch artifacts remain on disk, /// the updater should not try to boot from orphaned artifacts. #[serial] #[test] @@ -2936,15 +2937,15 @@ mod state_recovery_tests { report_launch_start()?; report_launch_success()?; - // Delete patches_state.json but leave artifacts. - let patches_state_path = tmp_dir.path().join("patches_state.json"); - std::fs::remove_file(&patches_state_path)?; + // Delete pointers.json but leave per-patch artifacts. + let pointers_path = tmp_dir.path().join("pointers.json"); + std::fs::remove_file(&pointers_path)?; // Reinitialize. init_for_testing(&tmp_dir, None); with_mut_state(|state| { - // Without state, we shouldn't boot from any patch. + // Without pointers, we shouldn't boot from any patch. assert!(state.next_boot_patch().is_none()); assert!(state.last_successfully_booted_patch().is_none()); Ok(()) @@ -3117,16 +3118,13 @@ mod state_recovery_tests { install_fake_patch(1)?; report_launch_start()?; - // Manually clear boot_started_at from the state file to simulate - // an old state format that doesn't have this field. - let state_path = tmp_dir.path().join("patches_state.json"); - let state_json = std::fs::read_to_string(&state_path)?; - let mut state_value: serde_json::Value = serde_json::from_str(&state_json)?; - state_value - .as_object_mut() - .unwrap() - .remove("boot_started_at"); - std::fs::write(&state_path, serde_json::to_string(&state_value)?)?; + // Manually clear boot_started_at from the pointers file to + // simulate an old state format that doesn't have this field. + let pointers_path = tmp_dir.path().join("pointers.json"); + let json = std::fs::read_to_string(&pointers_path)?; + let mut value: serde_json::Value = serde_json::from_str(&json)?; + value.as_object_mut().unwrap().remove("boot_started_at"); + std::fs::write(&pointers_path, serde_json::to_string(&value)?)?; // Reinitialize — triggers crash recovery. init_for_testing(&tmp_dir, None); @@ -3193,13 +3191,18 @@ mod state_recovery_tests { let original_client_id = with_state(|state| Ok(state.client_id()))?; - // Corrupt patches_state.json only. - let patches_state_path = tmp_dir.path().join("patches_state.json"); + // Install a patch so pointers.json gets written (it's lazy + // otherwise — load_or_default doesn't write a file until a + // pointer is set). + install_fake_patch(1)?; + + // Corrupt pointers.json only. + let pointers_path = tmp_dir.path().join("pointers.json"); assert!( - patches_state_path.exists(), - "patches_state.json should exist before we corrupt it" + pointers_path.exists(), + "pointers.json should exist before we corrupt it" ); - std::fs::write(&patches_state_path, "corrupt")?; + std::fs::write(&pointers_path, "corrupt")?; // Reinitialize — state.json is fine, so client_id should survive. init_for_testing(&tmp_dir, None); From bc13f6bd830986aed6fd76db625b127997978b4a Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 17:02:11 -0700 Subject: [PATCH 04/21] refactor: cut update_internal over to PatchLifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the download_state.rs sidecar machinery and the bespoke DownloadStartState / should_install_patch helpers in update_internal with calls into PatchLifecycle. Concrete changes: - Drops compute_resume_offset / determine_download_start_state / DownloadStartState — replaced by PatchLifecycle::decide_start returning DownloadAction. - Drops should_install_patch / ShouldInstallPatchCheckResult — folded into the same DownloadAction match. KnownBad → UpdateIsBadPatch, AlreadyInstalled → NoUpdate, the rest proceed. - Drops install_downloaded_patch's bespoke "stage in downloads/, rename on install" choreography. The lifecycle owns the per-patch directory; inflate writes directly into patches/{N}/dlc.vmcode and the Downloaded → Installed transition removes the now-unneeded compressed download file. - Drops cleanup_download_artifacts and clean_download_dir. mark_bad handles tombstone-aware cleanup for failures, and the lifecycle owns its directory. - Marks the patch Bad{InvalidPatchBytes} when inflate fails and Bad{InstallHashMismatch} when check_hash fails. Subsequent attempts short-circuit at decide_start (Skip(KnownBad)) instead of re-downloading and re-failing on every cycle. This is the marks-bad- on-install-failure behavior we deferred from #351. - Preserves the explicit "server-side Content-Length vs total_bytes" mismatch check — surfaces the contract violation directly instead of obliquely via inflate failure. decide_start consults the on-disk download file as the source of truth for "how many bytes do we have so far." The denormalized partial_size in PatchState::Downloading is kept for diagnostics/serde stability but not consulted for the resume offset; this matches the prior file-size-on-disk behavior and avoids needing to update state.json mid-stream from the network layer. For Downloading / Downloaded states where the OS evicted the artifact file out from under us (e.g. iOS code-cache eviction), decide_start falls through to Fresh so the next attempt re-downloads from scratch. The failing-test fixes update file paths and pre-staged state from the old layout (downloads/{N} + downloads/{N}.download.json) to the new layout (patches/{N}/download + patches/{N}/state.json). Several tests that targeted the now-deleted helpers directly are removed; their behavior is covered by the new lifecycle unit tests. --- library/src/cache/lifecycle.rs | 65 ++- library/src/cache/mod.rs | 2 +- library/src/updater.rs | 855 +++++++++------------------------ 3 files changed, 283 insertions(+), 639 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 8e8ef9f5..02901942 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -335,22 +335,36 @@ impl PatchLifecycle { /// through a different CDN URL, and the prior bytes can't be /// trusted. pub fn decide_start(&self, n: usize, url: &str, hash: &str) -> DownloadAction { + // For Downloading/Downloaded, the on-disk file is the source + // of truth for "how many bytes do we have." `partial_size` in + // the state is denormalized info from when the state was last + // written and may lag a partially-completed download. Reading + // from disk avoids needing to update the sidecar mid-stream. + let download_path = download_artifact_path(&self.root, n); match self.read_state(n) { None => DownloadAction::Fresh, Some(PatchState::Downloading { url: prior_url, hash: prior_hash, - partial_size, .. - }) if prior_url == url && prior_hash == hash => DownloadAction::Resume { - offset: partial_size, - }, + }) if prior_url == url && prior_hash == hash => { + match std::fs::metadata(&download_path) { + Ok(meta) => DownloadAction::Resume { offset: meta.len() }, + Err(_) => DownloadAction::Fresh, + } + } Some(PatchState::Downloading { .. }) => DownloadAction::Fresh, Some(PatchState::Downloaded { url: prior_url, hash: prior_hash, .. - }) if prior_url == url && prior_hash == hash => DownloadAction::Complete, + }) if prior_url == url && prior_hash == hash => { + if download_path.exists() { + DownloadAction::Complete + } else { + DownloadAction::Fresh + } + } Some(PatchState::Downloaded { .. }) => DownloadAction::Fresh, Some(PatchState::Installed { .. }) => { DownloadAction::Skip(SkipReason::AlreadyInstalled) @@ -895,12 +909,35 @@ mod tests { }, ) .unwrap(); + std::fs::write(download_artifact_path(&lifecycle.root, 1), vec![0u8; 250]).unwrap(); assert_eq!( lifecycle.decide_start(1, "https://example/p", "h"), DownloadAction::Resume { offset: 250 } ); } + #[test] + fn decide_start_downloading_with_missing_file_starts_fresh() { + let (_tmp, lifecycle) = fixture(); + // State says we were 250 bytes in, but the file is gone (e.g. + // OS evicted it from the code cache). + lifecycle + .write_state( + 1, + &PatchState::Downloading { + url: "https://example/p".into(), + hash: "h".into(), + signature: None, + partial_size: 250, + }, + ) + .unwrap(); + assert_eq!( + lifecycle.decide_start(1, "https://example/p", "h"), + DownloadAction::Fresh + ); + } + #[test] fn decide_start_url_mismatch_starts_fresh() { let (_tmp, lifecycle) = fixture(); @@ -952,12 +989,30 @@ mod tests { }, ) .unwrap(); + std::fs::write(download_artifact_path(&lifecycle.root, 1), vec![0u8; 1000]).unwrap(); assert_eq!( lifecycle.decide_start(1, "u", "h"), DownloadAction::Complete ); } + #[test] + fn decide_start_downloaded_with_missing_file_starts_fresh() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloaded { + url: "u".into(), + hash: "h".into(), + signature: None, + size: 1000, + }, + ) + .unwrap(); + assert_eq!(lifecycle.decide_start(1, "u", "h"), DownloadAction::Fresh); + } + #[test] fn decide_start_skips_installed() { let (_tmp, lifecycle) = fixture(); diff --git a/library/src/cache/mod.rs b/library/src/cache/mod.rs index 2792bd9e..04c4f5eb 100644 --- a/library/src/cache/mod.rs +++ b/library/src/cache/mod.rs @@ -1,4 +1,4 @@ -mod disk_io; +pub(crate) mod disk_io; pub mod lifecycle; mod patch_manager; mod signing; diff --git a/library/src/updater.rs b/library/src/updater.rs index eda48156..1c365e2c 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -9,9 +9,9 @@ use crate::file_errors::{FileOperation, IoResultExt}; use anyhow::{bail, Context, Result}; use dyn_clone::DynClone; +use crate::cache::lifecycle::{self, BadReason, DownloadAction, SkipReason}; use crate::cache::{PatchInfo, UpdaterState}; use crate::config::{set_config, with_config, UpdateConfig}; -use crate::download_state::{self, DownloadState}; use crate::events::{EventType, PatchEvent}; use crate::logging::init_logging; use crate::network::{ @@ -303,11 +303,15 @@ pub fn check_for_downloadable_update(channel: Option<&str>) -> anyhow::Result Ok(true), - ShouldInstallPatchCheckResult::PatchKnownBad => Ok(false), - ShouldInstallPatchCheckResult::PatchAlreadyInstalled => Ok(false), - } + let action = with_state(|state| { + Ok(state + .lifecycle() + .decide_start(patch.number, &patch.download_url, &patch.hash)) + })?; + Ok(matches!( + action, + DownloadAction::Fresh | DownloadAction::Resume { .. } | DownloadAction::Complete + )) } else { Ok(false) } @@ -427,10 +431,21 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul let patch = response.patch.ok_or(UpdateError::BadServerResponse)?; - match should_install_patch(patch.number)? { - ShouldInstallPatchCheckResult::PatchOkToInstall => {} - ShouldInstallPatchCheckResult::PatchKnownBad => return Ok(UpdateStatus::UpdateIsBadPatch), - ShouldInstallPatchCheckResult::PatchAlreadyInstalled => return Ok(UpdateStatus::NoUpdate), + let action = with_state(|state| { + Ok(state + .lifecycle() + .decide_start(patch.number, &patch.download_url, &patch.hash)) + })?; + match action { + DownloadAction::Skip(SkipReason::KnownBad) => { + shorebird_info!("Patch {} is known bad, skipping.", patch.number); + return Ok(UpdateStatus::UpdateIsBadPatch); + } + DownloadAction::Skip(SkipReason::AlreadyInstalled) => { + shorebird_info!("Patch {} is already installed, skipping.", patch.number); + return Ok(UpdateStatus::NoUpdate); + } + _ => {} } shorebird_info!( @@ -439,180 +454,159 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul config.app_id, config.release_version ); - let download_dir = PathBuf::from(&config.download_dir); - let download_path = download_dir.join(patch.number.to_string()); - let start_state = determine_download_start_state( - &download_path, - &patch.download_url, - patch.number, - &patch.hash, - ); + let storage_dir = PathBuf::from(&config.storage_dir); + let download_path = lifecycle::download_artifact_path(&storage_dir, patch.number); - // Ensure the download directory exists. - std::fs::create_dir_all(&download_dir) - .with_file_context(FileOperation::CreateDir, &download_dir)?; - - // Clean up any orphaned files in the download directory. We own this - // directory entirely, so anything that isn't for the current patch is - // stale (e.g. from a prior patch number, a crashed inflate, or a - // partial download for a patch that's since been replaced). - clean_download_dir(&download_dir, patch.number); - - let dl_result = match start_state { - DownloadStartState::Complete(size) => { - // Bytes from a prior attempt are already on disk and the sidecar - // says they're the expected size. Skip the network request and - // let the install path validate them — re-downloading would just - // waste bandwidth in cases like the app being killed between the - // download finishing and install starting. - shorebird_info!( - "Prior download already complete ({} bytes); skipping fetch.", - size - ); - DownloadResult { - total_bytes: size, - content_length: Some(size), + if !matches!(action, DownloadAction::Complete) { + let resume_from = match action { + DownloadAction::Resume { offset } => { + shorebird_info!("Resuming download from byte {}", offset); + offset } - } - DownloadStartState::Fresh | DownloadStartState::Resume(_) => { - // Write sidecar *before* downloading so we can resume on crash. - let dl_state = DownloadState { - url: patch.download_url.clone(), - patch_number: patch.number, - expected_size: None, - expected_hash: patch.hash.clone(), - }; - download_state::write_download_state(&download_path, &dl_state)?; - - let resume_from = match start_state { - DownloadStartState::Resume(offset) => { - shorebird_info!("Resuming download from byte {}", offset); - offset - } - _ => 0, - }; + _ => 0, + }; - // Consider supporting allowing the system to download for us (e.g. iOS). - let dl_result = download_to_path( - &config.network_hooks, + // Record `Downloading` *before* the network call so we can detect + // partial files on the next attempt and resume. + with_mut_state(|state| { + state.lifecycle_mut().record_download_started( + patch.number, &patch.download_url, - &download_path, + &patch.hash, + patch.hash_signature.as_deref(), resume_from, - )?; - - // Record the actual on-disk size, not the server's Content-Length. - // For chunked transfer encoding the server doesn't send - // Content-Length, so dl_result.content_length would be None — and - // a subsequent crash-before-install attempt would then see - // expected_size: None plus a full-size file, fall through to - // Resume(file_size), and re-create the same HTTP 416 this PR - // fixes. Using total_bytes works for both chunked and - // Content-Length responses (they're equal in the latter case). - // - // KNOWN GAP: if the process dies between download_to_path - // returning above and this sidecar write succeeding, expected_size - // stays None from the pre-download write and we hit the 416 loop - // anyway. The window is microseconds; closing it requires - // restructuring (e.g. download_to_path writing the sidecar - // itself, or a single atomic per-patch state document) — tracked - // in shorebirdtech/shorebird#3737. - let dl_state = DownloadState { - expected_size: Some(dl_result.total_bytes), - ..dl_state - }; - download_state::write_download_state(&download_path, &dl_state)?; + ) + })?; - dl_result + if let Some(parent) = download_path.parent() { + std::fs::create_dir_all(parent).with_file_context(FileOperation::CreateDir, parent)?; + } + + // Consider supporting allowing the system to download for us (e.g. iOS). + let dl_result = download_to_path( + &config.network_hooks, + &patch.download_url, + &download_path, + resume_from, + )?; + + // Server-side bug check: when the server told us Content-Length + // ahead of time but delivered a different number of bytes, + // surface the contract violation directly instead of letting it + // surface obliquely through inflate failure. + if let Some(expected) = dl_result.content_length { + if dl_result.total_bytes != expected { + let _ = with_mut_state(|state| state.uninstall_patch(patch.number)); + bail!( + "Download size mismatch: expected {} bytes, got {}", + expected, + dl_result.total_bytes + ); + } } - }; - let output_path = download_dir.join(format!("{}.full", patch.number)); - // Once the download bytes are on disk, they will either be consumed - // (install success) or rejected (any failure). Either way the artifacts - // have served their purpose — clean them up unconditionally so a stuck - // partial file can't make a future Range request go past the end of the - // file and yield HTTP 416. - let result = install_downloaded_patch(&config, &patch, &dl_result, &download_path, output_path); - cleanup_download_artifacts(&download_path); - result + // Transition to `Downloaded`. The recorded size is the actual + // on-disk byte count, not the server's Content-Length — chunked + // responses don't send the latter and the lifecycle relies on + // the state itself (not size comparisons) to distinguish + // complete from partial. + with_mut_state(|state| { + state + .lifecycle_mut() + .record_download_complete(patch.number, dl_result.total_bytes) + })?; + } else { + shorebird_info!("Prior download already complete; skipping fetch."); + } + + install_downloaded_patch(&config, &patch, &download_path) } -/// Consumes the bytes at `download_path`: validates the size, inflates them, -/// hash-checks the result, and installs the patch. Returns `UpdateInstalled` -/// on success. -/// -/// Caller is responsible for cleaning up `download_path` artifacts after this -/// returns, regardless of outcome. +/// Inflates the compressed bytes at `download_path` into the lifecycle's +/// installed location, hash-checks the result, and transitions the patch +/// from `Downloaded` to `Installed`. Marks the patch `Bad` (with the +/// appropriate reason) on inflate or hash-check failure so the next +/// update cycle short-circuits via `decide_start`. fn install_downloaded_patch( config: &UpdateConfig, patch: &crate::network::Patch, - dl_result: &DownloadResult, download_path: &Path, - output_path: PathBuf, ) -> anyhow::Result { - // Validate download size if Content-Length was provided. - if let Some(expected) = dl_result.content_length { - if dl_result.total_bytes != expected { - bail!( - "Download size mismatch: expected {} bytes, got {}", - expected, - dl_result.total_bytes - ); - } - } + let storage_dir = PathBuf::from(&config.storage_dir); + let installed_path = lifecycle::installed_artifact_path(&storage_dir, patch.number); let patch_base_rs = patch_base(config)?; - inflate(download_path, patch_base_rs, &output_path)?; + if let Err(e) = inflate(download_path, patch_base_rs, &installed_path) { + // Inflate failed — bytes won't decompress. Deterministically bad. + mark_patch_bad(patch.number, BadReason::InvalidPatchBytes); + return Err(e); + } - // Check the hash before moving into place. - check_hash(&output_path, &patch.hash).with_context(|| { + if let Err(e) = check_hash(&installed_path, &patch.hash).with_context(|| { format!( "This app reports version {}, but the binary is different from \ the version {} that was submitted to Shorebird.", config.release_version, config.release_version ) - })?; + }) { + // Hash mismatch — bytes decompressed but produced the wrong + // result. Most often a customer build/release mismatch on their + // side; deterministic on this device. + mark_patch_bad(patch.number, BadReason::InstallHashMismatch); + return Err(e); + } + + let installed_size = std::fs::metadata(&installed_path)?.len(); - // We're abusing the config lock as a UpdateState lock for now. - // This makes it so we never try to write to the UpdateState file from - // two threads at once. We could give UpdateState its own lock instead. with_mut_state(|state| { - let patch_info = PatchInfo { - path: output_path, - number: patch.number, - }; - // Move/state update should be "atomic" (it isn't today). - state.install_patch(&patch_info, &patch.hash, patch.hash_signature.as_deref())?; + state + .lifecycle_mut() + .record_install_complete(patch.number, installed_size)?; + state.lifecycle_mut().promote_to_next_boot(patch.number)?; shorebird_info!( "Patch {} successfully downloaded. It will be launched when the app next restarts.", patch.number ); let client_id = state.client_id(); - let config = config.clone(); + let config_clone = config.clone(); let patch_number = patch.number; std::thread::spawn(move || { let event = PatchEvent::new( - &config, + &config_clone, EventType::PatchDownload, patch_number, client_id, None, ); - let report_result = crate::network::send_patch_event(event, &config); - if let Err(err) = report_result { + if let Err(err) = crate::network::send_patch_event(event, &config_clone) { shorebird_error!("Failed to report patch download: {:?}", err); } }); - // Should set some state to say the status is "update required" and that - // we now have a different "next" version of the app from the current - // booted version (patched or not). Ok(UpdateStatus::UpdateInstalled) }) } +/// Marks `patch_number` Bad{reason} and recomputes `next_boot_patch`. +/// Errors are logged but not propagated — the caller is already +/// returning the underlying install error and the state-machine +/// bookkeeping is best-effort relative to that. +fn mark_patch_bad(patch_number: usize, reason: BadReason) { + let result = with_mut_state(|state| { + state.lifecycle_mut().mark_bad(patch_number, reason)?; + state.lifecycle_mut().recompute_next_boot() + }); + if let Err(e) = result { + shorebird_error!( + "Failed to mark patch {} bad after install failure: {:?}", + patch_number, + e + ); + } +} + fn roll_back_patches_if_needed(patch_numbers: Vec) -> anyhow::Result<()> { with_mut_state(|state| { for patch_number in patch_numbers { @@ -622,141 +616,6 @@ fn roll_back_patches_if_needed(patch_numbers: Vec) -> anyhow::Result<()> }) } -fn should_install_patch(patch_number: usize) -> Result { - // Don't install a patch if it has previously failed to boot. - let is_known_bad_patch = with_state(|state| Ok(state.is_known_bad_patch(patch_number)))?; - if is_known_bad_patch { - shorebird_info!( - "Patch {} has previously failed to boot, skipping.", - patch_number - ); - return Ok(ShouldInstallPatchCheckResult::PatchKnownBad); - } - - // If we already have the latest available patch downloaded, we don't need to download it again. - let next_boot_patch = with_mut_state(|state| Ok(state.next_boot_patch()))?; - if let Some(next_boot_patch) = next_boot_patch { - if next_boot_patch.number == patch_number { - shorebird_info!("Patch {} is already installed, skipping.", patch_number); - return Ok(ShouldInstallPatchCheckResult::PatchAlreadyInstalled); - } - } - - Ok(ShouldInstallPatchCheckResult::PatchOkToInstall) -} - -/// What the prior attempt left on disk, and what to do about it. -#[derive(Debug, PartialEq)] -enum DownloadStartState { - /// No usable prior artifacts — request the full file. - Fresh, - /// Partial file from an interrupted prior attempt. Send a Range header - /// from this byte offset to ask the server for the remainder. - Resume(u64), - /// Prior attempt finished downloading but didn't finish installing - /// (e.g. the process was killed between download and inflate, or - /// inflate/install failed). Skip the network request and let the - /// install path validate the bytes already on disk. - Complete(u64), -} - -/// Inspects the sidecar and partial file to decide how the next download -/// attempt should begin. Read-only — does not modify on-disk state. -fn determine_download_start_state( - download_path: &Path, - url: &str, - patch_number: usize, - expected_hash: &str, -) -> DownloadStartState { - let prior_state = match download_state::read_download_state(download_path) { - Ok(Some(state)) => state, - _ => return DownloadStartState::Fresh, - }; - - // The hash check catches the case where a patch is deleted and re-added - // with the same number — the URL might stay the same but the content - // differs. - if prior_state.url != url - || prior_state.patch_number != patch_number - || prior_state.expected_hash != expected_hash - { - shorebird_info!("Download state mismatch, starting fresh."); - return DownloadStartState::Fresh; - } - - let file_size = match std::fs::metadata(download_path) { - Ok(meta) if meta.len() > 0 => meta.len(), - _ => return DownloadStartState::Fresh, - }; - - // If the prior attempt's bytes look complete, skip the network request. - // The install path will validate them; if they're bad it will fail and - // the unconditional cleanup at the end of `update_internal` will wipe - // them so the next attempt re-downloads. - if let Some(expected) = prior_state.expected_size { - if file_size >= expected { - return DownloadStartState::Complete(file_size); - } - } - - DownloadStartState::Resume(file_size) -} - -/// Removes everything in `download_dir` except files belonging to -/// `current_patch_number`. We own this directory entirely, so anything -/// unrecognized or from a different patch number is safe to delete. -fn clean_download_dir(download_dir: &Path, current_patch_number: usize) { - let entries = match fs::read_dir(download_dir) { - Ok(entries) => entries, - Err(_) => return, // Directory may not exist yet. - }; - - let current_prefix = current_patch_number.to_string(); - for entry in entries.flatten() { - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - - // Keep files that belong to the current patch: - // "{number}", "{number}.full", "{number}.download.json" - if name == current_prefix - || name == format!("{current_prefix}.full") - || name == format!("{current_prefix}.download.json") - { - continue; - } - - // Everything else is an orphan — delete it. - let path = entry.path(); - if path.is_file() { - if let Err(e) = fs::remove_file(&path) { - shorebird_error!("Failed to clean up orphaned file {:?}: {:?}", path, e); - } else { - shorebird_info!("Cleaned up orphaned download file: {:?}", path); - } - } - } -} - -/// Removes the compressed download file and its sidecar. -/// -/// KNOWN GAP: delete errors are logged but not propagated. If a delete -/// silently fails (e.g. transient filesystem error), the stale artifacts -/// persist into the next cycle and could re-create the same 416 loop this -/// PR fixes — `determine_download_start_state` would still see a sidecar -/// with `expected_size` matching the file and try to resume against a -/// resource that's no longer the right one. Disk delete failures are rare -/// in practice; the proper fix lives in shorebirdtech/shorebird#3737. -fn cleanup_download_artifacts(download_path: &Path) { - if let Err(e) = download_state::delete_download_state(download_path) { - shorebird_error!("Failed to delete download sidecar: {:?}", e); - } - if download_path.exists() { - if let Err(e) = std::fs::remove_file(download_path) { - shorebird_error!("Failed to delete download file: {:?}", e); - } - } -} - /// Synchronously checks for an update and downloads and installs it if available. pub fn update(channel: Option<&str>) -> anyhow::Result { match with_updater_thread_lock(|lock_state| update_internal(lock_state, channel)) { @@ -1830,285 +1689,6 @@ patch_verification: bogus_mode .unwrap(); } - use super::DownloadStartState; - - fn write_state(download_path: &Path, expected_size: Option) { - crate::download_state::write_download_state( - download_path, - &crate::download_state::DownloadState { - url: "http://example.com/patch".to_string(), - patch_number: 1, - expected_size, - expected_hash: "abc123".to_string(), - }, - ) - .unwrap(); - } - - #[test] - fn download_start_state_no_sidecar() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/patch", - 1, - "abc123" - ), - DownloadStartState::Fresh - ); - } - - #[test] - fn download_start_state_matching_sidecar_resumes() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, vec![0u8; 500]).unwrap(); - write_state(&download_path, Some(1000)); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/patch", - 1, - "abc123" - ), - DownloadStartState::Resume(500) - ); - } - - #[test] - fn download_start_state_mismatched_url_starts_fresh() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, vec![0u8; 500]).unwrap(); - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { - url: "http://example.com/old-patch".to_string(), - patch_number: 1, - expected_size: None, - expected_hash: "abc123".to_string(), - }, - ) - .unwrap(); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/new-patch", - 1, - "abc123" - ), - DownloadStartState::Fresh - ); - } - - #[test] - fn download_start_state_mismatched_hash_starts_fresh() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, vec![0u8; 500]).unwrap(); - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { - url: "http://example.com/patch".to_string(), - patch_number: 1, - expected_size: None, - expected_hash: "hash_old".to_string(), - }, - ) - .unwrap(); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/patch", - 1, - "hash_new" - ), - DownloadStartState::Fresh - ); - } - - #[test] - fn download_start_state_mismatched_patch_number_starts_fresh() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, vec![0u8; 500]).unwrap(); - write_state(&download_path, None); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/patch", - 2, - "abc123" - ), - DownloadStartState::Fresh - ); - } - - #[test] - fn download_start_state_corrupt_sidecar_starts_fresh() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, vec![0u8; 500]).unwrap(); - let sidecar = crate::download_state::sidecar_path(&download_path); - fs::write(&sidecar, "not valid json").unwrap(); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/patch", - 1, - "abc123" - ), - DownloadStartState::Fresh - ); - } - - #[test] - fn download_start_state_empty_file_starts_fresh() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, []).unwrap(); - write_state(&download_path, None); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/patch", - 1, - "abc123" - ), - DownloadStartState::Fresh - ); - } - - #[test] - fn download_start_state_full_file_reports_complete() { - // Reproduces the customer's stuck state: a prior attempt finished - // downloading (file size == expected_size) but install failed (or the - // app was killed before install). The function should report Complete - // so the caller skips the network request and validates the bytes. - // It must NOT delete anything — that's the caller's job after install. - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, vec![0u8; 1000]).unwrap(); - write_state(&download_path, Some(1000)); - - assert_eq!( - super::determine_download_start_state( - &download_path, - "http://example.com/patch", - 1, - "abc123" - ), - DownloadStartState::Complete(1000) - ); - - // determine_download_start_state must be read-only. - assert!(download_path.exists()); - assert!(crate::download_state::sidecar_path(&download_path).exists()); - } - - #[test] - fn cleanup_download_artifacts_removes_file_and_sidecar() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - fs::create_dir_all(download_path.parent().unwrap()).unwrap(); - - fs::write(&download_path, b"partial data").unwrap(); - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { - url: "http://example.com/patch".to_string(), - patch_number: 1, - expected_size: None, - expected_hash: "abc123".to_string(), - }, - ) - .unwrap(); - - let sidecar = crate::download_state::sidecar_path(&download_path); - assert!(download_path.exists()); - assert!(sidecar.exists()); - - super::cleanup_download_artifacts(&download_path); - - assert!(!download_path.exists()); - assert!(!sidecar.exists()); - } - - #[test] - fn cleanup_download_artifacts_noop_when_missing() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("downloads/1"); - // Should not panic when files don't exist. - super::cleanup_download_artifacts(&download_path); - } - - #[test] - fn clean_download_dir_removes_orphans_keeps_current() { - let tmp = TempDir::new().unwrap(); - let download_dir = tmp.path().join("downloads"); - fs::create_dir_all(&download_dir).unwrap(); - - // Files for current patch (number 3) — should be kept. - fs::write(download_dir.join("3"), b"compressed").unwrap(); - fs::write(download_dir.join("3.full"), b"inflated").unwrap(); - fs::write(download_dir.join("3.download.json"), b"{}").unwrap(); - - // Files for old patches — should be deleted. - fs::write(download_dir.join("1"), b"old compressed").unwrap(); - fs::write(download_dir.join("1.full"), b"old inflated").unwrap(); - fs::write(download_dir.join("1.download.json"), b"{}").unwrap(); - fs::write(download_dir.join("2"), b"old compressed").unwrap(); - - // Unrecognized file — should be deleted. - fs::write(download_dir.join("garbage.tmp"), b"junk").unwrap(); - - super::clean_download_dir(&download_dir, 3); - - // Current patch files preserved. - assert!(download_dir.join("3").exists()); - assert!(download_dir.join("3.full").exists()); - assert!(download_dir.join("3.download.json").exists()); - - // Old and unrecognized files removed. - assert!(!download_dir.join("1").exists()); - assert!(!download_dir.join("1.full").exists()); - assert!(!download_dir.join("1.download.json").exists()); - assert!(!download_dir.join("2").exists()); - assert!(!download_dir.join("garbage.tmp").exists()); - } - - #[test] - fn clean_download_dir_noop_when_dir_missing() { - let tmp = TempDir::new().unwrap(); - let download_dir = tmp.path().join("nonexistent"); - // Should not panic. - super::clean_download_dir(&download_dir, 1); - } - #[serial] #[test] fn successful_update_cleans_up_download_artifacts() -> anyhow::Result<()> { @@ -2156,14 +1736,18 @@ patch_verification: bogus_mode let result = super::update(None)?; assert_eq!(result, crate::UpdateStatus::UpdateInstalled); - // After successful install, compressed download and sidecar should be cleaned up. - let download_path = tmp_dir.path().join("downloads/1"); - let sidecar_path = crate::download_state::sidecar_path(&download_path); - assert!( - !download_path.exists(), - "compressed download should be deleted" - ); - assert!(!sidecar_path.exists(), "sidecar should be deleted"); + // After successful install, the compressed download bytes are + // gone (record_install_complete deletes them) and the patch's + // state.json reads `Installed`. + let patch_dir = tmp_dir.path().join("patches/1"); + assert!(!patch_dir.join("download").exists()); + assert!(patch_dir.join("dlc.vmcode").exists()); + let state: crate::cache::lifecycle::PatchState = + crate::cache::disk_io::read(&patch_dir.join("state.json")).unwrap(); + assert!(matches!( + state, + crate::cache::lifecycle::PatchState::Installed { .. } + )); Ok(()) } @@ -2211,11 +1795,10 @@ patch_verification: bogus_mode "Expected size mismatch error, got: {err}" ); - // Verify artifacts were cleaned up after the mismatch. - let download_path = tmp_dir.path().join("downloads/1"); - let sidecar_path = crate::download_state::sidecar_path(&download_path); - assert!(!download_path.exists(), "download should be cleaned up"); - assert!(!sidecar_path.exists(), "sidecar should be cleaned up"); + // Server contract violation: the in-progress download was + // forgotten entirely (uninstall_patch). The next attempt starts + // fresh. + assert!(!tmp_dir.path().join("patches/1").exists()); Ok(()) } @@ -2334,20 +1917,20 @@ patch_verification: bogus_mode let apk_path = tmp_dir.path().join("base.apk"); write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); - // Simulate a prior partial download: write the first 10 bytes + sidecar. - let download_dir = tmp_dir.path().join("downloads"); - fs::create_dir_all(&download_dir).unwrap(); - let download_path = download_dir.join("1"); - fs::write(&download_path, first_part).unwrap(); - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { + // Simulate a prior partial download: write the first 10 bytes + // and a Downloading state.json that points at the same URL/hash. + let patch_dir = tmp_dir.path().join("patches/1"); + fs::create_dir_all(&patch_dir).unwrap(); + fs::write(patch_dir.join("download"), first_part).unwrap(); + crate::cache::disk_io::write( + &crate::cache::lifecycle::PatchState::Downloading { url: download_url.to_string(), - patch_number: 1, - expected_size: None, - expected_hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45" + hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45" .to_string(), + signature: None, + partial_size: first_part.len() as u64, }, + &patch_dir.join("state.json"), ) .unwrap(); @@ -2410,18 +1993,17 @@ patch_verification: bogus_mode write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); // Simulate prior partial download with a DIFFERENT URL. - let download_dir = tmp_dir.path().join("downloads"); - fs::create_dir_all(&download_dir).unwrap(); - let download_path = download_dir.join("1"); - fs::write(&download_path, b"stale data from old url").unwrap(); - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { + let patch_dir = tmp_dir.path().join("patches/1"); + fs::create_dir_all(&patch_dir).unwrap(); + fs::write(patch_dir.join("download"), b"stale data from old url").unwrap(); + crate::cache::disk_io::write( + &crate::cache::lifecycle::PatchState::Downloading { url: "http://old-cdn.example.com/patch/1".to_string(), - patch_number: 1, - expected_size: None, - expected_hash: "hash_old".to_string(), + hash: "hash_old".to_string(), + signature: None, + partial_size: 10, }, + &patch_dir.join("state.json"), ) .unwrap(); @@ -3592,20 +3174,20 @@ mod resume_edge_case_tests { let apk_path = tmp_dir.path().join("base.apk"); write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); - // Create sidecar without corresponding partial file. - let download_dir = tmp_dir.path().join("downloads"); - fs::create_dir_all(&download_dir)?; - let download_path = download_dir.join("1"); - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { + // Create the lifecycle state.json without a corresponding + // partial file (the bytes were deleted out from under us). + let patch_dir = tmp_dir.path().join("patches/1"); + fs::create_dir_all(&patch_dir)?; + crate::cache::disk_io::write( + &crate::cache::lifecycle::PatchState::Downloaded { url: "http://example.com/patch/1".to_string(), - patch_number: 1, - expected_size: Some(31), - expected_hash: PATCH_HASH.to_string(), + hash: PATCH_HASH.to_string(), + signature: None, + size: 31, }, + &patch_dir.join("state.json"), )?; - // Note: download_path itself does NOT exist — file was deleted. + // Note: patches/1/download itself does NOT exist — file was deleted. setup_hooks_with_download(|_url, dest: &Path, resume_from: u64| { // Should start fresh since partial file is missing. @@ -3640,19 +3222,18 @@ mod resume_edge_case_tests { let apk_path = tmp_dir.path().join("base.apk"); write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); - // Pre-create a partial download and sidecar. - let download_dir = tmp_dir.path().join("downloads"); - fs::create_dir_all(&download_dir)?; - let download_path = download_dir.join("1"); - fs::write(&download_path, &PATCH_BYTES[..10])?; - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { + // Pre-create a partial download and Downloading state.json. + let patch_dir = tmp_dir.path().join("patches/1"); + fs::create_dir_all(&patch_dir)?; + fs::write(patch_dir.join("download"), &PATCH_BYTES[..10])?; + crate::cache::disk_io::write( + &crate::cache::lifecycle::PatchState::Downloading { url: "http://example.com/patch/1".to_string(), - patch_number: 1, - expected_size: None, - expected_hash: PATCH_HASH.to_string(), + hash: PATCH_HASH.to_string(), + signature: None, + partial_size: 10, }, + &patch_dir.join("state.json"), )?; setup_hooks_with_download(|_url, dest: &Path, _resume_from: u64| { @@ -3697,17 +3278,16 @@ mod resume_edge_case_tests { let result = crate::update(None); assert!(result.is_err()); - // The sidecar should have been written before the download started. - let download_path = tmp_dir.path().join("downloads/1"); - let sidecar_path = crate::download_state::sidecar_path(&download_path); + // The lifecycle state.json was written before the download + // started; both it and the partial file survive the network + // error so the next attempt can resume. + let patch_dir = tmp_dir.path().join("patches/1"); assert!( - sidecar_path.exists(), - "Sidecar should survive a download failure for retry" + patch_dir.join("state.json").exists(), + "state.json should survive a download failure for retry" ); - - // The partial file should still exist. assert!( - download_path.exists(), + patch_dir.join("download").exists(), "Partial download should survive for resume" ); @@ -3785,16 +3365,25 @@ mod resume_edge_case_tests { let result = crate::update(None); assert!(result.is_err()); - let download_path = tmp_dir.path().join("downloads/1"); - let sidecar_path = crate::download_state::sidecar_path(&download_path); + // Inflate failure marks the patch Bad{InvalidPatchBytes}: the + // tombstone state.json survives, but artifact files are gone so + // the next attempt either short-circuits (Skip(KnownBad)) or + // can re-download from a clean slate. + let patch_dir = tmp_dir.path().join("patches/1"); + assert!(patch_dir.join("state.json").exists(), "tombstone preserved"); assert!( - !download_path.exists(), - "download file should be cleaned up after inflate failure" - ); - assert!( - !sidecar_path.exists(), - "sidecar should be cleaned up after inflate failure" + !patch_dir.join("download").exists(), + "download artifact removed on inflate failure" ); + let state: crate::cache::lifecycle::PatchState = + crate::cache::disk_io::read(&patch_dir.join("state.json")).unwrap(); + assert!(matches!( + state, + crate::cache::lifecycle::PatchState::Bad { + reason: crate::cache::lifecycle::BadReason::InvalidPatchBytes, + .. + } + )); Ok(()) } @@ -3820,19 +3409,19 @@ mod resume_edge_case_tests { let apk_path = tmp_dir.path().join("base.apk"); write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); - // Simulate the stuck state: full-size partial file plus matching sidecar. - let download_dir = tmp_dir.path().join("downloads"); - fs::create_dir_all(&download_dir)?; - let download_path = download_dir.join("1"); - fs::write(&download_path, PATCH_BYTES)?; - crate::download_state::write_download_state( - &download_path, - &crate::download_state::DownloadState { + // Simulate the post-download / pre-install state: full-size + // download file plus a `Downloaded` state.json. + let patch_dir = tmp_dir.path().join("patches/1"); + fs::create_dir_all(&patch_dir)?; + fs::write(patch_dir.join("download"), PATCH_BYTES)?; + crate::cache::disk_io::write( + &crate::cache::lifecycle::PatchState::Downloaded { url: "http://example.com/patch/1".to_string(), - patch_number: 1, - expected_size: Some(PATCH_BYTES.len() as u64), - expected_hash: PATCH_HASH.to_string(), + hash: PATCH_HASH.to_string(), + signature: None, + size: PATCH_BYTES.len() as u64, }, + &patch_dir.join("state.json"), )?; // The download hook must NOT be called — bytes are already on disk. From f55b2e233f0dce4cf7c58685876b9875cb1fe172 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 17:03:47 -0700 Subject: [PATCH 05/21] refactor: delete patch_manager.rs and download_state.rs Both modules are now subsumed by PatchLifecycle: - patch_manager.rs (~1600 lines) managed the per-release `patches/` tree, the `next_boot_patch` / `last_booted_patch` / `currently_booting_patch` / `known_bad_patches` fields of `PatchesState`, and the fall-back-from-bad-patch logic. All of this is now in lifecycle.rs split between `PatchLifecycle` (per-patch state.json) and `ReleasePointers` (a single pointers.json). - download_state.rs (~125 lines) wrote the per-download sidecar JSON. Now folded into PatchState::Downloading / PatchState::Downloaded variants in state.json, owned by the lifecycle module. The MockManagePatches / ManagePatches trait machinery in updater_state.rs's tests goes away with patch_manager. The lifecycle operations are tested directly in cache::lifecycle::tests against a real on-disk filesystem under TempDir, which catches issues the mocks couldn't (e.g. file-existence cross-checks in `decide_start`). The cargo workspace now has 212 passing tests vs. 257 before, the delta being the patch_manager unit tests whose subjects no longer exist. End-to-end coverage in `updater.rs::tests` is unchanged and all green. --- library/src/cache/mod.rs | 1 - library/src/cache/patch_manager.rs | 1615 ---------------------------- library/src/download_state.rs | 126 --- library/src/lib.rs | 1 - library/src/updater.rs | 4 +- 5 files changed, 1 insertion(+), 1746 deletions(-) delete mode 100644 library/src/cache/patch_manager.rs delete mode 100644 library/src/download_state.rs diff --git a/library/src/cache/mod.rs b/library/src/cache/mod.rs index 04c4f5eb..4d8e0334 100644 --- a/library/src/cache/mod.rs +++ b/library/src/cache/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod disk_io; pub mod lifecycle; -mod patch_manager; mod signing; pub mod updater_state; diff --git a/library/src/cache/patch_manager.rs b/library/src/cache/patch_manager.rs deleted file mode 100644 index 6fc8b2e7..00000000 --- a/library/src/cache/patch_manager.rs +++ /dev/null @@ -1,1615 +0,0 @@ -use super::{disk_io, signing, PatchInfo}; -use crate::file_errors::{FileOperation, IoResultExt}; -use crate::yaml::PatchVerificationMode; -use anyhow::{bail, Context, Result}; -use core::fmt::Debug; -use serde::{Deserialize, Serialize}; -use std::{ - collections::HashSet, - path::{Path, PathBuf}, -}; - -#[cfg(test)] -use mockall::automock; -#[cfg(test)] -use tempfile::TempDir; - -const PATCHES_DIR_NAME: &str = "patches"; -const PATCHES_STATE_FILE_NAME: &str = "patches_state.json"; -const PATCH_ARTIFACT_FILENAME: &str = "dlc.vmcode"; - -/// Information about a patch that is persisted to disk. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] -struct PatchMetadata { - /// The number of the patch. - number: usize, - - /// The size of the patch artifact on disk. - size: u64, - - /// The hash of the patch artifact on disk. - hash: String, - - /// The signature of `hash`. - signature: Option, -} - -/// What gets serialized to disk -#[derive(Debug, Default, Deserialize, Serialize)] -struct PatchesState { - /// Historical record of the patch that most recently completed - /// `record_boot_success` on any run. Used as a fallback target by - /// `try_fall_back_from_patch` when the next-boot patch is invalid. - /// Updated only by `record_boot_success` — boot failures and - /// server-driven rollbacks of the running patch don't erase the - /// historical fact that the patch booted. Not the same thing as the - /// patch this process is running; see `running_patch` for that. - last_booted_patch: Option, - - /// The patch that will be run on the next app boot, if any. This may be the same - /// as the last booted patch patch if no new patch has been downloaded. - next_boot_patch: Option, - - /// This is given a value when we start booting a patch (record_boot_start_for_patch) and is - /// cleared when: - /// - the patch boots successfully (record_boot_success) - /// - the patch fails to boot (record_boot_failure_for_patch) - /// - the system initializes (on_init, we take this to mean the patch failed to boot) - currently_booting_patch: Option, - - /// Unix timestamp (seconds) when the current boot attempt started. - /// Used to compute elapsed time in crash recovery diagnostics. - /// Added in 1.6.93; older state files will deserialize this as None. - boot_started_at: Option, - - /// A list of patch numbers that we have tried and failed to install. - /// We should never attempt to download or install these again for the - /// current release. - known_bad_patches: HashSet, -} - -/// Abstracts the storage of patches on disk. -/// -/// The implementation of this (PatchManager) should only be responsible for -/// translating what is on disk into a form that is useful for the updater and -/// vice versa. Some business logic has crept in in the form of validation, and -/// we should consider moving that into a separate module. -#[cfg_attr(test, automock)] -pub trait ManagePatches { - /// Copies the patch file at file_path to the manager's directory structure - /// sets this patch as the next patch to boot. - /// - /// The explicit lifetime is required for automock to work with Options. - /// See https://github.com/asomers/mockall/issues/61. - #[allow(clippy::needless_lifetimes)] - fn add_patch<'a>( - &mut self, - number: usize, - file_path: &Path, - hash: &str, - signature: Option<&'a str>, - ) -> Result<()>; - - /// The patch most recently known to have successfully booted on a prior - /// run, or None if no patch is installed. Used as a fallback target by - /// `try_fall_back_from_patch` when the next-boot patch becomes invalid. - /// Not the same thing as the patch this process is running; for that, see - /// [`running_patch`]. - fn last_successfully_booted_patch(&self) -> Option; - - /// The patch this process is using, set at `report_launch_start` from - /// whatever `next_boot_patch` was at that moment. `None` means the - /// process is running the base release. Survives server-driven - /// rollbacks of that patch (the process is still using it). Backed by - /// a session-scoped global, not by `PatchesState` on disk: a fresh - /// process starts with `None` until the next `report_launch_start`, - /// which flutter_engine calls before `dart:ffi` is available, so the - /// `None` window is not observable from Dart. Distinct from - /// `currently_booting_patch`, which is the persisted "boot in progress" - /// breadcrumb used for cross-restart crash detection. - fn running_patch(&self) -> Option; - - /// Sets the patch this process is using. Called from - /// `report_launch_start` with `Some(n)` when launching a patch, or - /// `None` when launching the base release. - fn set_running_patch(&mut self, patch_number: Option); - - /// The patch we are currently booting, if any. This will only have a value: - /// 1. Between record_boot_start_for_patch and record_boot_success or record_boot_failure_for_patch - /// 2. On init if we attempted to boot a patch but never recorded a successful boot (e.g., because - /// the system crashed). - fn currently_booting_patch(&self) -> Option; - - /// Unix timestamp (seconds) when the current boot attempt started, if known. - fn boot_started_at(&self) -> Option; - - /// Returns the next patch to boot, or None if: - /// - no patches have been downloaded - /// - we cannot boot from the patch(es) on disk - fn next_boot_patch(&self) -> Option; - - /// Performs integrity checks on the next boot patch and updates the state accordingly. Returns - /// an error if the patch exists but is not bootable. - fn validate_next_boot_patch(&mut self) -> anyhow::Result<()>; - - /// Record that we're booting. If we have a next path, updates the last - /// attempted patch to be the next boot patch. - fn record_boot_start_for_patch(&mut self, patch_number: usize) -> Result<()>; - - /// Marks last_attempted_patch as "good", updates last_booted_patch to be the same, - /// and deletes all patch artifacts older than the last_booted_patch. - fn record_boot_success(&mut self) -> Result<()>; - - /// Records that the patch with number patch_number failed to boot, and ensures - /// that it will never be returned as the next boot or last booted patch. - fn record_boot_failure_for_patch(&mut self, patch_number: usize) -> Result<()>; - - /// Whether we have failed to boot from the patch with `patch_number`. - fn is_known_bad_patch(&self, patch_number: usize) -> bool; - - /// Deletes artifacts for the provided patch_number if they exist. - /// If the patch is the next_boot_patch, it is cleared. - fn remove_patch(&mut self, patch_number: usize) -> Result<()>; - - /// Resets the patch manager to its initial state, removing all patches. This is - /// intended to be used when a new release version is installed. - fn reset(&mut self) -> Result<()>; -} - -// This allows us to use the Debug trait on dyn ManagePatches, which is -// required to have it as a property of UpdaterState. -impl Debug for dyn ManagePatches { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "ManagePatches") - } -} - -#[derive(Debug)] -pub struct PatchManager { - /// The base directory used to store patch artifacts and state. - /// The directory structure created within this directory is: - /// patches_state.json - /// patches/ - /// / - /// dlc.vmcode - /// / - /// dlc.vmcode - root_dir: PathBuf, - - /// Metadata about the patches we have downloaded that is persisted to disk. - patches_state: PatchesState, - - /// The key used to sign patch hashes for the current release, if any. If this is - /// not None, all patches must have a signature that can be verified with this key. - patch_public_key: Option, - - /// Controls when signature verification occurs: at boot time (strict) or only - /// at install time (install_only). - verification_mode: PatchVerificationMode, -} - -impl PatchManager { - /// Creates a new PatchManager with the given root directory. This directory is - /// assumed to exist. The PatchManager will use this directory to store its - /// state and patch binaries. - pub fn new( - root_dir: PathBuf, - patch_public_key: Option<&str>, - verification_mode: PatchVerificationMode, - ) -> Self { - let patches_state = Self::load_patches_state(&root_dir).unwrap_or_default(); - - Self { - root_dir, - patches_state, - patch_public_key: patch_public_key.map(|s| s.to_owned()), - verification_mode, - } - } - - fn load_patches_state(root_dir: &Path) -> Option { - let path = root_dir.join(PATCHES_STATE_FILE_NAME); - match disk_io::read(&path) { - Ok(maybe_state) => maybe_state, - Err(e) => { - shorebird_debug!( - "Failed to load patches state from {}: {}", - path.display(), - e - ); - None - } - } - } - - fn save_patches_state(&self) -> Result<()> { - let path = self.root_dir.join(PATCHES_STATE_FILE_NAME); - disk_io::write(&self.patches_state, &path) - } - - /// The directory where all patch artifacts are stored. - fn patches_dir(&self) -> PathBuf { - self.root_dir.join(PATCHES_DIR_NAME) - } - - /// The directory where artifacts for the patch with the given number are stored. - fn patch_dir(&self, patch_number: usize) -> PathBuf { - self.patches_dir().join(patch_number.to_string()) - } - - /// The path to the runnable patch artifact with the given number. Runnable patch artifact files are - /// named .vmcode - fn patch_artifact_path(&self, patch_number: usize) -> PathBuf { - self.patch_dir(patch_number).join(PATCH_ARTIFACT_FILENAME) - } - - fn patch_info_for_number(&self, patch_number: usize) -> PatchInfo { - PatchInfo { - path: self.patch_artifact_path(patch_number), - number: patch_number, - } - } - - /// Checks that the patch with the given number: - /// - Has an artifact on disk - /// - That artifact on disk is the same size it was when it was installed - /// - In Strict mode: verifies the signature against the hash - /// - /// Returns Ok if the patch is bootable, or an error if it is not. - fn validate_patch_is_bootable(&self, patch: &PatchMetadata) -> Result<()> { - let artifact_path = self.patch_artifact_path(patch.number); - if !Path::exists(&artifact_path) { - bail!( - "Patch {} does not exist at {}", - patch.number, - artifact_path.display() - ); - } - - let artifact_size_on_disk = std::fs::metadata(&artifact_path) - .with_file_context(FileOperation::GetMetadata, &artifact_path)? - .len(); - if artifact_size_on_disk != patch.size { - bail!( - "Patch {} has size {} on disk, but expected size {}", - patch.number, - artifact_size_on_disk, - patch.size - ); - } - - // In Strict mode, verify the signature at boot time. - // This ensures the patch file hasn't been tampered with since installation. - if self.verification_mode == PatchVerificationMode::Strict { - if let Some(public_key) = &self.patch_public_key { - let signature = patch - .signature - .clone() - .context("Patch signature is missing")?; - - // Compute the hash of the patch file on disk and verify it matches. - let patch_hash = signing::hash_file(&artifact_path)?; - signing::check_signature(&patch_hash, &signature, public_key)?; - } else { - shorebird_info!("No public key provided, skipping signature verification"); - } - } - - Ok(()) - } - - fn delete_patch_artifacts(&mut self, patch_number: usize) -> Result<()> { - let patch_dir = self.patch_dir(patch_number); - if !patch_dir.exists() { - shorebird_debug!("Patch {} not installed, nothing to delete", patch_number); - return Ok(()); - } - - shorebird_info!("Deleting patch artifacts for patch {}", patch_number); - - std::fs::remove_dir_all(&patch_dir) - .map_err(|e| { - shorebird_error!("Failed to delete patch dir {}: {}", patch_dir.display(), e); - e - }) - .with_file_context(FileOperation::DeleteDir, &patch_dir) - } - - /// Deletes artifacts for the provided bad_patch_number and attempts to set the next_boot_patch to the last - /// successfully booted patch. If the last successfully booted patch is not bootable or has the same number - /// as the patch we're falling back from, we clear it as well. - fn try_fall_back_from_patch(&mut self, bad_patch_number: usize) -> Result<()> { - // Continue even if we fail to delete the patch artifacts. It's more important to not try to - // boot from a bad patch than to delete its artifacts. - // No need to log failure – delete_patch_artifacts logs for us. - let _ = self.delete_patch_artifacts(bad_patch_number); - - let is_bad_patch_last_booted_patch = self - .patches_state - .last_booted_patch - .clone() - .map(|patch| patch.number == bad_patch_number) - .unwrap_or(false); - let is_bad_patch_next_boot_patch = self - .patches_state - .next_boot_patch - .clone() - .map(|patch| patch.number == bad_patch_number) - .unwrap_or(false); - - if is_bad_patch_last_booted_patch && is_bad_patch_next_boot_patch { - // The bad patch is both the last successfully-booted patch and - // the queued next-boot patch. Clear `next_boot_patch` so we boot - // the base release on next launch, but leave `last_booted_patch` - // alone — the patch *did* successfully boot, and the running - // process is still using it. Erasing that historical record - // while the field was being read by FFI as "what's running" - // caused the patch-to-release rollback bug in - // shorebirdtech/shorebird#3728. - // - // The else-if fallback path consults `is_known_bad_patch` - // before promoting `last_booted_patch` back to - // `next_boot_patch`. For server-driven rollbacks, `remove_patch` - // adds the patch to `known_bad_patches` first, so a later - // fallback can't accidentally re-promote a rolled-back patch - // even if its artifact deletion above silently failed. - shorebird_info!( - "Clearing next boot patch (rollback target was both last booted and next boot)" - ); - self.patches_state.next_boot_patch = None; - } else if is_bad_patch_next_boot_patch { - shorebird_info!("Clearing next boot patch"); - self.patches_state.next_boot_patch = None; - - if let Some(last_boot_patch) = self.patches_state.last_booted_patch.clone() { - let is_known_bad = self - .patches_state - .known_bad_patches - .contains(&last_boot_patch.number); - if !is_known_bad && self.validate_patch_is_bootable(&last_boot_patch).is_ok() { - shorebird_info!( - "Setting last booted patch {} as next boot patch", - last_boot_patch.number - ); - self.patches_state.next_boot_patch = Some(last_boot_patch); - } else { - shorebird_info!( - "Last booted patch {} is not a valid fallback, deleting artifacts", - last_boot_patch.number - ); - self.patches_state.last_booted_patch = None; - // No need to log failure – delete_patch_artifacts logs for us. - let _ = self.delete_patch_artifacts(last_boot_patch.number); - } - } - } - - self.save_patches_state() - } - - /// Deletes all patch artifacts with numbers less than patch_number. - /// We intentionally only delete older patch artifacts. Consider the case: - /// - /// 1. We start booting patch 2 - /// 2. While booting (i.e., in between boot start and boot success), we download and inflate patch 3 - /// 3. We finish booting patch 2 - /// - /// Deleting all other patch artifacts would delete patch 3, and because we've "seen" patch 3, - /// we would never try to download it again (it would be considered "bad"). - fn delete_patch_artifacts_older_than(&mut self, patch_number: usize) -> Result<()> { - shorebird_info!("Deleting patch artifacts older than {}", patch_number); - for entry in std::fs::read_dir(self.patches_dir())? { - let entry = entry?; - match entry.file_name().to_string_lossy().parse::() { - Ok(number) if number < patch_number => { - // delete_patch_artifacts logs for us, no need to log here. - let _ = self.delete_patch_artifacts(number); - } - Ok(_) => {} - Err(e) => { - shorebird_error!( - "Failed to parse patch number from patches directory entry, deleting: {}", - e - ); - // Attempt to delete the unrecognized directory, but don't stop - // the artifact deletion process if it fails. - let _ = std::fs::remove_dir_all(entry.path()); - } - } - } - - Ok(()) - } -} - -impl ManagePatches for PatchManager { - // The explicit lifetime is required for automock to work with Options. - // See https://github.com/asomers/mockall/issues/61. - #[allow(clippy::needless_lifetimes)] - fn add_patch<'a>( - &mut self, - patch_number: usize, - file_path: &Path, - hash: &str, - signature: Option<&'a str>, - ) -> Result<()> { - if !file_path.exists() { - bail!("Patch file {} does not exist", file_path.display()); - } - - // In InstallOnly mode, verify signature at install time. - // In Strict mode, signature verification happens at boot time instead. - 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 patch_path = self.patch_artifact_path(patch_number); - - let patch_dir = self.patch_dir(patch_number); - std::fs::create_dir_all(&patch_dir) - .with_file_context(FileOperation::CreateDir, &patch_dir)?; - - std::fs::rename(file_path, &patch_path) - .with_file_context(FileOperation::RenameFile, file_path)?; - - let new_patch = PatchMetadata { - number: patch_number, - size: std::fs::metadata(&patch_path) - .with_file_context(FileOperation::GetMetadata, &patch_path)? - .len(), - hash: hash.to_owned(), - signature: signature.map(|s| s.to_owned()), - }; - - // If a patch was never booted (next_boot_patch != last_booted_patch), we should delete - // it here before setting next_boot_patch to the new patch. - if let (Some(last_boot_patch), Some(next_boot_patch)) = ( - self.patches_state.last_booted_patch.clone(), - self.patches_state.next_boot_patch.clone(), - ) { - if last_boot_patch.number != next_boot_patch.number { - shorebird_info!( - "Patch {} was installed but never booted never booted, deleting artifacts", - next_boot_patch.number - ); - let _ = self.delete_patch_artifacts(next_boot_patch.number); - } - } - - self.patches_state.next_boot_patch = Some(new_patch); - self.save_patches_state() - } - - fn last_successfully_booted_patch(&self) -> Option { - self.patches_state - .last_booted_patch - .as_ref() - .map(|patch| self.patch_info_for_number(patch.number)) - } - - fn running_patch(&self) -> Option { - crate::config::running_patch_number().map(|number| self.patch_info_for_number(number)) - } - - fn set_running_patch(&mut self, patch_number: Option) { - crate::config::set_running_patch_number(patch_number); - } - - fn currently_booting_patch(&self) -> Option { - self.patches_state - .currently_booting_patch - .as_ref() - .map(|patch| self.patch_info_for_number(patch.number)) - } - - fn boot_started_at(&self) -> Option { - self.patches_state.boot_started_at - } - - fn validate_next_boot_patch(&mut self) -> anyhow::Result<()> { - let next_boot_patch = match self.patches_state.next_boot_patch.clone() { - Some(patch) => patch, - None => return anyhow::Ok(()), - }; - - shorebird_info!("Validating patch {}", next_boot_patch.number); - - if let Err(e) = self.validate_patch_is_bootable(&next_boot_patch) { - shorebird_error!("Patch {} is not bootable: {}", next_boot_patch.number, e); - - if let Err(e) = self.try_fall_back_from_patch(next_boot_patch.number) { - shorebird_error!( - "Failed to fall back from next_boot_patch {}: {}", - next_boot_patch.number, - e - ); - } - - return Err(e); - } - - anyhow::Ok(()) - } - - fn next_boot_patch(&self) -> Option { - self.patches_state - .next_boot_patch - .as_ref() - .map(|patch| self.patch_info_for_number(patch.number)) - } - - fn record_boot_start_for_patch(&mut self, patch_number: usize) -> Result<()> { - let next_boot_patch = self - .patches_state - .next_boot_patch - .clone() - .context("No next_boot_patch")?; - - if next_boot_patch.number != patch_number { - bail!( - "Attempted to record boot success for patch {} but next_boot_patch is {}", - patch_number, - next_boot_patch.number - ); - } - - self.patches_state.currently_booting_patch = Some(next_boot_patch.clone()); - self.patches_state.boot_started_at = Some(crate::time::unix_timestamp()); - self.save_patches_state() - } - - fn record_boot_success(&mut self) -> Result<()> { - let boot_patch = self - .patches_state - .currently_booting_patch - .clone() - .context("No currently_booting_patch")?; - - self.patches_state.currently_booting_patch = None; - self.patches_state.boot_started_at = None; - self.patches_state.last_booted_patch = Some(boot_patch.clone()); - if let Err(e) = self.delete_patch_artifacts_older_than(boot_patch.number) { - shorebird_error!( - "Failed to delete patch artifacts older than {}: {}", - boot_patch.number, - e - ); - } - self.save_patches_state() - } - - fn record_boot_failure_for_patch(&mut self, patch_number: usize) -> Result<()> { - self.patches_state.currently_booting_patch = None; - self.patches_state.boot_started_at = None; - self.patches_state.known_bad_patches.insert(patch_number); - self.try_fall_back_from_patch(patch_number) - } - - fn is_known_bad_patch(&self, patch_number: usize) -> bool { - self.patches_state.known_bad_patches.contains(&patch_number) - } - - fn remove_patch(&mut self, patch_number: usize) -> Result<()> { - // Server-driven rollback: mark known-bad so a later fallback path - // (e.g. record_boot_failure_for_patch on a *different* patch) can't - // promote `last_booted_patch` back to `next_boot_patch` if its - // artifact deletion in try_fall_back_from_patch silently fails. - self.patches_state.known_bad_patches.insert(patch_number); - self.try_fall_back_from_patch(patch_number) - } - - fn reset(&mut self) -> Result<()> { - self.patches_state = PatchesState::default(); - self.save_patches_state()?; - let patches_dir = self.patches_dir(); - std::fs::remove_dir_all(&patches_dir) - .with_file_context(FileOperation::DeleteDir, &patches_dir) - } -} - -#[cfg(test)] -impl PatchManager { - pub fn manager_for_test(temp_dir: &TempDir) -> PatchManager { - PatchManager::new( - temp_dir.path().to_owned(), - None, - PatchVerificationMode::default(), - ) - } - - pub fn add_patch_for_test(&mut self, temp_dir: &TempDir, patch_number: usize) -> Result<()> { - self.add_signed_patch_for_test(temp_dir, patch_number, "hash", None) - } - - pub fn add_signed_patch_for_test( - &mut self, - temp_dir: &TempDir, - patch_number: usize, - hash: &str, - signature: Option<&str>, - ) -> Result<()> { - let file_path = &temp_dir - .path() - .join(format!("patch{}.vmcode", patch_number)); - std::fs::write(file_path, patch_number.to_string().repeat(patch_number)).unwrap(); - shorebird_info!( - "Adding patch {} with contents {} hash {} at {}", - patch_number, - patch_number.to_string().repeat(patch_number), - hash, - file_path.display() - ); - self.add_patch(patch_number, file_path, hash, signature) - } -} - -#[cfg(test)] -mod debug_tests { - use tempfile::TempDir; - - use super::PatchManager; - use crate::yaml::PatchVerificationMode; - - #[test] - fn manage_patches_is_debug() { - let temp_dir = TempDir::new().unwrap(); - let patch_manager: Box = - Box::new(PatchManager::manager_for_test(&temp_dir)); - assert_eq!(format!("{:?}", patch_manager), "ManagePatches"); - } - - #[test] - fn patch_manager_is_debug() { - let temp_dir = TempDir::new().unwrap(); - let patch_manager = PatchManager::new( - temp_dir.path().to_owned(), - Some("public_key"), - PatchVerificationMode::default(), - ); - let actual = format!("{:?}", patch_manager); - assert!(actual.contains(r#"patches_state: PatchesState { last_booted_patch: None, next_boot_patch: None, currently_booting_patch: None, boot_started_at: None, known_bad_patches: {} }, patch_public_key: Some("public_key")"#)); - } -} - -#[cfg(test)] -mod add_patch_tests { - use super::*; - use std::path::Path; - use tempfile::TempDir; - - #[test] - fn errs_if_file_path_does_not_exist() { - let mut manager = PatchManager::manager_for_test(&TempDir::new().unwrap()); - assert!(manager - .add_patch( - 1, - Path::new("/path/to/file/that/does/not/exist"), - "hash", - None, - ) - .is_err()); - } - - #[test] - fn adds_patch_successfully() { - let patch_number = 1; - let patch_file_contents = "patch contents"; - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::manager_for_test(&temp_dir); - - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, patch_file_contents).unwrap(); - - assert!(manager - .add_patch( - patch_number, - Path::new(file_path), - "hash", - Some("my_signature") - ) - .is_ok()); - - assert_eq!( - manager.patches_state.next_boot_patch, - Some(PatchMetadata { - number: patch_number, - size: patch_file_contents.len() as u64, - hash: "hash".to_string(), - signature: Some("my_signature".to_owned()) - }) - ); - assert!(!file_path.exists()); - } - - // InstallOnly mode signature verification tests - these verify that signature - // checking happens at install time when using PatchVerificationMode::InstallOnly. - - // The constant values below were generated by taking an arbitrary sha256 hash (INFLATED_PATCH_HASH) - // and using openssl to sign it with the private key corresponding to `PUBLIC_KEY`. - - // The base64-encoded public key in a DER format. This is required by ring to verify signatures. - // See https://docs.rs/ring/latest/ring/signature/index.html#signing-and-verifying-with-rsa-pkcs1-15-padding - const PUBLIC_KEY: &str = "MIIBCgKCAQEA2wdpEGbuvlPsb9i0qYrfMefJnEw1BHTi8SYZTKrXOvJWmEpPE1hWfbkvYzXu5a96gV1yocF3DMwn04VmRlKhC4AhsD0NL0UNhYhotbKG91Kwi1vAXpHhCdz5gQEBw0K1uB4Jz+zK6WK+31PryYpwLwbyXNqXoY8IAAUQ4STsHYV5w+BMSi8pepWMRd7DR9RHcbNOZlJvdBQ5NxvB4JN4dRMq8cC73ez1P9d7Dfwv3TWY+he9EmuXLT2UivZSlHIrGBa7MFfqyUe2ro0F7Te/B0si12itBbWIqycvqcXjeOPNn6WEpqN7IWjb9LUh162JyYaz5Lb/VeeJX8LKtElccwIDAQAB"; - - // The message that was signed. In practice, this will be the sha256 hash of an inflated patch artifact. - const INFLATED_PATCH_HASH: &str = - "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"; - - // The base64-encoded signature of `INFLATED_PATCH_HASH` created using the private key corresponding - // to `PUBLIC_KEY`. - const SIGNATURE: &str = "ZGccldv01XqHQ76bXuKV/9EQnNK0Q+reQ9bJHVnGfLldF+BLRx0divgPfKP5Df9BJPA3dw1Z1VortfepmMGebP3kS593l5zoktu9MIepxvRAFWNKE5PDTIIvCL/ddTPEHt6NNCeD6HLOMLzbEX3cFZa+lq3UymGi0aqA5DlXirJBGtopojc9nOXZ22n/qHNZIHEkGcqKbSMSK9oC55whKHnlJTbCXdmSyDc65B4PcgseqJom1riVK3XGW1YMrSpuMAU+CDT7HhdESmI1UtH1bYeBITfRhQztdDTfti2vJTf2Y+lYC99CFiISgD7f1m0KUcC+VnEAMZSYtgxSk6AX2A=="; - - #[test] - fn install_only_errs_if_public_key_is_invalid() { - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some("not a valid key"), - PatchVerificationMode::InstallOnly, - ); - - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, "patch contents").unwrap(); - - // In InstallOnly mode, fails at install time because the public key is invalid - let result = manager.add_patch(1, file_path, INFLATED_PATCH_HASH, Some(SIGNATURE)); - assert!(result.is_err()); - assert!(manager.next_boot_patch().is_none()); - } - - #[test] - fn install_only_errs_if_signature_is_missing_when_public_key_configured() { - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some(PUBLIC_KEY), - PatchVerificationMode::InstallOnly, - ); - - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, "patch contents").unwrap(); - - // In InstallOnly mode, fails at install time because signature is missing - let result = manager.add_patch(1, file_path, INFLATED_PATCH_HASH, None); - assert!(result.is_err()); - assert!(manager.next_boot_patch().is_none()); - } - - #[test] - fn install_only_errs_if_signature_is_invalid() { - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some(PUBLIC_KEY), - PatchVerificationMode::InstallOnly, - ); - - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, "patch contents").unwrap(); - - // Using INFLATED_PATCH_HASH as a signature because it is valid base64, but not a valid signature. - // In InstallOnly mode, this fails immediately at install time. - let result = - manager.add_patch(1, file_path, INFLATED_PATCH_HASH, Some(INFLATED_PATCH_HASH)); - assert!(result.is_err()); - assert!(manager.next_boot_patch().is_none()); - } - - #[test] - fn install_only_succeeds_with_valid_signature() { - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some(PUBLIC_KEY), - PatchVerificationMode::InstallOnly, - ); - - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, "patch contents").unwrap(); - - // In InstallOnly mode, signature is verified at install time - let result = manager.add_patch(1, file_path, INFLATED_PATCH_HASH, Some(SIGNATURE)); - assert!(result.is_ok()); - assert!(manager.next_boot_patch().is_some()); - } - - #[test] - fn install_only_succeeds_with_any_signature_if_no_public_key() { - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - None, // No public key configured - PatchVerificationMode::InstallOnly, - ); - - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, "patch contents").unwrap(); - - // Without a public key, signature verification is skipped even in InstallOnly mode - let result = manager.add_patch(1, file_path, "hash", Some("not a valid signature")); - assert!(result.is_ok()); - assert!(manager.next_boot_patch().is_some()); - } -} - -#[cfg(test)] -mod last_successfully_booted_patch_tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn returns_none_if_no_patch_has_been_booted() -> Result<()> { - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::manager_for_test(&temp_dir); - manager.add_patch_for_test(&temp_dir, 1)?; - assert!(manager.last_successfully_booted_patch().is_none()); - - Ok(()) - } - - #[test] - fn returns_value_from_patches_state() -> Result<()> { - let temp_dir = TempDir::new().unwrap(); - let mut manager = PatchManager::manager_for_test(&temp_dir); - manager.add_patch_for_test(&temp_dir, 1)?; - - let expected = PatchInfo { - path: manager.patch_artifact_path(1), - number: 1, - }; - manager.patches_state.last_booted_patch = manager.patches_state.next_boot_patch.clone(); - assert_eq!(manager.last_successfully_booted_patch(), Some(expected)); - - Ok(()) - } -} - -#[cfg(test)] -mod next_boot_patch_tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn returns_none_if_no_next_boot_patch() { - let temp_dir = TempDir::new().unwrap(); - let manager = PatchManager::manager_for_test(&temp_dir); - assert!(manager.next_boot_patch().is_none()); - } - - #[test] - fn returns_none_patch_if_first_patch_failed_to_boot() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Add a first patch and pretend it failed to boot. - manager.add_patch_for_test(&temp_dir, 1)?; - manager.record_boot_start_for_patch(1)?; - manager.record_boot_failure_for_patch(1)?; - - // Because there is no previous patch, we should not attempt to boot any patch. - assert!(manager.next_boot_patch().is_none()); - assert!(manager.is_known_bad_patch(1)); - - Ok(()) - } - - #[test] - fn falls_back_to_last_booted_patch_if_still_bootable() -> Result<()> { - let patch_file_contents = "patch contents"; - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, patch_file_contents)?; - - // Add patch 1, pretend it booted successfully. - assert!(manager.add_patch(1, file_path, "hash", None).is_ok()); - assert!(manager.record_boot_start_for_patch(1).is_ok()); - assert!(manager.record_boot_success().is_ok()); - assert!(!manager.is_known_bad_patch(1)); - - // Add patch 2, pretend it failed to boot. - let file_path = &temp_dir.path().join("patch2.vmcode"); - std::fs::write(file_path, patch_file_contents)?; - assert!(manager.add_patch(2, file_path, "hash", None).is_ok()); - assert!(manager.record_boot_start_for_patch(2).is_ok()); - assert!(manager.record_boot_failure_for_patch(2).is_ok()); - assert!(manager.is_known_bad_patch(2)); - - // Verify that we will next attempt to boot from patch 1. - assert_eq!(manager.next_boot_patch().unwrap().number, 1); - - Ok(()) - } - - #[test] - fn adding_patch_deletes_unbooted_patch_not_last_booted() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Add patch 1 and boot it successfully. - manager.add_patch_for_test(&temp_dir, 1)?; - manager.record_boot_start_for_patch(1)?; - manager.record_boot_success()?; - - // Add patch 2 (not booted yet). - manager.add_patch_for_test(&temp_dir, 2)?; - - let patch_1_artifact = manager.patch_artifact_path(1); - let patch_2_artifact = manager.patch_artifact_path(2); - assert!(patch_1_artifact.exists()); - assert!(patch_2_artifact.exists()); - - // Add patch 3 — should delete patch 2 (unbooted), NOT patch 1 (last booted). - manager.add_patch_for_test(&temp_dir, 3)?; - - assert!( - patch_1_artifact.exists(), - "Last booted patch 1 artifacts should NOT be deleted" - ); - assert!( - !patch_2_artifact.exists(), - "Unbooted patch 2 artifacts should be deleted" - ); - - Ok(()) - } - - #[test] - fn returns_last_booted_patch_if_next_patch_failed_to_boot() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Add a first patch and pretend it booted successfully. - manager.add_patch_for_test(&temp_dir, 1)?; - manager.record_boot_start_for_patch(1)?; - manager.record_boot_success()?; - - // Add a second patch and pretend it failed to boot. - manager.add_patch_for_test(&temp_dir, 2)?; - manager.record_boot_start_for_patch(2)?; - manager.record_boot_failure_for_patch(2)?; - - // Verify that we will next attempt to boot from patch 1. - assert_eq!(manager.next_boot_patch().unwrap().number, 1); - assert!(!manager.is_known_bad_patch(1)); - assert!(manager.is_known_bad_patch(2)); - - Ok(()) - } -} - -#[cfg(test)] -mod validate_next_boot_patch_tests { - use super::*; - use anyhow::Result; - use tempfile::TempDir; - - #[test] - fn does_nothing_if_no_next_boot_patch() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - assert!(manager.validate_next_boot_patch().is_ok()); - Ok(()) - } - - #[test] - fn clears_next_boot_patch_if_it_is_not_bootable() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - manager.add_patch_for_test(&temp_dir, 1)?; - - // Write junk to the artifact, this should render the patch unbootable in the eyes - // of the PatchManager. - let artifact_path = manager.patch_artifact_path(1); - std::fs::write(&artifact_path, "junk")?; - - assert!(manager.next_boot_patch().is_some()); - assert!(manager.validate_next_boot_patch().is_err()); - assert!(manager.next_boot_patch().is_none()); - - // Ensure the internal state is cleared. - assert!(manager.patches_state.next_boot_patch.is_none()); - - // The artifact should have been deleted. - assert!(!&artifact_path.exists()); - - Ok(()) - } - - #[test] - fn clears_current_and_next_on_boot_failure_if_they_are_the_same() -> Result<()> { - let patch_file_contents = "patch contents"; - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, patch_file_contents)?; - assert!(manager.add_patch(1, file_path, "hash", None).is_ok()); - - // Write junk to the artifact, this should render the patch unbootable in the eyes - // of the PatchManager. - let artifact_path = manager.patch_artifact_path(1); - std::fs::write(&artifact_path, "junk")?; - - assert!(manager.next_boot_patch().is_some()); - assert!(manager.validate_next_boot_patch().is_err()); - assert!(manager.next_boot_patch().is_none()); - - // Ensure the internal state is cleared. - assert!(manager.patches_state.next_boot_patch.is_none()); - assert!(manager.patches_state.last_booted_patch.is_none()); - - // The artifact should have been deleted. - assert!(!&artifact_path.exists()); - - Ok(()) - } - - #[test] - fn does_not_fall_back_to_last_booted_patch_if_corrupted() -> Result<()> { - let patch_file_contents = "patch contents"; - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, patch_file_contents)?; - - // Add patch 1, pretend it booted successfully. - assert!(manager.add_patch(1, file_path, "hash", None).is_ok()); - assert!(manager.record_boot_start_for_patch(1).is_ok()); - assert!(manager.record_boot_success().is_ok()); - - // Add patch 2, pretend it failed to boot. - let file_path = &temp_dir.path().join("patch2.vmcode"); - std::fs::write(file_path, patch_file_contents)?; - assert!(manager.add_patch(2, file_path, "hash", None).is_ok()); - assert!(manager.record_boot_start_for_patch(2).is_ok()); - assert!(manager.record_boot_failure_for_patch(2).is_ok()); - - // Write junk to patch 1's artifact. This should prevent us from falling back to it. - let patch_1_artifact_path = manager.patch_artifact_path(1); - std::fs::write(patch_1_artifact_path, "junk")?; - - assert!(manager.next_boot_patch().is_some()); - assert!(manager.validate_next_boot_patch().is_err()); - - // Verify that we will not attempt to boot from either patch. - assert!(manager.next_boot_patch().is_none()); - - // Patch 1 should *not* be considered bad, as we successfully booted from it and it only - // became corrupted after that. Downloading it a second time might resolve the issue. - assert!(!manager.is_known_bad_patch(1)); - - // Patch 2 failed to boot, so it should be considered bad. - assert!(manager.is_known_bad_patch(2)); - - Ok(()) - } - - // The constant values below were generated by taking an arbitrary sha256 hash (INFLATED_PATCH_HASH) - // and using openssl to sign it with the private key corresponding to `PUBLIC_KEY`. - - // The base64-encoded public key in a DER format. This is required by ring to verify signatures. - // See https://docs.rs/ring/latest/ring/signature/index.html#signing-and-verifying-with-rsa-pkcs1-15-padding - const PUBLIC_KEY: &str = "MIIBCgKCAQEA2wdpEGbuvlPsb9i0qYrfMefJnEw1BHTi8SYZTKrXOvJWmEpPE1hWfbkvYzXu5a96gV1yocF3DMwn04VmRlKhC4AhsD0NL0UNhYhotbKG91Kwi1vAXpHhCdz5gQEBw0K1uB4Jz+zK6WK+31PryYpwLwbyXNqXoY8IAAUQ4STsHYV5w+BMSi8pepWMRd7DR9RHcbNOZlJvdBQ5NxvB4JN4dRMq8cC73ez1P9d7Dfwv3TWY+he9EmuXLT2UivZSlHIrGBa7MFfqyUe2ro0F7Te/B0si12itBbWIqycvqcXjeOPNn6WEpqN7IWjb9LUh162JyYaz5Lb/VeeJX8LKtElccwIDAQAB"; - - // The message that was signed. In practice, this will be the sha256 hash of an inflated patch artifact. - const INFLATED_PATCH_HASH: &str = - "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"; - - // The base64-encoded signature of `INFLATED_PATCH_HASH` created using the private key corresponding - // to `PUBLIC_KEY`. - const SIGNATURE: &str = "ZGccldv01XqHQ76bXuKV/9EQnNK0Q+reQ9bJHVnGfLldF+BLRx0divgPfKP5Df9BJPA3dw1Z1VortfepmMGebP3kS593l5zoktu9MIepxvRAFWNKE5PDTIIvCL/ddTPEHt6NNCeD6HLOMLzbEX3cFZa+lq3UymGi0aqA5DlXirJBGtopojc9nOXZ22n/qHNZIHEkGcqKbSMSK9oC55whKHnlJTbCXdmSyDc65B4PcgseqJom1riVK3XGW1YMrSpuMAU+CDT7HhdESmI1UtH1bYeBITfRhQztdDTfti2vJTf2Y+lYC99CFiISgD7f1m0KUcC+VnEAMZSYtgxSk6AX2A=="; - - // Strict mode boot-time signature verification tests. - // In Strict mode, signature verification happens at boot time (validate_next_boot_patch), - // not at install time. This provides protection against post-install tampering. - - #[test] - fn strict_mode_succeeds_with_valid_signature_at_boot_time() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some(PUBLIC_KEY), - PatchVerificationMode::Strict, - ); - - // In Strict mode, add_patch does NOT verify signature (that happens at boot time) - manager.add_signed_patch_for_test(&temp_dir, 1, INFLATED_PATCH_HASH, Some(SIGNATURE))?; - - // Boot-time validation verifies the signature by computing hash and checking signature - assert!(manager.next_boot_patch().is_some()); - assert!(manager.validate_next_boot_patch().is_ok()); - assert!(manager.next_boot_patch().is_some()); - - let patch = manager.next_boot_patch().unwrap(); - assert_eq!(patch.number, 1); - - Ok(()) - } - - #[test] - fn succeeds_with_arbitrary_signature_if_no_public_key() -> Result<()> { - let temp_dir = TempDir::new()?; - // Create a PatchManager without a public key - signature verification is skipped. - let mut manager = PatchManager::manager_for_test(&temp_dir); - - manager.add_signed_patch_for_test( - &temp_dir, - 1, - INFLATED_PATCH_HASH, - Some("not a valid signature"), - )?; - - // Without a public key, boot-time validation only checks file existence and size - assert!(manager.next_boot_patch().is_some()); - assert!(manager.validate_next_boot_patch().is_ok()); - assert!(manager.next_boot_patch().is_some()); - let patch = manager.next_boot_patch().unwrap(); - assert_eq!(patch.number, 1); - - Ok(()) - } - - #[test] - fn strict_mode_fails_boot_validation_if_signature_missing() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some(PUBLIC_KEY), - PatchVerificationMode::Strict, - ); - - // In Strict mode, add_patch succeeds without signature (no install-time check) - manager.add_signed_patch_for_test(&temp_dir, 1, INFLATED_PATCH_HASH, None)?; - - assert!(manager.next_boot_patch().is_some()); - // But boot-time validation fails because signature is required - assert!(manager.validate_next_boot_patch().is_err()); - assert!(manager.next_boot_patch().is_none()); - - Ok(()) - } - - #[test] - fn strict_mode_fails_boot_validation_if_signature_invalid() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some(PUBLIC_KEY), - PatchVerificationMode::Strict, - ); - - // In Strict mode, add_patch succeeds with invalid signature (no install-time check) - manager.add_signed_patch_for_test( - &temp_dir, - 1, - INFLATED_PATCH_HASH, - Some(INFLATED_PATCH_HASH), // Using hash as signature, which is invalid - )?; - - assert!(manager.next_boot_patch().is_some()); - // But boot-time validation fails because signature doesn't verify - assert!(manager.validate_next_boot_patch().is_err()); - assert!(manager.next_boot_patch().is_none()); - - Ok(()) - } - - #[test] - fn strict_mode_fails_boot_validation_if_public_key_invalid() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some("not a valid key"), - PatchVerificationMode::Strict, - ); - - // In Strict mode, add_patch succeeds (no install-time check) - manager.add_signed_patch_for_test(&temp_dir, 1, INFLATED_PATCH_HASH, Some(SIGNATURE))?; - - assert!(manager.next_boot_patch().is_some()); - // But boot-time validation fails because public key can't be used - assert!(manager.validate_next_boot_patch().is_err()); - assert!(manager.next_boot_patch().is_none()); - - Ok(()) - } - - #[test] - fn strict_mode_detects_tampered_patch_at_boot_time() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::new( - temp_dir.path().to_path_buf(), - Some(PUBLIC_KEY), - PatchVerificationMode::Strict, - ); - - // Install a valid patch - manager.add_signed_patch_for_test(&temp_dir, 1, INFLATED_PATCH_HASH, Some(SIGNATURE))?; - - // Tamper with the patch file after installation - let patch_path = manager.patch_artifact_path(1); - std::fs::write(&patch_path, "tampered content")?; - - assert!(manager.next_boot_patch().is_some()); - // Boot-time validation detects tampering: computed hash doesn't match signature - assert!(manager.validate_next_boot_patch().is_err()); - assert!(manager.next_boot_patch().is_none()); - - Ok(()) - } -} - -#[cfg(test)] -mod fall_back_tests { - use super::*; - - #[test] - fn does_nothing_if_no_patch_exists() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - assert!(manager.patches_state.last_booted_patch.is_none()); - assert!(manager.patches_state.next_boot_patch.is_none()); - - manager.try_fall_back_from_patch(1)?; - - assert!(manager.patches_state.last_booted_patch.is_none()); - assert!(manager.patches_state.next_boot_patch.is_none()); - - Ok(()) - } - - #[test] - fn sets_next_patch_to_latest_patch_if_both_are_present() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Download and successfully boot from patch 1 - manager.add_patch_for_test(&temp_dir, 1)?; - manager.record_boot_start_for_patch(1)?; - manager.record_boot_success()?; - - // Download and fall back from patch 2 - manager.add_patch_for_test(&temp_dir, 2)?; - - manager.try_fall_back_from_patch(2)?; - - assert_eq!(manager.patches_state.last_booted_patch.unwrap().number, 1); - assert_eq!(manager.patches_state.next_boot_patch.unwrap().number, 1); - - Ok(()) - } - - #[test] - fn clears_next_and_last_patches_if_both_fail_validation() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Download and successfully boot from patch 1, and then corrupt it on disk. - manager.add_patch_for_test(&temp_dir, 1)?; - manager.record_boot_start_for_patch(1)?; - manager.record_boot_success()?; - let patch_1_path = manager.patch_artifact_path(1); - std::fs::write(patch_1_path, "junk junk junk")?; - - // Download and fall back from patch 2 - manager.add_patch_for_test(&temp_dir, 2)?; - - manager.try_fall_back_from_patch(2)?; - - // Neither patch should exist. - assert!(manager.patches_state.last_booted_patch.is_none()); - assert!(manager.patches_state.next_boot_patch.is_none()); - - Ok(()) - } - - #[test] - fn does_not_clear_next_patch_if_changed_since_boot_start() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Simulate a situation where we download both patches 1 and 2. - manager.add_patch_for_test(&temp_dir, 1)?; - - // Start booting from patch 1. - manager.record_boot_start_for_patch(1)?; - - // Download patch 2 before patch 1 finishes booting. - manager.add_patch_for_test(&temp_dir, 2)?; - - manager.record_boot_failure_for_patch(1)?; - - manager.try_fall_back_from_patch(1)?; - - assert!(manager.patches_state.last_booted_patch.is_none()); - assert_eq!( - manager - .patches_state - .next_boot_patch - .clone() - .unwrap() - .number, - 2 - ); - assert!(manager.is_known_bad_patch(1)); - - Ok(()) - } - - /// Server rolls back patch 1, then patch 2 arrives and fails to boot. - /// The else-if fallback path must not promote patch 1 back into - /// `next_boot_patch` even though `last_booted_patch` still points at - /// patch 1 — the server told us not to use it. `remove_patch` records - /// patch 1 as known-bad so this can't happen. - #[test] - fn rollback_then_failed_replacement_does_not_resurrect_rolled_back_patch() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Patch 1: install + successful boot. - manager.add_patch_for_test(&temp_dir, 1)?; - manager.record_boot_start_for_patch(1)?; - manager.record_boot_success()?; - - // Server rolls back patch 1. - manager.remove_patch(1)?; - assert!(manager.is_known_bad_patch(1)); - assert!(manager.patches_state.next_boot_patch.is_none()); - // last_booted_patch is intentionally preserved; the running - // process is still using patch 1 until it restarts. - assert_eq!( - manager - .patches_state - .last_booted_patch - .as_ref() - .unwrap() - .number, - 1 - ); - - // Re-create patch 1's artifact to simulate a silent - // delete_patch_artifacts failure (e.g. transient FS issue). - let patch_1_path = manager.patch_artifact_path(1); - std::fs::create_dir_all(patch_1_path.parent().unwrap())?; - std::fs::write(&patch_1_path, "patch contents")?; - - // Patch 2 arrives and then fails to boot. - manager.add_patch_for_test(&temp_dir, 2)?; - manager.record_boot_start_for_patch(2)?; - manager.record_boot_failure_for_patch(2)?; - - // Patch 1 must NOT be promoted back to next_boot_patch. - assert!(manager.patches_state.next_boot_patch.is_none()); - assert!(manager.is_known_bad_patch(1)); - assert!(manager.is_known_bad_patch(2)); - - Ok(()) - } - - #[test] - fn succeeds_if_deleting_artifacts_fails() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Download and successfully boot from patch 1, and then corrupt it on disk. - manager.add_patch_for_test(&temp_dir, 1)?; - manager.record_boot_start_for_patch(1)?; - manager.record_boot_success()?; - - // Download patch 2. - manager.add_patch_for_test(&temp_dir, 2)?; - - // Remove all data for both patches. - let patch_dir = manager.patch_dir(1); - std::fs::remove_dir_all(patch_dir)?; - let patch_dir = manager.patch_dir(2); - std::fs::remove_dir_all(patch_dir)?; - - manager.try_fall_back_from_patch(2)?; - - assert!(manager.patches_state.last_booted_patch.is_none()); - assert!(manager.patches_state.next_boot_patch.is_none()); - - Ok(()) - } -} - -#[cfg(test)] -mod record_boot_success_for_patch_tests { - use super::*; - use anyhow::{Ok, Result}; - use tempfile::TempDir; - - #[test] - fn errs_if_no_next_boot_patch() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // This should fail because no patches have been added. - assert!(manager.record_boot_success().is_err()); - - Ok(()) - } - - #[test] - fn errs_if_patch_number_does_not_match_next_patch() -> Result<()> { - let patch_number = 1; - let patch_file_contents = "patch contents"; - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, patch_file_contents)?; - assert!(manager - .add_patch(patch_number, file_path, "hash", None) - .is_ok()); - assert!(manager.record_boot_success().is_err()); - - Ok(()) - } - - #[test] - fn succeeds_when_provided_next_boot_patch_number() -> Result<()> { - let patch_number = 1; - let patch_file_contents = "patch contents"; - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - let file_path = &temp_dir.path().join("patch1.vmcode"); - std::fs::write(file_path, patch_file_contents)?; - assert!(manager - .add_patch(patch_number, file_path, "hash", None) - .is_ok()); - - assert!(manager.record_boot_start_for_patch(1).is_ok()); - assert!(manager.record_boot_success().is_ok()); - - Ok(()) - } - - #[test] - fn deletes_other_patch_artifacts() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Download patches 1, 2, and 3 before we start booting from patch 2. - manager.add_patch_for_test(&temp_dir, 1)?; - manager.add_patch_for_test(&temp_dir, 2)?; - manager.add_patch_for_test(&temp_dir, 3)?; - - // Start booting from our latest patch. - manager.record_boot_start_for_patch(3)?; - - // Download patch 4 while we're booting from patch 3. - manager.add_patch_for_test(&temp_dir, 4)?; - - // Record success for patch 3, make sure the artifact still exists. - manager.record_boot_success()?; - - // Make sure that recording success for patch 2 deleted artifacts for prior - // patches but not for subsequent patches. - let mut patch_dir_names = std::fs::read_dir(manager.patches_dir())? - .map(|res| res.map(|e| e.path())) - .map(|e| e.unwrap()) - .map(|e| e.file_name().unwrap().to_owned()) - .collect::>(); - patch_dir_names.sort(); - assert_eq!(patch_dir_names, vec!["3", "4"]); - - Ok(()) - } - - #[test] - fn deletes_unrecognized_directories_in_patches_dir() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - - // Add a junk directory to the patches directory. - let junk_dir = manager.patches_dir().join("junk"); - std::fs::create_dir_all(&junk_dir)?; - - manager.add_patch_for_test(&temp_dir, 1)?; - manager.add_patch_for_test(&temp_dir, 2)?; - manager.record_boot_start_for_patch(2)?; - manager.record_boot_success()?; - - assert!(!junk_dir.exists()); - assert!(!manager.patch_dir(1).exists()); - - Ok(()) - } -} - -#[cfg(test)] -mod record_boot_failure_for_patch_tests { - use super::*; - use anyhow::{Ok, Result}; - use tempfile::TempDir; - - #[test] - fn deletes_failed_patch_artifacts() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - manager.add_patch_for_test(&temp_dir, 1)?; - assert!(manager.record_boot_start_for_patch(1).is_ok()); - assert!(manager.record_boot_success().is_ok()); - assert!(!manager.is_known_bad_patch(1)); - let succeeded_patch_artifact_path = manager.patch_artifact_path(1); - - manager.add_patch_for_test(&temp_dir, 2)?; - let failed_patch_artifact_path = manager.patch_artifact_path(2); - - // Make sure patch artifacts exist - assert!(failed_patch_artifact_path.exists()); - assert!(succeeded_patch_artifact_path.exists()); - - assert!(manager.record_boot_start_for_patch(2).is_ok()); - assert!(manager.record_boot_failure_for_patch(2).is_ok()); - assert!(!failed_patch_artifact_path.exists()); - assert!(manager.is_known_bad_patch(2)); - - Ok(()) - } - - /// Patch 1 successfully booted on a prior run; on this run boot fails. - /// `last_successfully_booted_patch` keeps reporting patch 1 — it *did* - /// successfully boot once, that's a historical fact. The operational - /// "don't try this patch again" intent is captured by - /// `is_known_bad_patch` and by deleting the artifacts; nobody falls - /// back to a patch with missing artifacts. - #[test] - fn preserves_last_booted_patch_on_failure_but_marks_bad() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - manager.add_patch_for_test(&temp_dir, 1)?; - let patch_artifact_path = manager.patch_artifact_path(1); - - // Pretend we booted from this patch - assert!(manager.record_boot_start_for_patch(1).is_ok()); - assert!(manager.record_boot_success().is_ok()); - assert_eq!(manager.last_successfully_booted_patch().unwrap().number, 1); - assert_eq!(manager.next_boot_patch().unwrap().number, 1); - assert!(patch_artifact_path.exists()); - assert!(!manager.is_known_bad_patch(1)); - - // Now pretend it failed to boot - assert!(manager.record_boot_start_for_patch(1).is_ok()); - assert!(manager.record_boot_failure_for_patch(1).is_ok()); - // Historical fact preserved. - assert_eq!(manager.last_successfully_booted_patch().unwrap().number, 1); - // Operational state: don't try this patch again. - assert!(manager.next_boot_patch().is_none()); - assert!(manager.is_known_bad_patch(1)); - assert!(!patch_artifact_path.exists()); - - Ok(()) - } -} - -#[cfg(test)] -mod reset_tests { - use super::*; - use anyhow::{Ok, Result}; - use tempfile::TempDir; - - #[test] - fn deletes_patches_dir_and_resets_patches_state() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut manager = PatchManager::manager_for_test(&temp_dir); - manager.add_patch_for_test(&temp_dir, 1)?; - let path_artifacts_dir = manager.patches_dir(); - - // Make sure the directory and artifact files were created - assert!(path_artifacts_dir.exists()); - assert_eq!(std::fs::read_dir(&path_artifacts_dir).unwrap().count(), 1); - - assert!(manager.reset().is_ok()); - - // Make sure the directory and artifact files were deleted - assert!(!path_artifacts_dir.exists()); - - Ok(()) - } -} diff --git a/library/src/download_state.rs b/library/src/download_state.rs deleted file mode 100644 index 4abf8b14..00000000 --- a/library/src/download_state.rs +++ /dev/null @@ -1,126 +0,0 @@ -/// Tracks metadata about an in-progress patch download so that it can be -/// resumed after a failure or app restart. -/// -/// Stored as a sidecar JSON file alongside the partial download: -/// {download_dir}/{patch_number}.download.json -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -use crate::file_errors::{FileOperation, IoResultExt}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DownloadState { - /// The URL this download was started from. Used to decide whether a - /// partial file on disk matches the current server response — if the URL - /// changed, we discard and start fresh. - pub url: String, - /// The patch number being downloaded. - pub patch_number: usize, - /// Expected total file size from Content-Length/Content-Range (if known - /// from a prior download attempt). Used for post-download validation. - pub expected_size: Option, - /// Hash of the inflated patch from the server response. Checked on resume - /// to catch the case where a patch is deleted and re-added with the same - /// number but different content (URL may stay the same but hash changes). - pub expected_hash: String, -} - -/// Returns the sidecar path for a given download path. -/// e.g. "{download_dir}/1" -> "{download_dir}/1.download.json" -pub fn sidecar_path(download_path: &Path) -> PathBuf { - let mut p = download_path.as_os_str().to_owned(); - p.push(".download.json"); - PathBuf::from(p) -} - -/// Write a DownloadState to its sidecar file. -pub fn write_download_state(download_path: &Path, state: &DownloadState) -> anyhow::Result<()> { - let path = sidecar_path(download_path); - let json = serde_json::to_string(state)?; - std::fs::write(&path, json).with_file_context(FileOperation::WriteFile, &path)?; - Ok(()) -} - -/// Read a DownloadState from its sidecar file, if it exists. -pub fn read_download_state(download_path: &Path) -> anyhow::Result> { - let path = sidecar_path(download_path); - if !path.exists() { - return Ok(None); - } - let json = std::fs::read_to_string(&path).with_file_context(FileOperation::ReadFile, &path)?; - let state: DownloadState = serde_json::from_str(&json)?; - Ok(Some(state)) -} - -/// Delete the sidecar file for a download, if it exists. -pub fn delete_download_state(download_path: &Path) -> anyhow::Result<()> { - let path = sidecar_path(download_path); - if path.exists() { - std::fs::remove_file(&path).with_file_context(FileOperation::DeleteFile, &path)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn round_trip_download_state() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("1"); - - let state = DownloadState { - url: "https://example.com/patch/1".to_string(), - patch_number: 1, - expected_size: Some(12345), - expected_hash: "abc123".to_string(), - }; - - write_download_state(&download_path, &state).unwrap(); - let loaded = read_download_state(&download_path).unwrap(); - assert_eq!(loaded, Some(state)); - } - - #[test] - fn read_missing_returns_none() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("99"); - let loaded = read_download_state(&download_path).unwrap(); - assert_eq!(loaded, None); - } - - #[test] - fn delete_removes_sidecar() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("1"); - - let state = DownloadState { - url: "https://example.com/patch/1".to_string(), - patch_number: 1, - expected_size: None, - expected_hash: "abc".to_string(), - }; - - write_download_state(&download_path, &state).unwrap(); - assert!(sidecar_path(&download_path).exists()); - - delete_download_state(&download_path).unwrap(); - assert!(!sidecar_path(&download_path).exists()); - } - - #[test] - fn delete_missing_is_ok() { - let tmp = TempDir::new().unwrap(); - let download_path = tmp.path().join("99"); - // Should not error. - delete_download_state(&download_path).unwrap(); - } - - #[test] - fn sidecar_path_is_correct() { - let p = sidecar_path(Path::new("/cache/downloads/1")); - assert_eq!(p, PathBuf::from("/cache/downloads/1.download.json")); - } -} diff --git a/library/src/lib.rs b/library/src/lib.rs index fbe31057..63d83ebc 100644 --- a/library/src/lib.rs +++ b/library/src/lib.rs @@ -11,7 +11,6 @@ pub mod c_api; // Declare other .rs file/module exists, but make them private. mod cache; mod config; -mod download_state; mod events; mod file_errors; mod logging; diff --git a/library/src/updater.rs b/library/src/updater.rs index 1c365e2c..7a63ed1a 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -14,9 +14,7 @@ use crate::cache::{PatchInfo, UpdaterState}; use crate::config::{set_config, with_config, UpdateConfig}; use crate::events::{EventType, PatchEvent}; use crate::logging::init_logging; -use crate::network::{ - download_to_path, patches_check_url, DownloadResult, NetworkHooks, PatchCheckRequest, -}; +use crate::network::{download_to_path, patches_check_url, NetworkHooks, PatchCheckRequest}; use crate::updater_lock::{with_updater_thread_lock, UpdaterLockState}; use crate::yaml::YamlConfig; From 6e396f42580ba6e974fc6e37778fee437b1c4de5 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 17:25:43 -0700 Subject: [PATCH 06/21] refactor: address self-review feedback on lifecycle PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine fixes from the self-review pass on shorebirdtech/updater#352: - Drop `partial_size` from PatchState::Downloading. The field was misleading (decide_start reads from disk, not from the recorded value) and unused. record_download_started loses its 5th arg. - Gate `UpdaterState::install_patch` to `#[cfg(test)]`. Production no longer routes through it; only test_utils and the updater_state tests do. The gate makes the divergence intentional and prevents future production callers. - install_patch defensively removes any prior `dlc.vmcode` before rename so behavior is OS-agnostic (POSIX rename overwrites silently; Windows fails). Also mirrors record_install_complete's cleanup of a stale `download` file in the patch dir. - Document on `lifecycle()` / `lifecycle_mut()` that the direct accessors are intentional — wrapping every transition would be churn for no reader benefit. - recompute_next_boot now clears `last_booted_patch` when its on-disk record is gone (Unknown), so pointers.json doesn't accumulate references to nothing. A `Bad` last_booted is left alone — that's a useful breadcrumb and recompute simply doesn't promote it. - Promote `download_artifact_path` and `installed_artifact_path` to methods on PatchLifecycle for symmetry with state_path / pointers_path. Updates all call sites. - Document mark_bad-on-Bad merge semantics: latest reason wins, other fields preserved. Hypothetical in practice but no longer silent. - Add tests for recompute_next_boot's stale-pointer clearing (Unknown → cleared, Bad → kept). Brings the suite to 214 tests. The mark_bad-cleanup-stale-bytes recovery path I flagged was already covered by `cleanup_on_bad_patch_keeps_tombstone` — no new test needed. --- library/src/cache/lifecycle.rs | 170 +++++++++++++++++++---------- library/src/cache/updater_state.rs | 51 ++++++++- library/src/updater.rs | 12 +- 3 files changed, 163 insertions(+), 70 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 02901942..5de8ead1 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -44,13 +44,13 @@ const POINTERS_FILE: &str = "pointers.json"; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind")] pub enum PatchState { - /// Compressed bytes are partially on disk. Resume sends - /// `Range: bytes={partial_size}-`. + /// Compressed bytes are partially on disk. The current bytes-on-disk + /// count is read from the `download` file at resume time — the state + /// itself just records "we're mid-download for this url+hash." Downloading { url: String, hash: String, signature: Option, - partial_size: u64, }, /// Compressed bytes are fully on disk and the size matches what we /// recorded after the download completed. Bytes are untrusted until @@ -183,14 +183,23 @@ impl PatchLifecycle { /// Write-then-cleanup ordering means a crash between the two leaves a /// tombstone with stale-but-unused artifact bytes — sweeping picks /// them up on the next `cleanup` call. + /// + /// Marking an already-Bad patch overwrites the `reason` field with + /// the new one (the old hash/signature/size are preserved). In + /// practice we don't double-fail patches — this just makes the + /// behavior obvious if it ever happens. pub fn mark_bad(&self, n: usize, reason: BadReason) -> Result<()> { let (hash, signature, size) = match self.read_state(n) { Some(PatchState::Downloading { - hash, - signature, - partial_size, - .. - }) => (Some(hash), signature, Some(partial_size)), + hash, signature, .. + }) => { + let size = self + .download_artifact_path(n) + .metadata() + .ok() + .map(|m| m.len()); + (Some(hash), signature, size) + } Some(PatchState::Downloaded { hash, signature, @@ -265,6 +274,18 @@ impl PatchLifecycle { Ok(()) } + /// Path the caller streams compressed download bytes to. Lives at + /// `{root}/patches/{N}/download`. + pub fn download_artifact_path(&self, n: usize) -> PathBuf { + self.patch_dir(n).join("download") + } + + /// Path of the installed (inflated) artifact. Lives at + /// `{root}/patches/{N}/dlc.vmcode`. + pub fn installed_artifact_path(&self, n: usize) -> PathBuf { + self.patch_dir(n).join("dlc.vmcode") + } + fn patches_root(&self) -> PathBuf { self.root.join(PATCHES_DIR) } @@ -282,20 +303,6 @@ impl PatchLifecycle { } } -/// Convenience accessor; returns the path a caller would write the -/// compressed download bytes to. Public so the network layer can stream -/// directly into it without knowing the on-disk layout details. -pub fn download_artifact_path(root: &Path, n: usize) -> PathBuf { - root.join(PATCHES_DIR).join(n.to_string()).join("download") -} - -/// Path to the installed (inflated) artifact for patch `n`. -pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf { - root.join(PATCHES_DIR) - .join(n.to_string()) - .join("dlc.vmcode") -} - /// What `update_internal` should do when starting work on a patch. /// /// Returned by [`PatchLifecycle::decide_start`] after inspecting the @@ -304,8 +311,8 @@ pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf { #[derive(Debug, Clone, PartialEq)] pub enum DownloadAction { /// No usable prior bytes — start a fresh download. The caller should - /// `record_download_started(... partial_size: 0)` and issue a GET - /// without a Range header. + /// `record_download_started(...)` and issue a GET without a Range + /// header. Fresh, /// Partial bytes from a matching prior attempt are on disk. The /// caller resumes from `offset` (the existing partial file size) @@ -336,11 +343,10 @@ impl PatchLifecycle { /// trusted. pub fn decide_start(&self, n: usize, url: &str, hash: &str) -> DownloadAction { // For Downloading/Downloaded, the on-disk file is the source - // of truth for "how many bytes do we have." `partial_size` in - // the state is denormalized info from when the state was last - // written and may lag a partially-completed download. Reading - // from disk avoids needing to update the sidecar mid-stream. - let download_path = download_artifact_path(&self.root, n); + // of truth for "how many bytes do we have." The state itself + // just records the url/hash/signature so we can detect a + // server change since the prior attempt. + let download_path = self.download_artifact_path(n); match self.read_state(n) { None => DownloadAction::Fresh, Some(PatchState::Downloading { @@ -373,16 +379,16 @@ impl PatchLifecycle { } } - /// Records that a download is starting (or restarting). `partial_size` - /// is what the caller will tell the server via Range — typically `0` - /// for a fresh download, or the existing file size for a resume. + /// Records that a download is starting (or restarting). The actual + /// bytes-on-disk count comes from the `download` file at resume + /// time; this just persists the url/hash/signature so a subsequent + /// `decide_start` can match against the server's current offer. pub fn record_download_started( &self, n: usize, url: &str, hash: &str, signature: Option<&str>, - partial_size: u64, ) -> Result<()> { self.write_state( n, @@ -390,7 +396,6 @@ impl PatchLifecycle { url: url.to_string(), hash: hash.to_string(), signature: signature.map(String::from), - partial_size, }, ) } @@ -523,22 +528,39 @@ impl PatchLifecycle { /// a freshly installed newer patch by promoting the older /// `last_booted_patch` back into `next_boot_patch`. /// + /// Also clears `last_booted_patch` if its on-disk record is gone + /// (Unknown), so `pointers.json` doesn't accumulate references to + /// nothing. A `last_booted_patch` whose state is `Bad` is left + /// alone — that's a useful historical breadcrumb and recompute + /// will simply not promote it. + /// /// We deliberately don't scan `patches/` for arbitrary Installed /// patches — within a release there are at most a couple of patches /// active at once, and the last successfully booted patch is the /// only one we have evidence works on this device. pub fn recompute_next_boot(&mut self) -> Result<()> { - if let Some(n) = self.pointers.next_boot_patch { - if matches!(self.read_state(n), Some(PatchState::Installed { .. })) { - return Ok(()); + let mut dirty = false; + if let Some(lb) = self.pointers.last_booted_patch { + if self.read_state(lb).is_none() { + self.pointers.last_booted_patch = None; + dirty = true; } } - let new_target = self + let already_valid = self .pointers - .last_booted_patch - .filter(|&lb| matches!(self.read_state(lb), Some(PatchState::Installed { .. }))); - if self.pointers.next_boot_patch != new_target { - self.pointers.next_boot_patch = new_target; + .next_boot_patch + .is_some_and(|n| matches!(self.read_state(n), Some(PatchState::Installed { .. }))); + if !already_valid { + let new_target = self + .pointers + .last_booted_patch + .filter(|&lb| matches!(self.read_state(lb), Some(PatchState::Installed { .. }))); + if self.pointers.next_boot_patch != new_target { + self.pointers.next_boot_patch = new_target; + dirty = true; + } + } + if dirty { self.save_pointers()?; } Ok(()) @@ -577,7 +599,7 @@ impl PatchLifecycle { }) => (size, signature), other => bail!("Patch {n} is not Installed: {other:?}"), }; - let path = installed_artifact_path(&self.root, n); + let path = self.installed_artifact_path(n); if !path.exists() { bail!("Patch {n} artifact missing at {}", path.display()); } @@ -879,9 +901,9 @@ mod tests { #[test] fn artifact_path_helpers_match_state_directory() { - let (tmp, lifecycle) = fixture(); - let download = download_artifact_path(tmp.path(), 7); - let installed = installed_artifact_path(tmp.path(), 7); + let (_tmp, lifecycle) = fixture(); + let download = lifecycle.download_artifact_path(7); + let installed = lifecycle.installed_artifact_path(7); assert_eq!(download.parent().unwrap(), lifecycle.patch_dir(7)); assert_eq!(installed.parent().unwrap(), lifecycle.patch_dir(7)); } @@ -905,11 +927,10 @@ mod tests { url: "https://example/p".into(), hash: "h".into(), signature: None, - partial_size: 250, }, ) .unwrap(); - std::fs::write(download_artifact_path(&lifecycle.root, 1), vec![0u8; 250]).unwrap(); + std::fs::write(lifecycle.download_artifact_path(1), vec![0u8; 250]).unwrap(); assert_eq!( lifecycle.decide_start(1, "https://example/p", "h"), DownloadAction::Resume { offset: 250 } @@ -928,7 +949,6 @@ mod tests { url: "https://example/p".into(), hash: "h".into(), signature: None, - partial_size: 250, }, ) .unwrap(); @@ -948,7 +968,6 @@ mod tests { url: "https://old.example/p".into(), hash: "h".into(), signature: None, - partial_size: 100, }, ) .unwrap(); @@ -989,7 +1008,7 @@ mod tests { }, ) .unwrap(); - std::fs::write(download_artifact_path(&lifecycle.root, 1), vec![0u8; 1000]).unwrap(); + std::fs::write(lifecycle.download_artifact_path(1), vec![0u8; 1000]).unwrap(); assert_eq!( lifecycle.decide_start(1, "u", "h"), DownloadAction::Complete @@ -1056,7 +1075,7 @@ mod tests { fn record_download_started_writes_downloading_state() { let (_tmp, lifecycle) = fixture(); lifecycle - .record_download_started(1, "u", "h", Some("s"), 0) + .record_download_started(1, "u", "h", Some("s")) .unwrap(); assert_eq!( lifecycle.read_state(1).unwrap(), @@ -1064,7 +1083,6 @@ mod tests { url: "u".into(), hash: "h".into(), signature: Some("s".into()), - partial_size: 0, } ); } @@ -1073,7 +1091,7 @@ mod tests { fn record_download_complete_transitions_downloading_to_downloaded() { let (_tmp, lifecycle) = fixture(); lifecycle - .record_download_started(1, "u", "h", None, 0) + .record_download_started(1, "u", "h", None) .unwrap(); lifecycle.record_download_complete(1, 1234).unwrap(); assert_eq!( @@ -1167,7 +1185,7 @@ mod tests { } fn install_patch(lifecycle: &PatchLifecycle, n: usize, size: u64) { - let path = installed_artifact_path(&lifecycle.root, n); + let path = lifecycle.installed_artifact_path(n); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, vec![0u8; size as usize]).unwrap(); lifecycle @@ -1272,6 +1290,46 @@ mod tests { assert_eq!(lifecycle.pointers().next_boot_patch, None); } + #[test] + fn recompute_next_boot_clears_stale_last_booted() { + let (_tmp, mut lifecycle) = fixture(); + // last_booted points at a patch we've forgotten — e.g. an older + // release version was wiped and we're carrying a stale pointer. + lifecycle.pointers.last_booted_patch = Some(7); + lifecycle.save_pointers().unwrap(); + + lifecycle.recompute_next_boot().unwrap(); + + assert_eq!(lifecycle.pointers().last_booted_patch, None); + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + + #[test] + fn recompute_next_boot_keeps_bad_last_booted_pointer() { + // A `Bad` patch in last_booted is a useful breadcrumb — recompute + // shouldn't promote it (next_boot stays None) but shouldn't clear + // the historical pointer either. + let (_tmp, mut lifecycle) = fixture(); + lifecycle + .write_state( + 3, + &PatchState::Bad { + reason: BadReason::BootCrash, + hash: None, + signature: None, + size: None, + }, + ) + .unwrap(); + lifecycle.pointers.last_booted_patch = Some(3); + lifecycle.save_pointers().unwrap(); + + lifecycle.recompute_next_boot().unwrap(); + + assert_eq!(lifecycle.pointers().last_booted_patch, Some(3)); + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + #[test] fn detect_boot_crash_on_init_recovers_when_breadcrumb_set() { let tmp = TempDir::new().unwrap(); @@ -1308,7 +1366,7 @@ mod tests { lifecycle.pointers.next_boot_patch = Some(1); lifecycle.save_pointers().unwrap(); // Truncate the artifact so it no longer matches. - std::fs::write(installed_artifact_path(&lifecycle.root, 1), b"short").unwrap(); + std::fs::write(lifecycle.installed_artifact_path(1), b"short").unwrap(); let result = lifecycle.validate_next_boot_patch(None, PatchVerificationMode::default()); assert!(result.is_err()); diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index ce6755f9..7f304084 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -8,8 +8,10 @@ use serde::{Deserialize, Serialize}; use crate::events::PatchEvent; use crate::yaml::PatchVerificationMode; -use super::lifecycle::{installed_artifact_path, PatchLifecycle, PatchState}; -use super::{disk_io, signing, PatchInfo}; +use super::lifecycle::{PatchLifecycle, PatchState}; +#[cfg(test)] +use super::signing; +use super::{disk_io, PatchInfo}; const STATE_FILE_NAME: &str = "state.json"; @@ -191,10 +193,19 @@ impl UpdaterState { /// 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 } @@ -264,6 +275,16 @@ impl UpdaterState { /// 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, @@ -281,11 +302,29 @@ impl UpdaterState { signing::check_signature(hash, sig, public_key)?; } } - let installed_path = installed_artifact_path(&self.cache_dir, patch.number); + let installed_path = self.lifecycle.installed_artifact_path(patch.number); if let Some(parent) = installed_path.parent() { std::fs::create_dir_all(parent)?; } + // Defensive: if a prior partial install left a `dlc.vmcode` + // behind, remove it before renaming so behavior is OS-agnostic + // (POSIX `rename` overwrites silently; Windows fails). + if installed_path.exists() { + std::fs::remove_file(&installed_path)?; + } 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, @@ -316,7 +355,7 @@ impl UpdaterState { fn patch_info(&self, n: usize) -> PatchInfo { PatchInfo { - path: installed_artifact_path(&self.cache_dir, n), + path: self.lifecycle.installed_artifact_path(n), number: n, } } @@ -424,7 +463,7 @@ mod tests { .install_patch(&fake_artifact(&tmp, 2), "h2", None) .unwrap(); assert_eq!(state.next_boot_patch().map(|p| p.number), Some(2)); - assert!(!installed_artifact_path(tmp.path(), 1).exists()); + assert!(!state.lifecycle.installed_artifact_path(1).exists()); } #[test] @@ -493,7 +532,7 @@ mod tests { 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!(!installed_artifact_path(tmp.path(), 1).exists()); + assert!(!state.lifecycle.installed_artifact_path(1).exists()); } #[test] diff --git a/library/src/updater.rs b/library/src/updater.rs index 7a63ed1a..c8b237ef 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -453,8 +453,8 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul config.release_version ); - let storage_dir = PathBuf::from(&config.storage_dir); - let download_path = lifecycle::download_artifact_path(&storage_dir, patch.number); + let download_path = + with_state(|state| Ok(state.lifecycle().download_artifact_path(patch.number)))?; if !matches!(action, DownloadAction::Complete) { let resume_from = match action { @@ -473,7 +473,6 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul &patch.download_url, &patch.hash, patch.hash_signature.as_deref(), - resume_from, ) })?; @@ -531,8 +530,8 @@ fn install_downloaded_patch( patch: &crate::network::Patch, download_path: &Path, ) -> anyhow::Result { - let storage_dir = PathBuf::from(&config.storage_dir); - let installed_path = lifecycle::installed_artifact_path(&storage_dir, patch.number); + let installed_path = + with_state(|state| Ok(state.lifecycle().installed_artifact_path(patch.number)))?; let patch_base_rs = patch_base(config)?; if let Err(e) = inflate(download_path, patch_base_rs, &installed_path) { @@ -1926,7 +1925,6 @@ patch_verification: bogus_mode hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45" .to_string(), signature: None, - partial_size: first_part.len() as u64, }, &patch_dir.join("state.json"), ) @@ -1999,7 +1997,6 @@ patch_verification: bogus_mode url: "http://old-cdn.example.com/patch/1".to_string(), hash: "hash_old".to_string(), signature: None, - partial_size: 10, }, &patch_dir.join("state.json"), ) @@ -3229,7 +3226,6 @@ mod resume_edge_case_tests { url: "http://example.com/patch/1".to_string(), hash: PATCH_HASH.to_string(), signature: None, - partial_size: 10, }, &patch_dir.join("state.json"), )?; From 0b7f65324d0051b18aa17827b19fca34526464d3 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 17:32:53 -0700 Subject: [PATCH 07/21] refactor: undo two over-corrections from the prior self-review fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues introduced by 07ea84a that the second self-review caught: - update_internal computed download_path / installed_path via `with_state(...).lifecycle().download_artifact_path(n)`. That routed a pure-function path computation through a state read, serializing it against other state operations and adding a disk read per call. Restore the free `download_artifact_path` / `installed_artifact_path` functions as the canonical entry point; the methods on PatchLifecycle are kept as thin wrappers for callers that already hold a lifecycle handle. - install_patch's "defensive remove_file before rename" added a TOCTOU window between the exists() check and the rename for no real benefit — install_patch is now `#[cfg(test)]`-only, the Windows-rename concern was theoretical for tests, and POSIX rename atomically overwrites. Drop the explicit remove_file. --- library/src/cache/lifecycle.rs | 30 ++++++++++++++++++++++++------ library/src/cache/updater_state.rs | 6 ------ library/src/updater.rs | 4 ++-- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 5de8ead1..8aaeb64a 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -274,16 +274,17 @@ impl PatchLifecycle { Ok(()) } - /// Path the caller streams compressed download bytes to. Lives at - /// `{root}/patches/{N}/download`. + /// Path the caller streams compressed download bytes to. Convenience + /// wrapper around the free [`download_artifact_path`] for callers + /// that already hold a lifecycle handle. pub fn download_artifact_path(&self, n: usize) -> PathBuf { - self.patch_dir(n).join("download") + download_artifact_path(&self.root, n) } - /// Path of the installed (inflated) artifact. Lives at - /// `{root}/patches/{N}/dlc.vmcode`. + /// Path of the installed (inflated) artifact. Convenience wrapper + /// around the free [`installed_artifact_path`]. pub fn installed_artifact_path(&self, n: usize) -> PathBuf { - self.patch_dir(n).join("dlc.vmcode") + installed_artifact_path(&self.root, n) } fn patches_root(&self) -> PathBuf { @@ -303,6 +304,23 @@ impl PatchLifecycle { } } +/// Path the caller streams compressed download bytes to. Lives at +/// `{root}/patches/{N}/download`. Free function so callers in +/// `update_internal` (which only has the cache root in hand) don't have +/// to reach through `with_state` to compute a pure-function path. +pub fn download_artifact_path(root: &Path, n: usize) -> PathBuf { + root.join(PATCHES_DIR).join(n.to_string()).join("download") +} + +/// Path of the installed (inflated) artifact. Lives at +/// `{root}/patches/{N}/dlc.vmcode`. See [`download_artifact_path`] for +/// why this is a free function rather than only a method. +pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf { + root.join(PATCHES_DIR) + .join(n.to_string()) + .join("dlc.vmcode") +} + /// What `update_internal` should do when starting work on a patch. /// /// Returned by [`PatchLifecycle::decide_start`] after inspecting the diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 7f304084..b1a8df17 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -306,12 +306,6 @@ impl UpdaterState { if let Some(parent) = installed_path.parent() { std::fs::create_dir_all(parent)?; } - // Defensive: if a prior partial install left a `dlc.vmcode` - // behind, remove it before renaming so behavior is OS-agnostic - // (POSIX `rename` overwrites silently; Windows fails). - if installed_path.exists() { - std::fs::remove_file(&installed_path)?; - } 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. diff --git a/library/src/updater.rs b/library/src/updater.rs index c8b237ef..9a902704 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -454,7 +454,7 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul ); let download_path = - with_state(|state| Ok(state.lifecycle().download_artifact_path(patch.number)))?; + lifecycle::download_artifact_path(Path::new(&config.storage_dir), patch.number); if !matches!(action, DownloadAction::Complete) { let resume_from = match action { @@ -531,7 +531,7 @@ fn install_downloaded_patch( download_path: &Path, ) -> anyhow::Result { let installed_path = - with_state(|state| Ok(state.lifecycle().installed_artifact_path(patch.number)))?; + lifecycle::installed_artifact_path(Path::new(&config.storage_dir), patch.number); let patch_base_rs = patch_base(config)?; if let Err(e) = inflate(download_path, patch_base_rs, &installed_path) { From 1f21c77f06c12f8f48b92357ddf5d5d798cb6349 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 17:38:51 -0700 Subject: [PATCH 08/21] ci: fix warnings-as-errors and cspell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI failures from the prior push: - `Context` and `bail` imports in updater_state.rs are only used inside the `#[cfg(test)] install_patch` helper. Moved them under `#[cfg(test)]` so non-test builds don't see unused imports. - `PathBuf` import in updater.rs became unused after switching the download/install paths to take `Path::new(&config.storage_dir)` directly. Dropped. - `FileOperation::DeleteDir` is no longer constructed by production code (was used by the deleted patch_manager.rs). Mirrored the existing `#[allow(dead_code)]` annotation already on `DeleteFile` rather than removing the variant — the format/handling code for it is still useful for any future code that needs to surface a "delete dir failed" error. CSpell additions: `unparseable`, `tombstoned`, `roundtrips` — words introduced by the lifecycle module's docs and test names. --- cspell.config.yaml | 3 +++ library/src/cache/updater_state.rs | 4 +++- library/src/file_errors.rs | 1 + library/src/updater.rs | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cspell.config.yaml b/cspell.config.yaml index 52a5a629..d6f75d11 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -68,6 +68,7 @@ words: - repr - reqwest - rlib + - roundtrips - rsplit - rollouts - RTLD @@ -82,9 +83,11 @@ words: - subosito - swiftpm - symbolication + - tombstoned - ureq - unbootable - unbooted + - unparseable - unreviewable - unwritable - usize diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index b1a8df17..20940704 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -2,7 +2,9 @@ use std::path::{Path, PathBuf}; -use anyhow::{bail, Context, Result}; +use anyhow::Result; +#[cfg(test)] +use anyhow::{bail, Context}; use serde::{Deserialize, Serialize}; use crate::events::PatchEvent; diff --git a/library/src/file_errors.rs b/library/src/file_errors.rs index 828f58c9..3bb1cd40 100644 --- a/library/src/file_errors.rs +++ b/library/src/file_errors.rs @@ -13,6 +13,7 @@ pub enum FileOperation { ReadFile, #[allow(dead_code)] // Included for completeness; not yet used outside tests. DeleteFile, + #[allow(dead_code)] // Included for completeness; not yet used outside tests. DeleteDir, RenameFile, GetMetadata, diff --git a/library/src/updater.rs b/library/src/updater.rs index 9a902704..7361dc6f 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -3,7 +3,7 @@ use std::fmt::{Debug, Display, Formatter}; use std::fs::{self}; use std::io::{Cursor, Read, Seek}; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::file_errors::{FileOperation, IoResultExt}; use anyhow::{bail, Context, Result}; From 36f25ebea52906a0c7ae907e5b8f9752768825b2 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 17:44:29 -0700 Subject: [PATCH 09/21] ci: gate PathBuf import to platforms that actually use it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix removed `PathBuf` from the top-level use, which broke the lib build on non-android non-test platforms (macOS, Linux, Windows) where `libapp_path_from_settings` uses `PathBuf`. That function is itself gated to `cfg(not(any(target_os = \"android\", test)))`, so the PathBuf import needs the same gate. Restoring it as a cfg-gated separate import — keeps the lib build green on all three runners and keeps the lib-test build clean under -D warnings. --- library/src/updater.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/library/src/updater.rs b/library/src/updater.rs index 7361dc6f..c52ff347 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -4,6 +4,10 @@ use std::fmt::{Debug, Display, Formatter}; use std::fs::{self}; use std::io::{Cursor, Read, Seek}; use std::path::Path; +// PathBuf is only used by `libapp_path_from_settings`, which is itself +// gated to non-android, non-test builds. +#[cfg(not(any(target_os = "android", test)))] +use std::path::PathBuf; use crate::file_errors::{FileOperation, IoResultExt}; use anyhow::{bail, Context, Result}; From 1f761d6ab8eeea064b25580677f3cd6a148472ec Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Mon, 4 May 2026 18:26:00 -0700 Subject: [PATCH 10/21] test: cover the gaps surfaced by the coverage report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 7 tests targeting branches that were uncovered: - mark_bad_from_downloading_records_partial_file_size: the Downloading source state in mark_bad reads bytes-on-disk for the recorded `size` field; the existing tests only covered Installed → Bad. - mark_bad_overwrites_reason_when_already_bad: documented merge semantics (latest reason wins, other fields preserved) now has a test backing the doc. - validate_next_boot_patch_marks_bad_when_artifact_missing: the "Installed state but dlc.vmcode is gone" path. Existing test covered size mismatch; this covers the missing-file branch. - validate_next_boot_patch_marks_bad_when_pointer_targets_non_installed: the case where the next_boot pointer references a state other than Installed (state.json + pointers can diverge through corruption). - install_patch_install_only_{accepts_valid,rejects_missing,rejects_bad}_signature: cover the InstallOnly verification path in UpdaterState::install_patch using the existing test keypair from signing.rs. Coverage improvements: - cache/lifecycle.rs: 94.65% → 96.06% lines, 98.72% → 100% fns - cache/updater_state.rs: 94.34% → 96.94% lines, 95.65% → 98% fns - total: 94.70% → 95.11% lines 214 → 221 tests, all passing under -D warnings. --- library/src/cache/lifecycle.rs | 129 +++++++++++++++++++++++++++++ library/src/cache/updater_state.rs | 58 +++++++++++++ 2 files changed, 187 insertions(+) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 8aaeb64a..2b2e2e80 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -790,6 +790,74 @@ mod tests { } } + #[test] + fn mark_bad_from_downloading_records_partial_file_size() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Downloading { + url: "u".into(), + hash: "h".into(), + signature: Some("s".into()), + }, + ) + .unwrap(); + // File size on disk is what gets recorded as the patch's "size" + // — there's no recorded count in Downloading anymore. + let path = lifecycle.download_artifact_path(1); + std::fs::write(&path, vec![0u8; 250]).unwrap(); + + lifecycle.mark_bad(1, BadReason::InvalidPatchBytes).unwrap(); + + match lifecycle.read_state(1).unwrap() { + PatchState::Bad { + reason, + hash, + signature, + size, + } => { + assert_eq!(reason, BadReason::InvalidPatchBytes); + assert_eq!(hash, Some("h".into())); + assert_eq!(signature, Some("s".into())); + assert_eq!(size, Some(250)); + } + other => panic!("expected Bad, got {other:?}"), + } + } + + #[test] + fn mark_bad_overwrites_reason_when_already_bad() { + let (_tmp, lifecycle) = fixture(); + lifecycle + .write_state( + 1, + &PatchState::Bad { + reason: BadReason::BootCrash, + hash: Some("h".into()), + signature: Some("s".into()), + size: Some(99), + }, + ) + .unwrap(); + lifecycle.mark_bad(1, BadReason::ValidationFailed).unwrap(); + // Reason changed, other fields preserved. + match lifecycle.read_state(1).unwrap() { + PatchState::Bad { + reason, + hash, + signature, + size, + } => { + assert_eq!(reason, BadReason::ValidationFailed); + assert_eq!(hash, Some("h".into())); + assert_eq!(signature, Some("s".into())); + assert_eq!(size, Some(99)); + } + other => panic!("expected Bad, got {other:?}"), + } + } + #[test] fn mark_bad_deletes_artifact_files_but_keeps_tombstone() { let (_tmp, lifecycle) = fixture(); @@ -1406,6 +1474,67 @@ mod tests { .is_ok()); } + #[test] + fn validate_next_boot_patch_marks_bad_when_artifact_missing() { + let (_tmp, mut lifecycle) = fixture(); + // State says Installed but the dlc.vmcode file is gone (e.g. + // the user cleared app data). Validation should mark Bad. + lifecycle + .write_state( + 1, + &PatchState::Installed { + hash: "h".into(), + signature: None, + size: 100, + }, + ) + .unwrap(); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + let result = lifecycle.validate_next_boot_patch(None, PatchVerificationMode::default()); + assert!(result.is_err()); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { + reason: BadReason::ValidationFailed, + .. + }) + )); + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + + #[test] + fn validate_next_boot_patch_marks_bad_when_pointer_targets_non_installed() { + let (_tmp, mut lifecycle) = fixture(); + // Pointer says boot patch 1, but patch 1 is in Downloading + // (shouldn't happen in normal flow, but pointers and state can + // diverge through corruption). Validation should mark Bad. + lifecycle + .write_state( + 1, + &PatchState::Downloading { + url: "u".into(), + hash: "h".into(), + signature: None, + }, + ) + .unwrap(); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + let result = lifecycle.validate_next_boot_patch(None, PatchVerificationMode::default()); + assert!(result.is_err()); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { + reason: BadReason::ValidationFailed, + .. + }) + )); + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + #[test] fn promote_to_next_boot_replaces_unbooted_predecessor() { let (_tmp, mut lifecycle) = fixture(); diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 20940704..478b5a89 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -473,6 +473,64 @@ mod tests { 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(), "1.0.0+1", public_key, mode) + } + + #[test] + fn install_patch_install_only_accepts_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() { + 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() { + 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() { let tmp = TempDir::new().unwrap(); From d23b2d58c4079f6d395c001991114131e42495bd Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 10:22:37 -0700 Subject: [PATCH 11/21] ci: add 'keypair' to cspell dictionary --- cspell.config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.config.yaml b/cspell.config.yaml index d6f75d11..b0786ef4 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -51,6 +51,7 @@ words: - gclient - hdpi - ifdef + - keypair - libapp - libc - libflutter From 08016a80beddeebed034aef52cfe73c1317afd18 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 10:51:03 -0700 Subject: [PATCH 12/21] test: audit deleted patch_manager tests; restore strict checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goes through every test deleted with patch_manager.rs (~40) and either maps it to a new test (with a `Ports patch_manager.rs::mod::name` comment) or adds a port. Three behavioral regressions caught and fixed: - record_boot_start now requires next_boot_patch to match the arg. Old PatchManager had this defensive check; my refactor dropped it. Carries forward the engine-vs-state agreement guard. - Added strict-mode signature tests for validate_next_boot_patch (valid, missing, invalid, no public key, bad public key). Ports five `validate_next_boot_patch_tests::strict_mode_*` tests. - Added InstallOnly + no public_key test (ports add_patch_tests::install_only_succeeds_with_any_signature_if_no_public_key). Plus two scenario tests that didn't have direct coverage: - rolled_back_patch_not_resurrected_when_replacement_fails: ports fall_back_tests::rollback_then_failed_replacement_does_not_resurrect_rolled_back_patch against the new state-based fallback (cleanup forgets, recompute won't promote a None state). - validate_then_promote_catches_corrupted_last_booted: ports validate_next_boot_patch_tests::does_not_fall_back_to_last_booted_patch_if_corrupted with a note: new code catches the corruption at the next validate pass instead of at fall-back time, but the user-visible outcome (boot the base release) is the same. Test helper split: `install_state` (writes Installed without touching pointers) vs callers explicitly setting pointers. Avoids the prior helper's auto-promote masking pointer-management behavior the tests were trying to exercise. Tests deliberately not ported (with reasoning): - debug_tests::manage_patches_is_debug, patch_manager_is_debug: Trait-Debug tests for types that no longer exist. Replaced implicitly by `#[derive(Debug)]` on PatchLifecycle. - fall_back_tests::succeeds_if_deleting_artifacts_fails: needs filesystem fault injection that wasn't worth the test infrastructure to bring back. - record_boot_success_for_patch_tests::deletes_unrecognized_directories_in_patches_dir: behavior change — new cleanup_older_than skips non-numeric directory names rather than deleting them. Defensive; the prior `rm -rf` was overly aggressive. Coverage: 222 → 230 tests, all green under -D warnings. --- library/src/cache/lifecycle.rs | 299 +++++++++++++++++++++++++++-- library/src/cache/updater_state.rs | 50 ++++- 2 files changed, 327 insertions(+), 22 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 2b2e2e80..a741cbac 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -458,6 +458,11 @@ impl PatchLifecycle { /// breadcrumb in `pointers.currently_booting_patch` survives a process /// crash, which is how we detect boot-time crashes on the next init /// (see [`detect_boot_crash_on_init`]). + /// + /// Sanity-checks that `n` matches `next_boot_patch` — guards against + /// the engine reporting that it's booting some patch other than the + /// one we said to boot. Carries forward the same defensive check the + /// prior `PatchManager::record_boot_start_for_patch` had. pub fn record_boot_start(&mut self, n: usize) -> Result<()> { match self.read_state(n) { Some(PatchState::Installed { .. }) => {} @@ -465,6 +470,11 @@ impl PatchLifecycle { bail!("record_boot_start({n}) expected Installed, got {other:?}"); } } + match self.pointers.next_boot_patch { + Some(next) if next == n => {} + Some(next) => bail!("record_boot_start({n}) but next_boot_patch is {next}"), + None => bail!("record_boot_start({n}) but next_boot_patch is unset"), + } self.pointers.currently_booting_patch = Some(n); self.pointers.boot_started_at = Some(crate::time::unix_timestamp()); self.save_pointers() @@ -859,6 +869,12 @@ mod tests { } #[test] + // Ports the artifact-deletion half of + // `patch_manager.rs::record_boot_failure_for_patch_tests::deletes_failed_patch_artifacts`. + // The state-machine equivalent is "mark_bad records the tombstone + // and deletes the artifact"; tested separately from the + // `record_boot_failure` flow because the same path is used by + // multiple Bad transitions (BootCrash, ValidationFailed, etc.). fn mark_bad_deletes_artifact_files_but_keeps_tombstone() { let (_tmp, lifecycle) = fixture(); lifecycle @@ -1270,7 +1286,10 @@ mod tests { assert!(lifecycle.record_install_complete(1, 1234).is_err()); } - fn install_patch(lifecycle: &PatchLifecycle, n: usize, size: u64) { + /// Test helper: writes `Installed` state and the artifact file for + /// patch `n` with the given size. Does *not* touch pointers — + /// tests that exercise pointer management set them explicitly. + fn install_state(lifecycle: &PatchLifecycle, n: usize, size: u64) { let path = lifecycle.installed_artifact_path(n); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, vec![0u8; size as usize]).unwrap(); @@ -1291,25 +1310,54 @@ mod tests { let (_tmp, mut lifecycle) = fixture(); assert!(lifecycle.record_boot_start(1).is_err()); - install_patch(&lifecycle, 1, 100); + install_state(&lifecycle, 1, 100); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + lifecycle.record_boot_start(1).unwrap(); assert_eq!(lifecycle.pointers().currently_booting_patch, Some(1)); assert!(lifecycle.pointers().boot_started_at.is_some()); } #[test] + fn record_boot_start_errs_when_no_next_boot_patch() { + // Ports `patch_manager.rs::record_boot_success_for_patch_tests::errs_if_no_next_boot_patch`. + let (_tmp, mut lifecycle) = fixture(); + install_state(&lifecycle, 1, 100); + // pointers.next_boot_patch is unset; engine claiming to boot + // patch 1 is a sanity violation. + assert!(lifecycle.record_boot_start(1).is_err()); + } + + #[test] + fn record_boot_start_errs_on_patch_number_mismatch() { + // Ports `patch_manager.rs::record_boot_success_for_patch_tests::errs_if_patch_number_does_not_match_next_patch`. + let (_tmp, mut lifecycle) = fixture(); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); + lifecycle.pointers.next_boot_patch = Some(2); + lifecycle.save_pointers().unwrap(); + // Engine claims to boot 1 but our pointer says 2. + assert!(lifecycle.record_boot_start(1).is_err()); + } + + #[test] + // Ports `patch_manager.rs::record_boot_success_for_patch_tests::deletes_other_patch_artifacts`. fn record_boot_success_promotes_and_cleans_older() { let (_tmp, mut lifecycle) = fixture(); - install_patch(&lifecycle, 1, 100); - install_patch(&lifecycle, 2, 200); - install_patch(&lifecycle, 3, 300); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); + install_state(&lifecycle, 3, 300); + // Pretend patch 3 is what we're booting. + lifecycle.pointers.next_boot_patch = Some(3); + lifecycle.save_pointers().unwrap(); lifecycle.record_boot_start(3).unwrap(); lifecycle.record_boot_success().unwrap(); assert_eq!(lifecycle.pointers().last_booted_patch, Some(3)); assert!(lifecycle.pointers().currently_booting_patch.is_none()); - // Older patches removed entirely. + // Older patches removed entirely by record_boot_success. assert!(!lifecycle.patch_dir(1).exists()); assert!(!lifecycle.patch_dir(2).exists()); // Booted patch survives. @@ -1319,9 +1367,11 @@ mod tests { #[test] fn record_boot_success_keeps_bad_tombstones_for_older() { let (_tmp, mut lifecycle) = fixture(); - install_patch(&lifecycle, 1, 100); - install_patch(&lifecycle, 2, 200); - install_patch(&lifecycle, 3, 300); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); + install_state(&lifecycle, 3, 300); + lifecycle.pointers.next_boot_patch = Some(3); + lifecycle.save_pointers().unwrap(); // Patch 2 went bad some time ago. lifecycle.mark_bad(2, BadReason::BootCrash).unwrap(); @@ -1339,10 +1389,14 @@ mod tests { } #[test] + // Ports + // `patch_manager.rs::next_boot_patch_tests::falls_back_to_last_booted_patch_if_still_bootable` + // and `patch_manager.rs::next_boot_patch_tests::returns_last_booted_patch_if_next_patch_failed_to_boot` + // and `patch_manager.rs::fall_back_tests::sets_next_patch_to_latest_patch_if_both_are_present`. fn record_boot_failure_marks_bad_and_recomputes_next_boot() { let (_tmp, mut lifecycle) = fixture(); - install_patch(&lifecycle, 1, 100); - install_patch(&lifecycle, 2, 200); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); // Pretend 1 was the last-booted, 2 is queued for next boot. lifecycle.pointers.last_booted_patch = Some(1); lifecycle.pointers.next_boot_patch = Some(2); @@ -1360,10 +1414,16 @@ mod tests { } #[test] + // Ports + // `patch_manager.rs::fall_back_tests::clears_next_and_last_patches_if_both_fail_validation`, + // adapted for the new pointer-vs-state separation: `last_booted`'s + // pointer is *kept* (as a Bad breadcrumb) where the old code cleared + // it. The functional outcome — `next_boot` becomes None when both + // candidates are unusable — is the same. fn record_boot_failure_clears_next_boot_when_last_booted_is_also_bad() { let (_tmp, mut lifecycle) = fixture(); - install_patch(&lifecycle, 1, 100); - install_patch(&lifecycle, 2, 200); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); lifecycle.mark_bad(1, BadReason::BootCrash).unwrap(); lifecycle.pointers.last_booted_patch = Some(1); lifecycle.pointers.next_boot_patch = Some(2); @@ -1423,7 +1483,9 @@ mod tests { // recording success or failure. { let mut lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); - install_patch(&lifecycle, 1, 100); + install_state(&lifecycle, 1, 100); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); lifecycle.record_boot_start(1).unwrap(); // Drop without record_boot_success/failure. } @@ -1445,10 +1507,14 @@ mod tests { } #[test] + // Ports + // `patch_manager.rs::validate_next_boot_patch_tests::clears_next_boot_patch_if_it_is_not_bootable` + // and the size-mismatch half of + // `patch_manager.rs::validate_next_boot_patch_tests::strict_mode_detects_tampered_patch_at_boot_time`. fn validate_next_boot_patch_marks_bad_on_size_mismatch() { let (_tmp, mut lifecycle) = fixture(); // Install patch 1 with a state.json claiming size=100. - install_patch(&lifecycle, 1, 100); + install_state(&lifecycle, 1, 100); lifecycle.pointers.next_boot_patch = Some(1); lifecycle.save_pointers().unwrap(); // Truncate the artifact so it no longer matches. @@ -1467,6 +1533,9 @@ mod tests { } #[test] + // Ports + // `patch_manager.rs::validate_next_boot_patch_tests::does_nothing_if_no_next_boot_patch` + // and `patch_manager.rs::fall_back_tests::does_nothing_if_no_patch_exists`. fn validate_next_boot_patch_is_noop_when_unset() { let (_tmp, mut lifecycle) = fixture(); assert!(lifecycle @@ -1535,11 +1604,199 @@ mod tests { assert_eq!(lifecycle.pointers().next_boot_patch, None); } + // The base64-encoded RSA key + matching signature were generated for + // signing.rs's tests; reused here to exercise the Strict-mode boot + // validation paths without standing up a separate keypair fixture. + const TEST_PUBLIC_KEY: &str = "MIIBCgKCAQEA2wdpEGbuvlPsb9i0qYrfMefJnEw1BHTi8SYZTKrXOvJWmEpPE1hWfbkvYzXu5a96gV1yocF3DMwn04VmRlKhC4AhsD0NL0UNhYhotbKG91Kwi1vAXpHhCdz5gQEBw0K1uB4Jz+zK6WK+31PryYpwLwbyXNqXoY8IAAUQ4STsHYV5w+BMSi8pepWMRd7DR9RHcbNOZlJvdBQ5NxvB4JN4dRMq8cC73ez1P9d7Dfwv3TWY+he9EmuXLT2UivZSlHIrGBa7MFfqyUe2ro0F7Te/B0si12itBbWIqycvqcXjeOPNn6WEpqN7IWjb9LUh162JyYaz5Lb/VeeJX8LKtElccwIDAQAB"; + /// Hash of the bytes `validate_signed_install` writes to dlc.vmcode + /// (via `install_signed`) — must match `TEST_SIGNATURE`. + const TEST_BYTES_HASH: &str = + "404e5caa5b906f6d03c97657e8c4d604d759f9cfba1a8bba9d5b49a5ebc174f9"; + const TEST_SIGNATURE: &str = "2ixSo5LpaWUSLg2GJEV+D+uyLeLjp0c3vNXnl0yb1iJjAdpn10BFlbcwCcjaJW9PNky2HU2hKOBe62PkFHOU8DDYOfxf2LGg/ToLGPHin85WrwFAceAUYDs7JpQr43dRTbrXcT8k5tuCQOTwXecGwuWcOFFvh0GbXFnyAmi7fLfN9CtTsG2GIOle/LyYLwoviTrXn/fZTZEYrqxD/wZ4QzoWOWLWNvrPbILhqWELkBLhdZeK0+nC2CIxFRYd3bUeOi1AGtPyHKBfdwuf4VO3+HbwJVaAEiD7HU2Bj+Zp1xeSdbznmYgBV86oizrLFd23D+lBfTlmDGgdfNE9J4Z2/g=="; + + /// Test helper: writes a signed `Installed` state for patch `n` + /// where the on-disk artifact's contents hash to `TEST_BYTES_HASH`, + /// matching `TEST_SIGNATURE`. The artifact is the same fixture used + /// in signing.rs's tests so the signature actually verifies. + fn install_signed(lifecycle: &PatchLifecycle, n: usize, signature: Option<&str>) { + // The bytes that hash to TEST_BYTES_HASH per signing.rs's + // fixture: an arbitrary 32-byte buffer. We just write the hash + // string itself as the bytes — what matters is that the + // dlc.vmcode hashes to TEST_BYTES_HASH, but + // validate_installed_patch only checks size + signature, not + // content hash. So any bytes of the recorded size work. + let path = lifecycle.installed_artifact_path(n); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, vec![0u8; 100]).unwrap(); + lifecycle + .write_state( + n, + &PatchState::Installed { + hash: TEST_BYTES_HASH.to_string(), + signature: signature.map(String::from), + size: 100, + }, + ) + .unwrap(); + } + + #[test] + fn validate_next_boot_strict_mode_succeeds_with_no_public_key() { + // Ports `patch_manager.rs::validate_next_boot_patch_tests::succeeds_with_arbitrary_signature_if_no_public_key`. + // No public_key configured — Strict mode skips signature + // verification entirely and just validates size. + let (_tmp, mut lifecycle) = fixture(); + install_signed(&lifecycle, 1, Some("ignored")); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + lifecycle + .validate_next_boot_patch(None, PatchVerificationMode::Strict) + .unwrap(); + // Patch is still good. + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Installed { .. }) + )); + } + + #[test] + fn validate_next_boot_strict_mode_marks_bad_when_signature_missing() { + // Ports `patch_manager.rs::validate_next_boot_patch_tests::strict_mode_fails_boot_validation_if_signature_missing`. + // Strict + public_key configured + Installed state has no + // signature → ValidationFailed. + let (_tmp, mut lifecycle) = fixture(); + install_signed(&lifecycle, 1, None); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + let result = lifecycle + .validate_next_boot_patch(Some(TEST_PUBLIC_KEY), PatchVerificationMode::Strict); + assert!(result.is_err()); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { + reason: BadReason::ValidationFailed, + .. + }) + )); + } + + #[test] + fn validate_next_boot_strict_mode_marks_bad_when_signature_invalid() { + // Ports `patch_manager.rs::validate_next_boot_patch_tests::strict_mode_fails_boot_validation_if_signature_invalid`. + let (_tmp, mut lifecycle) = fixture(); + install_signed(&lifecycle, 1, Some("not_a_valid_signature")); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + let result = lifecycle + .validate_next_boot_patch(Some(TEST_PUBLIC_KEY), PatchVerificationMode::Strict); + assert!(result.is_err()); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { + reason: BadReason::ValidationFailed, + .. + }) + )); + } + + #[test] + fn rolled_back_patch_not_resurrected_when_replacement_fails() { + // Ports + // `patch_manager.rs::fall_back_tests::rollback_then_failed_replacement_does_not_resurrect_rolled_back_patch`. + // + // Scenario: patch 2 was successfully booted (last_booted=2), + // server then rolled it back (cleanup forgets it), patch 3 was + // installed and we're about to boot it. Patch 3 fails to boot. + // Recompute must not promote 2 — its state.json is gone, so + // last_booted's stale pointer should be cleared instead. + let (_tmp, mut lifecycle) = fixture(); + // Pretend patch 2 was booted then rolled back: forget it. + // (Equivalent to receiving a server rollback for 2.) + install_state(&lifecycle, 3, 300); + lifecycle.pointers.last_booted_patch = Some(2); // stale + lifecycle.pointers.next_boot_patch = Some(3); + lifecycle.save_pointers().unwrap(); + + lifecycle.record_boot_start(3).unwrap(); + lifecycle.record_boot_failure(3).unwrap(); + + // Patch 2 is not promoted (its state is gone); next_boot ends + // up None because there's no usable fallback. + assert_eq!(lifecycle.pointers().next_boot_patch, None); + assert_eq!( + lifecycle.pointers().last_booted_patch, + None, + "stale last_booted pointer cleared by recompute" + ); + } + + #[test] + fn validate_then_promote_catches_corrupted_last_booted() { + // Ports + // `patch_manager.rs::validate_next_boot_patch_tests::does_not_fall_back_to_last_booted_patch_if_corrupted`. + // + // Scenario: next_boot fails boot, recompute promotes + // last_booted, but last_booted's artifact is corrupt (size + // mismatch). On the next boot attempt, validate catches it + // and marks Bad — boot falls through to base. + let (_tmp, mut lifecycle) = fixture(); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); + lifecycle.pointers.last_booted_patch = Some(1); + lifecycle.pointers.next_boot_patch = Some(2); + lifecycle.save_pointers().unwrap(); + + // Truncate patch 1's artifact so it'll fail validation later. + std::fs::write(lifecycle.installed_artifact_path(1), b"short").unwrap(); + + // Patch 2 fails to boot. + lifecycle.record_boot_start(2).unwrap(); + lifecycle.record_boot_failure(2).unwrap(); + // Recompute promoted 1 (state still says Installed), but... + assert_eq!(lifecycle.pointers().next_boot_patch, Some(1)); + + // ...the next boot's validation pass catches the corruption. + let result = lifecycle.validate_next_boot_patch(None, PatchVerificationMode::default()); + assert!(result.is_err()); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { + reason: BadReason::ValidationFailed, + .. + }) + )); + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + + #[test] + fn validate_next_boot_strict_mode_marks_bad_when_public_key_invalid() { + // Ports `patch_manager.rs::validate_next_boot_patch_tests::strict_mode_fails_boot_validation_if_public_key_invalid`. + let (_tmp, mut lifecycle) = fixture(); + install_signed(&lifecycle, 1, Some(TEST_SIGNATURE)); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + // public_key won't decode as base64 RSA → check_signature + // errors → validate_installed_patch errors → mark Bad. + let result = + lifecycle.validate_next_boot_patch(Some("not base64"), PatchVerificationMode::Strict); + assert!(result.is_err()); + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { .. }) + )); + } + #[test] + // Ports the unbooted-deletion half of + // `patch_manager.rs::next_boot_patch_tests::adding_patch_deletes_unbooted_patch_not_last_booted`. fn promote_to_next_boot_replaces_unbooted_predecessor() { let (_tmp, mut lifecycle) = fixture(); - install_patch(&lifecycle, 1, 100); - install_patch(&lifecycle, 2, 200); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); lifecycle.promote_to_next_boot(1).unwrap(); // Now install 2 and promote it; 1 was never booted (last_booted is // None) and should be forgotten. @@ -1549,10 +1806,14 @@ mod tests { } #[test] + // Ports the preservation half of + // `patch_manager.rs::next_boot_patch_tests::adding_patch_deletes_unbooted_patch_not_last_booted` + // and the running-patch protection in + // `patch_manager.rs::record_boot_failure_for_patch_tests::preserves_last_booted_patch_on_failure_but_marks_bad`. fn promote_to_next_boot_preserves_last_booted_patch() { let (_tmp, mut lifecycle) = fixture(); - install_patch(&lifecycle, 1, 100); - install_patch(&lifecycle, 2, 200); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); lifecycle.pointers.last_booted_patch = Some(1); lifecycle.pointers.next_boot_patch = Some(1); lifecycle.save_pointers().unwrap(); diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 478b5a89..0d3017ab 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -38,15 +38,24 @@ pub struct UpdaterState { } /// 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. /// client_id: String, - /// The release version this cache corresponds to. If this doesn't - /// match the release version we're booting from, the patch state - /// is wiped and rebuilt for the new release. + /// 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. @@ -402,6 +411,7 @@ mod tests { #[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); @@ -438,6 +448,7 @@ mod tests { #[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); @@ -450,6 +461,8 @@ mod tests { #[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 @@ -464,6 +477,7 @@ mod tests { #[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 { @@ -490,6 +504,7 @@ mod tests { #[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, @@ -505,6 +520,8 @@ mod tests { #[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, @@ -519,6 +536,10 @@ mod tests { #[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, @@ -533,6 +554,10 @@ mod tests { #[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 @@ -550,6 +575,10 @@ mod tests { #[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 @@ -602,4 +631,19 @@ mod tests { .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)); + } } From 6631c1c8a8b8cebc712510637bbf364e4b20d91b Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 11:02:06 -0700 Subject: [PATCH 13/21] test: port the two patch_manager tests I'd handwaved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both turned out trivial to port — neither was actually doing fault injection, just exploiting graceful-failure paths. - record_boot_success_deletes_unrecognized_directories_in_patches_dir: pre-creates a junk dir and a stray file in patches/ before the install/boot, asserts they're gone after record_boot_success. Restores the prior `cleanup_older_than` behavior of removing non-numeric entries — we own patches/, so anything not named like a patch number is corruption to sweep up. - record_boot_failure_succeeds_if_artifact_dirs_are_already_gone: pre-deletes both patch dirs and verifies record_boot_failure still completes correctly (mark_bad recreates the dir for the tombstone; cleanup paths are graceful when the dir is missing). Reverts the prior behavior change in cleanup_older_than that skipped non-numeric entries instead of deleting them. The "defensive" framing was wrong — we own patches/, anything unexpected there came from a different version of our code or actual corruption, and leaving it behind is the wrong default. --- library/src/cache/lifecycle.rs | 101 +++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 11 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index a741cbac..c137c584 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -653,23 +653,38 @@ impl PatchLifecycle { /// Walks `patches/` and runs [`cleanup`] on every patch with number /// < `n`. State-aware per-patch: Bad tombstones survive, everything - /// else is forgotten. Best-effort — read errors are logged and - /// skipped so a single bad entry can't block the cleanup of others. + /// else is forgotten. Anything in `patches/` whose name doesn't + /// parse as a patch number is unrecognized garbage (we own the + /// directory) and gets removed wholesale. Best-effort — errors + /// are logged so a single bad entry can't block the cleanup of + /// others. fn cleanup_older_than(&self, n: usize) { let entries = match std::fs::read_dir(self.patches_root()) { Ok(e) => e, Err(_) => return, }; for entry in entries.flatten() { - let Ok(name) = entry.file_name().into_string() else { - continue; - }; - let Ok(num) = name.parse::() else { - continue; - }; - if num < n { - if let Err(e) = self.cleanup(num) { - shorebird_error!("cleanup({}) failed: {:?}", num, e); + let path = entry.path(); + let name_string = entry.file_name().to_string_lossy().into_owned(); + match name_string.parse::() { + Ok(num) if num < n => { + if let Err(e) = self.cleanup(num) { + shorebird_error!("cleanup({}) failed: {:?}", num, e); + } + } + Ok(_) => {} // current or newer; leave alone. + Err(_) => { + // We own this directory; anything not named like a + // patch number is corruption / leftover from prior + // versions / debug residue and is safe to remove. + 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 remove unrecognized entry {:?}: {:?}", path, e); + } } } } @@ -1388,6 +1403,70 @@ mod tests { assert!(lifecycle.patch_dir(3).exists()); } + #[test] + // Ports + // `patch_manager.rs::record_boot_success_for_patch_tests::deletes_unrecognized_directories_in_patches_dir`. + // We own `patches/` — anything in it whose name isn't a patch + // number is corruption / debug residue / leftover-from-prior-code, + // and the older-than walk takes the opportunity to sweep it up. + fn record_boot_success_deletes_unrecognized_directories_in_patches_dir() { + let (_tmp, mut lifecycle) = fixture(); + // Drop a junk directory and a stray file in patches/ before any + // installs. + std::fs::create_dir_all(lifecycle.patches_root().join("junk_dir")).unwrap(); + std::fs::write(lifecycle.patches_root().join("not_a_number.txt"), b"x").unwrap(); + + install_state(&lifecycle, 3, 300); + lifecycle.pointers.next_boot_patch = Some(3); + lifecycle.save_pointers().unwrap(); + lifecycle.record_boot_start(3).unwrap(); + lifecycle.record_boot_success().unwrap(); + + assert!(!lifecycle.patches_root().join("junk_dir").exists()); + assert!(!lifecycle.patches_root().join("not_a_number.txt").exists()); + assert!(lifecycle.patch_dir(3).exists()); + } + + #[test] + // Ports + // `patch_manager.rs::fall_back_tests::succeeds_if_deleting_artifacts_fails`. + // The patch dirs were already deleted out from under us — every + // delete in mark_bad's cleanup path is graceful so the operation + // still succeeds and the pointer state is recomputed correctly. + fn record_boot_failure_succeeds_if_artifact_dirs_are_already_gone() { + let (_tmp, mut lifecycle) = fixture(); + install_state(&lifecycle, 1, 100); + install_state(&lifecycle, 2, 200); + lifecycle.pointers.last_booted_patch = Some(1); + lifecycle.pointers.next_boot_patch = Some(2); + lifecycle.save_pointers().unwrap(); + + // Wipe both patch dirs (state.json and artifact alike) before + // recording the failure — simulates filesystem-level corruption. + std::fs::remove_dir_all(lifecycle.patch_dir(1)).unwrap(); + std::fs::remove_dir_all(lifecycle.patch_dir(2)).unwrap(); + + // Need to set next_boot=2 manually since record_boot_start + // requires the matching state, but for this test we skip that + // and call record_boot_failure directly. + // record_boot_failure doesn't require currently_booting; clears it. + lifecycle.record_boot_failure(2).unwrap(); + + // Patch 2 transitioned to Bad{BootCrash}; mark_bad recreated + // its directory just for the tombstone state.json. + assert!(matches!( + lifecycle.read_state(2), + Some(PatchState::Bad { .. }) + )); + // Patch 1's state file is gone, so recompute can't promote it. + assert_eq!(lifecycle.pointers().next_boot_patch, None); + assert_eq!( + lifecycle.pointers().last_booted_patch, + None, + "stale last_booted pointer cleared by recompute" + ); + } + #[test] // Ports // `patch_manager.rs::next_boot_patch_tests::falls_back_to_last_booted_patch_if_still_bootable` From 75e1ba63eebe22281803bdd7dfa99c1175e2664f Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 11:14:30 -0700 Subject: [PATCH 14/21] test: port the remaining patch_manager tests with real coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate_next_boot_strict_mode_succeeds_with_valid_signature: ports `patch_manager.rs::validate_next_boot_patch_tests::strict_mode_succeeds_with_valid_signature_at_boot_time`. Required reusing the prior test fixture's INFLATED_PATCH_HASH / INFLATED_PATCH_SIGNATURE constants (a real signature for the 1-byte file content `b"1"`, matching TEST_PUBLIC_KEY's private half). - record_boot_failure_keeps_last_booted_pointer_when_failed_patch_was_last_booted: ports `patch_manager.rs::record_boot_failure_for_patch_tests::preserves_last_booted_patch_on_failure_but_marks_bad`. The new behavior is similar but more explicit: last_booted's *pointer* is preserved (with the patch now in Bad state); the underlying intent — "we still know what last booted, even after that patch failed" — survives. Test helper split: `install_signed` (failure paths, mismatched hash OK) vs `install_with_valid_signature` (happy path, real fixture). Now every behavioral test from the deleted patch_manager.rs has a corresponding new test with a porting comment. Two trivial ones not ported: the `Debug` impl tests for traits/types that no longer exist. 236 tests, all green under -D warnings. --- library/src/cache/lifecycle.rs | 122 +++++++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 21 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index c137c584..c7bdb4c1 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -1683,27 +1683,30 @@ mod tests { assert_eq!(lifecycle.pointers().next_boot_patch, None); } - // The base64-encoded RSA key + matching signature were generated for - // signing.rs's tests; reused here to exercise the Strict-mode boot - // validation paths without standing up a separate keypair fixture. + // base64-encoded RSA public key from the prior `signing.rs` / + // `patch_manager.rs` test fixtures. Reused here so the Strict-mode + // happy path can verify a real signature without committing a + // private key to the repo. const TEST_PUBLIC_KEY: &str = "MIIBCgKCAQEA2wdpEGbuvlPsb9i0qYrfMefJnEw1BHTi8SYZTKrXOvJWmEpPE1hWfbkvYzXu5a96gV1yocF3DMwn04VmRlKhC4AhsD0NL0UNhYhotbKG91Kwi1vAXpHhCdz5gQEBw0K1uB4Jz+zK6WK+31PryYpwLwbyXNqXoY8IAAUQ4STsHYV5w+BMSi8pepWMRd7DR9RHcbNOZlJvdBQ5NxvB4JN4dRMq8cC73ez1P9d7Dfwv3TWY+he9EmuXLT2UivZSlHIrGBa7MFfqyUe2ro0F7Te/B0si12itBbWIqycvqcXjeOPNn6WEpqN7IWjb9LUh162JyYaz5Lb/VeeJX8LKtElccwIDAQAB"; - /// Hash of the bytes `validate_signed_install` writes to dlc.vmcode - /// (via `install_signed`) — must match `TEST_SIGNATURE`. - const TEST_BYTES_HASH: &str = - "404e5caa5b906f6d03c97657e8c4d604d759f9cfba1a8bba9d5b49a5ebc174f9"; - const TEST_SIGNATURE: &str = "2ixSo5LpaWUSLg2GJEV+D+uyLeLjp0c3vNXnl0yb1iJjAdpn10BFlbcwCcjaJW9PNky2HU2hKOBe62PkFHOU8DDYOfxf2LGg/ToLGPHin85WrwFAceAUYDs7JpQr43dRTbrXcT8k5tuCQOTwXecGwuWcOFFvh0GbXFnyAmi7fLfN9CtTsG2GIOle/LyYLwoviTrXn/fZTZEYrqxD/wZ4QzoWOWLWNvrPbILhqWELkBLhdZeK0+nC2CIxFRYd3bUeOi1AGtPyHKBfdwuf4VO3+HbwJVaAEiD7HU2Bj+Zp1xeSdbznmYgBV86oizrLFd23D+lBfTlmDGgdfNE9J4Z2/g=="; - - /// Test helper: writes a signed `Installed` state for patch `n` - /// where the on-disk artifact's contents hash to `TEST_BYTES_HASH`, - /// matching `TEST_SIGNATURE`. The artifact is the same fixture used - /// in signing.rs's tests so the signature actually verifies. + + /// SHA256 of the single-byte file content `b"1"`. Matches the + /// `INFLATED_PATCH_HASH` constant from the prior `patch_manager` + /// tests — the same trick they used: write a 1-byte file `"1"`, + /// declare its hash as Installed.hash, sign with the matching + /// private key. + const INFLATED_PATCH_HASH: &str = + "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"; + /// Real signature of `INFLATED_PATCH_HASH` produced with the + /// private key matching `TEST_PUBLIC_KEY`. Carried over verbatim + /// from the prior `patch_manager.rs::SIGNATURE` constant. + const INFLATED_PATCH_SIGNATURE: &str = "ZGccldv01XqHQ76bXuKV/9EQnNK0Q+reQ9bJHVnGfLldF+BLRx0divgPfKP5Df9BJPA3dw1Z1VortfepmMGebP3kS593l5zoktu9MIepxvRAFWNKE5PDTIIvCL/ddTPEHt6NNCeD6HLOMLzbEX3cFZa+lq3UymGi0aqA5DlXirJBGtopojc9nOXZ22n/qHNZIHEkGcqKbSMSK9oC55whKHnlJTbCXdmSyDc65B4PcgseqJom1riVK3XGW1YMrSpuMAU+CDT7HhdESmI1UtH1bYeBITfRhQztdDTfti2vJTf2Y+lYC99CFiISgD7f1m0KUcC+VnEAMZSYtgxSk6AX2A=="; + + /// Test helper: writes an Installed state with a 100-byte artifact + /// whose hash *won't* match the recorded hash. Suitable for the + /// failure-path Strict-mode tests — those only need the + /// signature-verification call to fail somehow (missing / + /// invalid / bad pub key) and don't exercise the file-hash leg. fn install_signed(lifecycle: &PatchLifecycle, n: usize, signature: Option<&str>) { - // The bytes that hash to TEST_BYTES_HASH per signing.rs's - // fixture: an arbitrary 32-byte buffer. We just write the hash - // string itself as the bytes — what matters is that the - // dlc.vmcode hashes to TEST_BYTES_HASH, but - // validate_installed_patch only checks size + signature, not - // content hash. So any bytes of the recorded size work. let path = lifecycle.installed_artifact_path(n); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, vec![0u8; 100]).unwrap(); @@ -1711,7 +1714,7 @@ mod tests { .write_state( n, &PatchState::Installed { - hash: TEST_BYTES_HASH.to_string(), + hash: "irrelevant-for-failure-paths".to_string(), signature: signature.map(String::from), size: 100, }, @@ -1719,6 +1722,47 @@ mod tests { .unwrap(); } + /// Test helper for the Strict-mode happy path. Writes the + /// 1-byte artifact `b"1"` (whose SHA256 is `INFLATED_PATCH_HASH`) + /// and an `Installed` state whose hash + signature match. Strict + /// validation hashes the file, then verifies the signature + /// against that hash + the public key — all three line up here. + fn install_with_valid_signature(lifecycle: &PatchLifecycle, n: usize) { + let path = lifecycle.installed_artifact_path(n); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"1").unwrap(); + lifecycle + .write_state( + n, + &PatchState::Installed { + hash: INFLATED_PATCH_HASH.to_string(), + signature: Some(INFLATED_PATCH_SIGNATURE.to_string()), + size: 1, + }, + ) + .unwrap(); + } + + #[test] + // Ports + // `patch_manager.rs::validate_next_boot_patch_tests::strict_mode_succeeds_with_valid_signature_at_boot_time`. + fn validate_next_boot_strict_mode_succeeds_with_valid_signature() { + let (_tmp, mut lifecycle) = fixture(); + install_with_valid_signature(&lifecycle, 1); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + lifecycle + .validate_next_boot_patch(Some(TEST_PUBLIC_KEY), PatchVerificationMode::Strict) + .unwrap(); + // Patch survived — still Installed, still next_boot. + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Installed { .. }) + )); + assert_eq!(lifecycle.pointers().next_boot_patch, Some(1)); + } + #[test] fn validate_next_boot_strict_mode_succeeds_with_no_public_key() { // Ports `patch_manager.rs::validate_next_boot_patch_tests::succeeds_with_arbitrary_signature_if_no_public_key`. @@ -1781,6 +1825,42 @@ mod tests { )); } + #[test] + // Ports + // `patch_manager.rs::record_boot_failure_for_patch_tests::preserves_last_booted_patch_on_failure_but_marks_bad`. + // + // Scenario: patch 1 was successfully booted (last_booted=1, + // next_boot=1). Then patch 1 fails to boot. The new behavior + // *keeps* the last_booted pointer pointing at 1 — the patch is + // now Bad{BootCrash} but the historical "this is what last + // booted" remains as a useful breadcrumb. is_known_bad_patch + // returns true. next_boot is None (recompute can't promote a + // Bad patch). + fn record_boot_failure_keeps_last_booted_pointer_when_failed_patch_was_last_booted() { + let (_tmp, mut lifecycle) = fixture(); + install_state(&lifecycle, 1, 100); + lifecycle.pointers.last_booted_patch = Some(1); + lifecycle.pointers.next_boot_patch = Some(1); + lifecycle.save_pointers().unwrap(); + + lifecycle.record_boot_start(1).unwrap(); + lifecycle.record_boot_failure(1).unwrap(); + + assert!(matches!( + lifecycle.read_state(1), + Some(PatchState::Bad { + reason: BadReason::BootCrash, + .. + }) + )); + assert_eq!( + lifecycle.pointers().last_booted_patch, + Some(1), + "last_booted breadcrumb preserved even though the patch is now Bad" + ); + assert_eq!(lifecycle.pointers().next_boot_patch, None); + } + #[test] fn rolled_back_patch_not_resurrected_when_replacement_fails() { // Ports @@ -1854,7 +1934,7 @@ mod tests { fn validate_next_boot_strict_mode_marks_bad_when_public_key_invalid() { // Ports `patch_manager.rs::validate_next_boot_patch_tests::strict_mode_fails_boot_validation_if_public_key_invalid`. let (_tmp, mut lifecycle) = fixture(); - install_signed(&lifecycle, 1, Some(TEST_SIGNATURE)); + install_signed(&lifecycle, 1, Some(INFLATED_PATCH_SIGNATURE)); lifecycle.pointers.next_boot_patch = Some(1); lifecycle.save_pointers().unwrap(); From 96a8db7de09eec29156a028924472d5feaf94c1c Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 11:27:19 -0700 Subject: [PATCH 15/21] refactor: restore separate download directory under OS cache root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts an unintended policy change in the cutover: my refactor moved compressed download bytes from `{code_cache_dir}/downloads/{N}` (the OS-managed cache dir, evictable under storage pressure) into `{storage_dir}/patches/{N}/download` (persistent app storage). That made downloads count against persistent storage and survive in iCloud backups — neither of which we want for transient bytes. Restoring the original split: - `{state_root}/patches/{N}/{state.json,dlc.vmcode}` — persistent state and the installed artifact. - `{download_root}/{N}` — flat, in the OS cache dir. `PatchLifecycle::load_or_default` now takes both roots. The two artifact-path helpers each route to their respective root, the free-function variants used by `update_internal` now take the right root, and `mark_bad`/`cleanup`/`record_install_complete` clean both locations as appropriate. `UpdaterState::create_new_and_save` also wipes `download_dir` on release-version change, in addition to the persistent patches/ tree. `update_internal` and tests updated to use the right root for each file type. Test fixtures pass `tmp.path().join("downloads")` as the download root (separate subdir of the same TempDir). Net change in test surface: moved a handful of `patch_dir/download` references to `downloads_dir/N`, no behavioral changes to tests themselves. 236 tests pass under -D warnings. --- library/src/cache/lifecycle.rs | 178 +++++++++++++++++++---------- library/src/cache/updater_state.rs | 39 ++++++- library/src/test_utils.rs | 1 + library/src/updater.rs | 50 +++++--- 4 files changed, 192 insertions(+), 76 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index c7bdb4c1..5d9c90a7 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -5,18 +5,26 @@ //! `last_booted_patch` / `known_bad_patches` fields of `PatchesState`. //! //! On-disk layout (per release): -//! {root}/ +//! {state_root}/ //! pointers.json # ReleasePointers //! patches/ //! {N}/ //! state.json # PatchState -//! download # compressed bytes (Downloading/Downloaded only) //! dlc.vmcode # installed artifact (Installed only) +//! {download_root}/ +//! {N} # compressed bytes (Downloading/Downloaded) +//! +//! `state_root` is the persistent app-storage directory; `download_root` +//! is the OS-managed cache directory (e.g. iOS `NSCachesDirectory`). +//! Putting the compressed download bytes in cache lets the OS evict +//! them under storage pressure — `decide_start` falls back to `Fresh` +//! when the file is gone, costing a redownload but no data loss. //! //! state.json is the source of truth for "what state is patch N in?" and //! survives within a release as a tombstone for `Bad` patches even after //! their artifact files are removed. Everything under `patches/` is wiped -//! on release-version change. +//! on release-version change; `download_root` is also wiped at that +//! point (see `UpdaterState::create_new_and_save`). //! //! Mutations are exposed as two operations on top of the raw read/write: //! - `mark_bad(n, reason)` writes a Bad tombstone and deletes artifact @@ -113,20 +121,30 @@ pub struct ReleasePointers { pub boot_started_at: Option, } -/// Per-release patch lifecycle and storage. Owns `{root}/patches/` and -/// `{root}/pointers.json`. +/// Per-release patch lifecycle and storage. Owns +/// `{state_root}/patches/`, `{state_root}/pointers.json`, and +/// `{download_root}/{N}` files for in-flight compressed downloads. #[derive(Debug)] pub struct PatchLifecycle { - root: PathBuf, + /// Persistent app-storage root. Holds `pointers.json` and + /// `patches/{N}/{state.json,dlc.vmcode}`. + state_root: PathBuf, + /// OS-managed cache root. Holds `{N}` files for in-flight or + /// completed compressed download bytes. The OS may evict these + /// under storage pressure; `decide_start` recovers by falling + /// back to `Fresh`. + download_root: PathBuf, pointers: ReleasePointers, } impl PatchLifecycle { - /// Loads the lifecycle from `root`. Missing or unparseable - /// `pointers.json` falls back to defaults; per-patch state files are - /// read lazily. - pub fn load_or_default(root: PathBuf) -> Self { - let pointers_path = root.join(POINTERS_FILE); + /// Loads the lifecycle from disk. `state_root` is the persistent + /// app-storage dir; `download_root` is the OS-managed cache dir + /// (typically `{code_cache_dir}/downloads`). Missing or + /// unparseable `pointers.json` falls back to defaults; per-patch + /// state files are read lazily. + pub fn load_or_default(state_root: PathBuf, download_root: PathBuf) -> Self { + let pointers_path = state_root.join(POINTERS_FILE); let pointers = if pointers_path.exists() { match disk_io::read(&pointers_path) { Ok(p) => p, @@ -142,7 +160,11 @@ impl PatchLifecycle { } else { ReleasePointers::default() }; - Self { root, pointers } + Self { + state_root, + download_root, + pointers, + } } pub fn pointers(&self) -> &ReleasePointers { @@ -242,53 +264,71 @@ impl PatchLifecycle { } } - /// Removes everything under `{root}/patches/{N}/` except `state.json`. + /// Removes the artifact files for patch `n` while preserving its + /// `state.json` tombstone: everything under + /// `{state_root}/patches/{N}/` except `state.json`, plus the + /// compressed download at `{download_root}/{N}` if any. fn delete_artifact_files(&self, n: usize) -> Result<()> { let dir = self.patch_dir(n); - let entries = match std::fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => return Ok(()), // Directory doesn't exist; nothing to do. - }; - for entry in entries.flatten() { - if entry.file_name() == PATCH_STATE_FILE { - continue; - } - let path = entry.path(); - if path.is_dir() { - if let Err(e) = std::fs::remove_dir_all(&path) { + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + if entry.file_name() == PATCH_STATE_FILE { + continue; + } + let path = entry.path(); + 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 remove {:?}: {:?}", path, e); } - } else if let Err(e) = std::fs::remove_file(&path) { - shorebird_error!("Failed to remove {:?}: {:?}", path, e); + } + } + // Compressed download lives in the cache root, not the patch dir. + let download = self.download_artifact_path(n); + if download.exists() { + if let Err(e) = std::fs::remove_file(&download) { + shorebird_error!("Failed to remove {:?}: {:?}", download, e); } } Ok(()) } - /// Removes `{root}/patches/{N}/` entirely, including `state.json`. + /// Removes everything for patch `n`: `{state_root}/patches/{N}/` + /// (including `state.json`) and `{download_root}/{N}`. fn forget_dir(&self, n: usize) -> Result<()> { let dir = self.patch_dir(n); if dir.exists() { std::fs::remove_dir_all(&dir)?; } + let download = self.download_artifact_path(n); + if download.exists() { + if let Err(e) = std::fs::remove_file(&download) { + shorebird_error!("Failed to remove {:?}: {:?}", download, e); + } + } Ok(()) } - /// Path the caller streams compressed download bytes to. Convenience - /// wrapper around the free [`download_artifact_path`] for callers - /// that already hold a lifecycle handle. + /// Path the caller streams compressed download bytes to. Lives at + /// `{download_root}/{N}` — flat layout under the OS-managed cache + /// dir. Convenience wrapper around the free + /// [`download_artifact_path`]. pub fn download_artifact_path(&self, n: usize) -> PathBuf { - download_artifact_path(&self.root, n) + download_artifact_path(&self.download_root, n) } - /// Path of the installed (inflated) artifact. Convenience wrapper + /// Path of the installed (inflated) artifact. Lives at + /// `{state_root}/patches/{N}/dlc.vmcode`. Convenience wrapper /// around the free [`installed_artifact_path`]. pub fn installed_artifact_path(&self, n: usize) -> PathBuf { - installed_artifact_path(&self.root, n) + installed_artifact_path(&self.state_root, n) } fn patches_root(&self) -> PathBuf { - self.root.join(PATCHES_DIR) + self.state_root.join(PATCHES_DIR) } fn patch_dir(&self, n: usize) -> PathBuf { @@ -300,23 +340,24 @@ impl PatchLifecycle { } fn pointers_path(&self) -> PathBuf { - self.root.join(POINTERS_FILE) + self.state_root.join(POINTERS_FILE) } } /// Path the caller streams compressed download bytes to. Lives at -/// `{root}/patches/{N}/download`. Free function so callers in -/// `update_internal` (which only has the cache root in hand) don't have -/// to reach through `with_state` to compute a pure-function path. -pub fn download_artifact_path(root: &Path, n: usize) -> PathBuf { - root.join(PATCHES_DIR).join(n.to_string()).join("download") +/// `{download_root}/{N}` (flat under the OS-managed cache dir). Free +/// function so callers in `update_internal` can compute it without +/// holding a lifecycle handle. +pub fn download_artifact_path(download_root: &Path, n: usize) -> PathBuf { + download_root.join(n.to_string()) } /// Path of the installed (inflated) artifact. Lives at -/// `{root}/patches/{N}/dlc.vmcode`. See [`download_artifact_path`] for -/// why this is a free function rather than only a method. -pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf { - root.join(PATCHES_DIR) +/// `{state_root}/patches/{N}/dlc.vmcode`. See [`download_artifact_path`] +/// for why this is a free function rather than only a method. +pub fn installed_artifact_path(state_root: &Path, n: usize) -> PathBuf { + state_root + .join(PATCHES_DIR) .join(n.to_string()) .join("dlc.vmcode") } @@ -713,9 +754,9 @@ impl PatchLifecycle { size: installed_size, }, )?; - // The compressed bytes are no longer needed; the dlc.vmcode is - // the canonical artifact going forward. - let download = self.patch_dir(n).join("download"); + // The compressed bytes in the cache dir are no longer needed; + // the dlc.vmcode is the canonical artifact going forward. + let download = self.download_artifact_path(n); if download.exists() { if let Err(e) = std::fs::remove_file(&download) { shorebird_error!("Failed to remove download file for patch {}: {:?}", n, e); @@ -732,10 +773,18 @@ mod tests { fn fixture() -> (TempDir, PatchLifecycle) { let tmp = TempDir::new().unwrap(); - let lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + let state_root = tmp.path().to_path_buf(); + let download_root = tmp.path().join("downloads"); + let lifecycle = PatchLifecycle::load_or_default(state_root, download_root); (tmp, lifecycle) } + /// Two-process lifecycle helper: rebuilds a `PatchLifecycle` + /// against the same on-disk roots a prior `fixture()` set up. + fn reload_at(tmp_path: &Path) -> PatchLifecycle { + PatchLifecycle::load_or_default(tmp_path.to_path_buf(), tmp_path.join("downloads")) + } + #[test] fn read_state_returns_none_when_patch_unknown() { let (_tmp, lifecycle) = fixture(); @@ -831,6 +880,7 @@ mod tests { // File size on disk is what gets recorded as the patch's "size" // — there's no recorded count in Downloading anymore. let path = lifecycle.download_artifact_path(1); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, vec![0u8; 250]).unwrap(); lifecycle.mark_bad(1, BadReason::InvalidPatchBytes).unwrap(); @@ -994,7 +1044,7 @@ mod tests { fn pointers_save_and_reload_roundtrip() { let tmp = TempDir::new().unwrap(); { - let mut lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + let mut lifecycle = reload_at(tmp.path()); lifecycle.pointers = ReleasePointers { next_boot_patch: Some(3), last_booted_patch: Some(2), @@ -1003,7 +1053,7 @@ mod tests { }; lifecycle.save_pointers().unwrap(); } - let reloaded = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + let reloaded = reload_at(tmp.path()); assert_eq!(reloaded.pointers().next_boot_patch, Some(3)); assert_eq!(reloaded.pointers().last_booted_patch, Some(2)); } @@ -1012,17 +1062,24 @@ mod tests { fn pointers_load_default_on_corrupt_file() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join(POINTERS_FILE), "not json").unwrap(); - let lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + let lifecycle = reload_at(tmp.path()); assert_eq!(lifecycle.pointers(), &ReleasePointers::default()); } #[test] - fn artifact_path_helpers_match_state_directory() { + fn artifact_path_helpers_live_in_their_respective_roots() { + // The installed artifact lives under state_root/patches/{N}/. + // The compressed download lives under download_root/{N} (flat). + // They're in DIFFERENT roots so the OS can evict downloads + // independently of installed patches. let (_tmp, lifecycle) = fixture(); let download = lifecycle.download_artifact_path(7); let installed = lifecycle.installed_artifact_path(7); - assert_eq!(download.parent().unwrap(), lifecycle.patch_dir(7)); assert_eq!(installed.parent().unwrap(), lifecycle.patch_dir(7)); + // Download path is `{download_root}/{N}` — its parent is the + // download root, distinct from patch_dir. + assert_ne!(download.parent().unwrap(), lifecycle.patch_dir(7)); + assert_eq!(download.file_name().unwrap(), "7"); } #[test] @@ -1047,7 +1104,9 @@ mod tests { }, ) .unwrap(); - std::fs::write(lifecycle.download_artifact_path(1), vec![0u8; 250]).unwrap(); + let dl = lifecycle.download_artifact_path(1); + std::fs::create_dir_all(dl.parent().unwrap()).unwrap(); + std::fs::write(&dl, vec![0u8; 250]).unwrap(); assert_eq!( lifecycle.decide_start(1, "https://example/p", "h"), DownloadAction::Resume { offset: 250 } @@ -1125,7 +1184,9 @@ mod tests { }, ) .unwrap(); - std::fs::write(lifecycle.download_artifact_path(1), vec![0u8; 1000]).unwrap(); + let dl = lifecycle.download_artifact_path(1); + std::fs::create_dir_all(dl.parent().unwrap()).unwrap(); + std::fs::write(&dl, vec![0u8; 1000]).unwrap(); assert_eq!( lifecycle.decide_start(1, "u", "h"), DownloadAction::Complete @@ -1275,7 +1336,8 @@ mod tests { }, ) .unwrap(); - let download_path = lifecycle.patch_dir(1).join("download"); + let download_path = lifecycle.download_artifact_path(1); + std::fs::create_dir_all(download_path.parent().unwrap()).unwrap(); std::fs::write(&download_path, b"compressed").unwrap(); lifecycle.record_install_complete(1, 9999).unwrap(); @@ -1561,7 +1623,7 @@ mod tests { // First "process": records boot start, then "crashes" without // recording success or failure. { - let mut lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + let mut lifecycle = reload_at(tmp.path()); install_state(&lifecycle, 1, 100); lifecycle.pointers.next_boot_patch = Some(1); lifecycle.save_pointers().unwrap(); @@ -1569,7 +1631,7 @@ mod tests { // Drop without record_boot_success/failure. } // Second "process": init detects the breadcrumb and marks Bad. - let mut lifecycle = PatchLifecycle::load_or_default(tmp.path().to_path_buf()); + let mut lifecycle = reload_at(tmp.path()); let recovered = lifecycle.detect_boot_crash_on_init().unwrap(); assert_eq!(recovered, Some(1)); assert!(matches!( diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 0d3017ab..286f303f 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -30,6 +30,11 @@ const STATE_FILE_NAME: &str = "state.json"; // 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, @@ -84,13 +89,14 @@ impl UpdaterState { 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()), + 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, @@ -104,6 +110,7 @@ impl UpdaterState { fn load( cache_dir: &Path, + download_dir: &Path, patch_public_key: Option<&str>, verification_mode: PatchVerificationMode, ) -> Result { @@ -111,7 +118,10 @@ impl UpdaterState { 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()), + 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, @@ -123,6 +133,7 @@ impl UpdaterState { /// changes or when the on-disk state was unparseable. fn create_new_and_save( cache_dir: &Path, + download_dir: &Path, release_version: &str, patch_public_key: Option<&str>, verification_mode: PatchVerificationMode, @@ -130,6 +141,7 @@ impl UpdaterState { ) -> Self { let mut state = Self::new( cache_dir.to_owned(), + download_dir.to_owned(), release_version.to_owned(), patch_public_key, verification_mode, @@ -149,18 +161,26 @@ impl UpdaterState { if pointers_path.exists() { let _ = std::fs::remove_file(&pointers_path); } + // Also wipe stale compressed downloads from the prior release. + 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); + } + } // Reload lifecycle from a clean slate. - state.lifecycle = PatchLifecycle::load_or_default(cache_dir.to_path_buf()); + 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, patch_public_key, verification_mode) { + match Self::load(cache_dir, download_dir, patch_public_key, verification_mode) { Ok(loaded) => { if loaded.serialized_state.release_version != release_version { shorebird_info!( @@ -170,6 +190,7 @@ impl UpdaterState { ); return Self::create_new_and_save( cache_dir, + download_dir, release_version, patch_public_key, verification_mode, @@ -184,6 +205,7 @@ impl UpdaterState { } Self::create_new_and_save( cache_dir, + download_dir, release_version, patch_public_key, verification_mode, @@ -403,6 +425,7 @@ mod tests { 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(), @@ -499,7 +522,13 @@ mod tests { public_key: Option<&str>, mode: PatchVerificationMode, ) -> UpdaterState { - UpdaterState::load_or_new_on_error(tmp.path(), "1.0.0+1", public_key, mode) + UpdaterState::load_or_new_on_error( + tmp.path(), + &tmp.path().join("downloads"), + "1.0.0+1", + public_key, + mode, + ) } #[test] diff --git a/library/src/test_utils.rs b/library/src/test_utils.rs index 6415c3f7..48cb4abc 100644 --- a/library/src/test_utils.rs +++ b/library/src/test_utils.rs @@ -16,6 +16,7 @@ pub fn install_fake_patch(patch_number: usize) -> anyhow::Result<()> { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, diff --git a/library/src/updater.rs b/library/src/updater.rs index c52ff347..f330f13f 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -159,6 +159,7 @@ where with_config(|config| { let state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -174,6 +175,7 @@ where with_config(|config| { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -224,6 +226,7 @@ pub fn handle_prior_boot_failure_if_necessary() -> Result<(), InitError> { with_config(|config| { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -458,7 +461,7 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul ); let download_path = - lifecycle::download_artifact_path(Path::new(&config.storage_dir), patch.number); + lifecycle::download_artifact_path(Path::new(&config.download_dir), patch.number); if !matches!(action, DownloadAction::Complete) { let resume_from = match action { @@ -796,6 +799,7 @@ pub fn report_launch_failure() -> anyhow::Result<()> { with_config(|config| { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -833,6 +837,7 @@ pub fn report_launch_success() -> anyhow::Result<()> { // and make that the "current" patch. let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -1046,6 +1051,7 @@ mod tests { with_config(|config| { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -1343,6 +1349,7 @@ patch_verification: bogus_mode with_config(|config| { let state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -1374,6 +1381,7 @@ patch_verification: bogus_mode with_config(|config| { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -1562,6 +1570,7 @@ patch_verification: bogus_mode let mut updater_state = with_config(|config| { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -1647,6 +1656,7 @@ patch_verification: bogus_mode with_config(|config| { let mut state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -1679,6 +1689,7 @@ patch_verification: bogus_mode with_config(|config| { let state = UpdaterState::load_or_new_on_error( &config.storage_dir, + &config.download_dir, &config.release_version, config.patch_public_key.as_deref(), config.patch_verification, @@ -1741,7 +1752,7 @@ patch_verification: bogus_mode // gone (record_install_complete deletes them) and the patch's // state.json reads `Installed`. let patch_dir = tmp_dir.path().join("patches/1"); - assert!(!patch_dir.join("download").exists()); + assert!(!tmp_dir.path().join("downloads/1").exists()); assert!(patch_dir.join("dlc.vmcode").exists()); let state: crate::cache::lifecycle::PatchState = crate::cache::disk_io::read(&patch_dir.join("state.json")).unwrap(); @@ -1919,10 +1930,14 @@ patch_verification: bogus_mode write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); // Simulate a prior partial download: write the first 10 bytes - // and a Downloading state.json that points at the same URL/hash. + // (in the cache-rooted download dir) and a Downloading + // state.json (in the persistent state-rooted patch dir) that + // points at the same URL/hash. let patch_dir = tmp_dir.path().join("patches/1"); fs::create_dir_all(&patch_dir).unwrap(); - fs::write(patch_dir.join("download"), first_part).unwrap(); + let downloads_dir = tmp_dir.path().join("downloads"); + fs::create_dir_all(&downloads_dir).unwrap(); + fs::write(downloads_dir.join("1"), first_part).unwrap(); crate::cache::disk_io::write( &crate::cache::lifecycle::PatchState::Downloading { url: download_url.to_string(), @@ -1995,7 +2010,9 @@ patch_verification: bogus_mode // Simulate prior partial download with a DIFFERENT URL. let patch_dir = tmp_dir.path().join("patches/1"); fs::create_dir_all(&patch_dir).unwrap(); - fs::write(patch_dir.join("download"), b"stale data from old url").unwrap(); + let downloads_dir = tmp_dir.path().join("downloads"); + fs::create_dir_all(&downloads_dir).unwrap(); + fs::write(downloads_dir.join("1"), b"stale data from old url").unwrap(); crate::cache::disk_io::write( &crate::cache::lifecycle::PatchState::Downloading { url: "http://old-cdn.example.com/patch/1".to_string(), @@ -3221,10 +3238,13 @@ mod resume_edge_case_tests { let apk_path = tmp_dir.path().join("base.apk"); write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); - // Pre-create a partial download and Downloading state.json. + // Pre-create a partial download (in cache-rooted downloads/) + // and Downloading state.json (in state-rooted patches/). let patch_dir = tmp_dir.path().join("patches/1"); fs::create_dir_all(&patch_dir)?; - fs::write(patch_dir.join("download"), &PATCH_BYTES[..10])?; + let downloads_dir = tmp_dir.path().join("downloads"); + fs::create_dir_all(&downloads_dir)?; + fs::write(downloads_dir.join("1"), &PATCH_BYTES[..10])?; crate::cache::disk_io::write( &crate::cache::lifecycle::PatchState::Downloading { url: "http://example.com/patch/1".to_string(), @@ -3277,15 +3297,16 @@ mod resume_edge_case_tests { assert!(result.is_err()); // The lifecycle state.json was written before the download - // started; both it and the partial file survive the network - // error so the next attempt can resume. + // started; both it (state-rooted) and the partial file + // (cache-rooted) survive the network error so the next attempt + // can resume. let patch_dir = tmp_dir.path().join("patches/1"); assert!( patch_dir.join("state.json").exists(), "state.json should survive a download failure for retry" ); assert!( - patch_dir.join("download").exists(), + tmp_dir.path().join("downloads/1").exists(), "Partial download should survive for resume" ); @@ -3370,7 +3391,7 @@ mod resume_edge_case_tests { let patch_dir = tmp_dir.path().join("patches/1"); assert!(patch_dir.join("state.json").exists(), "tombstone preserved"); assert!( - !patch_dir.join("download").exists(), + !tmp_dir.path().join("downloads/1").exists(), "download artifact removed on inflate failure" ); let state: crate::cache::lifecycle::PatchState = @@ -3408,10 +3429,13 @@ mod resume_edge_case_tests { write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes()); // Simulate the post-download / pre-install state: full-size - // download file plus a `Downloaded` state.json. + // download file (cache-rooted) plus a `Downloaded` state.json + // (state-rooted). let patch_dir = tmp_dir.path().join("patches/1"); fs::create_dir_all(&patch_dir)?; - fs::write(patch_dir.join("download"), PATCH_BYTES)?; + let downloads_dir = tmp_dir.path().join("downloads"); + fs::create_dir_all(&downloads_dir)?; + fs::write(downloads_dir.join("1"), PATCH_BYTES)?; crate::cache::disk_io::write( &crate::cache::lifecycle::PatchState::Downloaded { url: "http://example.com/patch/1".to_string(), From 612cef0adad94bf7bb3cc1a0d8141736ba25113b Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 11:32:19 -0700 Subject: [PATCH 16/21] test: cover the new download_root cleanup paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review caught two gaps in the download-root split: - release_version_change_wipes_download_dir: the cache-rooted download dir should be wiped along with the persistent patches/ tree on a release-version mismatch. Adds explicit coverage — the code path was already in create_new_and_save but not directly tested. - record_boot_success_promotes_and_cleans_older: extends the existing test to drop a stale download in download_root alongside an older patch's state, asserts that cleanup_older_than's chain (cleanup → forget_dir) deletes both roots' artifacts. Also adds a comment on cleanup_older_than noting that it only walks the persistent patches/ tree — orphan downloads (no state.json) would persist until the OS evicts them. Noted as a known limitation, not blocking. --- library/src/cache/lifecycle.rs | 18 +++++++++++++++++- library/src/cache/updater_state.rs | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 5d9c90a7..e5209ff6 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -699,6 +699,14 @@ impl PatchLifecycle { /// directory) and gets removed wholesale. Best-effort — errors /// are logged so a single bad entry can't block the cleanup of /// others. + /// + /// Note: only walks the persistent `state_root/patches/` tree. + /// Compressed download files in `download_root/` are removed via + /// each patch's `cleanup` call (which deletes both roots), so any + /// download whose state.json still exists gets swept here. A + /// download file in `download_root/` whose state.json no longer + /// exists (out-of-band corruption, never seen in practice) would + /// persist until the OS evicts it from cache. fn cleanup_older_than(&self, n: usize) { let entries = match std::fs::read_dir(self.patches_root()) { Ok(e) => e, @@ -1425,6 +1433,12 @@ mod tests { install_state(&lifecycle, 1, 100); install_state(&lifecycle, 2, 200); install_state(&lifecycle, 3, 300); + // Drop a stale compressed download alongside an older patch's + // state to verify cleanup walks both roots through the cleanup() + // → forget_dir() chain. + let dl1 = lifecycle.download_artifact_path(1); + std::fs::create_dir_all(dl1.parent().unwrap()).unwrap(); + std::fs::write(&dl1, b"stale older download").unwrap(); // Pretend patch 3 is what we're booting. lifecycle.pointers.next_boot_patch = Some(3); lifecycle.save_pointers().unwrap(); @@ -1434,9 +1448,11 @@ mod tests { assert_eq!(lifecycle.pointers().last_booted_patch, Some(3)); assert!(lifecycle.pointers().currently_booting_patch.is_none()); - // Older patches removed entirely by record_boot_success. + // Older patches removed entirely by record_boot_success — both + // the persistent patch dir and the cache-rooted download file. assert!(!lifecycle.patch_dir(1).exists()); assert!(!lifecycle.patch_dir(2).exists()); + assert!(!dl1.exists(), "stale download for older patch removed"); // Booted patch survives. assert!(lifecycle.patch_dir(3).exists()); } diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 286f303f..5e62d735 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -446,6 +446,30 @@ mod tests { assert!(next.next_boot_patch().is_none()); } + #[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(); From e3da960851769bd8bc7088702973efcfb9c41f5e Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 12:20:14 -0700 Subject: [PATCH 17/21] refactor: targeted wipe + legacy patches_state.json + orphan-download walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the release-version-change wipe and one to the boot- success cleanup walk. - The "wipe everything in cache_dir" approach broke 27 tests whose setup uses cache_dir as the test temp dir (with base.apk and libapp.so as siblings). Production gives shorebird a dedicated subdir but the API doesn't enforce that. Switched to a targeted wipe with a documented allowlist of paths shorebird has ever written under cache_dir: `SHOREBIRD_OWNED_PATHS`. - Added `patches_state.json` to the wipe list — that's the legacy file from the prior `PatchManager` implementation, and devices upgrading through this PR would otherwise orphan it. New test `release_version_change_wipes_legacy_patches_state_json` covers it. - `cleanup_older_than` now also calls `cleanup_orphan_downloads`, which walks `download_root/` and removes any file that doesn't correspond to a patch in `Downloading`/`Downloaded` state. We own the cache root and shouldn't rely on OS eviction. Catches the "state.json gone but download lingered" case the prior comment handwaved. Adding a new file under cache_dir means adding it to SHOREBIRD_OWNED_PATHS — small bookkeeping cost, but it lets us keep cache_dir co-tenant with embedder-provided files. --- library/src/cache/lifecycle.rs | 114 +++++++++++++++++++---------- library/src/cache/updater_state.rs | 88 ++++++++++++++++------ 2 files changed, 143 insertions(+), 59 deletions(-) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index e5209ff6..490fefc0 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -692,51 +692,89 @@ impl PatchLifecycle { Ok(()) } - /// Walks `patches/` and runs [`cleanup`] on every patch with number - /// < `n`. State-aware per-patch: Bad tombstones survive, everything - /// else is forgotten. Anything in `patches/` whose name doesn't - /// parse as a patch number is unrecognized garbage (we own the - /// directory) and gets removed wholesale. Best-effort — errors - /// are logged so a single bad entry can't block the cleanup of - /// others. + /// Walks `state_root/patches/` and runs [`cleanup`] on every patch + /// with number < `n`. State-aware per-patch: Bad tombstones + /// survive, everything else is forgotten. Anything in `patches/` + /// whose name doesn't parse as a patch number is unrecognized + /// garbage (we own the directory) and gets removed wholesale. /// - /// Note: only walks the persistent `state_root/patches/` tree. - /// Compressed download files in `download_root/` are removed via - /// each patch's `cleanup` call (which deletes both roots), so any - /// download whose state.json still exists gets swept here. A - /// download file in `download_root/` whose state.json no longer - /// exists (out-of-band corruption, never seen in practice) would - /// persist until the OS evicts it from cache. + /// Then sweeps `download_root/` via [`cleanup_orphan_downloads`]: + /// every file in there should correspond to a patch in + /// `Downloading` or `Downloaded` state; anything else is orphan + /// or stale and gets removed. We own `download_root/` and don't + /// rely on OS cache eviction to clean up after us. + /// + /// Best-effort — errors are logged so a single bad entry can't + /// block the cleanup of others. fn cleanup_older_than(&self, n: usize) { - let entries = match std::fs::read_dir(self.patches_root()) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let path = entry.path(); - let name_string = entry.file_name().to_string_lossy().into_owned(); - match name_string.parse::() { - Ok(num) if num < n => { - if let Err(e) = self.cleanup(num) { - shorebird_error!("cleanup({}) failed: {:?}", num, e); + if let Ok(entries) = std::fs::read_dir(self.patches_root()) { + for entry in entries.flatten() { + let path = entry.path(); + let name_string = entry.file_name().to_string_lossy().into_owned(); + match name_string.parse::() { + Ok(num) if num < n => { + if let Err(e) = self.cleanup(num) { + shorebird_error!("cleanup({}) failed: {:?}", num, e); + } } - } - Ok(_) => {} // current or newer; leave alone. - Err(_) => { - // We own this directory; anything not named like a - // patch number is corruption / leftover from prior - // versions / debug residue and is safe to remove. - 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 remove unrecognized entry {:?}: {:?}", path, e); + Ok(_) => {} // current or newer; leave alone. + Err(_) => { + // Anything in patches/ whose name isn't a patch + // number is corruption / leftover from prior + // versions / debug residue and is safe to remove. + 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 remove unrecognized entry {:?}: {:?}", + path, + e + ); + } } } } } + self.cleanup_orphan_downloads(); + } + + /// Sweeps `download_root/` for files that don't correspond to a + /// live download. A download file is "live" only when its patch + /// is in `Downloading` or `Downloaded` state — any other + /// situation (no state.json, state is `Installed` or `Bad`, name + /// isn't a patch number) is an orphan we should clean up. The + /// `Installed` and `Bad` cases shouldn't happen in normal flow + /// (`record_install_complete` and `mark_bad` already remove the + /// download), but the safety net costs nothing. + fn cleanup_orphan_downloads(&self) { + let Ok(entries) = std::fs::read_dir(&self.download_root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + let keep = match name.parse::() { + Ok(num) => matches!( + self.read_state(num), + Some(PatchState::Downloading { .. } | PatchState::Downloaded { .. }) + ), + Err(_) => false, + }; + if keep { + 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 remove orphan download {:?}: {:?}", path, e); + } + } } /// Transitions `n` from `Downloaded` to `Installed`. `installed_size` diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 5e62d735..6ccccc0e 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -17,6 +17,17 @@ 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. +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. @@ -128,9 +139,18 @@ impl UpdaterState { }) } - /// Initializes a new UpdaterState and saves it to disk. Wipes any - /// existing per-release patch state — used when the release version - /// changes or when the on-disk state was unparseable. + /// 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, @@ -139,6 +159,27 @@ impl UpdaterState { 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(), @@ -150,24 +191,6 @@ impl UpdaterState { if let Err(e) = state.save() { shorebird_warn!("Error saving state {:?}, ignoring.", e); } - // Wipe per-release patch storage from any prior release. - let patches_root = cache_dir.join("patches"); - if patches_root.exists() { - if let Err(e) = std::fs::remove_dir_all(&patches_root) { - shorebird_error!("Failed to wipe patches dir on reset: {:?}", e); - } - } - let pointers_path = cache_dir.join("pointers.json"); - if pointers_path.exists() { - let _ = std::fs::remove_file(&pointers_path); - } - // Also wipe stale compressed downloads from the prior release. - 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); - } - } - // Reload lifecycle from a clean slate. state.lifecycle = PatchLifecycle::load_or_default(cache_dir.to_path_buf(), download_dir.to_path_buf()); state @@ -446,6 +469,29 @@ mod tests { 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 From 30b1a7212135b203f9111d3c04bc61ab4387e8bb Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 12:36:27 -0700 Subject: [PATCH 18/21] test: cover all four branches of cleanup_orphan_downloads The new orphan-download sweep distinguishes four cases but only the "older patch via cleanup chain" branch was being exercised. Single test now drops one of each kind of file into download_root before a boot success and asserts which survive: - orphan (numeric, no state.json): removed - stale (numeric, state is Installed): removed - non-numeric name: removed - live (numeric, state is Downloading): preserved --- library/src/cache/lifecycle.rs | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 490fefc0..7e38a3f0 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -1519,6 +1519,65 @@ mod tests { assert!(lifecycle.patch_dir(3).exists()); } + #[test] + // Cleanup-on-boot-success runs `cleanup_orphan_downloads`, which + // walks `download_root/` and removes anything that doesn't + // correspond to a `Downloading`/`Downloaded` patch. This test + // exercises every "remove" branch in one shot: an orphan (no + // state.json), a stale download (state is `Installed`), and a + // non-numeric file. Live downloads (`Downloading` / + // `Downloaded`) are kept. + fn record_boot_success_sweeps_orphan_and_stale_downloads() { + let (_tmp, mut lifecycle) = fixture(); + install_state(&lifecycle, 5, 500); + lifecycle.pointers.next_boot_patch = Some(5); + lifecycle.save_pointers().unwrap(); + + // Drop four files into download_root with varying states: + std::fs::create_dir_all(&lifecycle.download_root).unwrap(); + // 1) Orphan: numeric name with no state.json on disk. + std::fs::write(lifecycle.download_artifact_path(2), b"orphan bytes").unwrap(); + // 2) Stale: numeric name with state.json saying Installed + // (record_install_complete should have removed this; this + // is the safety net). + install_state(&lifecycle, 3, 300); + std::fs::write(lifecycle.download_artifact_path(3), b"stale bytes").unwrap(); + // 3) Non-numeric: garbage in our directory. + std::fs::write(lifecycle.download_root.join("not_a_number"), b"junk").unwrap(); + // 4) Live: a Downloading patch that should survive. + lifecycle + .write_state( + 7, + &PatchState::Downloading { + url: "u".into(), + hash: "h".into(), + signature: None, + }, + ) + .unwrap(); + std::fs::write(lifecycle.download_artifact_path(7), b"in flight").unwrap(); + + lifecycle.record_boot_start(5).unwrap(); + lifecycle.record_boot_success().unwrap(); + + assert!( + !lifecycle.download_artifact_path(2).exists(), + "orphan removed" + ); + assert!( + !lifecycle.download_artifact_path(3).exists(), + "stale Installed download removed" + ); + assert!( + !lifecycle.download_root.join("not_a_number").exists(), + "non-numeric garbage removed" + ); + assert!( + lifecycle.download_artifact_path(7).exists(), + "live Downloading patch's bytes preserved" + ); + } + #[test] // Ports // `patch_manager.rs::record_boot_success_for_patch_tests::deletes_unrecognized_directories_in_patches_dir`. From b1893902fff98b078152116b60ba1fd89f0819b0 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 5 May 2026 13:08:15 -0700 Subject: [PATCH 19/21] ci: add 'embedder' to cspell dictionary --- cspell.config.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cspell.config.yaml b/cspell.config.yaml index b0786ef4..936b46f8 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -39,8 +39,9 @@ words: - dllexport - dlopen - downloadables - - EDQUOT - EACCES + - EDQUOT + - embedder - embedders - EOCD - endtemplate From d03c4e2d9e9cb62201c06380c2f260434cef95b5 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Thu, 7 May 2026 15:31:07 -0700 Subject: [PATCH 20/21] refactor: drop Installed.hash, fix mark_bad ordering, address bdero review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bdero's review on #352 surfaced six items worth landing inline: 1. record_boot_failure used to clear `currently_booting_patch` before `mark_bad`. A crash between the two left the patch `Installed` with no breadcrumb, so `detect_boot_crash_on_init` wouldn't fire next boot — the patch silently retried. Flipped: mark_bad first, then clear. mark_bad on Bad is idempotent, so the worst case is a redundant tombstone-rewrite on next init. 2. Drop the `hash` field from `PatchState::Installed`. It was recorded at install but never read at boot — Strict-mode validation recomputes the hash from the artifact's bytes and feeds it into `check_signature`. A hash that lives only on disk can't be trusted as a security input; deleting the field removes the temptation. `Downloading.hash`/`Downloaded.hash` stay as comparators against the server's freshly-delivered hash (a tampered on-disk hash there just causes a redownload). 3. Port the customer-scenario test from #356 into `c_api/mod.rs`: server rolls patch 1 back, then forward again with the same number and hash. Pre-lifecycle this was permanently dropped via `known_bad_patches`. Post-lifecycle `cleanup` is state-aware and forgets the patch entirely on server-driven rollback, so the rollforward installs cleanly. End-to-end coverage of the regression #356 hotfixed. 4. Add a TODO + target version on the `patches_state.json` entry in `SHOREBIRD_OWNED_PATHS` so the legacy wipe doesn't outlive its usefulness. 5. Document that `running_patch` lives in `config.rs` (session-scoped), not in this module — saves a future grep. 6. Clarify `recompute_next_boot`'s fallback policy: it's fall-back-to-`last_booted_patch`-only, not fall-back-to-anything- bootable. A fresh release whose first Installed patch fails validation goes to base, even if older patches sit in `patches/`. cargo test: 242 passed (was 241). --- library/src/c_api/mod.rs | 99 ++++++++++++++++++++++++++++++ library/src/cache/lifecycle.rs | 99 +++++++++++++++++------------- library/src/cache/updater_state.rs | 6 +- 3 files changed, 160 insertions(+), 44 deletions(-) diff --git a/library/src/c_api/mod.rs b/library/src/c_api/mod.rs index 0c052431..96319fbd 100644 --- a/library/src/c_api/mod.rs +++ b/library/src/c_api/mod.rs @@ -876,6 +876,105 @@ mod test { assert_eq!(shorebird_current_boot_patch_number(), 0); } + /// Server rolls back patch 1, then later rolls it forward again with + /// the same number and same hash. The pre-lifecycle code added the + /// rolled-back number to `known_bad_patches` permanently for the + /// release, so the rollforward was silently dropped on the device. + /// The lifecycle's `cleanup` is state-aware: a server-driven + /// rollback on a non-`Bad` patch forgets the patch entirely (no + /// tombstone), leaving the number free to be reinstalled. + /// shorebirdtech/shorebird#3728. + #[serial] + #[test] + fn rollforward_after_server_rollback_reinstalls_patch() { + testing_reset_config(); + let tmp_dir = TempDir::new().unwrap(); + + let apk_path = tmp_dir.path().join("base.apk"); + write_fake_apk( + apk_path.to_str().unwrap(), + HELLO_TESTS_PATCH.base.as_bytes(), + ); + let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so"); + let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap()); + let c_yaml = c_string("app_id: foo"); + assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml)); + free_c_string(c_yaml); + free_parameters(c_params); + + // Phase 1: install patch 1 and report a successful launch. + testing_set_network_hooks( + |_url, _request| { + Ok(PatchCheckResponse { + patch_available: true, + patch: Some(crate::Patch { + number: 1, + hash: HELLO_TESTS_PATCH.hash.to_owned(), + download_url: "ignored".to_owned(), + hash_signature: None, + }), + rolled_back_patch_numbers: None, + }) + }, + |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest), + |_url, _event| Ok(()), + ); + assert!(shorebird_check_for_downloadable_update(std::ptr::null())); + run_update_expecting(std::ptr::null(), SHOREBIRD_UPDATE_INSTALLED); + shorebird_report_launch_start(); + shorebird_report_launch_success(); + assert_eq!(shorebird_current_boot_patch_number(), 1); + + // Phase 2: server rolls patch 1 back with no replacement. + // Phase-1 spawned threads (PatchDownload, PatchInstallSuccess) + // hold a clone of the config from when they were spawned, so they + // hit the old report hook above. Nothing in phase 2 should report + // or download — only a patch-check happens. + testing_set_network_hooks( + |_url, _request| { + Ok(PatchCheckResponse { + patch_available: false, + patch: None, + rolled_back_patch_numbers: Some(vec![1]), + }) + }, + UNEXPECTED_DOWNLOAD, + UNEXPECTED_REPORT, + ); + assert!(!shorebird_check_for_downloadable_update(std::ptr::null())); + assert_eq!(shorebird_next_boot_patch_number(), 0); + + // Phase 3: server rolls patch 1 forward — same number, same hash, + // empty rolled_back list (the row's `is_rolled_back` flipped back + // to false on the server). The device must accept this as a normal + // "patch available" response and reinstall. + testing_set_network_hooks( + |_url, _request| { + Ok(PatchCheckResponse { + patch_available: true, + patch: Some(crate::Patch { + number: 1, + hash: HELLO_TESTS_PATCH.hash.to_owned(), + download_url: "ignored".to_owned(), + hash_signature: None, + }), + rolled_back_patch_numbers: Some(vec![]), + }) + }, + |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest), + |_url, _event| Ok(()), + ); + + // Pre-lifecycle: returns false because patch 1 sat in + // `known_bad_patches` from phase 2's `remove_patch` call. + // Post-lifecycle: phase 2's cleanup forgot patch 1 entirely + // (no Bad tombstone for server-driven rollbacks), so this + // installs cleanly. + assert!(shorebird_check_for_downloadable_update(std::ptr::null())); + run_update_expecting(std::ptr::null(), SHOREBIRD_UPDATE_INSTALLED); + assert_eq!(shorebird_next_boot_patch_number(), 1); + } + /// Patch-to-patch rollback: device on patch 2, server rolls back to /// patch 1 (sends rollback signal AND a downloadable replacement). /// `check_for_downloadable_update` returns true (replacement available), diff --git a/library/src/cache/lifecycle.rs b/library/src/cache/lifecycle.rs index 7e38a3f0..6268e237 100644 --- a/library/src/cache/lifecycle.rs +++ b/library/src/cache/lifecycle.rs @@ -35,6 +35,12 @@ //! Callers never pick between "delete tombstone" and "preserve tombstone"; //! the state on disk decides. See the design notes that led here in //! shorebirdtech/shorebird#3737. +//! +//! The session-scoped "what patch is the engine currently executing" +//! signal lives in `config.rs` as `running_patch`, not here — it's not +//! persisted across launches and is set from `report_launch_start` +//! before any lifecycle transitions happen. `shorebird_current_boot_patch_number` +//! reads that, not anything in this module. use std::path::{Path, PathBuf}; @@ -55,6 +61,12 @@ pub enum PatchState { /// Compressed bytes are partially on disk. The current bytes-on-disk /// count is read from the `download` file at resume time — the state /// itself just records "we're mid-download for this url+hash." + /// + /// `hash`/`signature` here are *comparators*, not trusted values. + /// `decide_start` checks them against the server's freshly-delivered + /// hash for this number; a mismatch (e.g. a server-side reupload + /// under the same patch number) discards the prior bytes and + /// restarts. A tampered on-disk hash just causes a redownload. Downloading { url: String, hash: String, @@ -63,6 +75,11 @@ pub enum PatchState { /// Compressed bytes are fully on disk and the size matches what we /// recorded after the download completed. Bytes are untrusted until /// install validates them (inflate + check_hash). + /// + /// As with `Downloading`, `hash`/`signature` are comparators only. + /// `record_install_complete` carries `signature` forward into + /// `Installed`, where Strict-mode boot validation re-verifies it + /// against the on-disk artifact's freshly-recomputed hash. Downloaded { url: String, hash: String, @@ -70,8 +87,14 @@ pub enum PatchState { size: u64, }, /// `dlc.vmcode` is present; the patch is bootable. + /// + /// `hash` is intentionally absent. Install-time validation + /// (`check_hash` against the server-fresh hash held in memory) + /// has already happened, and we don't trust a hash we'd have to + /// re-read from disk to redo that check. Strict-mode boot + /// validation recomputes the artifact's hash from bytes and feeds + /// it to `check_signature` — `signature` is enough. Installed { - hash: String, signature: Option, size: u64, }, @@ -228,11 +251,7 @@ impl PatchLifecycle { size, .. }) => (Some(hash), signature, Some(size)), - Some(PatchState::Installed { - hash, - signature, - size, - }) => (Some(hash), signature, Some(size)), + Some(PatchState::Installed { signature, size }) => (None, signature, Some(size)), Some(PatchState::Bad { hash, signature, @@ -537,19 +556,27 @@ impl PatchLifecycle { Ok(()) } - /// Records that patch `n` failed to boot. Clears the boot - /// breadcrumb, marks the patch `Bad{BootCrash}`, and recomputes + /// Records that patch `n` failed to boot. Marks the patch + /// `Bad{BootCrash}`, clears the boot breadcrumb, and recomputes /// `next_boot_patch`. /// /// The patch number is passed in (rather than read from /// `currently_booting_patch`) to match the prior PatchManager API /// shape — most call sites already have the number in hand. The /// breadcrumb is cleared regardless of whether it matched. + /// + /// `mark_bad` runs *before* clearing the breadcrumb so a crash + /// between the two leaves a still-set `currently_booting_patch` + /// pointing at an already-Bad patch. Next init's + /// `detect_boot_crash_on_init` re-runs this path; `mark_bad` is + /// idempotent on Bad → Bad. Reverse the order and a crash strands + /// an `Installed` patch with no breadcrumb — the next boot retries + /// it silently. pub fn record_boot_failure(&mut self, n: usize) -> Result<()> { + self.mark_bad(n, BadReason::BootCrash)?; self.pointers.currently_booting_patch = None; self.pointers.boot_started_at = None; self.save_pointers()?; - self.mark_bad(n, BadReason::BootCrash)?; self.recompute_next_boot() } @@ -607,6 +634,13 @@ impl PatchLifecycle { /// patches — within a release there are at most a couple of patches /// active at once, and the last successfully booted patch is the /// only one we have evidence works on this device. + /// + /// Concretely: this is a fall-back-to-`last_booted_patch`-only + /// policy, not a fall-back-to-anything-bootable scan. If + /// `last_booted_patch` is `None` (e.g. fresh install of a release + /// where the freshly Installed `next_boot_patch` then fails + /// validation), we go to base release even when an older Installed + /// patch may be sitting in `patches/`. pub fn recompute_next_boot(&mut self) -> Result<()> { let mut dirty = false; if let Some(lb) = self.pointers.last_booted_patch { @@ -663,9 +697,7 @@ impl PatchLifecycle { mode: PatchVerificationMode, ) -> Result<()> { let (expected_size, signature) = match self.read_state(n) { - Some(PatchState::Installed { - size, signature, .. - }) => (size, signature), + Some(PatchState::Installed { size, signature }) => (size, signature), other => bail!("Patch {n} is not Installed: {other:?}"), }; let path = self.installed_artifact_path(n); @@ -782,10 +814,8 @@ impl PatchLifecycle { /// `validate_installed_patch` will check against on next boot). /// Also removes the now-unneeded compressed `download` file. pub fn record_install_complete(&self, n: usize, installed_size: u64) -> Result<()> { - let (hash, signature) = match self.read_state(n) { - Some(PatchState::Downloaded { - hash, signature, .. - }) => (hash, signature), + let signature = match self.read_state(n) { + Some(PatchState::Downloaded { signature, .. }) => signature, other => { anyhow::bail!( "record_install_complete called on patch {n} in unexpected state: {other:?}" @@ -795,7 +825,6 @@ impl PatchLifecycle { self.write_state( n, &PatchState::Installed { - hash, signature, size: installed_size, }, @@ -867,7 +896,6 @@ mod tests { .write_state( 1, &PatchState::Installed { - hash: "h".into(), signature: Some("s".into()), size: 999, }, @@ -882,7 +910,9 @@ mod tests { size, } => { assert_eq!(reason, BadReason::BootCrash); - assert_eq!(hash, Some("h".into())); + // Installed has no hash field; Bad.hash is None for + // Installed→Bad transitions. + assert_eq!(hash, None); assert_eq!(signature, Some("s".into())); assert_eq!(size, Some(999)); } @@ -1043,7 +1073,6 @@ mod tests { .write_state( 1, &PatchState::Installed { - hash: "h".into(), signature: None, size: 100, }, @@ -1070,7 +1099,6 @@ mod tests { .write_state( 1, &PatchState::Installed { - hash: "h".into(), signature: None, size: 1, }, @@ -1263,7 +1291,6 @@ mod tests { .write_state( 1, &PatchState::Installed { - hash: "h".into(), signature: None, size: 1000, }, @@ -1359,7 +1386,6 @@ mod tests { .write_state( 1, &PatchState::Installed { - hash: "h".into(), signature: None, size: 100, }, @@ -1391,7 +1417,6 @@ mod tests { assert_eq!( lifecycle.read_state(1).unwrap(), PatchState::Installed { - hash: "h".into(), signature: Some("s".into()), size: 9999, } @@ -1420,7 +1445,6 @@ mod tests { .write_state( n, &PatchState::Installed { - hash: format!("hash{n}"), signature: None, size, }, @@ -1806,7 +1830,6 @@ mod tests { .write_state( 1, &PatchState::Installed { - hash: "h".into(), signature: None, size: 100, }, @@ -1864,16 +1887,9 @@ mod tests { // private key to the repo. const TEST_PUBLIC_KEY: &str = "MIIBCgKCAQEA2wdpEGbuvlPsb9i0qYrfMefJnEw1BHTi8SYZTKrXOvJWmEpPE1hWfbkvYzXu5a96gV1yocF3DMwn04VmRlKhC4AhsD0NL0UNhYhotbKG91Kwi1vAXpHhCdz5gQEBw0K1uB4Jz+zK6WK+31PryYpwLwbyXNqXoY8IAAUQ4STsHYV5w+BMSi8pepWMRd7DR9RHcbNOZlJvdBQ5NxvB4JN4dRMq8cC73ez1P9d7Dfwv3TWY+he9EmuXLT2UivZSlHIrGBa7MFfqyUe2ro0F7Te/B0si12itBbWIqycvqcXjeOPNn6WEpqN7IWjb9LUh162JyYaz5Lb/VeeJX8LKtElccwIDAQAB"; - /// SHA256 of the single-byte file content `b"1"`. Matches the - /// `INFLATED_PATCH_HASH` constant from the prior `patch_manager` - /// tests — the same trick they used: write a 1-byte file `"1"`, - /// declare its hash as Installed.hash, sign with the matching - /// private key. - const INFLATED_PATCH_HASH: &str = - "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"; - /// Real signature of `INFLATED_PATCH_HASH` produced with the - /// private key matching `TEST_PUBLIC_KEY`. Carried over verbatim - /// from the prior `patch_manager.rs::SIGNATURE` constant. + /// Real signature of SHA256(`b"1"`) produced with the private key + /// matching `TEST_PUBLIC_KEY`. Carried over verbatim from the prior + /// `patch_manager.rs::SIGNATURE` constant. const INFLATED_PATCH_SIGNATURE: &str = "ZGccldv01XqHQ76bXuKV/9EQnNK0Q+reQ9bJHVnGfLldF+BLRx0divgPfKP5Df9BJPA3dw1Z1VortfepmMGebP3kS593l5zoktu9MIepxvRAFWNKE5PDTIIvCL/ddTPEHt6NNCeD6HLOMLzbEX3cFZa+lq3UymGi0aqA5DlXirJBGtopojc9nOXZ22n/qHNZIHEkGcqKbSMSK9oC55whKHnlJTbCXdmSyDc65B4PcgseqJom1riVK3XGW1YMrSpuMAU+CDT7HhdESmI1UtH1bYeBITfRhQztdDTfti2vJTf2Y+lYC99CFiISgD7f1m0KUcC+VnEAMZSYtgxSk6AX2A=="; /// Test helper: writes an Installed state with a 100-byte artifact @@ -1889,7 +1905,6 @@ mod tests { .write_state( n, &PatchState::Installed { - hash: "irrelevant-for-failure-paths".to_string(), signature: signature.map(String::from), size: 100, }, @@ -1897,11 +1912,10 @@ mod tests { .unwrap(); } - /// Test helper for the Strict-mode happy path. Writes the - /// 1-byte artifact `b"1"` (whose SHA256 is `INFLATED_PATCH_HASH`) - /// and an `Installed` state whose hash + signature match. Strict - /// validation hashes the file, then verifies the signature - /// against that hash + the public key — all three line up here. + /// Test helper for the Strict-mode happy path. Writes the 1-byte + /// artifact `b"1"` and an `Installed` state whose signature matches + /// SHA256(`b"1"`). Strict validation rehashes the file then verifies + /// the signature against that hash + the public key. fn install_with_valid_signature(lifecycle: &PatchLifecycle, n: usize) { let path = lifecycle.installed_artifact_path(n); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -1910,7 +1924,6 @@ mod tests { .write_state( n, &PatchState::Installed { - hash: INFLATED_PATCH_HASH.to_string(), signature: Some(INFLATED_PATCH_SIGNATURE.to_string()), size: 1, }, diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 6ccccc0e..ab8fb1d0 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -26,6 +26,11 @@ const STATE_FILE_NAME: &str = "state.json"; /// `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 @@ -379,7 +384,6 @@ impl UpdaterState { self.lifecycle.write_state( patch.number, &PatchState::Installed { - hash: hash.to_string(), signature: signature.map(String::from), size: installed_size, }, From d4384a0cb3f403bbca97e41057bf81bbbc4ef152 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Thu, 7 May 2026 15:46:33 -0700 Subject: [PATCH 21/21] ci: add 'rollforward' to cspell dictionary --- cspell.config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.config.yaml b/cspell.config.yaml index 936b46f8..be1e8021 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -72,6 +72,7 @@ words: - rlib - roundtrips - rsplit + - rollforward - rollouts - RTLD - rustflags