From 41969f2e79010ef5e372710009af46b3baa78ac4 Mon Sep 17 00:00:00 2001 From: Johann Date: Fri, 2 Oct 2026 22:10:08 +0800 Subject: [PATCH 1/2] Install macOS updates automatically Stage the matching signed app from a verified DMG beside the running bundle, then hand replacement and relaunch to a detached helper after host shutdown. Keep the manual installation flow for protected and translocated apps. Use same-filesystem renames, restore the old bundle on replacement failure, and preserve backups and diagnostic logs when recovery or relaunch fails. Add a shutdown callback and explicit macOS relaunch environment overrides so applications can persist state and retain custom settings directories. Prepare updates off the UI thread and defer completion until native progress calls return, avoiding dialog destruction and RefCell reentrancy panics. Cover helper success and recovery paths, cancellation, timeouts, competing installers, bundle identity and signatures, and progress-dialog lifetime. Include a disposable signed fixture demo and document integration and limits. Co-Authored-By: Codex --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 45 +++++- examples/macos_update_demo.rs | 127 +++++++++++++++ src/config.rs | 12 ++ src/download.rs | 12 +- src/platform.rs | 6 +- src/platform/macos.rs | 292 ++++++++++++++++++++++++++++++++-- src/platform/macos_install.sh | 86 ++++++++++ src/platform/macos_tests.rs | 254 +++++++++++++++++++++++++++++ src/ui.rs | 121 ++++++++++---- src/ui/progress.rs | 127 +++++++++++++++ 12 files changed, 1038 insertions(+), 48 deletions(-) create mode 100644 examples/macos_update_demo.rs create mode 100644 src/platform/macos_install.sh create mode 100644 src/platform/macos_tests.rs create mode 100644 src/ui/progress.rs diff --git a/Cargo.lock b/Cargo.lock index bb6f9d6..9f2d410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1221,6 +1221,7 @@ dependencies = [ "patois", "pulldown-cmark", "serde", + "tempfile", "ureq", "wxdragon", ] diff --git a/Cargo.toml b/Cargo.toml index ab9b0cb..8966901 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,9 @@ ureq = { version = "3.4.2", default-features = false, features = ["json", "rustl # which parts of wxWidgets get built; nothing here needs any of them. wxdragon = { version = "0.9.22", default-features = false } +[target.'cfg(target_os = "macos")'.dependencies] +tempfile = "3.27.0" + [lints.clippy] all = { level = "warn", priority = -1 } cargo = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index ddb6c15..dd41c32 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ Auto-updater library for wxDragon desktop apps, supporting Windows, macOS, and L - Minisign signature verification before applying - `ui` module: update/progress dialogs, plus platform-specific install flows: - Windows: PowerShell install/extract scripts that relaunch the app afterward - - macOS: downloads and mounts a signed, notarized `.dmg` for the user to drag into - Applications; the app must be quit and relaunched manually + - macOS: privately mounts the verified `.dmg`, stages and validates its matching app bundle, + then replaces and relaunches writable installations; protected, mounted-image, or translocated + installations retain manual DMG instructions - Linux: a shell script that extracts the tarball or replaces the running `.AppImage`, then relaunches the app, the same self-updating flow as Windows - Other platforms: downloads the file and tells the user where it is @@ -62,6 +63,46 @@ ui::run_update_check(config, &frame, UpdateChannel::Stable, CheckTrigger::Manual - `UpdateError` variants dropped their `Error` suffix (`HttpError` is now `Http(u16)`), gained `Io`, and the enum is `#[non_exhaustive]`. +## macOS installation + +Updates replace the running app at its existing location, including custom folders and renamed +bundles. The DMG must contain exactly one top-level `.app` matching the running bundle identifier, +with a valid code signature and executable. Mounted images and Gatekeeper-translocated apps are +not modified. No administrator privileges are requested. + +Mounting, validation, copying, and the helper handshake run off the UI thread. Canceling before +shutdown drops the staged update. The detached helper waits up to 60 seconds for the app to exit, +then swaps bundles using same-filesystem renames. A failed replacement restores the old bundle; +a failed restore or launch preserves the backup and reports its location. Helper logs survive +cleanup under `~/Library/Logs/ship-shape-update-*.log`. A successful Launch Services request does +not detect subsequent application crashes. + +Use `ui::run_update_check_with_exit_handler` to save state and terminate normally. The existing +`run_update_check` entry point keeps its immediate process-exit behavior. The handler runs on the +UI thread only after successful installer preparation. It must terminate the process within +60 seconds; simply hiding the main window is insufficient. + +`UpdaterConfig::with_macos_relaunch_env(name, value)` preserves an explicit environment override +when launching through macOS `open`, such as an isolated settings directory. It is ignored on +other platforms; values are passed as arguments and never interpolated into shell code. + +For local testing, build `cargo build --example macos_update_demo`. Run the example with +`--setup /tmp/ship-shape-demo` using a new directory, then run the generated +`Installed Demo.app/Contents/MacOS/demo` executable with `--cancel` or `--apply` followed by the +fixture's `demo.dmg` path. Applying must display version 2; canceling must retain version 1. +The demo trusts its own local, ad-hoc signed fixture; it does not change production verification. + +The download gauge stays below 100 until verification and preparation finish. Native progress +updates can yield to the event loop, so completion waits for the active Update/Pulse call to +return before destroying the dialog or calling the shutdown handler. Regression tests cover this +ordering without requiring a graphical session. + +Run `cargo test` on macOS for coverage of cancellation, helper timeouts, replacement and rollback, +failed restore and launch, competing destination changes, protected locations, bundle selection, +signature tampering, and paths with spaces and apostrophes. Tests substitute launch commands and +alerts and only modify temporary fixture apps. The demo above additionally exercises actual DMG +mounting and Launch Services; testing a signed, notarized release establishes the Gatekeeper path. + ## License MIT diff --git a/examples/macos_update_demo.rs b/examples/macos_update_demo.rs new file mode 100644 index 0000000..b95e7c7 --- /dev/null +++ b/examples/macos_update_demo.rs @@ -0,0 +1,127 @@ +//! Disposable macOS updater demo. It includes the actual installer implementation, without +//! adding a production API for applying arbitrary local files. No network or key bypass is +//! added to the updater. This demo trusts ONLY its own locally generated fixture image. +#![allow(dead_code, reason = "the shared platform module has APIs unused by the demo")] + +#[cfg(target_os = "macos")] +#[path = "../src/platform/macos.rs"] +mod macos; + +#[cfg(target_os = "macos")] +use std::{env, fs, path::Path, process::Command}; + +#[cfg(target_os = "macos")] +use ship_shape::{InstallKind, UpdateError, UpdaterConfig}; + +#[cfg(target_os = "macos")] +enum InstallOutcome { + Prepared(macos::PreparedUpdate), + ManualStep(String), +} + +#[cfg(target_os = "macos")] +fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + if args.get(1).is_some_and(|arg| arg == "--setup") { + let root = Path::new(args.get(2).ok_or("Pass a NEW destination directory after --setup")?); + // Refuse reuse or an existing destination. The demo only replaces copies it creates. + fs::create_dir(root)?; + let running = root.join("Installed Demo.app"); + let image_root = root.join("image"); + fs::create_dir(&image_root)?; + make_bundle(&running, "1")?; + make_bundle(&image_root.join("Update Demo.app"), "2")?; + let dmg = root.join("demo.dmg"); + let status = Command::new("/usr/bin/hdiutil") + .args(["create", "-quiet", "-fs", "HFS+", "-srcfolder"]) + .arg(&image_root) + .arg(&dmg) + .status()?; + if !status.success() { + return Err("Failed to create demo DMG".into()); + } + println!("Created disposable demo at {}", root.display()); + println!("Run the installed bundle's Contents/MacOS/demo executable with --apply and the demo.dmg path."); + return Ok(()); + } + let exe = env::current_exe()?; + let bundle = exe.parent().and_then(Path::parent).and_then(Path::parent).ok_or("Not in a demo bundle")?; + if bundle.join("Contents/Resources/fixture-version").is_file() { + if args.get(1).is_some_and(|arg| arg == "--apply" || arg == "--cancel") { + let path = Path::new(args.get(2).ok_or("Pass demo.dmg after --apply or --cancel")?); + // Bind the test harness to the destination and DMG produced by --setup. + let root = bundle.parent().ok_or("Missing fixture directory")?; + if bundle.file_name().is_none_or(|name| name != "Installed Demo.app") + || path.canonicalize()? != root.join("demo.dmg").canonicalize()? + { + return Err("Use only the fixture's Installed Demo.app and demo.dmg".into()); + } + let config = UpdaterConfig::new("fixture/demo", "demo", "Updater Demo", "unused-fixture-key", "1"); + match macos::install(&config, path)? { + InstallOutcome::Prepared(update) if args[1] == "--cancel" => { + drop(update); + println!("Cancelled prepared update; version 1 must remain installed."); + } + InstallOutcome::Prepared(update) => { + update.commit()?; + println!("Helper ready. Exiting so it can replace this disposable bundle."); + } + InstallOutcome::ManualStep(message) => println!("{message}"), + } + return Ok(()); + } + let version = fs::read_to_string(bundle.join("Contents/Resources/fixture-version"))?; + if let Some(root) = bundle.parent() + && root.join(".demo-headless").is_file() + { + fs::write(root.join("launched-version"), &version)?; + return Ok(()); + } + let message = format!("Updater demo version {version} launched. This is a disposable test app."); + Command::new("/usr/bin/osascript") + .args([ + "-e", + "on run argv", + "-e", + "display dialog (item 1 of argv) buttons {\"OK\"} default button 1", + "-e", + "end run", + &message, + ]) + .status()?; + return Ok(()); + } + Err("Use --setup with a new disposable directory first".into()) +} + +#[cfg(target_os = "macos")] +fn make_bundle(bundle: &Path, version: &str) -> Result<(), Box> { + let macos = bundle.join("Contents/MacOS"); + fs::create_dir_all(&macos)?; + fs::create_dir_all(bundle.join("Contents/Resources"))?; + fs::copy(env::current_exe()?, macos.join("demo"))?; + fs::write(bundle.join("Contents/Resources/fixture-version"), version)?; + fs::write( + bundle.join("Contents/Info.plist"), + format!( + r#" + + +CFBundleIdentifierorg.ship-shape.disposable-demo +CFBundleNameUpdater Demo +CFBundleExecutabledemo +CFBundlePackageTypeAPPL +CFBundleVersion{version} +"# + ), + )?; + if !Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(bundle).status()?.success() { + return Err("Failed to ad-hoc sign fixture".into()); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("This disposable demo requires macOS."); +} diff --git a/src/config.rs b/src/config.rs index e3aa693..8a79f33 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,6 +56,10 @@ pub struct UpdaterConfig { /// Command-line arguments passed to a downloaded installer on Windows. Defaults to /// `["/silent"]` (Inno Setup); NSIS installers need `["/S"]`. pub installer_args: Vec, + /// Environment overrides passed to macOS `open` when relaunching the app. Empty by default. + /// Use this for an explicit configuration directory that must survive a Launch Services launch. + /// Ignored on other platforms. Values are passed as arguments, never as shell source. + pub macos_relaunch_env: Vec<(String, String)>, } impl UpdaterConfig { @@ -81,6 +85,7 @@ impl UpdaterConfig { asset_suffix: String::new(), installer_asset_suffix: None, installer_args: vec!["/silent".into()], + macos_relaunch_env: Vec::new(), } } @@ -133,6 +138,13 @@ impl UpdaterConfig { self } + /// Preserve an environment override when macOS relaunches the updated app. + #[must_use] + pub fn with_macos_relaunch_env(mut self, name: impl Into, value: impl Into) -> Self { + self.macos_relaunch_env.push((name.into(), value.into())); + self + } + pub(crate) fn effective_asset_suffix(&self) -> &str { match self.install_kind { InstallKind::Installer => self.installer_asset_suffix.as_deref().unwrap_or(&self.asset_suffix), diff --git a/src/download.rs b/src/download.rs index 9888772..0855504 100644 --- a/src/download.rs +++ b/src/download.rs @@ -70,7 +70,15 @@ pub fn download_update_file( .and_then(|v| v.parse::().ok()) .unwrap_or(0); let file_name = url.rsplit('/').next().unwrap_or("update.bin"); - let final_path = platform::download_dir(config)?.join(file_name); + let download_dir = platform::download_dir(config)?; + #[cfg(target_os = "macos")] + let private_dir = tempfile::Builder::new() + .prefix("ship-shape-download-") + .tempdir_in(&download_dir) + .map_err(|e| UpdateError::Io(format!("Failed to create private download directory: {e}")))?; + #[cfg(target_os = "macos")] + let download_dir = private_dir.path(); + let final_path = download_dir.join(file_name); let temp = TempDownload(final_path.with_file_name(format!("{file_name}.tmp"))); let mut file = File::create(&temp.0).map_err(|e| UpdateError::Io(format!("Failed to create file: {e}")))?; let mut downloaded: u64 = 0; @@ -93,6 +101,8 @@ pub fn download_update_file( .verify(&data, &signature, true) .map_err(|e| UpdateError::Verification(format!("Signature verification failed: {e}")))?; fs::rename(&temp.0, &final_path).map_err(|e| UpdateError::Io(format!("Failed to rename verified file: {e}")))?; + #[cfg(target_os = "macos")] + let _ = private_dir.keep(); Ok(final_path) } diff --git a/src/platform.rs b/src/platform.rs index 132abe6..d3a07c6 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -13,7 +13,8 @@ mod windows; #[cfg(target_os = "linux")] pub use linux::{asset_name_parts, download_dir, install}; #[cfg(target_os = "macos")] -pub use macos::{asset_name_parts, download_dir, install}; +#[allow(unused_imports, reason = "macOS UI uses cancellable preparation")] +pub use macos::{asset_name_parts, download_dir, install, install_with_cancel, remove_private_download}; #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] pub use other::{asset_name_parts, download_dir, install}; #[cfg(target_os = "windows")] @@ -22,6 +23,9 @@ pub use windows::{asset_name_parts, download_dir, install}; /// What the caller must do after a successful [`install`]. #[allow(dead_code, reason = "each platform constructs only some variants")] pub enum InstallOutcome { + /// macOS preparation succeeded; commit on the UI thread before requesting shutdown. + #[cfg(target_os = "macos")] + Prepared(macos::PreparedUpdate), /// The installer is waiting for this process to exit. Exit, /// The user must finish the install by hand. Holds the instructions to show. diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 7ac49ab..33b40b4 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -1,10 +1,14 @@ use std::{ - env, + env, fs, path::{Path, PathBuf}, - process::Command, + process::{self, Command, Stdio}, + sync::atomic::{AtomicBool, Ordering}, + thread, + time::{Duration, Instant}, }; use patois::t; +use tempfile::TempDir; use super::InstallOutcome; use crate::{InstallKind, UpdateError, UpdaterConfig}; @@ -19,23 +23,254 @@ pub fn download_dir(_config: &UpdaterConfig) -> Result { Ok(env::temp_dir()) } -/// Mount the downloaded disk image and prompt the user to finish installing by hand. -/// -/// There's no way to self-replace a running, Gatekeeper-checked `.app` bundle the way the -/// Windows install script does, so this stops at opening the mounted image; dragging the app -/// into Applications is left to the user. +/// A staged update with a helper waiting for an explicit commit. Dropping it cancels the helper +/// and removes the staging directory; accepting it transfers cleanup to the helper. +pub struct PreparedUpdate { + stage: TempDir, +} + +impl PreparedUpdate { + pub fn commit(self) -> Result<(), String> { + fs::write(self.stage.path().join("commit"), b"install") + .map_err(|e| format!("{}: {e}", t("Failed to start installation")))?; + let _ = self.stage.keep(); + Ok(()) + } +} + +/// Prepare an in-place bundle update while the application is still running. A detached helper +/// waits for both an explicit commit and process termination before replacing the bundle. +/// This must only receive a downloaded disk image whose Minisign signature has been verified. +#[allow(dead_code, reason = "UI uses the cancellable entry point; the demo uses install")] pub fn install(config: &UpdaterConfig, path: &Path) -> Result { - // `open` mounts the disk image and shows it in a Finder window, the same as - // double-clicking it. - Command::new("open").arg(path).spawn().map_err(|e| format!("{}: {e}", t("Failed to open disk image")))?; + install_with_cancel(config, path, &AtomicBool::new(false)) +} + +pub fn install_with_cancel( + config: &UpdaterConfig, + path: &Path, + cancelled: &AtomicBool, +) -> Result { + check_cancelled(cancelled)?; + let current = env::current_exe().map_err(|e| e.to_string())?; + let Some(bundle) = bundle_for_executable(¤t) else { + return manual_install(config, path); + }; + if !can_update(&bundle) { + return manual_install(config, path); + } + let Some(parent) = bundle.parent() else { + return manual_install(config, path); + }; + let Ok(stage) = tempfile::Builder::new().prefix(".ship-shape-").tempdir_in(parent) else { + return manual_install(config, path); + }; + let original_stamp = bundle_stamp(&bundle)?; + let identity = plist_value(&bundle, "CFBundleIdentifier")?; + let mount = Mount::attach(path)?; + check_cancelled(cancelled)?; + let replacement = find_replacement(&mount.point, &identity)?; + validate_bundle(&replacement)?; + let staged = stage.path().join("new.app"); + run(Command::new("/usr/bin/ditto").arg(&replacement).arg(&staged), &t("Failed to stage the application"))?; + check_cancelled(cancelled)?; + validate_bundle(&staged)?; + if plist_value(&staged, "CFBundleIdentifier")? != identity { + return Err(t("The update contains a different application.")); + } + mount.detach()?; + check_cancelled(cancelled)?; + // Keep the diagnostic outside the staging directory so it survives cleanup and relaunch. + let logs = env::var_os("HOME") + .map(|home| PathBuf::from(home).join("Library/Logs")) + .ok_or_else(|| t("Could not determine the update log directory."))?; + fs::create_dir_all(&logs).map_err(|e| e.to_string())?; + let log = tempfile::Builder::new() + .prefix("ship-shape-update-") + .suffix(".log") + .tempfile_in(logs) + .map_err(|e| e.to_string())?; + let (log_file, log_path) = log.keep().map_err(|e| e.to_string())?; + write_messages(stage.path())?; + let script_path = stage.path().join("install.sh"); + fs::write(&script_path, include_str!("macos_install.sh")).map_err(|e| e.to_string())?; + let mut command = Command::new("/bin/sh"); + command + .arg(&script_path) + .arg(process::id().to_string()) + .arg(&bundle) + .arg(stage.path()) + .arg(&log_path) + .arg(original_stamp) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file.try_clone().map_err(|e| e.to_string())?)) + .stderr(Stdio::from(log_file)); + for (name, value) in &config.macos_relaunch_env { + command.arg("--env").arg(format!("{name}={value}")); + } + let mut helper = command.spawn().map_err(|e| format!("{}: {e}", t("Failed to launch update helper")))?; + let deadline = Instant::now() + Duration::from_secs(5); + while !stage.path().join("ready").is_file() { + if helper.try_wait().map_err(|e| e.to_string())?.is_some() || Instant::now() >= deadline { + let _ = helper.kill(); + let _ = helper.wait(); + return Err(format!("{} {}", t("The update helper did not start. See the log:"), log_path.display())); + } + thread::sleep(Duration::from_millis(20)); + } + // The helper is intentionally independent of the host process. It exits itself on cancellation + // or timeout; a detached reaper prevents a zombie if this application stays open after cancel. + thread::spawn(move || { + let _ = helper.wait(); + }); + Ok(InstallOutcome::Prepared(PreparedUpdate { stage })) +} + +fn write_messages(stage: &Path) -> Result<(), String> { + let messages = [ + t("The application did not quit within 60 seconds. The current app was kept."), + t("The installation changed while waiting. The current app was kept."), + t("Could not move the current application. The current app was kept."), + t("Could not install the update. The previous version was restored."), + t("Could not restore the previous version. Recover it from:"), + t("macOS could not launch the update. The previous version is preserved at:"), + ]; + for (index, message) in messages.iter().enumerate() { + fs::write(stage.join(format!("message-{index}")), message).map_err(|e| e.to_string())?; + } + fs::write(stage.join("message-title"), t("Application update failed")).map_err(|e| e.to_string())?; + fs::write(stage.join("message-log"), t("Log:")).map_err(|e| e.to_string())?; + Ok(()) +} + +fn check_cancelled(cancelled: &AtomicBool) -> Result<(), String> { + if cancelled.load(Ordering::Relaxed) { + return Err(t("Update cancelled.")); + } + Ok(()) +} + +fn bundle_stamp(bundle: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let metadata = fs::symlink_metadata(bundle).map_err(|e| e.to_string())?; + Ok(format!("{}:{}", metadata.dev(), metadata.ino())) +} + +fn manual_install(config: &UpdaterConfig, path: &Path) -> Result { + run(Command::new("/usr/bin/open").arg(path), &t("Failed to open disk image"))?; Ok(InstallOutcome::ManualStep( - t( - "The update has been downloaded and its disk image opened. Quit %s and drag the new version into Applications to finish installing.", - ) - .replace("%s", &config.app_display_name), + t("The update has been downloaded and its disk image opened. Quit %s and drag the new version into Applications to finish installing.") + .replace("%s", &config.app_display_name), )) } +fn bundle_for_executable(exe: &Path) -> Option { + let macos = exe.parent()?; + let contents = macos.parent()?; + let bundle = contents.parent()?; + (macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" && bundle.extension()? == "app") + .then(|| bundle.to_path_buf()) +} + +fn can_update(bundle: &Path) -> bool { + !bundle.components().any(|part| part.as_os_str() == "AppTranslocation") + && !fs::symlink_metadata(bundle).is_ok_and(|meta| meta.file_type().is_symlink()) + && Command::new("/bin/test").arg("-w").arg(bundle).status().is_ok_and(|s| s.success()) +} + +fn plist_value(bundle: &Path, key: &str) -> Result { + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", &format!("Print :{key}")]) + .arg(bundle.join("Contents/Info.plist")) + .output() + .map_err(|e| e.to_string())?; + let value = String::from_utf8(output.stdout).map_err(|e| e.to_string())?; + if !output.status.success() || value.trim().is_empty() { + return Err(format!("{}: {key}", t("Invalid application metadata"))); + } + Ok(value.trim().to_owned()) +} + +fn find_replacement(root: &Path, identity: &str) -> Result { + let mut matches = Vec::new(); + for entry in fs::read_dir(root).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().map_err(|e| e.to_string())?.is_dir() + && entry.path().extension().is_some_and(|ext| ext == "app") + && plist_value(&entry.path(), "CFBundleIdentifier").is_ok_and(|id| id == identity) + { + matches.push(entry.path()); + } + } + if matches.len() != 1 { + return Err(t("The disk image must contain exactly one matching application.")); + } + Ok(matches.remove(0)) +} + +fn validate_bundle(bundle: &Path) -> Result<(), String> { + let name = plist_value(bundle, "CFBundleExecutable")?; + if Path::new(&name).file_name().is_none_or(|n| n != name.as_str()) { + return Err(t("Invalid application executable.")); + } + let exe = bundle.join("Contents/MacOS").join(name); + let resolved = exe.canonicalize().map_err(|e| e.to_string())?; + if !resolved.starts_with(bundle.canonicalize().map_err(|e| e.to_string())?) || !resolved.is_file() { + return Err(t("Invalid application executable.")); + } + run(Command::new("/bin/test").arg("-x").arg(exe), &t("Invalid application executable"))?; + run( + Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(bundle), + &t("Application signature verification failed"), + ) +} + +fn run(command: &mut Command, message: &str) -> Result<(), String> { + let output = command.output().map_err(|e| format!("{message}: {e}"))?; + if !output.status.success() { + return Err(format!("{}: {}", message, String::from_utf8_lossy(&output.stderr).trim())); + } + Ok(()) +} + +struct Mount { + point: PathBuf, + _directory: TempDir, + attached: bool, +} + +impl Mount { + fn attach(dmg: &Path) -> Result { + let directory = tempfile::Builder::new().prefix("ship-shape-mount-").tempdir().map_err(|e| e.to_string())?; + let point = directory.path().join("image"); + fs::create_dir(&point).map_err(|e| e.to_string())?; + // A failed attach can still partially mount a volume; Drop attempts detach. + let mount = Self { point, _directory: directory, attached: true }; + run( + Command::new("/usr/bin/hdiutil") + .args(["attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint"]) + .arg(&mount.point) + .arg(dmg), + &t("Failed to mount disk image"), + )?; + Ok(mount) + } + + fn detach(mut self) -> Result<(), String> { + run(Command::new("/usr/bin/hdiutil").arg("detach").arg(&self.point), &t("Failed to unmount disk image"))?; + self.attached = false; + Ok(()) + } +} + +impl Drop for Mount { + fn drop(&mut self) { + if self.attached { + let _ = Command::new("/usr/bin/hdiutil").args(["detach", "-force"]).arg(&self.point).output(); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -45,4 +280,33 @@ mod tests { assert_eq!(asset_name_parts(InstallKind::Installer), ("", "dmg")); assert_eq!(asset_name_parts(InstallKind::Portable), ("", "dmg")); } + + #[test] + fn discovers_only_bundle_executables() { + assert_eq!( + bundle_for_executable(Path::new("/tmp/O'Brien App.app/Contents/MacOS/app")), + Some(PathBuf::from("/tmp/O'Brien App.app")) + ); + assert!(bundle_for_executable(Path::new("/tmp/app")).is_none()); + assert!(bundle_for_executable(Path::new("/tmp/App.app/Other/MacOS/app")).is_none()); + } + + #[test] + fn translocated_bundle_is_never_updated() { + assert!(!can_update(Path::new("/private/var/AppTranslocation/id/d/App.app"))); + } } + +/// Only delete directories created by our download pipeline, never a caller's arbitrary DMG. +pub fn remove_private_download(path: &Path) { + if let Some(parent) = path.parent() + && parent.parent() == Some(env::temp_dir().as_path()) + && parent.file_name().is_some_and(|name| name.to_string_lossy().starts_with("ship-shape-download-")) + { + let _ = fs::remove_dir_all(parent); + } +} + +#[cfg(test)] +#[path = "macos_tests.rs"] +mod integration_tests; diff --git a/src/platform/macos_install.sh b/src/platform/macos_install.sh new file mode 100644 index 0000000..c43487b --- /dev/null +++ b/src/platform/macos_install.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Arguments are passed directly by Rust; paths never become shell source. +pid=$1 +bundle=$2 +stage=$3 +log=$4 +original_stamp=$5 +shift 5 +launch_app() { + /usr/bin/open -n "$bundle" "$@" +} +backup="$stage/old.app" +new="$stage/new.app" +# This helper remains outside both app bundles and survives their replacement. +trap '' HUP +report() { + message="$(/bin/cat "$stage/message-$1") ${2:-} $(/bin/cat "$stage/message-log") $log" + title=$(/bin/cat "$stage/message-title") + printf '%s\n' "$message" + # Pass the message as argv, rather than interpolating it into AppleScript. + /usr/bin/osascript - "$title" "$message" <<'APPLESCRIPT' +on run argv + display alert (item 1 of argv) message (item 2 of argv) as warning +end run +APPLESCRIPT +} +cleanup() { + /bin/rm -rf "$stage" +} +: > "$stage/ready" || exit 1 +# Cancelled UI flows drop the staging directory. Never touch the app without commit. +count=0 +while [ ! -f "$stage/commit" ]; do + [ -d "$stage" ] || exit 0 + count=$((count + 1)) + if [ "$count" -ge 300 ]; then + printf '%s\n' 'Installation was not committed; keeping the current app.' + cleanup + exit 1 + fi + /bin/sleep 0.2 +done +count=0 +while /bin/kill -0 "$pid" 2>/dev/null; do + count=$((count + 1)) + if [ "$count" -ge 300 ]; then + report 0 + cleanup + exit 1 + fi + /bin/sleep 0.2 +done +printf '%s\n' 'Application exited; replacing the bundle.' +rename_bundle() { + # rename(2) fails on an occupied, nonempty destination instead of nesting the source + # inside it. /usr/bin/perl ships with our supported macOS versions. + /usr/bin/perl -e 'rename $ARGV[0], $ARGV[1] or die "rename: $!\n"' "$1" "$2" +} +# Recheck after waiting: another installer may have changed the destination. +if [ ! -d "$bundle" ] || [ -L "$bundle" ] || [ ! -d "$new" ] || [ "$(/usr/bin/stat -f '%d:%i' "$bundle")" != "$original_stamp" ]; then + report 1 + cleanup + exit 1 +fi +if ! rename_bundle "$bundle" "$backup"; then + report 2 + launch_app "$@" + cleanup + exit 1 +fi +if ! rename_bundle "$new" "$bundle"; then + if rename_bundle "$backup" "$bundle"; then + report 3 + launch_app "$@" + cleanup + else + report 4 "$backup" + fi + exit 1 +fi +if ! launch_app "$@"; then + report 5 "$backup" + exit 1 +fi +printf '%s\n' 'Replacement completed and macOS accepted the launch request.' +cleanup diff --git a/src/platform/macos_tests.rs b/src/platform/macos_tests.rs new file mode 100644 index 0000000..c639898 --- /dev/null +++ b/src/platform/macos_tests.rs @@ -0,0 +1,254 @@ +//! Exercise the actual helper script with disposable directories. Only launch/report commands +//! and fault injection are substituted, so tests cannot open apps or display desktop alerts. +use std::{ + fs, + path::PathBuf, + process::{Child, Command}, + thread, + time::{Duration, Instant}, +}; + +use tempfile::TempDir; + +use super::{PreparedUpdate, bundle_stamp, check_cancelled, find_replacement, validate_bundle}; + +struct Fixture { + root: TempDir, + bundle: PathBuf, + stage: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let root = tempfile::Builder::new().prefix("ship-shape-O'Brien test-").tempdir().unwrap(); + let bundle = root.path().join("Current App.app"); + let stage = root.path().join("stage"); + fs::create_dir(&bundle).unwrap(); + fs::create_dir(&stage).unwrap(); + super::write_messages(&stage).unwrap(); + fs::create_dir(stage.join("new.app")).unwrap(); + fs::write(bundle.join("version"), "old").unwrap(); + fs::write(stage.join("new.app/version"), "new").unwrap(); + Self { root, bundle, stage } + } + + fn launch(&self, pid: u32, commit: bool, fault: &str) -> Child { + let mut script = include_str!("macos_install.sh").to_string(); + // Replace desktop alerts with a successful noninteractive command. + script = script.replace("/usr/bin/osascript -", "/usr/bin/true"); + let launcher = self.root.path().join("launch.sh"); + fs::write( + &launcher, + if fault == "launch" { + "exit 1\n" + } else { + "printf '%s\\n' \"$@\" > \"$(dirname \"$0\")/launch-args\"\nexit 0\n" + }, + ) + .unwrap(); + script = script.replace("/usr/bin/open -n", &format!("/bin/sh {}", quote(launcher.to_str().unwrap()))); + if fault == "rename" || fault == "restore" { + let renamer = self.root.path().join("rename.sh"); + let count = self.root.path().join("renames"); + let restore = if fault == "restore" { " -o \"$n\" -eq 3" } else { "" }; + fs::write(&renamer, format!("count={}\nn=0\n[ ! -f \"$count\" ] || n=$(cat \"$count\")\nn=$((n+1))\nprintf '%s' \"$n\" > \"$count\"\n[ \"$n\" -ne 2{restore} ] || exit 1\n/usr/bin/perl -e 'rename $ARGV[0], $ARGV[1] or die $!' \"$1\" \"$2\"\n", quote(count.to_str().unwrap()))).unwrap(); + // Use an explicit condition for the restore failure (both calls 2 and 3 fail). + if fault == "restore" { + let contents = fs::read_to_string(&renamer) + .unwrap() + .replace("[ \"$n\" -ne 2 -o \"$n\" -eq 3 ]", "[ \"$n\" -eq 1 ]"); + fs::write(&renamer, contents).unwrap(); + } + script = script.replace( + "/usr/bin/perl -e 'rename $ARGV[0], $ARGV[1] or die \"rename: $!\\n\"'", + &format!("/bin/sh {}", quote(renamer.to_str().unwrap())), + ); + } + if fault == "timeout" { + script = script.replace("-ge 300", "-ge 3").replace("/bin/sleep 0.2", "/bin/sleep 0.01"); + } + let stamp = bundle_stamp(&self.bundle).unwrap(); + if fault == "changed" { + fs::rename(&self.bundle, self.root.path().join("another-installer-backup.app")).unwrap(); + fs::create_dir(&self.bundle).unwrap(); + fs::write(self.bundle.join("version"), "other").unwrap(); + } + let script_path = self.root.path().join("helper.sh"); + fs::write(&script_path, script).unwrap(); + if commit { + fs::write(self.stage.join("commit"), "yes").unwrap(); + } + Command::new("/bin/sh") + .arg(script_path) + .arg(pid.to_string()) + .arg(&self.bundle) + .arg(&self.stage) + .arg(self.root.path().join("log")) + .arg(stamp) + .arg("--env") + .arg("TEST_CONFIG_PATH=O'Brien test/settings") + .spawn() + .unwrap() + } + + fn version(&self) -> String { + fs::read_to_string(self.bundle.join("version")).unwrap() + } +} + +fn quote(path: &str) -> String { + format!("'{}'", path.replace('\'', "'\\''")) +} + +fn wait(mut child: Child) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status.success(); + } + if Instant::now() > deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("helper did not exit"); + } + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn helper_replaces_and_cleans_up_with_quoted_paths() { + let fixture = Fixture::new(); + assert!(wait(fixture.launch(u32::MAX, true, ""))); + assert_eq!(fixture.version(), "new"); + assert!(!fixture.stage.exists()); + let args = fs::read_to_string(fixture.root.path().join("launch-args")).unwrap(); + assert!(args.ends_with("--env\nTEST_CONFIG_PATH=O'Brien test/settings\n")); +} + +#[test] +fn helper_restores_old_bundle_after_install_failure() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "rename"))); + assert_eq!(fixture.version(), "old"); + assert!(!fixture.stage.exists()); +} + +#[test] +fn helper_preserves_backup_when_restore_fails() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "restore"))); + assert_eq!(fs::read_to_string(fixture.stage.join("old.app/version")).unwrap(), "old"); + assert_eq!(fs::read_to_string(fixture.stage.join("new.app/version")).unwrap(), "new"); +} + +#[test] +fn helper_preserves_backup_when_launch_fails() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "launch"))); + assert_eq!(fixture.version(), "new"); + assert_eq!(fs::read_to_string(fixture.stage.join("old.app/version")).unwrap(), "old"); +} + +#[test] +fn helper_does_not_replace_an_installation_changed_by_another_installer() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "changed"))); + assert_eq!(fixture.version(), "other"); +} + +#[test] +fn helper_times_out_without_commit_or_host_exit() { + for commit in [false, true] { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(std::process::id(), commit, "timeout"))); + assert_eq!(fixture.version(), "old"); + assert!(!fixture.stage.exists()); + } +} + +#[test] +fn dropping_prepared_update_cancels_before_replacement() { + let fixture = Fixture::new(); + let child = fixture.launch(u32::MAX, false, ""); + let deadline = Instant::now() + Duration::from_secs(2); + while !fixture.stage.join("ready").exists() { + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(10)); + } + fs::remove_dir_all(&fixture.stage).unwrap(); + assert!(wait(child)); + assert_eq!(fixture.version(), "old"); + let stage = tempfile::tempdir().unwrap(); + let path = stage.path().to_owned(); + drop(PreparedUpdate { stage }); + assert!(!path.exists()); +} + +fn metadata_bundle(root: &std::path::Path, name: &str, identity: &str, executable: &str) -> PathBuf { + let bundle = root.join(name); + fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + fs::write(bundle.join("Contents/Info.plist"), format!("CFBundleIdentifier{identity}CFBundleExecutable{executable}")).unwrap(); + bundle +} + +#[test] +fn replacement_requires_exactly_one_matching_bundle_and_ignores_symlinks() { + let root = tempfile::tempdir().unwrap(); + assert!(find_replacement(root.path(), "correct.id").is_err()); + metadata_bundle(root.path(), "Wrong.app", "wrong.id", "app"); + let first = metadata_bundle(root.path(), "First.app", "correct.id", "app"); + std::os::unix::fs::symlink(&first, root.path().join("Link.app")).unwrap(); + assert_eq!(find_replacement(root.path(), "correct.id").unwrap(), first); + metadata_bundle(root.path(), "Duplicate.app", "correct.id", "app"); + assert!(find_replacement(root.path(), "correct.id").is_err()); +} + +#[test] +fn rejects_missing_escaping_and_unsigned_executables() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Missing.app", "id", "app"); + assert!(validate_bundle(&bundle).is_err()); + let bundle = metadata_bundle(root.path(), "Escaping.app", "id", "../../outside"); + assert!(validate_bundle(&bundle).is_err()); + let bundle = metadata_bundle(root.path(), "Unsigned.app", "id", "app"); + let executable = bundle.join("Contents/MacOS/app"); + fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(validate_bundle(&bundle).is_err()); +} + +#[test] +fn cancellation_stops_preparation() { + assert!(check_cancelled(&std::sync::atomic::AtomicBool::new(true)).is_err()); +} + +#[test] +fn protected_and_symlinked_bundles_use_manual_fallback() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Protected.app", "id", "app"); + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o555)).unwrap(); + assert!(!super::can_update(&bundle)); + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o755)).unwrap(); + let link = root.path().join("Link.app"); + std::os::unix::fs::symlink(&bundle, &link).unwrap(); + assert!(!super::can_update(&link)); +} + +#[test] +fn signed_bundle_passes_but_tampering_fails() { + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Signed.app", "id", "app"); + fs::create_dir(bundle.join("Contents/Resources")).unwrap(); + let resource = bundle.join("Contents/Resources/content.txt"); + fs::write(&resource, "original").unwrap(); + // A Mach-O executable, copied into a disposable fixture, avoids signing shell scripts. + fs::copy("/usr/bin/true", bundle.join("Contents/MacOS/app")).unwrap(); + let status = Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(&bundle).output().unwrap(); + assert!(status.status.success(), "{}", String::from_utf8_lossy(&status.stderr)); + validate_bundle(&bundle).unwrap(); + fs::write(&resource, "tampered").unwrap(); + let error = validate_bundle(&bundle).unwrap_err(); + assert!(error.contains("signature verification failed"), "{error}"); +} diff --git a/src/ui.rs b/src/ui.rs index e5bd354..c04744e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,4 @@ use std::{ - cell::RefCell, - path::PathBuf, process, ptr, sync::{ Arc, @@ -21,11 +19,14 @@ use crate::{ }; mod markdown; +mod progress; + +use progress::{ProgressLifecycle, download_percent}; const PROGRESS_INTERVAL: Duration = Duration::from_millis(200); thread_local! { - static ACTIVE_PROGRESS: RefCell> = const { RefCell::new(None) }; + static ACTIVE_PROGRESS: ProgressLifecycle = const { ProgressLifecycle::new() }; } /// Guards against a second update-check flow (silent startup check, manual "Check for @@ -82,6 +83,8 @@ struct DownloadProgress { downloaded: AtomicU64, total: AtomicU64, finished: AtomicBool, + #[cfg(target_os = "macos")] + preparing: AtomicBool, cancelled: AtomicBool, } @@ -143,6 +146,21 @@ pub fn run_update_check( channel: UpdateChannel, trigger: CheckTrigger, ) { + run_update_check_with_exit_handler(config, parent, channel, trigger, || process::exit(0)); +} + +/// Like [`run_update_check`], but calls `on_exit` on the UI thread when an installer is ready. +/// +/// The handler must save application state and terminate the process. macOS installation is +/// cancelled if the host does not exit within 60 seconds. It is not called on failure or cancel. +pub fn run_update_check_with_exit_handler( + config: Arc, + parent: &dyn WxWidget, + channel: UpdateChannel, + trigger: CheckTrigger, + on_exit: impl FnOnce() + Send + 'static, +) { + let on_exit: Box = Box::new(on_exit); let Some(check) = ActiveCheck::acquire() else { return; }; @@ -150,7 +168,7 @@ pub fn run_update_check( thread::spawn(move || { let outcome = check_for_updates(&config, channel); wxdragon::call_after(Box::new(move || match outcome { - Ok(UpdateCheckOutcome::UpdateAvailable(result)) => offer_update(config, check, parent, result), + Ok(UpdateCheckOutcome::UpdateAvailable(result)) => offer_update(config, check, parent, result, on_exit), Ok(UpdateCheckOutcome::UpToDate(version)) if trigger == CheckTrigger::Manual => { show_up_to_date(parent, &version); } @@ -163,15 +181,27 @@ pub fn run_update_check( }); } -fn offer_update(config: Arc, check: ActiveCheck, parent: ParentWindow, result: UpdateAvailableResult) { +fn offer_update( + config: Arc, + check: ActiveCheck, + parent: ParentWindow, + result: UpdateAvailableResult, + on_exit: Box, +) { let plain_notes = markdown_to_text(&result.release_notes); let release_notes = if plain_notes.is_empty() { t("No release notes provided.") } else { plain_notes }; if show_update_dialog(&parent, &result.latest_version, &release_notes, &config.app_display_name) { - start_download(config, check, parent, result); + start_download(config, check, parent, result, on_exit); } } -fn start_download(config: Arc, check: ActiveCheck, parent: ParentWindow, result: UpdateAvailableResult) { +fn start_download( + config: Arc, + check: ActiveCheck, + parent: ParentWindow, + result: UpdateAvailableResult, + on_exit: Box, +) { let title = t("%s Update").replace("%s", &config.app_display_name); let progress_dialog = ProgressDialog::builder(&parent, &title, &t("Downloading update..."), 100) .with_style( @@ -181,7 +211,7 @@ fn start_download(config: Arc, check: ActiveCheck, parent: Parent | ProgressDialogStyle::CanAbort, ) .build(); - ACTIVE_PROGRESS.with(|p| *p.borrow_mut() = Some(progress_dialog)); + ACTIVE_PROGRESS.with(|p| p.set(progress_dialog)); let progress = Arc::new(DownloadProgress::default()); spawn_progress_heartbeat(Arc::clone(&progress)); thread::spawn(move || { @@ -190,13 +220,39 @@ fn start_download(config: Arc, check: ActiveCheck, parent: Parent progress.downloaded.store(d, Ordering::Relaxed); progress.total.store(t, Ordering::Relaxed); }); + #[cfg(target_os = "macos")] + let download = if progress.cancelled.load(Ordering::Relaxed) { + if let Ok(ref path) = download { + platform::remove_private_download(path); + } + Err(t("Update cancelled.")) + } else { + download.map_err(|e| format!("{}: {e}", t("Update failed"))).and_then(|path| { + progress.preparing.store(true, Ordering::Relaxed); + let result = platform::install_with_cancel(&config, &path, &progress.cancelled); + // A manual fallback still needs its DMG. Automatic preparation has copied the + // bundle, so its private verified download can be removed. + if !matches!(result, Ok(InstallOutcome::ManualStep(_))) { + platform::remove_private_download(&path); + } + result + }) + }; progress.finished.store(true, Ordering::Relaxed); wxdragon::call_after(Box::new(move || { - ACTIVE_PROGRESS.with(|p| *p.borrow_mut() = None); - if !progress.cancelled.load(Ordering::Relaxed) { - install_update(&config, parent, download); - } - drop(check); + ACTIVE_PROGRESS.with(|p| { + p.finish(Box::new(move || { + ACTIVE_PROGRESS.with(ProgressLifecycle::clear); + if !progress.cancelled.load(Ordering::Relaxed) { + #[cfg(not(target_os = "macos"))] + let download = download + .map_err(|e| format!("{}: {e}", t("Update failed"))) + .and_then(|path| platform::install(&config, &path)); + install_update(parent, download, on_exit); + } + drop(check); + })) + }); })); wxdragon::wake_up_idle(); }); @@ -219,32 +275,37 @@ fn spawn_progress_heartbeat(progress: Arc) { fn update_progress_dialog(progress: &DownloadProgress, downloaded: u64, total: u64) { ACTIVE_PROGRESS.with(|p| { - let Some(keep_going) = p.borrow().as_ref().map(|dialog| { - downloaded - .saturating_mul(100) - .checked_div(total) - .and_then(|percent| i32::try_from(percent).ok()) - .map_or_else(|| dialog.pulse(None), |percent| dialog.update(percent, None)) - }) else { + let Some(update) = p.begin_update() else { return; }; + let dialog = update.dialog(); + #[cfg(target_os = "macos")] + let preparing = progress.preparing.load(Ordering::Relaxed); + #[cfg(not(target_os = "macos"))] + let preparing = false; + let keep_going = if preparing { + dialog.pulse(Some(&t("Preparing update..."))) + } else { + download_percent(downloaded, total) + .map_or_else(|| dialog.pulse(None), |percent| dialog.update(percent, None)) + }; if !keep_going { - // Signal the download thread to abort immediately instead of letting the transfer - // run to completion (or its 10-minute timeout) unattended in the background. progress.cancelled.store(true, Ordering::Relaxed); - if let Some(dialog) = p.borrow().as_ref() { - dialog.update(100, None); - } - *p.borrow_mut() = None; + p.clear(); } + // Dropping `update` invokes any completion queued during the native event-loop yield, + // after Update/Pulse returns and its reference to the dialog is released. }); } -fn install_update(config: &UpdaterConfig, parent: ParentWindow, download: Result) { - let outcome = - download.map_err(|e| format!("{}: {e}", t("Update failed"))).and_then(|path| platform::install(config, &path)); +fn install_update(parent: ParentWindow, outcome: Result, on_exit: Box) { match outcome { - Ok(InstallOutcome::Exit) => process::exit(0), + Ok(InstallOutcome::Exit) => on_exit(), + #[cfg(target_os = "macos")] + Ok(InstallOutcome::Prepared(update)) => match update.commit() { + Ok(()) => on_exit(), + Err(msg) => show_message(parent, &msg, &t("Error"), MessageDialogStyle::IconError), + }, Ok(InstallOutcome::ManualStep(msg)) => { show_message(parent, &msg, &t("Update Ready"), MessageDialogStyle::IconInformation); } diff --git a/src/ui/progress.rs b/src/ui/progress.rs new file mode 100644 index 0000000..13c505b --- /dev/null +++ b/src/ui/progress.rs @@ -0,0 +1,127 @@ +//! wx progress updates yield to the event loop. Keep dialog ownership and completion separate +//! so a nested callback cannot destroy a dialog, or borrow its storage, during Update/Pulse. +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; + +pub(super) struct ProgressLifecycle { + dialog: RefCell>>, + updating: Cell, + completion: RefCell>>, +} + +impl ProgressLifecycle { + pub(super) const fn new() -> Self { + Self { dialog: RefCell::new(None), updating: Cell::new(false), completion: RefCell::new(None) } + } + + pub(super) fn set(&self, dialog: D) { + *self.dialog.borrow_mut() = Some(Rc::new(dialog)); + } + + pub(super) fn clear(&self) { + // Native dialog destruction can process events too. Release the borrow before dropping. + let dialog = self.dialog.borrow_mut().take(); + drop(dialog); + } + + pub(super) fn begin_update(&self) -> Option> { + if self.updating.get() { + return None; + } + let dialog = self.dialog.borrow().as_ref().map(Rc::clone)?; + self.updating.set(true); + Some(ProgressUpdate { state: self, dialog: Some(dialog) }) + } + + pub(super) fn finish(&self, completion: Box) { + if self.updating.get() { + *self.completion.borrow_mut() = Some(completion); + } else { + completion(); + } + } +} + +pub(super) struct ProgressUpdate<'a, D> { + state: &'a ProgressLifecycle, + dialog: Option>, +} + +impl ProgressUpdate<'_, D> { + pub(super) fn dialog(&self) -> &D { + self.dialog.as_ref().expect("progress update owns its dialog") + } +} + +impl Drop for ProgressUpdate<'_, D> { + fn drop(&mut self) { + // Native Update/Pulse has returned. Release its reference before handing off shutdown. + drop(self.dialog.take()); + self.state.updating.set(false); + let completion = self.state.completion.borrow_mut().take(); + if let Some(completion) = completion { + completion(); + } + } +} + +/// Keep the gauge below its native completion value until preparation and verification finish. +/// Update(100) without AutoHide opens a nested modal loop and waits for the user to close it. +pub(super) fn download_percent(downloaded: u64, total: u64) -> Option { + downloaded.saturating_mul(100).checked_div(total).map(|value| i32::try_from(value.min(99)).unwrap_or(99)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completed_download_never_enters_native_completion_loop() { + assert_eq!(download_percent(50, 100), Some(50)); + assert_eq!(download_percent(100, 100), Some(99)); + assert_eq!(download_percent(200, 100), Some(99)); + assert_eq!(download_percent(u64::MAX, 1), Some(99)); + assert_eq!(download_percent(0, 0), None); + } + + #[test] + fn nested_completion_waits_for_native_update_to_return_and_destroy_dialog() { + struct Dialog(Rc>>); + impl Drop for Dialog { + fn drop(&mut self) { + self.0.borrow_mut().push("destroyed"); + } + } + let events = Rc::new(RefCell::new(Vec::new())); + let state = Rc::new(ProgressLifecycle::new()); + state.set(Dialog(Rc::clone(&events))); + let guard = state.begin_update().unwrap(); + assert!(state.begin_update().is_none()); + let complete_state = Rc::clone(&state); + let complete_events = Rc::clone(&events); + state.finish(Box::new(move || { + complete_state.clear(); + complete_events.borrow_mut().push("shutdown"); + })); + assert!(events.borrow().is_empty()); + // A reentrant callback can borrow the storage while Update/Pulse is active. + assert!(state.dialog.try_borrow_mut().is_ok()); + let _ = guard.dialog(); + drop(guard); + assert_eq!(*events.borrow(), ["destroyed", "shutdown"]); + assert!(state.begin_update().is_none()); + } + + #[test] + fn cancellation_keeps_native_dialog_alive_until_update_returns() { + let state = ProgressLifecycle::new(); + state.set(42); + let guard = state.begin_update().unwrap(); + state.clear(); + assert_eq!(*guard.dialog(), 42); + drop(guard); + assert!(state.begin_update().is_none()); + } +} From bbbdc5761a54a9b0b742b0f393e28ae0548e895b Mon Sep 17 00:00:00 2001 From: Johann Date: Sat, 3 Oct 2026 10:21:24 +0800 Subject: [PATCH 2/2] Verify macOS signers and clean up update artifacts Require the running and replacement bundles to satisfy an Apple signing chain requirement for the same developer Team ID. Keep unsigned and ad-hoc hosts on the manual path, and validate the replacement before and after staging. Test rejection of valid ad-hoc signatures and provide an optional read-only check for an existing developer-signed release. Register stage ownership and host/helper PIDs. On the next automatic update, sweep stages older than 24 hours only for the same bundle and user, with no live host/helper and no recovery backup. Preserve uncertain metadata, symlinks and other apps' stages. Remove successful/canceled helper logs and retain failure diagnostics. Cover these cleanup rules in the helper tests. Correct the standalone clippy findings, keep native relaunch environment values intact, and exclude the repository demo from published packages. Allow the demo to use an Apple-issued signing identity and document its manual fallback when an ad-hoc signature is used. Co-Authored-By: Codex --- Cargo.toml | 1 + README.md | 28 +++++--- examples/macos_update_demo.rs | 6 +- src/config.rs | 6 +- src/platform/macos.rs | 119 +++++++++++++++++++++++++++++++--- src/platform/macos_install.sh | 7 +- src/platform/macos_tests.rs | 85 ++++++++++++++++++++++-- src/ui.rs | 2 +- src/ui/progress.rs | 2 +- 9 files changed, 226 insertions(+), 30 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8966901..8a98faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/trypsynth/ship-shape" readme = "README.md" keywords = ["updater", "wxdragon", "auto-update", "desktop", "github-releases"] categories = ["gui"] +exclude = ["examples/"] [package.metadata.patois] translatable = true diff --git a/README.md b/README.md index dd41c32..dad3108 100644 --- a/README.md +++ b/README.md @@ -67,15 +67,17 @@ ui::run_update_check(config, &frame, UpdateChannel::Stable, CheckTrigger::Manual Updates replace the running app at its existing location, including custom folders and renamed bundles. The DMG must contain exactly one top-level `.app` matching the running bundle identifier, -with a valid code signature and executable. Mounted images and Gatekeeper-translocated apps are -not modified. No administrator privileges are requested. +with a valid code signature and executable. Both the running app and its replacement must carry +the same Apple developer Team ID, verified against an Apple-issued signing chain. Unsigned and +ad-hoc signed development builds use the manual flow. Mounted images and Gatekeeper-translocated +apps are not modified. No administrator privileges are requested. Mounting, validation, copying, and the helper handshake run off the UI thread. Canceling before shutdown drops the staged update. The detached helper waits up to 60 seconds for the app to exit, then swaps bundles using same-filesystem renames. A failed replacement restores the old bundle; -a failed restore or launch preserves the backup and reports its location. Helper logs survive -cleanup under `~/Library/Logs/ship-shape-update-*.log`. A successful Launch Services request does -not detect subsequent application crashes. +a failed restore or launch preserves the backup and reports its location. Failure logs survive +cleanup under `~/Library/Logs/ship-shape-update-*.log`; successful and canceled updates remove +their logs. A successful Launch Services request does not detect subsequent application crashes. Use `ui::run_update_check_with_exit_handler` to save state and terminate normally. The existing `run_update_check` entry point keeps its immediate process-exit behavior. The handler runs on the @@ -86,11 +88,14 @@ UI thread only after successful installer preparation. It must terminate the pro when launching through macOS `open`, such as an isolated settings directory. It is ignored on other platforms; values are passed as arguments and never interpolated into shell code. -For local testing, build `cargo build --example macos_update_demo`. Run the example with +From a repository checkout, build `cargo build --example macos_update_demo`. Run the example with `--setup /tmp/ship-shape-demo` using a new directory, then run the generated `Installed Demo.app/Contents/MacOS/demo` executable with `--cancel` or `--apply` followed by the -fixture's `demo.dmg` path. Applying must display version 2; canceling must retain version 1. -The demo trusts its own local, ad-hoc signed fixture; it does not change production verification. +fixture's `demo.dmg` path. For automatic installation, set `SHIP_SHAPE_DEMO_SIGNING_ID` to an +Apple-issued signing identity when running `--setup`. Applying must display version 2; canceling must retain version 1. Without +that identity, fixtures are ad-hoc signed and demonstrate the manual fallback instead. The demo +trusts only its generated local fixture and keeps the production signer checks. It is a repository +test harness, excluded from the published crate package. The download gauge stays below 100 until verification and preparation finish. Native progress updates can yield to the event loop, so completion waits for the active Update/Pulse call to @@ -102,6 +107,13 @@ failed restore and launch, competing destination changes, protected locations, b signature tampering, and paths with spaces and apostrophes. Tests substitute launch commands and alerts and only modify temporary fixture apps. The demo above additionally exercises actual DMG mounting and Launch Services; testing a signed, notarized release establishes the Gatekeeper path. +For a read-only signer check with an existing release, set `SHIP_SHAPE_TEST_SIGNED_APP` to its +app path and run `cargo test developer_signed_release_accepts_only_its_team -- --ignored`. + +On the next automatic update, stages older than 24 hours are swept only when they belong to the +same bundle and user and neither the host nor helper process is alive. Unregistered stages, +symlinks, and stages containing `old.app` recovery backups are preserved. This avoids deleting +another app's staging area, an active update, or the only recoverable copy after a failed swap. ## License diff --git a/examples/macos_update_demo.rs b/examples/macos_update_demo.rs index b95e7c7..bc69bc8 100644 --- a/examples/macos_update_demo.rs +++ b/examples/macos_update_demo.rs @@ -1,6 +1,7 @@ //! Disposable macOS updater demo. It includes the actual installer implementation, without //! adding a production API for applying arbitrary local files. No network or key bypass is //! added to the updater. This demo trusts ONLY its own locally generated fixture image. +//! Set `SHIP_SHAPE_DEMO_SIGNING_ID` to an Apple-issued signing identity for automatic installation. #![allow(dead_code, reason = "the shared platform module has APIs unused by the demo")] #[cfg(target_os = "macos")] @@ -115,8 +116,9 @@ fn make_bundle(bundle: &Path, version: &str) -> Result<(), Box"# ), )?; - if !Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(bundle).status()?.success() { - return Err("Failed to ad-hoc sign fixture".into()); + let identity = env::var("SHIP_SHAPE_DEMO_SIGNING_ID").unwrap_or_else(|_| "-".to_owned()); + if !Command::new("/usr/bin/codesign").args(["--force", "--sign", &identity]).arg(bundle).status()?.success() { + return Err("Failed to sign fixture".into()); } Ok(()) } diff --git a/src/config.rs b/src/config.rs index 8a79f33..2a0bd58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::ffi::OsString; + /// How the running copy of the app was installed. Selects which release asset to download and how /// to apply it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -59,7 +61,7 @@ pub struct UpdaterConfig { /// Environment overrides passed to macOS `open` when relaunching the app. Empty by default. /// Use this for an explicit configuration directory that must survive a Launch Services launch. /// Ignored on other platforms. Values are passed as arguments, never as shell source. - pub macos_relaunch_env: Vec<(String, String)>, + pub macos_relaunch_env: Vec<(String, OsString)>, } impl UpdaterConfig { @@ -140,7 +142,7 @@ impl UpdaterConfig { /// Preserve an environment override when macOS relaunches the updated app. #[must_use] - pub fn with_macos_relaunch_env(mut self, name: impl Into, value: impl Into) -> Self { + pub fn with_macos_relaunch_env(mut self, name: impl Into, value: impl Into) -> Self { self.macos_relaunch_env.push((name.into(), value.into())); self } diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 33b40b4..a1f1ad2 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -1,10 +1,13 @@ use std::{ - env, fs, + env, + ffi::OsString, + fs, + os::unix::fs::MetadataExt, path::{Path, PathBuf}, process::{self, Command, Stdio}, sync::atomic::{AtomicBool, Ordering}, thread, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime}, }; use patois::t; @@ -13,6 +16,8 @@ use tempfile::TempDir; use super::InstallOutcome; use crate::{InstallKind, UpdateError, UpdaterConfig}; +const STALE_STAGE_AGE: Duration = Duration::from_hours(24); + /// macOS has a single asset kind, a disk image, so `install_kind` is ignored. pub const fn asset_name_parts(_install_kind: InstallKind) -> (&'static str, &'static str) { ("", "dmg") @@ -62,25 +67,31 @@ pub fn install_with_cancel( let Some(parent) = bundle.parent() else { return manual_install(config, path); }; + let Some(team) = signing_team(&bundle) else { + return manual_install(config, path); + }; + validate_bundle(&bundle, &team)?; let Ok(stage) = tempfile::Builder::new().prefix(".ship-shape-").tempdir_in(parent) else { return manual_install(config, path); }; + register_stage(stage.path(), &bundle)?; + sweep_stale_stages(parent, &bundle, stage.path().metadata().map_err(|e| e.to_string())?.uid(), SystemTime::now()); let original_stamp = bundle_stamp(&bundle)?; let identity = plist_value(&bundle, "CFBundleIdentifier")?; let mount = Mount::attach(path)?; check_cancelled(cancelled)?; let replacement = find_replacement(&mount.point, &identity)?; - validate_bundle(&replacement)?; + validate_bundle(&replacement, &team)?; let staged = stage.path().join("new.app"); run(Command::new("/usr/bin/ditto").arg(&replacement).arg(&staged), &t("Failed to stage the application"))?; check_cancelled(cancelled)?; - validate_bundle(&staged)?; + validate_bundle(&staged, &team)?; if plist_value(&staged, "CFBundleIdentifier")? != identity { return Err(t("The update contains a different application.")); } mount.detach()?; check_cancelled(cancelled)?; - // Keep the diagnostic outside the staging directory so it survives cleanup and relaunch. + // Failure diagnostics survive cleanup. The helper removes the log after a successful launch. let logs = env::var_os("HOME") .map(|home| PathBuf::from(home).join("Library/Logs")) .ok_or_else(|| t("Could not determine the update log directory."))?; @@ -106,7 +117,10 @@ pub fn install_with_cancel( .stdout(Stdio::from(log_file.try_clone().map_err(|e| e.to_string())?)) .stderr(Stdio::from(log_file)); for (name, value) in &config.macos_relaunch_env { - command.arg("--env").arg(format!("{name}={value}")); + let mut assignment = OsString::from(name); + assignment.push("="); + assignment.push(value); + command.arg("--env").arg(assignment); } let mut helper = command.spawn().map_err(|e| format!("{}: {e}", t("Failed to launch update helper")))?; let deadline = Instant::now() + Duration::from_secs(5); @@ -151,7 +165,6 @@ fn check_cancelled(cancelled: &AtomicBool) -> Result<(), String> { } fn bundle_stamp(bundle: &Path) -> Result { - use std::os::unix::fs::MetadataExt; let metadata = fs::symlink_metadata(bundle).map_err(|e| e.to_string())?; Ok(format!("{}:{}", metadata.dev(), metadata.ino())) } @@ -208,7 +221,34 @@ fn find_replacement(root: &Path, identity: &str) -> Result { Ok(matches.remove(0)) } -fn validate_bundle(bundle: &Path) -> Result<(), String> { +fn signing_team(bundle: &Path) -> Option { + let output = Command::new("/usr/bin/codesign").args(["--display", "--verbose=4"]).arg(bundle).output().ok()?; + if !output.status.success() { + return None; + } + let metadata = String::from_utf8(output.stderr).ok()?; + let team = metadata.lines().find_map(|line| line.strip_prefix("TeamIdentifier="))?; + // Apple Team IDs contain ten ASCII letters/digits. This also rejects "not set" and + // keeps the value safe to embed in the native code requirement language. + (team.len() == 10 && team.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())) + .then(|| team.to_owned()) +} + +fn validate_bundle(bundle: &Path, team: &str) -> Result<(), String> { + validate_bundle_integrity(bundle)?; + // Checking TeamIdentifier metadata alone could trust a self-signed certificate. Require + // an Apple-issued signing chain and the running app's team in the certificate itself. + let requirement = format!("anchor apple generic and certificate leaf[subject.OU] = \"{team}\""); + run( + Command::new("/usr/bin/codesign") + .args(["--verify", "--deep", "--strict"]) + .arg(format!("-R={requirement}")) + .arg(bundle), + &t("The update must be signed by the application's developer team"), + ) +} + +fn validate_bundle_integrity(bundle: &Path) -> Result<(), String> { let name = plist_value(bundle, "CFBundleExecutable")?; if Path::new(&name).file_name().is_none_or(|n| n != name.as_str()) { return Err(t("Invalid application executable.")); @@ -225,6 +265,69 @@ fn validate_bundle(bundle: &Path) -> Result<(), String> { ) } +fn register_stage(stage: &Path, bundle: &Path) -> Result<(), String> { + let bundle = bundle.canonicalize().map_err(|e| e.to_string())?; + fs::write(stage.join("owner-bundle"), bundle.as_os_str().as_encoded_bytes()).map_err(|e| e.to_string())?; + fs::write(stage.join("host-pid"), process::id().to_string()).map_err(|e| e.to_string()) +} + +/// Only remove abandoned stages belonging to this app and user. Recovery backups are kept. +fn sweep_stale_stages(parent: &Path, bundle: &Path, owner: u32, now: SystemTime) { + let Ok(bundle) = bundle.canonicalize() else { return }; + let Ok(entries) = fs::read_dir(parent) else { return }; + for entry in entries.flatten() { + if !entry.file_name().to_string_lossy().starts_with(".ship-shape-") { + continue; + } + let stage = entry.path(); + let Ok(metadata) = fs::symlink_metadata(&stage) else { continue }; + if !metadata.is_dir() || metadata.uid() != owner { + continue; + } + match fs::symlink_metadata(stage.join("old.app")) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => continue, + } + let marker = stage.join("owner-bundle"); + let Ok(metadata) = fs::symlink_metadata(&marker) else { continue }; + if !metadata.is_file() || metadata.uid() != owner { + continue; + } + let old_enough = metadata + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= STALE_STAGE_AGE); + if !old_enough || !fs::read(&marker).is_ok_and(|path| path == bundle.as_os_str().as_encoded_bytes()) { + continue; + } + if stage_process_alive(&stage.join("host-pid"), owner, false) + || stage_process_alive(&stage.join("helper-pid"), owner, true) + { + continue; + } + let _ = fs::remove_dir_all(stage); + } +} + +fn stage_process_alive(path: &Path, owner: u32, optional: bool) -> bool { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if optional && error.kind() == std::io::ErrorKind::NotFound => return false, + Err(_) => return true, + }; + if !metadata.is_file() || metadata.uid() != owner { + return true; + } + let Some(pid) = + fs::read_to_string(path).ok().and_then(|text| text.trim().parse::().ok()).filter(|pid| *pid > 0) + else { + return true; + }; + // Fail closed if process inspection itself cannot run. A reused PID only delays cleanup. + Command::new("/bin/kill").args(["-0", &pid.to_string()]).output().map_or(true, |output| output.status.success()) +} + fn run(command: &mut Command, message: &str) -> Result<(), String> { let output = command.output().map_err(|e| format!("{message}: {e}"))?; if !output.status.success() { diff --git a/src/platform/macos_install.sh b/src/platform/macos_install.sh index c43487b..78a0967 100644 --- a/src/platform/macos_install.sh +++ b/src/platform/macos_install.sh @@ -27,11 +27,15 @@ APPLESCRIPT cleanup() { /bin/rm -rf "$stage" } +printf '%s\n' "$$" > "$stage/helper-pid" || exit 1 : > "$stage/ready" || exit 1 # Cancelled UI flows drop the staging directory. Never touch the app without commit. count=0 while [ ! -f "$stage/commit" ]; do - [ -d "$stage" ] || exit 0 + if [ ! -d "$stage" ]; then + /bin/rm -f "$log" + exit 0 + fi count=$((count + 1)) if [ "$count" -ge 300 ]; then printf '%s\n' 'Installation was not committed; keeping the current app.' @@ -84,3 +88,4 @@ if ! launch_app "$@"; then fi printf '%s\n' 'Replacement completed and macOS accepted the launch request.' cleanup +/bin/rm -f "$log" diff --git a/src/platform/macos_tests.rs b/src/platform/macos_tests.rs index c639898..53824f3 100644 --- a/src/platform/macos_tests.rs +++ b/src/platform/macos_tests.rs @@ -5,12 +5,15 @@ use std::{ path::PathBuf, process::{Child, Command}, thread, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime}, }; use tempfile::TempDir; -use super::{PreparedUpdate, bundle_stamp, check_cancelled, find_replacement, validate_bundle}; +use super::{ + PreparedUpdate, STALE_STAGE_AGE, bundle_stamp, check_cancelled, find_replacement, register_stage, signing_team, + sweep_stale_stages, validate_bundle, validate_bundle_integrity, +}; struct Fixture { root: TempDir, @@ -29,6 +32,7 @@ impl Fixture { fs::create_dir(stage.join("new.app")).unwrap(); fs::write(bundle.join("version"), "old").unwrap(); fs::write(stage.join("new.app/version"), "new").unwrap(); + fs::write(root.path().join("log"), "diagnostic").unwrap(); Self { root, bundle, stage } } @@ -121,6 +125,7 @@ fn helper_replaces_and_cleans_up_with_quoted_paths() { assert!(wait(fixture.launch(u32::MAX, true, ""))); assert_eq!(fixture.version(), "new"); assert!(!fixture.stage.exists()); + assert!(!fixture.root.path().join("log").exists()); let args = fs::read_to_string(fixture.root.path().join("launch-args")).unwrap(); assert!(args.ends_with("--env\nTEST_CONFIG_PATH=O'Brien test/settings\n")); } @@ -131,6 +136,7 @@ fn helper_restores_old_bundle_after_install_failure() { assert!(!wait(fixture.launch(u32::MAX, true, "rename"))); assert_eq!(fixture.version(), "old"); assert!(!fixture.stage.exists()); + assert!(fixture.root.path().join("log").exists()); } #[test] @@ -147,6 +153,7 @@ fn helper_preserves_backup_when_launch_fails() { assert!(!wait(fixture.launch(u32::MAX, true, "launch"))); assert_eq!(fixture.version(), "new"); assert_eq!(fs::read_to_string(fixture.stage.join("old.app/version")).unwrap(), "old"); + assert!(fixture.root.path().join("log").exists()); } #[test] @@ -178,6 +185,7 @@ fn dropping_prepared_update_cancels_before_replacement() { fs::remove_dir_all(&fixture.stage).unwrap(); assert!(wait(child)); assert_eq!(fixture.version(), "old"); + assert!(!fixture.root.path().join("log").exists()); let stage = tempfile::tempdir().unwrap(); let path = stage.path().to_owned(); drop(PreparedUpdate { stage }); @@ -208,14 +216,14 @@ fn rejects_missing_escaping_and_unsigned_executables() { use std::os::unix::fs::PermissionsExt; let root = tempfile::tempdir().unwrap(); let bundle = metadata_bundle(root.path(), "Missing.app", "id", "app"); - assert!(validate_bundle(&bundle).is_err()); + assert!(validate_bundle(&bundle, "TESTTEAM01").is_err()); let bundle = metadata_bundle(root.path(), "Escaping.app", "id", "../../outside"); - assert!(validate_bundle(&bundle).is_err()); + assert!(validate_bundle(&bundle, "TESTTEAM01").is_err()); let bundle = metadata_bundle(root.path(), "Unsigned.app", "id", "app"); let executable = bundle.join("Contents/MacOS/app"); fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); - assert!(validate_bundle(&bundle).is_err()); + assert!(validate_bundle(&bundle, "TESTTEAM01").is_err()); } #[test] @@ -247,8 +255,71 @@ fn signed_bundle_passes_but_tampering_fails() { fs::copy("/usr/bin/true", bundle.join("Contents/MacOS/app")).unwrap(); let status = Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(&bundle).output().unwrap(); assert!(status.status.success(), "{}", String::from_utf8_lossy(&status.stderr)); - validate_bundle(&bundle).unwrap(); + validate_bundle_integrity(&bundle).unwrap(); + assert!(signing_team(&bundle).is_none()); + let error = validate_bundle(&bundle, "TESTTEAM01").unwrap_err(); + assert!(error.contains("developer team"), "{error}"); fs::write(&resource, "tampered").unwrap(); - let error = validate_bundle(&bundle).unwrap_err(); + let error = validate_bundle_integrity(&bundle).unwrap_err(); assert!(error.contains("signature verification failed"), "{error}"); } + +#[test] +fn unsigned_host_has_no_signing_team() { + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Unsigned.app", "id", "app"); + fs::copy("/usr/bin/true", bundle.join("Contents/MacOS/app")).unwrap(); + assert!(signing_team(&bundle).is_none()); +} + +#[test] +#[ignore = "requires SHIP_SHAPE_TEST_SIGNED_APP pointing to an Apple Developer-signed app"] +fn developer_signed_release_accepts_only_its_team() { + let bundle = PathBuf::from(std::env::var_os("SHIP_SHAPE_TEST_SIGNED_APP").expect("provide a signed app path")); + let team = signing_team(&bundle).expect("the fixture must have an Apple developer team"); + validate_bundle(&bundle, &team).unwrap(); + let other = if team == "TESTTEAM01" { "TESTTEAM02" } else { "TESTTEAM01" }; + let error = validate_bundle(&bundle, other).unwrap_err(); + assert!(error.contains("developer team"), "{error}"); +} + +#[test] +fn stale_stage_cleanup_preserves_live_helpers_backups_and_other_apps() { + use std::os::unix::fs::MetadataExt; + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Current.app", "id", "app"); + let other = metadata_bundle(root.path(), "Other.app", "other.id", "app"); + let make_stage = |name: &str, app: &std::path::Path| { + let stage = root.path().join(format!(".ship-shape-{name}")); + fs::create_dir(&stage).unwrap(); + register_stage(&stage, app).unwrap(); + fs::write(stage.join("host-pid"), "999999999").unwrap(); + fs::create_dir(stage.join("new.app")).unwrap(); + stage + }; + let abandoned = make_stage("abandoned", &bundle); + let live_host = make_stage("host", &bundle); + fs::write(live_host.join("host-pid"), std::process::id().to_string()).unwrap(); + let live_helper = make_stage("helper", &bundle); + fs::write(live_helper.join("helper-pid"), std::process::id().to_string()).unwrap(); + let recovery = make_stage("recovery", &bundle); + fs::create_dir(recovery.join("old.app")).unwrap(); + let unrelated = make_stage("other", &other); + let malformed = make_stage("malformed", &bundle); + fs::write(malformed.join("host-pid"), "-1").unwrap(); + let unregistered = root.path().join(".ship-shape-unregistered"); + fs::create_dir(&unregistered).unwrap(); + let symlink = root.path().join(".ship-shape-link"); + std::os::unix::fs::symlink(&abandoned, &symlink).unwrap(); + let owner = abandoned.metadata().unwrap().uid(); + sweep_stale_stages(root.path(), &bundle, owner, SystemTime::now()); + assert!(abandoned.exists(), "recent stages must not be swept"); + sweep_stale_stages(root.path(), &bundle, owner.wrapping_add(1), SystemTime::now() + STALE_STAGE_AGE * 2); + assert!(abandoned.exists(), "stages owned by another user must not be swept"); + sweep_stale_stages(root.path(), &bundle, owner, SystemTime::now() + STALE_STAGE_AGE * 2); + assert!(!abandoned.exists()); + for stage in [live_host, live_helper, recovery, unrelated, malformed, unregistered] { + assert!(stage.exists(), "must preserve {}", stage.display()); + } + assert!(fs::symlink_metadata(symlink).unwrap().file_type().is_symlink()); +} diff --git a/src/ui.rs b/src/ui.rs index c04744e..7ce57cb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -251,7 +251,7 @@ fn start_download( install_update(parent, download, on_exit); } drop(check); - })) + })); }); })); wxdragon::wake_up_idle(); diff --git a/src/ui/progress.rs b/src/ui/progress.rs index 13c505b..2fded34 100644 --- a/src/ui/progress.rs +++ b/src/ui/progress.rs @@ -68,7 +68,7 @@ impl Drop for ProgressUpdate<'_, D> { } /// Keep the gauge below its native completion value until preparation and verification finish. -/// Update(100) without AutoHide opens a nested modal loop and waits for the user to close it. +/// `Update(100)` without `AutoHide` opens a nested modal loop and waits for the user to close it. pub(super) fn download_percent(downloaded: u64, total: u64) -> Option { downloaded.saturating_mul(100).checked_div(total).map(|value| i32::try_from(value.min(99)).unwrap_or(99)) }