diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index 5351729..c254242 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -152,6 +152,24 @@ pub(crate) async fn check_app_update( state: tauri::State<'_, AppUpdateState>, gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { + let (info, _) = resolve_app_update(&app, gui_config_state.inner()).await?; + state.set_available(AppUpdateTask { + phase: if info.update_available { + "available".to_string() + } else { + "idle".to_string() + }, + target_version: info.update_available.then(|| info.latest_version.clone()), + total_bytes: info.download_size_bytes, + ..AppUpdateTask::default() + }); + Ok(info) +} + +async fn resolve_app_update( + app: &tauri::AppHandle, + gui_config_state: &GuiConfigState, +) -> Result<(AppUpdateInfo, Option), String> { let _detection_guard = VERSION_SOURCE_DETECTION_LOCK.lock().await; let config = gui_config_state.snapshot()?; let proxy_url = config.proxy_url.clone(); @@ -174,8 +192,8 @@ pub(crate) async fn check_app_update( ) .await?; persist_automatic_download_source_switch( - &app, - gui_config_state.inner(), + app, + gui_config_state, &requested_source, &resolved_source, )?; @@ -207,21 +225,7 @@ pub(crate) async fn check_app_update( } else { None }; - state.set_pending( - pending, - AppUpdateTask { - phase: if update_available { - "available".to_string() - } else { - "idle".to_string() - }, - target_version: update_available.then(|| latest_version.clone()), - total_bytes: asset.as_ref().map(|value| value.size_bytes), - ..AppUpdateTask::default() - }, - ); - - Ok(AppUpdateInfo { + let info = AppUpdateInfo { current_version, latest_version, update_available, @@ -231,7 +235,8 @@ pub(crate) async fn check_app_update( auto_update_supported, download_size_bytes: asset.map(|value| value.size_bytes), unsupported_reason, - }) + }; + Ok((info, pending)) } pub(crate) async fn fetch_portable_update_manifest( @@ -276,8 +281,7 @@ pub(crate) async fn fetch_portable_update_manifest_from_gitcode( repository: &str, ) -> Result { let release_url = format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest"); - let release = client - .get(release_url) + let release = app_update_metadata_request(client, &release_url) .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::USER_AGENT, APP_USER_AGENT) .send() @@ -298,8 +302,7 @@ pub(crate) async fn fetch_portable_update_manifest_url( client: &reqwest::Client, url: &str, ) -> Result { - let manifest = client - .get(url) + let manifest = app_update_metadata_request(client, url) .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::USER_AGENT, APP_USER_AGENT) .send() @@ -314,6 +317,19 @@ pub(crate) async fn fetch_portable_update_manifest_url( Ok(manifest) } +fn app_update_metadata_request(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_string(); + client + .get(url) + .query(&[("_", nonce)]) + .header(reqwest::header::CACHE_CONTROL, "no-cache") + .header(reqwest::header::PRAGMA, "no-cache") +} + pub(crate) fn configured_gitcode_gui_repository() -> Option<&'static str> { configured_gitcode_repository(option_env!("GITCODE_GUI_REPOSITORY")) } @@ -651,27 +667,43 @@ pub(crate) fn cancel_app_update(state: tauri::State<'_, AppUpdateState>) -> Resu pub(crate) async fn start_app_update( app: tauri::AppHandle, state: tauri::State<'_, AppUpdateState>, - gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result<(), String> { if portable_update_platform_key().is_none() { return Err("当前平台不支持应用内自动升级".to_string()); } - let config = gui_config_state.snapshot()?; - let proxy_url = config.proxy_url.clone(); - let download_source = config.selected_download_candidate(); let token = CancellationToken::new(); - let pending = state.start(token.clone())?; + state.start(token.clone())?; let task = state.snapshot(); let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task); let update_app = app.clone(); tauri::async_runtime::spawn(async move { - let outcome = download_and_stage_portable_app_update( - &update_app, - &pending, - &token, - &proxy_url, - download_source, - ) + let outcome = async { + let state = update_app.state::(); + let gui_config_state = update_app.state::(); + let pending = refresh_app_update_for_install(state.inner(), &token, async { + let (info, pending) = + resolve_app_update(&update_app, gui_config_state.inner()).await?; + pending.ok_or_else(|| { + if info.update_available { + info.unsupported_reason + .unwrap_or_else(|| "当前版本不支持自动更新".to_string()) + } else { + "当前没有可安装的软件更新,请重新检查更新".to_string() + } + }) + }) + .await?; + let _ = update_app.emit(APP_UPDATE_PROGRESS_EVENT, state.snapshot()); + let config = gui_config_state.snapshot()?; + download_and_stage_portable_app_update( + &update_app, + &pending, + &token, + &config.proxy_url, + config.selected_download_candidate(), + ) + .await + } .await; if let Err(error) = outcome { let state = update_app.state::(); @@ -690,6 +722,23 @@ pub(crate) async fn start_app_update( Ok(()) } +pub(crate) async fn refresh_app_update_for_install( + state: &AppUpdateState, + token: &CancellationToken, + refresh: F, +) -> Result +where + F: std::future::Future>, +{ + let pending = tokio::select! { + biased; + _ = token.cancelled() => return Err("应用更新下载已取消".to_string()), + result = refresh => result?, + }; + state.start_download(&pending)?; + Ok(pending) +} + pub(crate) async fn download_and_stage_portable_app_update( app: &tauri::AppHandle, pending: &PendingAppUpdate, diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index f54dd8f..b038c73 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1,6 +1,7 @@ use super::*; pub(crate) static CORE_OPERATION_LOCK: Mutex<()> = Mutex::new(()); +const BUNDLED_CORE_HANDLED_VERSION_FILE: &str = "cpa-gui-bundled-core-handled.txt"; pub(crate) fn lock_core_operation( process_state: &CoreProcessState, @@ -203,25 +204,87 @@ pub(crate) async fn install_bundled_core( .map_err(|error| format!("离线内核安装后台任务失败: {error}"))? } -pub(crate) fn core_needs_bundled_bootstrap(install_dir: &Path) -> bool { - find_core_binary(install_dir).is_none() +pub(crate) fn core_needs_bundled_install(install_dir: &Path, bundled_version: &str) -> bool { + let Ok(bundled_version) = + semver::Version::parse(bundled_version.trim().trim_start_matches('v')) + else { + return false; + }; + if find_core_binary(install_dir).is_none() { + return true; + } + if bundled_core_version_handled(install_dir, &bundled_version) { + return false; + } + read_core_metadata(install_dir) + .and_then(|metadata| { + semver::Version::parse(metadata.version.trim().trim_start_matches('v')).ok() + }) + .is_none_or(|installed| bundled_version > installed) } -pub(crate) fn auto_install_bundled_core_if_missing(app: &tauri::AppHandle) -> Result { +fn bundled_core_version_handled(install_dir: &Path, bundled_version: &semver::Version) -> bool { + fs::read_to_string(install_dir.join(BUNDLED_CORE_HANDLED_VERSION_FILE)) + .ok() + .and_then(|version| semver::Version::parse(version.trim().trim_start_matches('v')).ok()) + .is_some_and(|version| &version == bundled_version) +} + +pub(crate) fn mark_bundled_core_version_handled( + install_dir: &Path, + bundled_version: &str, +) -> Result<(), String> { + write_bytes_atomically( + &install_dir.join(BUNDLED_CORE_HANDLED_VERSION_FILE), + bundled_version.as_bytes(), + ) + .map_err(|error| format!("记录已处理的内置内核版本失败: {error}")) +} + +pub(crate) fn remember_bundled_core_when_up_to_date( + install_dir: &Path, + bundled_version: &str, +) -> Result<(), String> { + let Ok(bundled) = semver::Version::parse(bundled_version.trim().trim_start_matches('v')) else { + return Ok(()); + }; + if bundled_core_version_handled(install_dir, &bundled) { + return Ok(()); + } + let installed = read_core_metadata(install_dir).and_then(|metadata| { + semver::Version::parse(metadata.version.trim().trim_start_matches('v')).ok() + }); + if find_core_binary(install_dir).is_some() + && installed.is_some_and(|version| version >= bundled) + { + mark_bundled_core_version_handled(install_dir, bundled_version)?; + } + Ok(()) +} + +pub(crate) fn auto_install_bundled_core_if_needed(app: &tauri::AppHandle) -> Result { let install_dir = core_install_dir()?; - if !core_needs_bundled_bootstrap(&install_dir) { + let (info, archive_path) = match bundled_core_archive()? { + Some(bundled) => bundled, + None if find_core_binary(&install_dir).is_some() => return Ok(false), + None => return Err("未检测到 CPA 内核,且当前发行包没有匹配的离线内核".to_string()), + }; + if !core_needs_bundled_install(&install_dir, &info.version) { + remember_bundled_core_when_up_to_date(&install_dir, &info.version)?; return Ok(false); } - - let (info, archive_path) = bundled_core_archive()? - .ok_or_else(|| "未检测到 CPA 内核,且当前发行包没有匹配的离线内核".to_string())?; let window = app .get_webview_window("main") .map(|webview| webview.as_ref().window()) .ok_or_else(|| "无法获取主窗口,不能自动安装离线内核".to_string())?; let state = app.state::(); state.start(CancellationToken::new(), Some(info.version.clone()))?; - let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path); + let result = install_core_with_runtime_restore( + app, + app.state::().inner(), + app.state::().inner(), + || install_bundled_core_inner(&window, state.inner(), &info, &archive_path), + ); if result.is_err() { let _ = cleanup_core_work_dirs(); } @@ -585,6 +648,7 @@ pub(crate) fn install_bundled_core_inner( }, )?; overlay_install_dir(&install_dir, &staging_dir)?; + mark_bundled_core_version_handled(&install_dir, &info.version)?; Ok(CoreInstallResult { version: info.version.clone(), @@ -1691,13 +1755,14 @@ pub(crate) fn bundled_core_locations( base_dir: &Path, executable_dir: &Path, ) -> Vec<(PathBuf, PathBuf)> { - let mut locations = vec![(base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core"))]; + let mut locations = Vec::new(); if let Some(resources_dir) = macos_app_resources_dir(executable_dir) { locations.push(( resources_dir.join(CORE_VERSION_FILE), resources_dir.join("cpa-core"), )); } + locations.push((base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core"))); if let Some(project_root) = source_project_root(executable_dir) { if project_root != base_dir { locations.push(( @@ -1918,7 +1983,7 @@ pub(crate) fn validate_bundled_core_checksum(archive_path: &Path) -> Result<(), (name == archive_name && digest.len() == 64).then(|| digest.to_ascii_lowercase()) }); let Some(expected) = expected else { - return Err(format!("校验文件中没有 {archive_name} 的 SHA-256")); + return Ok(()); }; let actual = sha256_file(archive_path)?; if actual != expected { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 264e2d1..36a72f9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -309,7 +309,6 @@ struct AppUpdateState { struct AppUpdateInner { task: AppUpdateTask, token: Option, - pending: Option, } #[derive(Default)] @@ -1772,16 +1771,15 @@ impl AppUpdateState { .unwrap_or_default() } - fn set_pending(&self, pending: Option, task: AppUpdateTask) { + fn set_available(&self, task: AppUpdateTask) { if let Ok(mut inner) = self.inner.lock() { - inner.pending = pending; if !inner.task.running { inner.task = task; } } } - fn start(&self, token: CancellationToken) -> Result { + fn start(&self, token: CancellationToken) -> Result<(), String> { let mut inner = self .inner .lock() @@ -1789,11 +1787,31 @@ impl AppUpdateState { if inner.task.running { return Err("已有应用更新任务正在运行".to_string()); } - let pending = inner - .pending - .clone() - .ok_or_else(|| "没有可安装的应用更新,请先检查更新".to_string())?; inner.token = Some(token); + inner.task = AppUpdateTask { + running: true, + cancellable: true, + phase: "checking".to_string(), + ..AppUpdateTask::default() + }; + Ok(()) + } + + fn start_download(&self, pending: &PendingAppUpdate) -> Result<(), String> { + let mut inner = self + .inner + .lock() + .map_err(|_| "应用更新状态锁已损坏".to_string())?; + if !inner.task.running || inner.task.phase != "checking" { + return Err("应用更新未处于检查阶段".to_string()); + } + if inner + .token + .as_ref() + .is_none_or(CancellationToken::is_cancelled) + { + return Err("应用更新下载已取消".to_string()); + } inner.task = AppUpdateTask { running: true, cancellable: true, @@ -1804,7 +1822,7 @@ impl AppUpdateState { percent: Some(0.0), message: None, }; - Ok(pending) + Ok(()) } #[cfg(any(windows, target_os = "linux", target_os = "macos"))] @@ -2563,8 +2581,8 @@ fn main() { } } - match auto_install_bundled_core_if_missing(&core_app) { - Ok(true) => eprintln!("未检测到 CPA 内核,已自动安装内置离线版本"), + match auto_install_bundled_core_if_needed(&core_app) { + Ok(true) => eprintln!("已自动安装或升级至软件内置的 CPA 内核版本"), Ok(false) => {} Err(error) => eprintln!("自动安装 CPA 离线内核失败: {error}"), } diff --git a/src-tauri/src/tests/app_update.rs b/src-tauri/src/tests/app_update.rs index c5b4194..dbea39f 100644 --- a/src-tauri/src/tests/app_update.rs +++ b/src-tauri/src/tests/app_update.rs @@ -352,18 +352,17 @@ fn portable_update_state_supports_cancellation_and_snapshot_recovery() { asset: portable_update_test_asset("1.2.3", "amd64"), arch: "amd64".to_string(), }; - state.set_pending( - Some(pending), - AppUpdateTask { - phase: "available".to_string(), - target_version: Some("1.2.3".to_string()), - ..AppUpdateTask::default() - }, - ); + state.set_available(AppUpdateTask { + phase: "available".to_string(), + target_version: Some("1.2.3".to_string()), + ..AppUpdateTask::default() + }); let token = CancellationToken::new(); - let started = state.start(token.clone()).unwrap(); - assert_eq!(started.version, "1.2.3"); + state.start(token.clone()).unwrap(); + assert_eq!(state.snapshot().phase, "checking"); + assert!(state.start(CancellationToken::new()).is_err()); + state.start_download(&pending).unwrap(); let recovered = state.snapshot(); assert!(recovered.running); assert!(recovered.cancellable); @@ -377,6 +376,136 @@ fn portable_update_state_supports_cancellation_and_snapshot_recovery() { assert_eq!(state.snapshot().phase, "cancelled"); } +#[tokio::test] +async fn installing_refreshes_an_old_offer_and_bypasses_cached_manifest_responses() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://{}/releases/latest/manifest.json", + listener.local_addr().unwrap() + ); + let server = tokio::spawn(async move { + let (mut stream, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)) + .await + .unwrap() + .unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8(request).unwrap().to_ascii_lowercase(); + let fresh = request.contains("manifest.json?_=") + && request.contains("cache-control: no-cache") + && request.contains("pragma: no-cache"); + let manifest = portable_update_test_manifest(if fresh { "0.3.3" } else { "0.3.2" }); + let assets: serde_json::Map = manifest + .assets + .iter() + .map(|(key, asset)| { + ( + key.clone(), + serde_json::json!({ + "url": asset.url, "sha256": asset.sha256, "sizeBytes": asset.size_bytes, + }), + ) + }) + .collect(); + let body = serde_json::json!({ + "schemaVersion": 1, "version": manifest.version, + "publishedAt": manifest.published_at, "releaseUrl": manifest.release_url, + "assets": assets, + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let state = AppUpdateState::default(); + state.set_available(AppUpdateTask { + phase: "available".to_string(), + target_version: Some("v0.3.2".to_string()), + total_bytes: Some(1), + ..AppUpdateTask::default() + }); + let token = CancellationToken::new(); + state.start(token.clone()).unwrap(); + let pending = refresh_app_update_for_install(&state, &token, async { + let manifest = fetch_portable_update_manifest_url(&client, &url).await?; + let (key, arch) = portable_update_target().unwrap(); + Ok(PendingAppUpdate { + version: normalize_version(&manifest.version), + asset: manifest.assets.get(key).unwrap().clone(), + arch: arch.to_string(), + }) + }) + .await + .unwrap(); + server.await.unwrap(); + assert_eq!(pending.version, "v0.3.3"); + assert!(pending.asset.url.contains("/v0.3.3/")); + assert_eq!(state.snapshot().target_version.as_deref(), Some("v0.3.3")); + assert_eq!(state.snapshot().total_bytes, Some(pending.asset.size_bytes)); + assert_eq!(state.snapshot().phase, "downloading"); + state.set_available(AppUpdateTask::default()); + assert_eq!(state.snapshot().target_version.as_deref(), Some("v0.3.3")); +} + +#[tokio::test] +async fn failed_or_cancelled_refresh_never_downloads_the_previous_offer() { + let state = AppUpdateState::default(); + state.set_available(AppUpdateTask { + phase: "available".to_string(), + target_version: Some("v0.3.2".to_string()), + ..AppUpdateTask::default() + }); + let token = CancellationToken::new(); + state.start(token.clone()).unwrap(); + let result = refresh_app_update_for_install(&state, &token, async { + Err("version check failed".to_string()) + }) + .await; + assert_eq!(result.err().as_deref(), Some("version check failed")); + assert_eq!(state.snapshot().phase, "checking"); + assert!(state.snapshot().target_version.is_none()); + state.finish("failed", None); + + let token = CancellationToken::new(); + state.start(token.clone()).unwrap(); + let refresh = refresh_app_update_for_install( + &state, + &token, + std::future::pending::>(), + ); + let (result, _) = tokio::join!(refresh, async { + state.cancel(); + }); + assert_eq!(result.err().as_deref(), Some("应用更新下载已取消")); + assert!(state.snapshot().target_version.is_none()); + let pending = PendingAppUpdate { + version: "v0.3.3".to_string(), + asset: portable_update_test_asset("0.3.3", "amd64"), + arch: "amd64".to_string(), + }; + assert!(state.start_download(&pending).is_err()); +} + #[test] fn sha256_file_hashes_exact_portable_asset_bytes() { let root = agent_test_home("portable-sha256"); diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index 0c24da5..4b8095d 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -656,17 +656,153 @@ fn replacing_a_core_rejects_locked_config_without_using_defaults() { } #[test] -fn bundled_core_bootstrap_runs_only_when_no_core_binary_exists() { +fn bundled_core_install_handles_missing_and_unversioned_binaries() { let root = agent_test_home("bundled-bootstrap-detection"); let install_dir = root.join("cpa-core"); - assert!(core_needs_bundled_bootstrap(&install_dir)); + assert!(core_needs_bundled_install(&install_dir, "v7.3.17")); let existing_version = install_dir.join("existing-version"); fs::create_dir_all(&existing_version).unwrap(); fs::write(existing_version.join(core_binary_name()), b"existing core").unwrap(); - assert!(!core_needs_bundled_bootstrap(&install_dir)); + assert!(core_needs_bundled_install(&install_dir, "v7.3.17")); + assert!(!core_needs_bundled_install(&install_dir, "invalid")); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn bundled_core_install_replaces_older_installs_without_downgrading() { + let root = agent_test_home("bundled-upgrade-detection"); + let install_dir = root.join("cpa-core"); + fs::create_dir_all(&install_dir).unwrap(); + fs::write(install_dir.join(core_binary_name()), b"installed core").unwrap(); + + for (installed, bundled, should_install) in [ + ("v7.3.9", "v7.3.17", true), + ("7.3.15", "v7.3.17", true), + ("v7.3.17-beta.1", "v7.3.17", true), + ("v7.3.17", "7.3.17", false), + ("v7.3.18", "v7.3.17", false), + ("unknown", "v7.3.17", true), + ("v7.3.15", "invalid", false), + ] { + write_core_metadata( + &install_dir, + &CoreMetadata { + version: installed.to_string(), + asset_name: "installed-core.zip".to_string(), + installed_at_unix: 0, + }, + ) + .unwrap(); + assert_eq!( + core_needs_bundled_install(&install_dir, bundled), + should_install, + "installed {installed}, bundled {bundled}" + ); + } + + fs::remove_file(install_dir.join(core_binary_name())).unwrap(); + assert!(core_needs_bundled_install(&install_dir, "v7.3.17")); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn handled_bundled_core_does_not_undo_a_manual_rollback_on_restart() { + let root = agent_test_home("bundled-manual-rollback"); + let install_dir = root.join("cpa-core"); + fs::create_dir_all(&install_dir).unwrap(); + let binary = install_dir.join(core_binary_name()); + fs::write(&binary, b"installed core").unwrap(); + write_core_metadata( + &install_dir, + &CoreMetadata { + version: "v7.3.15".to_string(), + asset_name: "installed-core.zip".to_string(), + installed_at_unix: 0, + }, + ) + .unwrap(); + + assert!(core_needs_bundled_install(&install_dir, "v7.3.17")); + remember_bundled_core_when_up_to_date(&install_dir, "v7.3.17").unwrap(); + assert!(core_needs_bundled_install(&install_dir, "v7.3.17")); + + mark_bundled_core_version_handled(&install_dir, "v7.3.17").unwrap(); + assert!(!core_needs_bundled_install(&install_dir, "v7.3.17")); + assert!(core_needs_bundled_install(&install_dir, "v7.3.18")); + + fs::remove_file(binary).unwrap(); + assert!(core_needs_bundled_install(&install_dir, "v7.3.17")); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn an_already_newer_core_satisfies_the_bundle_after_manual_rollback() { + let root = agent_test_home("bundled-newer-manual-rollback"); + let install_dir = root.join("cpa-core"); + fs::create_dir_all(&install_dir).unwrap(); + fs::write(install_dir.join(core_binary_name()), b"installed core").unwrap(); + write_core_metadata( + &install_dir, + &CoreMetadata { + version: "v7.3.18".to_string(), + asset_name: "installed-core.zip".to_string(), + installed_at_unix: 0, + }, + ) + .unwrap(); + + assert!(!core_needs_bundled_install(&install_dir, "v7.3.17")); + remember_bundled_core_when_up_to_date(&install_dir, "v7.3.17").unwrap(); + write_core_metadata( + &install_dir, + &CoreMetadata { + version: "v7.3.15".to_string(), + asset_name: "manual-rollback.zip".to_string(), + installed_at_unix: 1, + }, + ) + .unwrap(); + assert!(!core_needs_bundled_install(&install_dir, "v7.3.17")); + assert!(core_needs_bundled_install(&install_dir, "v7.3.19")); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn stale_core_checksums_do_not_block_a_new_bundled_archive() { + let root = agent_test_home("bundled-stale-checksums"); + let archive = root.join("CLIProxyAPI_7.3.17_windows_amd64.zip"); + fs::write(&archive, b"new bundled core").unwrap(); + fs::write( + root.join(CORE_CHECKSUMS_FILE), + format!("{} CLIProxyAPI_7.3.15_windows_amd64.zip\n", "a".repeat(64)), + ) + .unwrap(); + validate_bundled_core_checksum(&archive).unwrap(); + + fs::write( + root.join(CORE_CHECKSUMS_FILE), + format!( + "{} {}\n", + "a".repeat(64), + archive.file_name().unwrap().to_string_lossy() + ), + ) + .unwrap(); + assert!(validate_bundled_core_checksum(&archive).is_err()); + + fs::write( + root.join(CORE_CHECKSUMS_FILE), + format!( + "{} {}\n", + sha256_file(&archive).unwrap(), + archive.file_name().unwrap().to_string_lossy() + ), + ) + .unwrap(); + validate_bundled_core_checksum(&archive).unwrap(); fs::remove_dir_all(root).unwrap(); } @@ -686,7 +822,8 @@ fn bundled_core_locations_include_macos_app_resources() { macos_app_resources_dir(&executable_dir), Some(contents_dir.join("Resources")) ); - assert!(bundled_core_locations(&base_dir, &executable_dir).contains(&resource_location)); + let locations = bundled_core_locations(&base_dir, &executable_dir); + assert_eq!(locations.first(), Some(&resource_location)); } #[test]