mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
fix(launcher): preserve running macOS app bundles during updates
This commit is contained in:
1 parent
9658ee1fef
commit
4f1ffce47d
10 files changed
+883
-62
No files matched your search
Generated
+1
@@ -2757,6 +2757,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"pulldown-cmark",
|
||||
"raw-window-handle",
|
||||
"security-framework 3.7.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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::<security_framework::base::Error>()
|
||||
.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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
+10
-62
@@ -2824,71 +2824,19 @@ fn find_app(dir: &Path) -> Option<PathBuf> {
|
||||
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"))]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Output>) -> 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::<u32>()
|
||||
.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<PathBuf> {
|
||||
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;
|
||||
@@ -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<PathBuf> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<keyring::Entry, String> {
|
||||
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('''<?xml version="1.0"?><plist version="1.0"><dict>
|
||||
<key>CFBundleExecutable</key><string>horizon-launcher</string>
|
||||
<key>CFBundleIdentifier</key><string>dev.playhorizon.launcher.diagnostic</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string></dict></plist>''')
|
||||
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()
|
||||
Reference in new issue
Block a user