diff --git a/Cargo.lock b/Cargo.lock index b381bfc1..b2e8f2ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,7 +324,9 @@ dependencies = [ "tar", "tempfile", "ureq", + "verifysign", "winreg", + "winsafe", "zip", ] @@ -4989,6 +4991,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -5781,6 +5793,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs index f629f07b..8ce7ff7f 100644 --- a/apps/artcraft-launcher/src/main.rs +++ b/apps/artcraft-launcher/src/main.rs @@ -37,6 +37,11 @@ fn main() -> ExitCode { // First, so the panic report and every start-up warning are recorded (`logging`). let logs = logging::install(); logging::install_panic_hook(); + // A copy of the launcher finishing a self-update: no window, log file or single-instance claim. + #[cfg(windows)] + if let Some(done) = artcraft_launcher_native::self_update::finish(&std::env::args_os().skip(1).collect::>()) { + return if done.is_ok() { ExitCode::SUCCESS } else { ExitCode::FAILURE }; + } // Detect system language in the background so the first frame can use it. artcraft_launcher_ui_egui::i18n::detect_system_lang_in_background(); // Detect Discord protocol handler in the background without blocking startup. @@ -72,6 +77,11 @@ fn main() -> ExitCode { } } } + if let Some(error) = + artcraft_launcher_native::paths::Paths::new().ok().and_then(|paths| artcraft_launcher_native::self_update::take_failure(&paths)) + { + log::warn!("the last self-update didn't install: {error}"); + } let (tray_loop, woken) = sync_channel(1); let waker = Arc::new(Waker { window: Mutex::new(None), tray_loop, quit_pending: AtomicBool::new(false) }); { diff --git a/apps/artcraft-launcher/tests/update_helper.rs b/apps/artcraft-launcher/tests/update_helper.rs new file mode 100644 index 00000000..155237cf --- /dev/null +++ b/apps/artcraft-launcher/tests/update_helper.rs @@ -0,0 +1,30 @@ +//! The Windows update helper: the launcher started with `--finish-update` does its step and +//! exits, without opening a window or claiming the single instance. + +#![cfg(windows)] + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn the_update_helper_exits_without_opening_the_launcher() { + let mut helper = Command::new(env!("CARGO_BIN_EXE_artcraft-launcher")) + .arg("--finish-update") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + let status = loop { + if let Some(status) = helper.try_wait().unwrap() { + break status; + } + if Instant::now() > deadline { + let _ = helper.kill(); + panic!("the launcher started instead of the update helper"); + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert!(!status.success(), "incomplete helper arguments are an error"); +} diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index 687009ba..0836bed2 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -40,3 +40,7 @@ winreg = "0.55" # Resolves junctions/symlinks without forcing short MSI paths into the verbatim namespace. dunce = "1" zip = { version = "8", default-features = false, features = ["deflate"] } +# Safe wrappers for the Win32 calls that have no std equivalent (waiting for another process to exit). +winsafe = { version = "0.0.29", features = ["kernel"] } +# Authenticode checks through WinTrust: the update helper installs only files from the launcher's publisher. +verifysign = "1" diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 1e974eac..87596360 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1490,6 +1490,13 @@ pub mod windows { }) } + /// Runs the launcher's own new MSI for a self-update, with the same progress window and no + /// restart as [`run_msi`], and waits for it. + pub fn reinstall(msi: &Path) -> Result<(), String> { + let status = msi_command(&msiexec(), msi)?.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?; + explain(status.code()) + } + /// Runs `work` once no other Windows Installer job of ours is running (a second one would /// stop with error 1618). Cancelled while it waits, it never runs. fn in_turn(stop: &Stop, phase: &mut dyn FnMut(JobPhase), working: JobPhase, work: impl FnOnce() -> Result<(), String>) -> Result<(), String> { diff --git a/crates/native/src/portable.rs b/crates/native/src/portable.rs index f43db2bb..5bdf89ca 100644 --- a/crates/native/src/portable.rs +++ b/crates/native/src/portable.rs @@ -304,6 +304,44 @@ pub fn remove(app: &CraftApp, record: &Record) -> Result<(), String> { Ok(()) } +/// Unpacks the launcher's own portable zip into `staging`, with the same checks as an app's, and +/// copies it over `folder` for a self-update. `check` must accept every unpacked file before +/// any is copied. When every file sits in one top-level folder, that folder's contents are the +/// program. +pub fn unpack_over(zip: &Path, staging: &Path, folder: &Path, check: &dyn Fn(&Path) -> Result<(), String>) -> Result<(), String> { + let _ = std::fs::remove_dir_all(staging); + std::fs::create_dir_all(staging).map_err(|e| e.to_string())?; + let files = extract(zip, staging)?; + for relative in &files { + check(&staging.join(relative))?; + } + let top = single_top_folder(&files); + for relative in &files { + let inside = match &top { + Some(top) => relative.strip_prefix(top).map_err(|e| e.to_string())?, + None => relative.as_path(), + }; + let target = folder.join(inside); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::copy(staging.join(relative), &target).map_err(|e| format!("couldn't replace {} ({e})", target.display()))?; + } + Ok(()) +} + +/// The one folder that holds every file, when there is one. +fn single_top_folder(files: &[PathBuf]) -> Option { + let first = files.first()?.components().next()?; + files + .iter() + .all(|file| { + let mut parts = file.components(); + parts.next() == Some(first) && parts.next().is_some() + }) + .then(|| PathBuf::from(first.as_os_str())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs index cd4f88ef..06b79b43 100644 --- a/crates/native/src/process.rs +++ b/crates/native/src/process.rs @@ -115,6 +115,17 @@ pub fn spawn_detached(mut cmd: Command) -> Result<(), String> { Ok(()) } +/// Waits up to `timeout` for the process `pid` to exit, and says whether it has. A process that +/// can't be opened, because it's gone or never was, counts as exited. +#[cfg(windows)] +pub fn wait_for_exit(pid: u32, timeout: Duration) -> bool { + use winsafe::{HPROCESS, co}; + let Ok(process) = HPROCESS::OpenProcess(co::PROCESS::SYNCHRONIZE, false, pid) else { return true }; + // u32::MAX would mean INFINITE. + let ms = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX - 1); + process.WaitForSingleObject(Some(ms)).is_ok_and(|w| w == co::WAIT::OBJECT_0) +} + /// Whether this process is running inside a Flatpak container sandbox. pub fn in_sandbox() -> bool { std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists() @@ -507,6 +518,15 @@ mod run_tests { if cfg!(windows) { ("cmd", ["/C", script]) } else { ("/bin/sh", ["-c", script]) } } + #[cfg(windows)] + #[test] + fn waiting_for_a_process_ends_when_it_exits() { + let mut child = Command::new("ping").args(["-n", "3", "127.0.0.1"]).stdout(Stdio::null()).spawn().unwrap(); + assert!(!wait_for_exit(child.id(), Duration::from_millis(200)), "it's still running"); + assert!(wait_for_exit(child.id(), Duration::from_secs(30)), "it has exited"); + child.wait().unwrap(); + } + #[test] fn a_failing_helper_reports_its_own_error() { let (program, args) = shell(if cfg!(windows) { "echo no such image 1>&2 & exit /b 3" } else { "echo no such image >&2; exit 3" }); diff --git a/crates/native/src/self_update.rs b/crates/native/src/self_update.rs index f3860384..1aa61c69 100644 --- a/crates/native/src/self_update.rs +++ b/crates/native/src/self_update.rs @@ -17,6 +17,7 @@ //! //! Anything else (FreeBSD, a development build) gets the download link instead. +use std::ffi::OsString; use std::path::{Path, PathBuf}; use artcraft_launcher_core::PackageKind; @@ -227,6 +228,8 @@ pub fn update( let asset = asset.clone(); let mut job = Job { http, paths, stop, phase, install_locations: &Default::default(), replace: false }; let file = install::download(release, &asset, &mut job)?; + #[cfg(windows)] + windows_precheck(install, &file)?; // The restart ends this process: nothing else may be under way. last()?; phase(JobPhase::Installing); @@ -265,23 +268,11 @@ pub fn update( relaunch_flatpak_after_exit() } #[cfg(windows)] - Install::Msi(exe) => windows_helper(pid, &msi_update_step(&file)?, exe), + Install::Msi(exe) => windows_helper(paths, pid, exe, &msi_finish(&file)?), #[cfg(windows)] Install::Portable(exe) => { - let dir = exe.parent().ok_or("the launcher isn't in a folder")?; - let tmp = paths.cache.join("launcher-update"); - windows_helper( - pid, - &format!( - "$t = {tmp}; Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction SilentlyContinue; Expand-Archive -LiteralPath {zip} -DestinationPath $t -Force; \ - $top = Get-ChildItem -LiteralPath $t | Select-Object -First 1; Copy-Item -Path (Join-Path $top.FullName '*') -Destination {dir} -Recurse -Force; \ - Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath {zip} -ErrorAction SilentlyContinue", - tmp = ps_quote(&tmp), - zip = ps_quote(&file), - dir = ps_quote(dir), - ), - exe, - ) + exe.parent().ok_or("the launcher isn't in a folder")?; + windows_helper(paths, pid, exe, &Finish::Portable { zip: file, tmp: paths.cache.join(PORTABLE_STAGING) }) } #[allow(unreachable_patterns)] _ => { @@ -391,35 +382,223 @@ fn relaunch_flatpak_after_exit() -> Result<(), String> { process::spawn_detached(cmd).map_err(|e| format!("couldn't schedule the Flatpak restart ({e})")) } -/// Starts a hidden PowerShell that waits for `pid` to exit, runs `step`, then starts `exe`. +/// The first argument of the Windows update helper: a copy of this program that finishes an +/// update once the launcher has exited (see [`finish`]). +pub const FINISH_FLAG: &str = "--finish-update"; + +/// What the update helper does once the old launcher has exited. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(not(windows), allow(dead_code))] +enum Finish { + /// Run the new MSI, which reinstalls into the same folder. + Msi(PathBuf), + /// Unpack the new portable zip into `tmp` and copy it over the launcher's folder. + Portable { zip: PathBuf, tmp: PathBuf }, +} + +/// The helper's arguments: [`FINISH_FLAG`], the process id to wait for, the exe to start +/// afterwards, then the step. +#[cfg_attr(not(windows), allow(dead_code))] +fn helper_args(pid: u32, exe: &Path, finish: &Finish) -> Vec { + let mut args: Vec = vec![FINISH_FLAG.into(), pid.to_string().into(), exe.into()]; + match finish { + Finish::Msi(msi) => args.extend(["msi".into(), msi.into()]), + Finish::Portable { zip, tmp } => args.extend(["portable".into(), zip.into(), tmp.into()]), + } + args +} + +/// Reads [`helper_args`] back. +#[cfg_attr(not(windows), allow(dead_code))] +fn parse_helper_args(args: &[OsString]) -> Option<(u32, PathBuf, Finish)> { + let [flag, pid, exe, step @ ..] = args else { return None }; + if flag != FINISH_FLAG { + return None; + } + let pid = pid.to_str()?.parse().ok()?; + let finish = match step { + [kind, msi] if kind == "msi" => Finish::Msi(msi.into()), + [kind, zip, tmp] if kind == "portable" => Finish::Portable { zip: zip.into(), tmp: tmp.into() }, + _ => return None, + }; + Some((pid, exe.into(), finish)) +} + +#[cfg_attr(not(windows), allow(dead_code))] +fn msi_finish(file: &Path) -> Result { + install::msi_path(file).map(Finish::Msi) +} + +/// The cache folder the update helper is copied into. +#[cfg_attr(not(windows), allow(dead_code))] +const HELPER_DIR: &str = "launcher-update-helper"; +/// The cache folder a portable update is unpacked into. +#[cfg_attr(not(windows), allow(dead_code))] +const PORTABLE_STAGING: &str = "launcher-update"; + +/// Copies this program into the cache and starts the copy as the update helper, which waits for +/// `pid` to exit, does `finish` and starts `exe`. Run from the cache, the helper doesn't hold the +/// installed exe that the step replaces. #[cfg(windows)] -fn windows_helper(pid: u32, step: &str, exe: &Path) -> Result<(), String> { - let script = format!("Wait-Process -Id {pid} -Timeout 60 -ErrorAction SilentlyContinue; {step}; Start-Process -FilePath {}", ps_quote(exe)); - let mut cmd = process::command("powershell.exe"); - cmd.args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", &script]).env_remove(PRETEND_VAR); +fn windows_helper(paths: &Paths, pid: u32, exe: &Path, finish: &Finish) -> Result<(), String> { + let program = std::env::current_exe().map_err(|e| format!("couldn't find this program ({e})"))?; + let dir = paths.cache.join(HELPER_DIR); + // The previous update's copy, when it's still there. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).map_err(|e| format!("couldn't schedule the update ({e})"))?; + let helper = dir.join(program.file_name().ok_or("this program has no file name")?); + std::fs::copy(&program, &helper).map_err(|e| format!("couldn't schedule the update ({e})"))?; + let mut cmd = process::command(&helper); + cmd.args(helper_args(pid, exe, finish)).env_remove(PRETEND_VAR); process::spawn_detached(cmd).map_err(|e| format!("couldn't schedule the update ({e})")) } -/// A PowerShell single-quoted literal. -#[cfg_attr(not(windows), allow(dead_code))] -fn ps_quote(path: &Path) -> String { - format!("'{}'", path.display().to_string().replace('\'', "''")) +/// Finishes an update when this program was started as the update helper; `args` are its +/// arguments without the program name. `None` when they don't start with [`FINISH_FLAG`]. The +/// launcher is started again even when the step fails: the old copy is then still in place. +#[cfg(windows)] +pub fn finish(args: &[OsString]) -> Option> { + if args.first().is_none_or(|a| a != FINISH_FLAG) { + return None; + } + let Some((pid, exe, step)) = parse_helper_args(args) else { return Some(Err("the update helper's arguments are incomplete".into())) }; + let Ok(me) = std::env::current_exe() else { return Some(Err("the update helper couldn't find itself".into())) }; + let paths = match launcher_paths(&exe) { + Ok(paths) => paths, + Err(e) => return Some(Err(e)), + }; + // Nothing runs unless this is the launcher's own update, signed like this helper. + if let Err(e) = check_helper(&me, &paths, &exe, &step).and_then(|()| signed_like(&me, &exe)) { + record_failure(&paths, &e); + return Some(Err(e)); + } + let done = run_step(&me, pid, &exe, &step, std::time::Duration::from_secs(60)); + if let Err(e) = &done { + record_failure(&paths, e); + } + // The new launcher, or the old one when the step failed. + let started = signed_like(&me, &exe) + .and_then(|()| process::spawn_detached(std::process::Command::new(&exe)).map_err(|e| format!("couldn't start {} ({e})", exe.display()))); + Some(done.and(started)) } -/// A PowerShell literal for an msiexec argument: the path in double quotes, as msiexec expects. +/// The launcher's folders for the installation at `exe`, as [`Paths::new`] finds them when that +/// exe runs: beside it with `portable.txt`, the per-user folders otherwise. #[cfg_attr(not(windows), allow(dead_code))] -fn ps_quote_arg(path: &Path) -> String { - format!("'\"{}\"'", path.display().to_string().replace('\'', "''")) +fn launcher_paths(exe: &Path) -> Result { + match exe.parent() { + Some(dir) if dir.join("portable.txt").is_file() => Ok(Paths::under(&dir.join("ArtCraftLauncherData"))), + _ => Paths::new(), + } } +/// Where a failed update's error waits for the next start. +fn failure_file(paths: &Paths) -> PathBuf { + paths.logs().join("self-update-error.txt") +} + +/// Records a failed update: the next check offers the update again, and the next start logs `error`. #[cfg_attr(not(windows), allow(dead_code))] -fn msi_update_step(file: &Path) -> Result { - let file = install::msi_path(file)?; - Ok(format!( - "Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\\msiexec.exe') -ArgumentList '/i',{},'/passive','/norestart' -Wait; Remove-Item -LiteralPath {} -ErrorAction SilentlyContinue", - ps_quote_arg(&file), - ps_quote(&file) - )) +fn record_failure(paths: &Paths, error: &str) { + let _ = std::fs::remove_file(marker(paths)); + let _ = std::fs::create_dir_all(paths.logs()); + let _ = crate::paths::write_atomic(&failure_file(paths), error.as_bytes()); +} + +/// The error of the last update, if it failed, once. +pub fn take_failure(paths: &Paths) -> Option { + let file = failure_file(paths); + let error = std::fs::read_to_string(&file).ok()?; + let _ = std::fs::remove_file(&file); + Some(error) +} + +/// Checks, while the launcher still runs and can show the error, what the helper checks before +/// it installs: this program, the installed exe and the new MSI are signed by one publisher. +#[cfg(windows)] +fn windows_precheck(install: &Install, download: &Path) -> Result<(), String> { + let me = std::env::current_exe().map_err(|e| format!("couldn't find this program ({e})"))?; + match install { + Install::Msi(exe) => signed_like(&me, exe).and_then(|()| signed_like(&me, download)), + Install::Portable(exe) => signed_like(&me, exe), + _ => Ok(()), + } +} + +/// Accepts `file` when it carries a valid Authenticode signature from the same publisher as +/// `reference`, this program. Refuses an unsigned file, another publisher's, and every file when +/// `reference` itself isn't signed. +#[cfg(windows)] +fn signed_like(reference: &Path, file: &Path) -> Result<(), String> { + let publisher = |path: &Path| verifysign::verify_file(path).map(|signature| signature.subject_name()); + let ours = publisher(reference).map_err(|e| format!("this copy of ArtCraft Launcher isn't signed ({e}), so it doesn't install updates"))?; + let theirs = publisher(file).map_err(|e| format!("{} isn't signed ({e})", file.display()))?; + if ours == theirs { Ok(()) } else { Err(format!("{} isn't signed by ArtCraft Launcher's publisher", file.display())) } +} + +/// Whether `file` is a Windows program or library (it starts with the `MZ` header), whatever its +/// name. +#[cfg(windows)] +fn is_program(file: &Path) -> bool { + use std::io::Read; + let mut magic = [0u8; 2]; + std::fs::File::open(file).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && magic == *b"MZ" +} + +/// Allows only the launcher's own update: `exe` is this launcher's MSI or portable installation, +/// of the kind `step` updates, and the helper (`me`), the download and the staging folder are in +/// that installation's cache. Anything else is refused before the helper runs or starts a file. +#[cfg(windows)] +fn check_helper(me: &Path, paths: &Paths, exe: &Path, step: &Finish) -> Result<(), String> { + let refused = || "the update helper only finishes ArtCraft Launcher's own update".to_string(); + let canonical = |p: &Path| p.canonicalize().map_err(|_| refused()); + let cache = canonical(&paths.cache)?; + if canonical(me.parent().ok_or_else(refused)?)? != cache.join(HELPER_DIR) { + return Err(refused()); + } + let exe = canonical(exe)?; + let downloads = canonical(&paths.downloads())?; + let in_downloads = |file: &Path| canonical(file).map(|f| f.parent() == Some(downloads.as_path())); + let allowed = match (detect(&exe), step) { + (Some(Install::Msi(installed)), Finish::Msi(msi)) => installed == exe && in_downloads(msi)?, + (Some(Install::Portable(installed)), Finish::Portable { zip, tmp }) => { + installed == exe + && in_downloads(zip)? + && tmp.file_name().is_some_and(|n| n == PORTABLE_STAGING) + && canonical(tmp.parent().ok_or_else(refused)?)? == cache + } + _ => false, + }; + if allowed { Ok(()) } else { Err(refused()) } +} + +/// Waits up to `wait` for `pid` to exit, does `step` once it has, and removes the download either +/// way. The MSI, and every program in the portable zip, must be signed like `reference`. +#[cfg(windows)] +fn run_step(reference: &Path, pid: u32, exe: &Path, step: &Finish, wait: std::time::Duration) -> Result<(), String> { + let result = if process::wait_for_exit(pid, wait) { + match step { + Finish::Msi(msi) => signed_like(reference, msi).and_then(|()| install::windows::reinstall(msi)), + Finish::Portable { zip, tmp } => match exe.parent() { + Some(dir) => { + crate::portable::unpack_over(zip, tmp, dir, &|file| if is_program(file) { signed_like(reference, file) } else { Ok(()) }) + } + None => Err("the launcher isn't in a folder".into()), + }, + } + } else { + Err("ArtCraft Launcher was still running after a minute, so the update wasn't installed".into()) + }; + match step { + Finish::Msi(msi) => { + let _ = std::fs::remove_file(msi); + } + Finish::Portable { zip, tmp } => { + let _ = std::fs::remove_dir_all(tmp); + let _ = std::fs::remove_file(zip); + } + } + result } #[cfg(test)] @@ -434,16 +613,263 @@ mod tests { std::fs::write(&file, b"test package").unwrap(); let link = dir.path().join("cached.msi"); std::os::unix::fs::symlink(&file, &link).unwrap(); - let step = msi_update_step(&link).unwrap(); - let target = file.canonicalize().unwrap(); - assert!(step.contains(&format!("-ArgumentList '/i',{},", ps_quote_arg(&target))), "{step}"); - assert!(step.contains(&format!("Remove-Item -LiteralPath {} ", ps_quote(&target))), "{step}"); + assert_eq!(msi_finish(&link).unwrap(), Finish::Msi(file.canonicalize().unwrap())); } #[test] fn a_missing_msi_cannot_schedule_a_self_update() { let dir = tempfile::tempdir().unwrap(); - assert!(msi_update_step(&dir.path().join("missing.msi")).is_err()); + assert!(msi_finish(&dir.path().join("missing.msi")).is_err()); + } + + /// The helper reads back exactly what the launcher passed it, paths with spaces and + /// apostrophes included, and nothing that is incomplete or unknown. + #[test] + fn the_update_helper_reads_back_its_arguments() { + let exe = Path::new(r"C:\Users\O'Brien\AppData\Local\Programs\ArtCraft Launcher\artcraft-launcher.exe"); + let msi = Finish::Msi(PathBuf::from(r"C:\Users\O'Brien\AppData\Local\ArtCraft Launcher\cache\artcraft-launcher-0.7.0-windows-x64.msi")); + let portable = + Finish::Portable { zip: PathBuf::from(r"D:\Tools\x 1.zip"), tmp: PathBuf::from(r"D:\Tools\ArtCraftLauncherData\cache\launcher-update") }; + for finish in [msi, portable] { + let args = helper_args(4242, exe, &finish); + assert_eq!(args.first().map(OsString::as_os_str), Some(std::ffi::OsStr::new(FINISH_FLAG))); + assert_eq!(parse_helper_args(&args), Some((4242, exe.to_path_buf(), finish))); + } + let os = |args: &[&str]| args.iter().map(OsString::from).collect::>(); + for bad in [ + os(&[]), + os(&["--hidden"]), + os(&[FINISH_FLAG, "4242", "launcher.exe"]), + os(&[FINISH_FLAG, "4242", "launcher.exe", "msi"]), + os(&[FINISH_FLAG, "4242", "launcher.exe", "msi", "a.msi", "extra"]), + os(&[FINISH_FLAG, "4242", "launcher.exe", "portable", "a.zip"]), + os(&[FINISH_FLAG, "not a pid", "launcher.exe", "msi", "a.msi"]), + os(&[FINISH_FLAG, "4242", "launcher.exe", "script", "Remove-Item C:\\"]), + os(&["--finish", "4242", "launcher.exe", "msi", "a.msi"]), + ] { + assert_eq!(parse_helper_args(&bad), None, "{bad:?}"); + } + } + + /// Arguments that aren't the helper's leave the launcher to start as usual. + #[cfg(windows)] + #[test] + fn only_the_helper_flag_starts_the_update_helper() { + assert_eq!(finish(&[]), None); + assert_eq!(finish(&[OsString::from("--hidden")]), None); + assert!(finish(&[OsString::from(FINISH_FLAG)]).is_some_and(|done| done.is_err())); + } + + /// The portable update waits for the old launcher to exit, then replaces its files with the + /// zip's folder, keeps the settings beside it, and removes the download and the staging folder. + #[cfg(windows)] + #[test] + fn a_portable_update_replaces_the_launcher_after_it_exits() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("ArtCraft Launcher"); + std::fs::create_dir_all(folder.join("ArtCraftLauncherData")).unwrap(); + std::fs::write(folder.join("artcraft-launcher.exe"), "0.1.0").unwrap(); + std::fs::write(folder.join("portable.txt"), "").unwrap(); + std::fs::write(folder.join("ArtCraftLauncherData").join("settings.json"), "{}").unwrap(); + let zip = dir.path().join("artcraft-launcher-0.2.0-windows-x64-portable.zip"); + let mut writer = zip::ZipWriter::new(std::fs::File::create(&zip).unwrap()); + for (name, body) in [ + ("artcraft-launcher-0.2.0-windows-x64-portable/artcraft-launcher.exe", "0.2.0"), + ("artcraft-launcher-0.2.0-windows-x64-portable/portable.txt", ""), + ] { + writer.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + writer.write_all(body.as_bytes()).unwrap(); + } + writer.finish().unwrap(); + let tmp = dir.path().join("cache").join("launcher-update"); + let step = Finish::Portable { zip: zip.clone(), tmp: tmp.clone() }; + + let mut old = std::process::Command::new("ping").args(["-n", "3", "127.0.0.1"]).stdout(std::process::Stdio::null()).spawn().unwrap(); + let exe = folder.join("artcraft-launcher.exe"); + let helper = { + let (exe, step, pid) = (exe.clone(), step.clone(), old.id()); + std::thread::spawn(move || run_step(&system_program("cmd.exe"), pid, &exe, &step, std::time::Duration::from_secs(30))) + }; + std::thread::sleep(std::time::Duration::from_millis(500)); + assert_eq!(std::fs::read_to_string(&exe).unwrap(), "0.1.0", "replaced while the old launcher was still running"); + old.wait().unwrap(); + helper.join().unwrap().unwrap(); + assert_eq!(std::fs::read_to_string(&exe).unwrap(), "0.2.0"); + assert_eq!(std::fs::read_to_string(folder.join("ArtCraftLauncherData").join("settings.json")).unwrap(), "{}"); + assert!(!folder.join("artcraft-launcher-0.2.0-windows-x64-portable").exists(), "the zip's folder must not be nested in the launcher's"); + assert!(!zip.exists() && !tmp.exists(), "the download and the staging folder are removed"); + } + + /// The helper finishes only the launcher's own update: its own installation, with the + /// download from its own cache. A signed launcher must not run or start anything else. + #[cfg(windows)] + #[test] + fn the_update_helper_refuses_anything_but_the_launchers_own_update() { + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("ArtCraft Launcher"); + std::fs::create_dir_all(&folder).unwrap(); + let exe = folder.join("artcraft-launcher.exe"); + std::fs::write(&exe, "0.1.0").unwrap(); + std::fs::write(folder.join("portable.txt"), "").unwrap(); + let paths = Paths::under(dir.path()); + let cache = paths.cache.clone(); + std::fs::create_dir_all(paths.downloads()).unwrap(); + std::fs::create_dir_all(cache.join(HELPER_DIR)).unwrap(); + let me = cache.join(HELPER_DIR).join("artcraft-launcher.exe"); + std::fs::write(&me, "helper").unwrap(); + let zip = paths.downloads().join("artcraft-launcher-0.2.0-windows-x64-portable.zip"); + std::fs::write(&zip, "zip").unwrap(); + let elsewhere = dir.path().join("elsewhere.zip"); + std::fs::write(&elsewhere, "zip").unwrap(); + let other = dir.path().join("other.exe"); + std::fs::write(&other, "other").unwrap(); + let staging = cache.join(PORTABLE_STAGING); + let portable = |zip: &Path, tmp: &Path| Finish::Portable { zip: zip.to_path_buf(), tmp: tmp.to_path_buf() }; + + assert_eq!(check_helper(&me, &paths, &exe, &portable(&zip, &staging)), Ok(())); + // A helper that isn't in the installation's cache. + assert!(check_helper(&other, &paths, &exe, &portable(&zip, &staging)).is_err()); + // Not the launcher's installation. + assert!(check_helper(&me, &paths, &other, &portable(&zip, &staging)).is_err()); + // A download from outside the cache. + assert!(check_helper(&me, &paths, &exe, &portable(&elsewhere, &staging)).is_err()); + // Unpacking anywhere but the cache's staging folder. + assert!(check_helper(&me, &paths, &exe, &portable(&zip, &dir.path().join(PORTABLE_STAGING))).is_err()); + assert!(check_helper(&me, &paths, &exe, &portable(&zip, &paths.downloads())).is_err()); + // An MSI for a portable installation. + assert!(check_helper(&me, &paths, &exe, &Finish::Msi(zip.clone())).is_err()); + // No longer a portable installation. + std::fs::remove_file(folder.join("portable.txt")).unwrap(); + assert!(check_helper(&me, &paths, &exe, &portable(&zip, &staging)).is_err()); + } + + /// The helper finds a portable launcher's folders beside it, as that launcher does. + #[test] + fn the_helper_uses_the_portable_launchers_own_folders() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("artcraft-launcher.exe"); + std::fs::write(dir.path().join("portable.txt"), "").unwrap(); + let paths = launcher_paths(&exe).unwrap(); + assert_eq!(paths.cache, dir.path().join("ArtCraftLauncherData").join("cache")); + assert_eq!(paths.data, dir.path().join("ArtCraftLauncherData").join("data")); + } + + /// A failed update is offered again and its error is logged at the next start, once. + #[test] + fn a_failed_update_is_offered_again_and_reported_once() { + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::under(dir.path()); + let release = Release { tag: "v0.2.0".into(), ..Release::default() }; + std::fs::create_dir_all(&paths.data).unwrap(); + crate::paths::write_atomic(&marker(&paths), format!("v0.2.0\n{}\n", crate::now()).as_bytes()).unwrap(); + assert!(just_updated_to(&paths, &release)); + record_failure(&paths, "the Windows Installer stopped with error 1603"); + assert!(!just_updated_to(&paths, &release)); + assert_eq!(take_failure(&paths).as_deref(), Some("the Windows Installer stopped with error 1603")); + assert_eq!(take_failure(&paths), None); + } + + /// A launcher that doesn't quit keeps its files, and the download is still removed. + #[cfg(windows)] + #[test] + fn a_launcher_that_keeps_running_is_not_replaced() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("artcraft-launcher.exe"); + std::fs::write(&exe, "0.1.0").unwrap(); + let msi = dir.path().join("artcraft-launcher-0.2.0-windows-x64.msi"); + std::fs::write(&msi, "not a package").unwrap(); + let running = std::process::id(); + let started = std::time::Instant::now(); + // The test process itself never exits within the wait, so the package is never run. + let error = + run_step(&system_program("cmd.exe"), running, &exe, &Finish::Msi(msi.clone()), std::time::Duration::from_millis(300)).unwrap_err(); + assert!(error.contains("still running"), "{error}"); + assert!(started.elapsed() < std::time::Duration::from_secs(30)); + assert_eq!(std::fs::read_to_string(&exe).unwrap(), "0.1.0"); + assert!(!msi.exists()); + } + + #[cfg(windows)] + fn system_program(name: &str) -> PathBuf { + PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32").join(name) + } + + /// The process id of a process that has already exited. + #[cfg(windows)] + fn exited_pid() -> u32 { + let mut gone = std::process::Command::new("cmd").args(["/C", "exit"]).spawn().unwrap(); + gone.wait().unwrap(); + gone.id() + } + + /// Two programs from one publisher match; an unsigned one never does, on either side. + #[cfg(windows)] + #[test] + fn a_file_must_be_signed_by_the_launchers_own_publisher() { + let (cmd, msiexec) = (system_program("cmd.exe"), system_program("msiexec.exe")); + // `cargo test` binaries are unsigned. + let unsigned = std::env::current_exe().unwrap(); + assert_eq!(signed_like(&cmd, &msiexec), Ok(())); + assert!(signed_like(&cmd, &unsigned).is_err_and(|e| e.contains("isn't signed"))); + assert!(signed_like(&unsigned, &cmd).is_err_and(|e| e.contains("doesn't install updates"))); + } + + /// An unsigned launcher says so before it quits for an update. `cargo test` binaries are + /// unsigned, like a package built without the signing secrets. + #[cfg(windows)] + #[test] + fn an_unsigned_launcher_refuses_to_update_before_it_quits() { + let dir = tempfile::tempdir().unwrap(); + let msi = dir.path().join("artcraft-launcher-0.2.0-windows-x64.msi"); + std::fs::write(&msi, "not a package").unwrap(); + for install in [Install::Msi(system_program("msiexec.exe")), Install::Portable(system_program("msiexec.exe"))] { + let error = windows_precheck(&install, &msi).unwrap_err(); + assert!(error.contains("isn't signed"), "{error}"); + } + } + + /// An MSI that isn't signed by the launcher's publisher is never handed to the Windows Installer. + #[cfg(windows)] + #[test] + fn an_unsigned_msi_is_never_run() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("artcraft-launcher.exe"); + std::fs::write(&exe, "0.1.0").unwrap(); + let msi = dir.path().join("artcraft-launcher-0.2.0-windows-x64.msi"); + std::fs::write(&msi, "not a package").unwrap(); + let error = + run_step(&system_program("cmd.exe"), exited_pid(), &exe, &Finish::Msi(msi.clone()), std::time::Duration::from_secs(5)).unwrap_err(); + assert!(error.contains("isn't signed"), "{error}"); + assert!(!msi.exists()); + } + + /// A program in the portable zip that isn't signed by the launcher's publisher stops the + /// update before any file is replaced. + #[cfg(windows)] + #[test] + fn an_unsigned_program_in_the_portable_zip_is_not_installed() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("ArtCraft Launcher"); + std::fs::create_dir_all(&folder).unwrap(); + let exe = folder.join("artcraft-launcher.exe"); + std::fs::write(&exe, "0.1.0").unwrap(); + let zip = dir.path().join("artcraft-launcher-0.2.0-windows-x64-portable.zip"); + let mut writer = zip::ZipWriter::new(std::fs::File::create(&zip).unwrap()); + writer.start_file("artcraft-launcher-0.2.0-windows-x64-portable/README.md", zip::write::SimpleFileOptions::default()).unwrap(); + writer.write_all(b"# ArtCraft Launcher").unwrap(); + // An unsigned program under a harmless name. + writer.start_file("artcraft-launcher-0.2.0-windows-x64-portable/notes.txt", zip::write::SimpleFileOptions::default()).unwrap(); + writer.write_all(&std::fs::read(std::env::current_exe().unwrap()).unwrap()).unwrap(); + writer.finish().unwrap(); + let tmp = dir.path().join("cache").join(PORTABLE_STAGING); + let step = Finish::Portable { zip: zip.clone(), tmp: tmp.clone() }; + let error = run_step(&system_program("cmd.exe"), exited_pid(), &exe, &step, std::time::Duration::from_secs(5)).unwrap_err(); + assert!(error.contains("isn't signed"), "{error}"); + assert_eq!(std::fs::read_to_string(&exe).unwrap(), "0.1.0"); + assert!(!folder.join("README.md").exists() && !folder.join("notes.txt").exists(), "nothing is copied"); + assert!(!zip.exists() && !tmp.exists()); } #[test] @@ -553,13 +979,6 @@ mod tests { ); } - #[test] - fn powershell_quoting_survives_apostrophes() { - let p = Path::new(r"C:\Users\O'Brien\AppData\Local\ArtCraft Launcher\x.msi"); - assert_eq!(ps_quote(p), r"'C:\Users\O''Brien\AppData\Local\ArtCraft Launcher\x.msi'"); - assert_eq!(ps_quote_arg(p), r#"'"C:\Users\O''Brien\AppData\Local\ArtCraft Launcher\x.msi"'"#); - } - #[test] fn a_development_build_does_not_update_itself() { // `cargo test` runs from target/, which no installer puts anywhere.