mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-12 21:23:08 +00:00
fix(windows): self-update runs no PowerShell and installs only files signed like the launcher
On Windows the launcher finished its own update with a detached powershell.exe started with -ExecutionPolicy Bypass -WindowStyle Hidden. The script waited for the launcher to exit, ran the new MSI or unpacked the portable zip with Expand-Archive, deleted the download and started the new exe. Antivirus heuristics score that sequence, and the command line sits in the signed exe as a string. The update helper is now a copy of the launcher itself, put in <cache>\launcher-update-helper and started with --finish-update. It waits for the old process with OpenProcess and WaitForSingleObject (winsafe), runs msiexec /i /passive /norestart and checks its exit code, or unpacks the portable zip with the checks portable app installs already use, and then starts the launcher again. main() hands --finish-update to the helper before logging, the single-instance claim and the window. The helper finishes only the launcher's own update: - the exe is this launcher's MSI or portable installation, and the helper, the download and the staging folder are in that installation's cache; - the MSI, every program in the portable zip and the exe it starts carry a valid Authenticode signature from the helper's own publisher (verifysign, through WinTrust). The launcher checks its own signature, the installed exe and the new MSI before it quits, so an unsigned build reports that it doesn't install updates instead of closing. A failed update clears the self-update marker, so the next check offers it again, and the next start logs the error.
This commit is contained in:
1 parent
7dc837560e
commit
bf16e21f76
8 files changed
+592
-46
No files matched your search
Generated
+18
@@ -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"
|
||||
|
||||
@@ -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::<Vec<_>>()) {
|
||||
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) });
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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> {
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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::*;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<OsString> {
|
||||
let mut args: Vec<OsString> = 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<Finish, String> {
|
||||
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<Result<(), String>> {
|
||||
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<Paths, String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>();
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user