diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index d722eafd83..716ff63c8b 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -2757,6 +2757,7 @@ dependencies = [ "proc-macro2", "pulldown-cmark", "raw-window-handle", + "security-framework 3.7.0", "serde", "serde_json", "sha1", diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index 040599985f..5d361d7edc 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -65,6 +65,8 @@ oo7 = { version = "0.5", default-features = false, features = ["async-std", "nat futures-lite = "2" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } +# Decode Keychain OSStatus errors without matching localized error strings (account.rs). +security-framework = "3" # Painting the window's title bar dark or light (titlebar::apply_appearance), and the system's # reduced motion (sky::moves): crates already in the lockfile through gpui and wgpu. objc2-app-kit = { version = "0.3", default-features = false, features = ["NSAccessibility", "NSAppearance", "NSView", "NSWindow", "NSWorkspace"] } diff --git a/launcher/src/account.rs b/launcher/src/account.rs index 8e28a61c5e..e71ff0f81b 100644 --- a/launcher/src/account.rs +++ b/launcher/src/account.rs @@ -850,9 +850,28 @@ fn credential_manager_says(code: u32) -> Option<&'static str> { (code == ERROR_NOT_ENOUGH_MEMORY).then_some(CREDENTIAL_MANAGER_FULL) } +/// A running executable removed by an older macOS updater cannot create Keychain credentials. +#[cfg(any(target_os = "macos", test))] +pub const MACOS_LAUNCHER_RESTART: &str = "The launcher was updated while it was running. Restart the launcher, then sign in with Microsoft again."; + +#[cfg(any(target_os = "macos", test))] +fn macos_keychain_says(code: i32) -> Option<&'static str> { + // CSSM's Unix ENOENT: Keychain cannot identify an executable removed by an older updater. + (code == 100002).then_some(MACOS_LAUNCHER_RESTART) +} + /// The keyring crate's error as the player reads it. #[cfg(any(target_os = "macos", target_os = "windows"))] fn write_error(e: keyring::Error) -> String { + #[cfg(target_os = "macos")] + if let keyring::Error::PlatformFailure(inner) = &e { + if let Some(said) = inner + .downcast_ref::() + .and_then(|error| macos_keychain_says(error.code())) + { + return said.to_string(); + } + } #[cfg(target_os = "windows")] if let keyring::Error::PlatformFailure(inner) = &e { if let Some(said) = inner diff --git a/launcher/src/account/tests.rs b/launcher/src/account/tests.rs index 7074cad5a0..e9016132b1 100644 --- a/launcher/src/account/tests.rs +++ b/launcher/src/account/tests.rs @@ -885,6 +885,42 @@ fn credential_manager_error_8_says_the_store_is_full() { assert_eq!(credential_manager_says(1312), None); } +#[test] +fn macos_removed_executable_error_requests_a_restart_and_new_sign_in() { + let said = macos_keychain_says(100002).unwrap(); + assert_eq!(said, MACOS_LAUNCHER_RESTART); + assert!(said.contains("Restart the launcher")); + assert!(said.contains("sign in with Microsoft again")); + for code in [0, 2, -25300, -25293, -25291, 100001, 100003] { + assert_eq!(macos_keychain_says(code), None, "OSStatus {code}"); + } +} + +/// Constructs native errors, without creating or reading any Keychain entry. +#[cfg(target_os = "macos")] +#[test] +fn only_the_typed_macos_removed_executable_failure_gets_the_restart_message() { + let failure = |code| { + keyring::Error::PlatformFailure(Box::new(security_framework::base::Error::from_code(code))) + }; + assert_eq!(write_error(failure(100002)), MACOS_LAUNCHER_RESTART); + for code in [-25300, -25293, -25291, 100003] { + let error = failure(code); + let original = error.to_string(); + assert_ne!(original, MACOS_LAUNCHER_RESTART); + assert_eq!(write_error(error), original); + } + let unrelated = keyring::Error::PlatformFailure(Box::new(std::io::Error::other( + "OSStatus 100002: unrelated provider", + ))); + let original = unrelated.to_string(); + assert_eq!(write_error(unrelated), original, "not a string match"); + assert_eq!( + write_error(keyring::Error::NoEntry), + keyring::Error::NoEntry.to_string() + ); +} + #[test] fn session_files_are_private_and_complete() { let dir = std::env::temp_dir().join(format!("horizon-launcher-session-{}", std::process::id())); diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 95c72da673..381ae37b12 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2824,71 +2824,19 @@ fn find_app(dir: &Path) -> Option { None } -/// Whether the bundle's signature (Developer ID or ad hoc) is whole: every file it seals is there -/// and unchanged. -#[cfg(target_os = "macos")] -fn signature_holds(app: &Path) -> bool { - Command::new("codesign") - .args(["--verify", "--deep", "--strict"]) - .arg(app) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) -} - -/// Puts the bundle `new` in place of `app`: copied beside it first (`ditto` keeps its links, -/// modes and signature) and checked, then the old one is moved aside and the new one renamed in, -/// so `app` is never half written, and checked again where it lands (the old one comes back if it -/// doesn't hold). The running launcher keeps its open files and works on. +/// Puts a verified app bundle in place, retaining old bundles until no process uses them. #[cfg(target_os = "macos")] fn replace_bundle(new: &Path, app: &Path) -> Result<(), String> { - let dir = app.parent().ok_or("The app has no folder.")?; - let name = app.file_name().unwrap_or_default().to_string_lossy(); - let staged = dir.join(format!(".{name}.update-{}", std::process::id())); - let old = dir.join(format!(".{name}.old-{}", std::process::id())); - let _ = fs::remove_dir_all(&staged); - let copied = Command::new("ditto") - .arg(new) - .arg(&staged) - .status() - .map_err(|e| format!("ditto: {e}"))?; - if !copied.success() { - let _ = fs::remove_dir_all(&staged); - return Err(format!( - "Could not copy the new app next to {}.", - app.display() - )); + crate::mac_update::replace_bundle(new, app) +} + +/// Retired macOS launchers are removed only once their executable is no longer in use. +/// Run for every entry point (GUI, CLI and MCP), before a command can return or exec. +pub fn cleanup_launcher_updates() { + #[cfg(target_os = "macos")] + if let Some(app) = app_bundle(exe_path()) { + crate::mac_update::cleanup(app); } - if !signature_holds(&staged) { - let _ = fs::remove_dir_all(&staged); - return Err( - "The downloaded app's signature doesn't hold; the launcher stays as it is.".into(), - ); - } - fs::rename(app, &old).map_err(|e| { - let _ = fs::remove_dir_all(&staged); - format!("Could not move {} aside: {e}", app.display()) - })?; - if let Err(e) = fs::rename(&staged, app) { - let _ = fs::rename(&old, app); - let _ = fs::remove_dir_all(&staged); - return Err(format!( - "Could not put the new app at {}: {e}", - app.display() - )); - } - if !signature_holds(app) { - let failed = dir.join(format!(".{name}.failed-{}", std::process::id())); - if fs::rename(app, &failed).is_ok() && fs::rename(&old, app).is_ok() { - let _ = fs::remove_dir_all(&failed); - } - return Err(format!( - "The new app's signature doesn't hold at {}; the launcher stays as it was.", - app.display() - )); - } - let _ = fs::remove_dir_all(&old); - Ok(()) } #[cfg(not(target_os = "macos"))] diff --git a/launcher/src/lib.rs b/launcher/src/lib.rs index 36e50c57cd..5b41f0346b 100644 --- a/launcher/src/lib.rs +++ b/launcher/src/lib.rs @@ -19,6 +19,8 @@ pub mod java; pub mod java_launch; pub mod launch; pub mod loaders; +#[cfg(target_os = "macos")] +mod mac_update; pub mod minecraft_versions; pub mod modpacks; pub mod mojang; diff --git a/launcher/src/mac_update.rs b/launcher/src/mac_update.rs new file mode 100644 index 0000000000..6c591de010 --- /dev/null +++ b/launcher/src/mac_update.rs @@ -0,0 +1,168 @@ +//! Retired app bundles must outlive every process using them. Removing a running macOS +//! executable breaks Keychain credential creation (OSStatus 100002), even though it keeps running. +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const BINARY: &str = "horizon-launcher"; + +fn signature_holds(app: &Path) -> bool { + Command::new("/usr/bin/codesign") + .args(["--verify", "--deep", "--strict"]) + .arg(app) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// lsof returns 1 with no output when there are no users of the file. Any warning, failure, +/// signal, or unexpected output means we cannot safely remove it. In particular, the updater's +/// PID alone is insufficient: other windows and MCP processes can run from the same old bundle. +fn definitely_unused(output: io::Result) -> bool { + output.is_ok_and(|out| { + out.status.code() == Some(1) && out.stdout.is_empty() && out.stderr.is_empty() + }) +} + +fn unused_by_processes(exe: &Path) -> bool { + definitely_unused( + Command::new("/usr/sbin/lsof") + // -t also suppresses warnings, which could turn an incomplete query into an + // apparently empty result. Keep warnings enabled and request only PID fields. + .args(["-nP", "+w", "-F", "p", "--"]) + .arg(exe) + .output(), + ) +} + +fn is_retired(name: &str, app_name: &str) -> bool { + let prefix = format!(".{app_name}.old-"); + let Some((pid, nonce)) = name.strip_prefix(&prefix).and_then(|s| s.split_once('-')) else { + return false; + }; + pid.parse::() + .is_ok_and(|number| number != 0 && number.to_string() == pid) + && nonce.len() == 32 + && nonce + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Only consider a real bundle's executable, never a symlink or an incomplete/unknown folder. +fn retired_executable(app: &Path) -> Option { + let mut path = app.to_path_buf(); + for component in ["", "Contents", "MacOS", BINARY] { + if !component.is_empty() { + path.push(component); + } + let kind = fs::symlink_metadata(&path).ok()?.file_type(); + if kind.is_symlink() + || (component == BINARY && !kind.is_file()) + || (component != BINARY && !kind.is_dir()) + { + return None; + } + } + Some(path) +} + +/// Best effort: run on startup and after an update. Failed queries and inaccessible files stay +/// for a later start. A restart via exec releases the old mapping before the new process cleans. +pub(crate) fn cleanup(app: &Path) { + cleanup_with(app, unused_by_processes); +} + +fn cleanup_with(app: &Path, mut unused: impl FnMut(&Path) -> bool) { + let (Some(dir), Some(name)) = (app.parent(), app.file_name().and_then(|s| s.to_str())) else { + return; + }; + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if !entry + .file_name() + .to_str() + .is_some_and(|s| is_retired(s, name)) + { + continue; + } + let path = entry.path(); + if retired_executable(&path).is_some_and(|exe| unused(&exe)) { + let _ = fs::remove_dir_all(path); + } + } +} + +pub(crate) fn replace_bundle(new: &Path, app: &Path) -> Result<(), String> { + replace_bundle_with(new, app, signature_holds, unused_by_processes) +} + +fn replace_bundle_with( + new: &Path, + app: &Path, + mut signature: impl FnMut(&Path) -> bool, + unused: impl FnMut(&Path) -> bool, +) -> Result<(), String> { + let dir = app.parent().ok_or("The app has no folder.")?; + let name = app.file_name().unwrap_or_default().to_string_lossy(); + // A single process may update more than once, with older generations still in use. + let mut random = [0u8; 16]; + getrandom::fill(&mut random).map_err(|e| e.to_string())?; + let nonce: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let suffix = format!("{}-{nonce}", std::process::id()); + let staged = dir.join(format!(".{name}.update-{suffix}")); + let rollback = dir.join(format!(".{name}.rollback-{suffix}")); + let old = dir.join(format!(".{name}.old-{suffix}")); + fs::create_dir(&staged).map_err(|e| format!("Could not stage the new app: {e}"))?; + let copied = Command::new("/usr/bin/ditto") + .arg(new) + .arg(&staged) + .status(); + if !copied.is_ok_and(|s| s.success()) { + let _ = fs::remove_dir_all(&staged); + return Err(format!( + "Could not copy the new app next to {}.", + app.display() + )); + } + if !signature(&staged) { + let _ = fs::remove_dir_all(&staged); + return Err( + "The downloaded app's signature doesn't hold; the launcher stays as it is.".into(), + ); + } + // Startup cleanup in another process must not collect an idle backup while this + // transaction can still need it. Publish it as retired only after the final check. + fs::rename(app, &rollback).map_err(|e| { + let _ = fs::remove_dir_all(&staged); + format!("Could not move {} aside: {e}", app.display()) + })?; + if let Err(e) = fs::rename(&staged, app) { + let _ = fs::rename(&rollback, app); + let _ = fs::remove_dir_all(&staged); + return Err(format!( + "Could not put the new app at {}: {e}", + app.display() + )); + } + if !signature(app) { + let failed = dir.join(format!(".{name}.failed-{suffix}")); + if fs::rename(app, &failed).is_ok() && fs::rename(&rollback, app).is_ok() { + let _ = fs::remove_dir_all(&failed); + } + return Err(format!( + "The new app's signature doesn't hold at {}; the launcher stays as it was.", + app.display() + )); + } + // Failure to publish the now-retired bundle only leaves a conservative quarantine; + // the new installation is verified and the running old executable remains available. + let _ = fs::rename(&rollback, &old); + cleanup_with(app, unused); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/launcher/src/mac_update/tests.rs b/launcher/src/mac_update/tests.rs new file mode 100644 index 0000000000..eaffa1e86b --- /dev/null +++ b/launcher/src/mac_update/tests.rs @@ -0,0 +1,356 @@ +//! Filesystem-only regression tests; process queries and signature decisions are injected. +//! Real executable mappings and Keychain writes belong to the opt-in Python harness. +use super::*; +use std::os::unix::{fs::symlink, process::ExitStatusExt}; + +struct Scratch(PathBuf); +impl Scratch { + fn new() -> Self { + let mut nonce = [0; 16]; + getrandom::fill(&mut nonce).unwrap(); + let root = std::env::temp_dir().join(format!( + "horizon-mac-update-{}-{:x?}", + std::process::id(), + nonce + )); + fs::create_dir(&root).unwrap(); + Self(root) + } + fn app(&self) -> PathBuf { + self.0.join("Horizon.app") + } + fn retired(&self, generation: u32) -> PathBuf { + self.0.join(format!(".Horizon.app.old-1-{generation:032x}")) + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn bundle(app: &Path, version: &str) { + fs::create_dir_all(app.join("Contents/MacOS")).unwrap(); + fs::write(app.join("Contents/MacOS/horizon-launcher"), b"fixture").unwrap(); + fs::write(app.join("Contents/version"), version).unwrap(); +} + +fn version(app: &Path) -> String { + fs::read_to_string(app.join("Contents/version")).unwrap() +} + +fn retired(work: &Scratch) -> Vec { + let mut paths: Vec<_> = fs::read_dir(&work.0) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| is_retired(p.file_name().unwrap().to_str().unwrap(), "Horizon.app")) + .collect(); + paths.sort(); + paths +} + +#[test] +fn only_an_unambiguous_empty_lsof_exit_one_allows_deletion() { + for (code, stdout, stderr, unused) in [ + (1, "", "", true), + (0, "123\n456\n", "", false), + (0, "", "", false), + (1, "123\n", "", false), + (1, "", "warning", false), + (1, "\n", "", false), + (1, "", "\n", false), + (2, "", "", false), + (127, "", "", false), + ] { + assert_eq!( + definitely_unused(Ok(Output { + status: std::process::ExitStatus::from_raw(code << 8), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + })), + unused, + "exit={code}, stdout={stdout:?}, stderr={stderr:?}" + ); + } + assert!(!definitely_unused(Ok(Output { + status: std::process::ExitStatus::from_raw(15), + stdout: vec![], + stderr: vec![], + }))); + assert!(!definitely_unused(Err(io::Error::new( + io::ErrorKind::NotFound, + "no lsof" + )))); +} + +#[test] +fn cleanup_names_are_exactly_our_app_positive_pid_and_lowercase_nonce() { + let hex = "0123456789abcdef0123456789abcdef"; + for pid in ["1", "4294967295"] { + assert!(is_retired( + &format!(".Horizon.app.old-{pid}-{hex}"), + "Horizon.app" + )); + } + for suffix in [ + format!("0-{hex}"), + format!("4294967296-{hex}"), + format!("+1-{hex}"), + format!("01-{hex}"), + format!("-1-{hex}"), + format!("1-{}", hex.to_uppercase()), + "1-123".into(), + format!("1-{hex}0"), + format!("1-{hex}.app"), + format!("1-{}g", &hex[..31]), + "123".into(), + ] { + assert!( + !is_retired(&format!(".Horizon.app.old-{suffix}"), "Horizon.app"), + "{suffix}" + ); + } + assert!(!is_retired( + &format!(".Other.app.old-1-{hex}"), + "Horizon.app" + )); + assert!(!is_retired( + &format!(".Horizon.app.update-1-{hex}"), + "Horizon.app" + )); +} + +#[test] +fn cleanup_preserves_unknown_names_incomplete_bundles_and_every_symlink_component() { + let work = Scratch::new(); + let outside = work.0.join("unrelated"); + bundle(&outside, "keep"); + let safe = work.retired(1); + bundle(&safe, "unused"); + symlink(&outside, work.retired(2)).unwrap(); + for (n, component, target) in [ + (3, "Contents", outside.join("Contents")), + (4, "Contents/MacOS", outside.join("Contents/MacOS")), + ( + 5, + "Contents/MacOS/horizon-launcher", + outside.join("Contents/MacOS/horizon-launcher"), + ), + ] { + let root = work.retired(n); + let link = root.join(component); + fs::create_dir_all(link.parent().unwrap()).unwrap(); + symlink(target, link).unwrap(); + } + let missing = work.retired(6); + fs::create_dir_all(missing.join("Contents/MacOS")).unwrap(); + let directory = work.retired(7); + fs::create_dir_all(directory.join("Contents/MacOS/horizon-launcher")).unwrap(); + for name in [ + ".Horizon.app.old-123", + ".Horizon.app.old-1-not-a-nonce", + ".Other.app.old-1-00000000000000000000000000000000", + ] { + bundle(&work.0.join(name), "keep"); + } + fs::write(work.retired(8), b"not a bundle").unwrap(); + let before = fs::read_dir(&work.0).unwrap().count(); + let mut queried = vec![]; + cleanup_with(&work.app(), |exe| { + queried.push(exe.to_path_buf()); + true + }); + assert_eq!(queried, [safe.join("Contents/MacOS/horizon-launcher")]); + assert_eq!(fs::read_dir(&work.0).unwrap().count(), before - 1); + assert!(!safe.exists()); + assert_eq!(version(&outside), "keep"); +} + +#[test] +fn multiple_users_and_ambiguous_queries_keep_the_bundle_until_the_last_user_leaves() { + let work = Scratch::new(); + let old = work.retired(1); + bundle(&old, "v1"); + for users in [2, 1] { + cleanup_with(&work.app(), |_| users == 0); + assert_eq!(version(&old), "v1"); + } + cleanup_with(&work.app(), |_| { + definitely_unused(Err(io::Error::other("query failed"))) + }); + assert!(old.exists()); + cleanup_with(&work.app(), |_| true); + assert!(!old.exists()); + cleanup_with(&work.app(), |_| panic!("already cleaned")); +} + +#[test] +fn repeated_updates_retain_distinct_generations_and_query_their_actual_executables() { + let work = Scratch::new(); + let app = work.app(); + let new = work.0.join("release.app"); + bundle(&app, "v1"); + bundle(&new, "v2"); + replace_bundle_with(&new, &app, |_| true, |_| false).unwrap(); + let first = retired(&work); + assert_eq!(first.len(), 1); + assert_eq!(version(&first[0]), "v1"); + fs::write(new.join("Contents/version"), "v3").unwrap(); + replace_bundle_with(&new, &app, |_| true, |_| false).unwrap(); + let both = retired(&work); + assert_eq!( + both.len(), + 2, + "same updater PID must not overwrite the previous generation" + ); + assert_eq!( + fs::read_dir(&work.0).unwrap().count(), + 4, + "no staging or failed residue" + ); + assert_eq!(version(&app), "v3"); + let second = both.iter().find(|p| **p != first[0]).unwrap(); + assert_eq!(version(second), "v2"); + cleanup_with(&app, |exe| { + assert_eq!(exe.file_name().unwrap(), "horizon-launcher"); + assert_eq!(exe.parent().unwrap().file_name().unwrap(), "MacOS"); + exe == second.join("Contents/MacOS/horizon-launcher") + }); + assert_eq!(retired(&work), first); + assert_eq!(version(&first[0]), "v1"); + cleanup_with(&app, |_| true); + assert!(retired(&work).is_empty()); + assert_eq!(fs::read_dir(&work.0).unwrap().count(), 2); +} + +#[test] +fn failed_signature_before_or_after_swap_keeps_the_original_and_cleans_only_its_own_staging() { + for fail_check in [1, 2] { + let work = Scratch::new(); + let app = work.app(); + let new = work.0.join("release.app"); + bundle(&app, "original"); + bundle(&new, "replacement"); + let unrelated = work.0.join(".Horizon.app.update-someone-elses-work"); + bundle(&unrelated, "preserve"); + let previous = work.retired(1); + bundle(&previous, "previous generation still in use"); + let mut checks = vec![]; + let error = replace_bundle_with( + &new, + &app, + |path| { + checks.push(path.to_path_buf()); + checks.len() != fail_check + }, + |_| panic!("a failed update must not clean unrelated generations"), + ) + .unwrap_err(); + assert!(error.contains("signature")); + assert_eq!(checks.len(), fail_check); + if fail_check == 2 { + assert_eq!(checks[1], app); + } + assert_eq!(version(&app), "original"); + assert_eq!(version(&new), "replacement"); + assert_eq!(version(&unrelated), "preserve"); + assert_eq!(version(&previous), "previous generation still in use"); + assert_eq!( + fs::read_dir(&work.0).unwrap().count(), + 4, + "no new staging/failed/old residue" + ); + } +} + +#[test] +fn a_concurrent_cleanup_during_final_signature_cannot_destroy_the_rollback() { + for final_signature_holds in [false, true] { + let work = Scratch::new(); + let app = work.app(); + let new = work.0.join("release.app"); + // An older updater is replacing v2, which has no executable users at all. + bundle(&app, "v2"); + bundle(&new, "v3"); + let foreign = work + .0 + .join(".Other.app.old-1-00000000000000000000000000000000"); + let quarantine = work + .0 + .join(".Horizon.app.rollback-1-00000000000000000000000000000000"); + let other_staging = work.0.join(".Horizon.app.update-another-transaction"); + for path in [&foreign, &quarantine, &other_staging] { + bundle(path, "unrelated"); + } + let mut checks = 0; + let mut backup_survived_collection = false; + let result = replace_bundle_with( + &new, + &app, + |path| { + checks += 1; + assert_eq!(version(path), "v3"); + if checks == 1 { + return true; + } + assert_eq!(path, app.as_path()); + // A startup collector runs after installation, before the final verdict. + // Join fixes the interleaving; no timing assumptions or false lsof answer. + std::thread::scope(|scope| { + scope.spawn(|| cleanup_with(&app, |_| true)).join().unwrap(); + }); + // Identify the backup by its data, not by the fix's chosen namespace. + backup_survived_collection = fs::read_dir(&work.0).unwrap().any(|entry| { + fs::read_to_string(entry.unwrap().path().join("Contents/version")) + .is_ok_and(|v| v == "v2") + }); + final_signature_holds + }, + |_| false, + ); + assert_eq!(checks, 2); + if !final_signature_holds { + assert!(result.unwrap_err().contains("signature")); + assert_eq!( + fs::read_to_string(app.join("Contents/version")) + .ok() + .as_deref(), + Some("v2"), + "a concurrent collector must not destroy the only rollback source" + ); + assert!(retired(&work).is_empty()); + } else { + result.unwrap(); + assert_eq!(version(&app), "v3"); + let published = retired(&work); + assert_eq!( + published.len(), + 1, + "publish a collectable generation only after success" + ); + assert_eq!(version(&published[0]), "v2"); + cleanup_with(&app, |_| true); + assert!( + !published[0].exists(), + "the committed, unused generation is collectable" + ); + } + assert!( + backup_survived_collection, + "the transaction still needs its backup before the verdict" + ); + assert!(!is_retired( + quarantine.file_name().unwrap().to_str().unwrap(), + "Horizon.app" + )); + assert_eq!(version(&new), "v3"); + for path in [&foreign, &quarantine, &other_staging] { + assert_eq!(version(path), "unrelated"); + } + assert_eq!( + fs::read_dir(&work.0).unwrap().count(), + 5, + "no new transaction residue" + ); + } +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index e8d6f1e746..8d9e48833a 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -4330,6 +4330,7 @@ fn main() { backend::exe_path(); #[cfg(windows)] backend::remove_replaced_binaries(); + backend::cleanup_launcher_updates(); // What the launcher downloads goes through the player's proxy, if they set one: the parts // that build their own agents (`horizon-port-pack`) are told before anything starts. horizon_launcher::proxy::announce(horizon_launcher::backend::Settings::load().proxy); diff --git a/tools/test_launcher_macos_update.py b/tools/test_launcher_macos_update.py new file mode 100644 index 0000000000..da0eeefc11 --- /dev/null +++ b/tools/test_launcher_macos_update.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Opt-in macOS updater regression, compiling the EXACT product module without GPUI. + + python3 tools/test_launcher_macos_update.py --run + python3 tools/test_launcher_macos_update.py --run --keychain + +Requires an approved build window, cached lockfile dependencies and macOS developer tools. +Without --run, only prints this help. --run tests filesystem/query/signature seams; adding +--keychain runs signed synthetic bundles, real lsof and real Keychain with UUID keys under +dev.playhorizon.launcher.diagnostic ONLY, removing and checking those keys afterwards. +No real launcher, OAuth, accounts, network or player data. Synthetic peers model multiple +GUI/MCP processes sharing a vnode; actual launcher startup/MCP wiring needs separate QA. +The scratch crate uses normal Rust module layout and byte-identical copies of the product +module and its tests, SHA-256 checked against the starting snapshot before invoking cargo. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import select +import shutil +import subprocess +import sys +import tempfile +import uuid + +try: + from . import lab +except ImportError: + import lab + +ROOT = Path(__file__).resolve().parents[1] +MODULE = ROOT / 'launcher/src/mac_update.rs' +LOCK = ROOT / 'launcher/Cargo.lock' +PROBE = r''' +#[allow(dead_code)] +mod mac_update; +use std::{io::{self, BufRead, Write}, path::PathBuf, process::Command}; +use std::os::unix::process::CommandExt; +const SERVICE: &str = "dev.playhorizon.launcher.diagnostic"; +const MARKER: &str = "horizon synthetic updater regression"; +fn entry(key: &str) -> Result { + if key.len() != 36 || !key.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') { + return Err("expected diagnostic UUID".into()); + } + keyring::Entry::new(SERVICE, key).map_err(|e| e.to_string()) +} +fn main() { + let app = PathBuf::from(std::env::args_os().nth(1).expect("scratch app path")); + mac_update::cleanup(&app); + println!("READY {}", std::process::id()); + io::stdout().flush().unwrap(); + for line in io::stdin().lock().lines() { + let line = line.unwrap(); + let (op, arg) = line.split_once(' ').unwrap_or((&line, "")); + let result: Result<(), String> = (|| match op { + "update" => mac_update::replace_bundle(std::path::Path::new(arg), &app), + "cleanup" => { mac_update::cleanup(&app); Ok(()) }, + "write" => { + let e = entry(arg)?; + match e.get_password() { + Err(keyring::Error::NoEntry) => e.set_password(MARKER).map_err(|e| e.to_string()), + _ => Err("diagnostic key was not absent before write".into()), + } + }, + "read" => { + if entry(arg)?.get_password().map_err(|e| e.to_string())? != MARKER { + return Err("diagnostic marker differs".into()); + } + Ok(()) + }, + "delete" => { + let e = entry(arg)?; + match e.get_password() { + Err(keyring::Error::NoEntry) => return Ok(()), + Ok(value) if value == MARKER => (), + Ok(_) => return Err("unexpected diagnostic value; preserved".into()), + Err(error) => return Err(error.to_string()), + } + match e.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => (), + Err(e) => return Err(e.to_string()), + } + match e.get_password() { + Err(keyring::Error::NoEntry) => Ok(()), + _ => Err("diagnostic key absence not verified".into()), + } + }, + "exec" => Err(Command::new(app.join("Contents/MacOS/horizon-launcher")) + .arg(&app).exec().to_string()), + _ => Err("unknown probe command".into()), + })(); + match result { Ok(()) => println!("OK"), Err(e) => println!("ERR {e}") } + io::stdout().flush().unwrap(); + } +} +''' + + +def run(*args, env): + subprocess.run([str(a) for a in args], env=env, check=True, timeout=180) + + +def pins(text): + return {tuple(re.findall(r'^(?:name|version|source|checksum) = "([^"]*)"$', block, re.M)) + for block in text.split('[[package]]')[1:] if '\nchecksum = ' in block} + + +class Peer: + def __init__(self, app, env): + self.proc = subprocess.Popen([str(app / 'Contents/MacOS/horizon-launcher'), str(app)], + env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + + def line(self): + if not select.select([self.proc.stdout], [], [], 45)[0]: + raise TimeoutError(f'probe {self.proc.pid} did not reply (Keychain may need interaction)') + line = self.proc.stdout.readline().strip() + assert line, f'probe {self.proc.pid} exited without reply' + return line + + def ask(self, command, expected='OK'): + self.proc.stdin.write(command + '\n') + self.proc.stdin.flush() + answer = self.line() + assert answer == expected, f'{command.split()[0]}: {answer}' + + def close(self): + if not self.proc.stdin.closed: + self.proc.stdin.close() + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=10) + + +def keychain_scenario(work, binary, env): + peers, keys = [], [str(uuid.uuid4()) for _ in range(3)] + app = work / 'Applications/Horizon.app' + # Recovery custody if an assertion or native permission prompt interrupts the run. + print(json.dumps({'diagnostic_service': 'dev.playhorizon.launcher.diagnostic', + 'diagnostic_uuids': keys, 'scratch': str(work)}), flush=True) + + def bundle(path, version): + (path / 'Contents/MacOS').mkdir(parents=True) + (path / 'Contents/Resources').mkdir() + shutil.copy2(binary, path / 'Contents/MacOS/horizon-launcher') + (path / 'Contents/Resources/version').write_text(version) + (path / 'Contents/Info.plist').write_text(''' +CFBundleExecutablehorizon-launcher +CFBundleIdentifierdev.playhorizon.launcher.diagnostic +CFBundlePackageTypeAPPL''') + run('/usr/bin/codesign', '--force', '--sign', '-', path, env=env) + return path + + def start(): + p = Peer(app, env) + peers.append(p) + assert p.line() == f'READY {p.proc.pid}' + return p + + def retired(): + return set(app.parent.glob('.Horizon.app.old-*')) + + bundle(app, 'v1') + v2 = bundle(work / 'v2/Horizon.app', 'v2') + v3 = bundle(work / 'v3/Horizon.app', 'v3') + try: + a, b = start(), start() # two users of the same original executable, like GUI + MCP + a.ask(f'update {v2}') + a.ask(f'write {keys[0]}') # the causal failure occurs HERE if the old executable was deleted + reader = start() + reader.ask(f'read {keys[0]}') + old_v1 = retired() + assert len(old_v1) == 1 + a.ask(f'update {v3}') # updater PID unchanged, while a new-generation reader is alive + b.ask(f'write {keys[1]}') + reader_v3 = start() + reader_v3.ask(f'read {keys[1]}') + assert len(retired()) == 2 and old_v1 < retired() + b.close() + a.ask('cleanup') + assert len(retired()) == 2, 'one original user remains, plus the v2 reader' + reader.close() + a.ask('cleanup') + assert retired() == old_v1, 'only the intermediate generation is unused' + a.ask('exec', f'READY {a.proc.pid}') # same PID, new vnode, startup cleanup + assert not retired(), 'exec must release the last original executable mapping' + a.ask(f'write {keys[2]}') + reader_v3.ask(f'read {keys[2]}') + (v2 / 'Contents/Resources/version').write_text('tampered') + a.proc.stdin.write(f'update {v2}\n') + a.proc.stdin.flush() + assert a.line().startswith('ERR '), 'tampered signed source must be refused' + assert (app / 'Contents/Resources/version').read_text() == 'v3' + run('/usr/bin/codesign', '--verify', '--deep', '--strict', app, env=env) + finally: + # A fresh process has a valid executable even if the test exposed an unsafe old updater. + removed, cleanup_errors = [], [] + try: + try: + cleaner = start() + except Exception as error: + cleanup_errors = [{'uuid': key, 'error': str(error)} for key in keys] + else: + for key in keys: + try: + cleaner.ask(f'delete {key}') + removed.append(key) + except Exception as error: + cleanup_errors.append({'uuid': key, 'error': str(error)}) + finally: + for p in peers: + p.close() + print(json.dumps({'diagnostic_uuids_removed_and_absent': removed, + 'unverified_cleanup': cleanup_errors}), flush=True) + assert not cleanup_errors, 'diagnostic cleanup incomplete; reconcile UUIDs in the receipt' + return {'synthetic_keys_removed_and_absent': len(keys), 'shared_vnode_users': 2, + 'updates': 2, 'exec_preserved_pid': True, 'tampered_source_refused': True} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--run', action='store_true', help='explicitly build/run in an approved QA window') + parser.add_argument('--keychain', action='store_true', help='also access ONLY synthetic diagnostic UUIDs') + args = parser.parse_args() + if not args.run: + if args.keychain: + parser.error('--keychain requires --run') + parser.print_help() + return + if sys.platform != 'darwin': + parser.error('macOS is required') + sources = [MODULE, MODULE.parent / 'mac_update/tests.rs', LOCK, Path(__file__).resolve(), + Path(lab.__file__).resolve()] + before = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources} + lock = LOCK.read_text() + print(json.dumps({'starting_snapshot_sha256': before}), flush=True) + with tempfile.TemporaryDirectory(prefix='horizon-macos-update-') as tmp: + work = Path(tmp) + env = dict(os.environ, CARGO_BUILD_JOBS='1', CARGO_TARGET_DIR=str(work / 'target')) + for key in ['HORIZON_HOME', 'HORIZON_LAUNCHER_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', + 'HORIZON_LAUNCHER_TRASH', 'HORIZON_LAUNCHER_CLIPBOARD', 'HORIZON_LAUNCHER_DESKTOP']: + env[key] = str(work / key.lower()) + Path(env[key]).mkdir() + # Force fresh homes before the official helper preserves caller-provided folders. + env = lab.game_env(env) + (work / 'src/mac_update').mkdir(parents=True) + (work / 'src/main.rs').write_text(PROBE) + # Normal `mod mac_update;` resolves its children under src/mac_update/. + # An absolute #[path] would instead make `mod tests;` seek a sibling tests.rs. + for source, target in [(MODULE, work / 'src/mac_update.rs'), + (sources[1], work / 'src/mac_update/tests.rs')]: + target.write_bytes(source.read_bytes()) + expected = before[str(source.relative_to(ROOT))] + assert hashlib.sha256(target.read_bytes()).hexdigest() == expected, \ + f'copied source differs from starting snapshot: {source}' + manifest = work / 'Cargo.toml' + manifest.write_text('''[package] +name = "horizon-macos-update-regression" +version = "0.0.0" +edition = "2021" +[workspace] +[dependencies] +getrandom = "=0.3.4" +keyring = { version = "=3.6.3", features = ["apple-native"] } +''') + (work / 'Cargo.lock').write_text(lock) + # Resolve only, offline; reject ANY transitive version/checksum drift before compiling. + subprocess.run(['cargo', 'metadata', '--offline', '--format-version', '1', + '--manifest-path', str(manifest)], env=env, check=True, + stdout=subprocess.DEVNULL, timeout=60) + resolved = pins((work / 'Cargo.lock').read_text()) + assert resolved and resolved <= pins(lock), 'probe dependency drift from launcher/Cargo.lock' + run('cargo', 'test', '--offline', '--locked', '-j1', '--manifest-path', manifest, env=env) + result = {'module_tests': 'see cargo counts above', 'keychain': 'not requested'} + if args.keychain: + run('cargo', 'build', '--offline', '--locked', '-j1', '--manifest-path', manifest, env=env) + result['keychain'] = keychain_scenario(work, work / 'target/debug/horizon-macos-update-regression', env) + after = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources} + assert before == after, 'source/lock snapshot changed during regression' + print(json.dumps({'snapshot_sha256': before, 'registry_pins': len(resolved), **result}, indent=2)) + + +if __name__ == '__main__': + main()