//! Update state machine (port of `updates/updateService.js`). The UI never supplies a path, //! key or "verified" flag; installation re-verifies the internally tracked artifact. use crate::core::config::ConfigStore; use crate::core::error::{CoreError, CoreResult}; use crate::core::updates::contract::Release; use crate::core::updates::provider::{ReleaseProvider, VerifiedDownload}; use crate::core::version::compare_versions; use parking_lot::Mutex; use serde_json::{json, Value}; use std::cmp::Ordering; use std::path::PathBuf; use std::sync::Arc; #[derive(Default)] struct State { checked: bool, release: Option, releases: Vec, selected: Option, download: Option, launch_authorized: bool, operation: Option<&'static str>, } pub struct UpdateService { pub app_version: String, pub build_id: String, pub platform: String, pub arch: String, config: Arc, provider: Box CoreResult + Send + Sync>, logger: Arc, state: Mutex, } struct Operation<'a>(&'a Mutex); impl Drop for Operation<'_> { fn drop(&mut self) { self.0.lock().operation = None; } } impl UpdateService { pub fn new(app_version: &str, build_id: &str, platform: &str, arch: &str, config: Arc, provider: Box CoreResult + Send + Sync>, logger: Arc) -> Self { Self { app_version: app_version.into(), build_id: build_id.into(), platform: platform.into(), arch: arch.into(), config, provider, logger, state: Mutex::new(State::default()) } } fn begin(&self, name: &'static str) -> CoreResult> { let mut state = self.state.lock(); if state.operation.is_some() { return Err(CoreError::plain("Another update operation is already running.")); } state.operation = Some(name); Ok(Operation(&self.state)) } fn relation(&self, release: &Release, releases: &[Release]) -> &'static str { match compare_versions(&release.version, &self.app_version).unwrap_or(Ordering::Equal) { Ordering::Greater => return "newerVersion", Ordering::Less => return "olderVersion", Ordering::Equal => {} } if !self.build_id.is_empty() && release.build_id.as_deref() == Some(self.build_id.as_str()) { return "sameVersionCurrentRelease"; } let last = self.config.get()["updates"]["lastLaunchedRelease"].clone(); if !last.is_null() && compare_versions(last["version"].as_str().unwrap_or("0.0"), &release.version).ok() == Some(Ordering::Equal) { if last["id"].as_str() == Some(release.id.as_str()) || last["sha512"].as_str() == Some(release.sha512.as_str()) { return "sameVersionCurrentRelease"; } let parse = |v: &str| crate::core::updates::contract::parse_date(v); if let (Some(a), Some(b)) = (release.published_at.as_deref().and_then(parse), last["publishedAt"].as_str().and_then(parse)) { return if a > b { "sameVersionNewerRelease" } else { "sameVersionOlderRelease" }; } } let newest_same = releases.iter().find(|r| compare_versions(&r.version, &self.app_version).ok() == Some(Ordering::Equal)); if newest_same.map(|r| r.id == release.id).unwrap_or(false) { "sameVersionNewerRelease" } else { "sameVersionOlderRelease" } } pub fn get_state(&self) -> Value { let state = self.state.lock(); let relation = state.release.as_ref().map(|r| self.relation(r, &state.releases)); json!({ "status": state.operation.unwrap_or("idle"), "checked": state.checked, "currentVersion": self.app_version, "currentBuildId": self.build_id, "updateAvailable": state.release.is_some() && relation != Some("sameVersionCurrentRelease"), "release": state.release, "releases": state.releases, "selectedReleaseId": state.selected, "releaseRelation": relation, "downloaded": state.download.as_ref().map(|d| json!({ "releaseId": d.release_id, "filename": d.filename, "size": d.size, "sha512": d.sha512, "signatureVerified": d.signature_verified, "primaryFingerprint": d.primary_fingerprint, "signingFingerprints": d.signing_fingerprints })) }) } pub async fn check(&self) -> CoreResult { let operation = self.begin("checking")?; let channel = self.config.get()["updates"]["channel"].as_str().unwrap_or("beta").to_string(); let provider = (self.provider)()?; let (releases, source) = provider.list_releases(&self.app_version, &self.platform, &self.arch, &channel).await?; let preferred = releases.iter().find(|r| compare_versions(&r.version, &self.app_version).ok() == Some(Ordering::Greater)).or(releases.first()).cloned(); { let mut state = self.state.lock(); state.selected = preferred.as_ref().map(|r| r.id.clone()); state.release = preferred.clone(); state.releases = releases; state.checked = true; state.download = None; state.launch_authorized = false; } self.logger.info("Update check completed.", json!({ "channel": channel, "updateAvailable": preferred.is_some(), "version": preferred.as_ref().map(|r| r.version.clone()), "sourceBaseUrl": source })); drop(operation); Ok(self.get_state()) } pub fn select(&self, id: &str) -> CoreResult { { let mut state = self.state.lock(); if state.operation.is_some() { return Err(CoreError::plain("Another update operation is already running.")); } let release = state.releases.iter().find(|r| r.id == id).cloned().ok_or_else(|| CoreError::plain("Selected release is not available in the current catalog."))?; state.selected = Some(release.id.clone()); state.release = Some(release); state.download = None; state.launch_authorized = false; } Ok(self.get_state()) } pub async fn download(&self) -> CoreResult { let release = self.state.lock().release.clone().ok_or_else(|| CoreError::plain("No update release is selected."))?; let operation = self.begin("downloading")?; { let mut state = self.state.lock(); state.download = None; state.launch_authorized = false; } let provider = (self.provider)()?; let mut download = provider.download_and_verify(&release).await?; if !download.signature_verified { return Err(CoreError::plain("The update did not pass mandatory OpenPGP verification.")); } download.release_id = Some(release.id.clone()); self.logger.info( "Update downloaded and verified with SHA-512 and OpenPGP.", json!({ "filename": download.filename, "size": download.size, "reused": download.reused, "primaryFingerprint": download.primary_fingerprint, "signingFingerprints": download.signing_fingerprints }), ); self.state.lock().download = Some(download); drop(operation); Ok(self.get_state()) } pub fn verified_download_path(&self) -> Option { self.state.lock().download.as_ref().filter(|d| d.signature_verified).map(|d| d.path.clone()) } pub async fn prepare_installer_launch(&self) -> CoreResult { let (release, download) = { let state = self.state.lock(); match (&state.release, &state.download) { (Some(r), Some(d)) if d.release_id.as_deref() == Some(r.id.as_str()) => (r.clone(), d.clone()), _ => return Err(CoreError::plain("No verified update release is ready to install.")), } }; let operation = self.begin("verifying-signature")?; self.state.lock().launch_authorized = false; let provider = (self.provider)()?; match provider.reverify_for_install(&download, &release).await { Ok(path) => { self.state.lock().launch_authorized = true; drop(operation); Ok(path) } Err(error) => { self.state.lock().download = None; Err(error) } } } pub fn mark_installer_launched(&self) -> CoreResult { let release = { let mut state = self.state.lock(); let ok = matches!((&state.release, &state.download), (Some(r), Some(d)) if d.release_id.as_deref() == Some(r.id.as_str()) && d.signature_verified) && state.launch_authorized; if !ok { return Err(CoreError::plain("No verified update release is ready to install.")); } state.launch_authorized = false; state.release.clone().expect("release") }; self.config.record_launched_release(&json!({ "id": release.id, "version": release.version, "publishedAt": release.published_at, "sha512": release.sha512 }))?; Ok(self.get_state()) } pub fn cleanup_downloads(&self) -> CoreResult { let result = (self.provider)()?.cleanup_downloads(); let mut state = self.state.lock(); state.download = None; state.launch_authorized = false; result } } #[cfg(test)] mod safety_tests { use super::*; use crate::core::updates::contract::{validate_release, Expected}; fn release(id: &str, version: &str, date: &str) -> Release { validate_release(&json!({"id":id,"version":version,"platform":"mac","arch":"arm64","channel":"beta","sha512":"a".repeat(128),"size":10,"filename":"test.dmg","download_url":"/files/test.dmg","signature_url":"/files/test.dmg.asc","published_at":date}), &Expected {platform:"mac",arch:"arm64",channel:"beta",current_version:"0.1.1"}).unwrap() } fn service(root: &std::path::Path) -> UpdateService { UpdateService::new("0.1.1","fixture-current-build","mac","arm64",Arc::new(ConfigStore::new(root,vec!["en".into()])), Box::new(|| Err(CoreError::new("FIXTURE_PROVIDER_FAILURE","offline fixture"))),Arc::new(crate::core::logging::Logger::new(root))) } fn downloaded(release: &Release) -> VerifiedDownload { VerifiedDownload {path:PathBuf::from("unused.dmg"),signature_path:PathBuf::from("unused.dmg.asc"),filename:release.filename.clone(),size:release.size,sha512:release.sha512.clone(),reused:false,signature_verified:true,primary_fingerprint:"fixture".into(),signing_fingerprints:vec![],signature_created_at:None,release_id:Some(release.id.clone())} } #[test] fn relation_version_build_and_catalog_fallback_matrix() { let dir=tempfile::tempdir().unwrap();let service=service(dir.path()); let newest=release("newest","0.1.1","2026-02-02");let older=release("older","0.1.1","2026-01-01");let list=vec![newest.clone(),older.clone()]; for (candidate,expected) in [(release("higher","0.1.2","2026-01-01"),"newerVersion"),(release("lower","0.1.0","2026-01-01"),"olderVersion"),(newest.clone(),"sameVersionNewerRelease"),(older,"sameVersionOlderRelease")] { assert_eq!(service.relation(&candidate,&list),expected); } let mut current=newest.clone();current.build_id=Some("fixture-current-build".into()); assert_eq!(service.relation(¤t,&list),"sameVersionCurrentRelease"); } #[test] fn launched_release_identity_hash_and_publication_matrix() { let dir=tempfile::tempdir().unwrap();let service=service(dir.path()); service.config.record_launched_release(&json!({"id":"launched","version":"0.1.1","sha512":"b".repeat(128),"publishedAt":"2026-02-01"})).unwrap(); for (id,date,hash,expected) in [("launched","2026-03-01",'a',"sameVersionCurrentRelease"),("other","2026-03-01",'b',"sameVersionCurrentRelease"),("other","2026-03-01",'a',"sameVersionNewerRelease"),("other","2026-01-01",'a',"sameVersionOlderRelease")] { let mut r=release(id,"0.1.1",date);r.sha512=hash.to_string().repeat(128); assert_eq!(service.relation(&r,&[r.clone()]),expected); } } #[test] fn selection_clears_download_and_launch_authorization_and_rejects_unknown_id() { let dir=tempfile::tempdir().unwrap();let service=service(dir.path());let first=release("first","0.1.2","2026-02-02");let second=release("second","0.1.1","2026-01-01"); {let mut state=service.state.lock();state.releases=vec![first.clone(),second.clone()];state.release=Some(first.clone());state.selected=Some(first.id.clone());state.download=Some(downloaded(&first));state.launch_authorized=true;} let before=service.get_state();assert!(service.select("unknown").is_err());assert_eq!(service.get_state(),before); let state=service.select("second").unwrap();assert_eq!(state["selectedReleaseId"],"second");assert_eq!(state["release"]["id"],"second");assert!(state["downloaded"].is_null());assert!(!service.state.lock().launch_authorized);assert!(service.verified_download_path().is_none());assert!(service.mark_installer_launched().is_err()); } #[test] fn operation_guard_serializes_selection_and_releases_on_drop() { let dir=tempfile::tempdir().unwrap();let service=service(dir.path());let r=release("r","0.1.2","2026-01-01");service.state.lock().releases.push(r); let guard=service.begin("checking").unwrap();assert_eq!(service.get_state()["status"],"checking");assert!(service.begin("downloading").is_err());assert!(service.select("r").is_err()); drop(guard);assert_eq!(service.get_state()["status"],"idle");assert!(service.select("r").is_ok()); } #[tokio::test] async fn failed_check_and_download_release_operation_guard() { let dir=tempfile::tempdir().unwrap();let service=service(dir.path()); assert_eq!(service.check().await.unwrap_err().code,"FIXTURE_PROVIDER_FAILURE");assert_eq!(service.get_state()["status"],"idle");assert_eq!(service.get_state()["checked"],false); let r=release("r","0.1.2","2026-01-01");{let mut s=service.state.lock();s.release=Some(r.clone());s.download=Some(downloaded(&r));s.launch_authorized=true;} assert_eq!(service.download().await.unwrap_err().code,"FIXTURE_PROVIDER_FAILURE");assert_eq!(service.get_state()["status"],"idle");assert!(service.verified_download_path().is_none());assert!(!service.state.lock().launch_authorized); } #[test] fn installer_launch_requires_matching_release_and_one_use_authorization() { let dir=tempfile::tempdir().unwrap();let service=service(dir.path());let r=release("r","0.1.2","2026-01-01"); assert!(service.mark_installer_launched().is_err()); {let mut s=service.state.lock();s.release=Some(r.clone());s.download=Some(downloaded(&r));} assert!(service.mark_installer_launched().is_err()); service.state.lock().launch_authorized=true; service.mark_installer_launched().unwrap();assert_eq!(service.config.get()["updates"]["lastLaunchedRelease"]["id"],"r");assert!(service.mark_installer_launched().is_err()); {let mut s=service.state.lock();s.launch_authorized=true;s.download.as_mut().unwrap().release_id=Some("other".into());} assert!(service.mark_installer_launched().is_err()); } }