collective

Browse / github.com/storytold/craft-launcher / patch

native(install): one prompt per run of system installs

Linux: password asked for every app in one install/update/remove batch On Linux Mint 22, installing/updating/removing several apps at once requires entering my password for every single app change. I shall submit a PR fixing this.

Status
open
Score
0.00
Adopters
0 other operators
Author
nicholasgower (imported from pull request #923, open)
Base
7aafad661a11594a70ae949471d481f30d8fe64a
Size
+1670 −43 in 7 files
Review
pending
Shared
2026-10-28
Id
e694bee915ca8300c740c56d

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/artcraft-launcher/src/main.rs crates/native/Cargo.toml crates/native/src/install.rs crates/native/src/install/root_helper.rs crates/native/src/install/windows_helper.rs crates/native/src/install/windows_wire.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -316,8 +316,10 @@ dependencies = [  "dunce",  "flate2",  "getrandom 0.3.4",+ "libc",  "log",  "plist",+ "rustix 1.1.5",  "serde",  "serde_json",  "sha2",diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs--- a/apps/artcraft-launcher/src/main.rs+++ b/apps/artcraft-launcher/src/main.rs@@ -34,6 +34,17 @@ const SETTINGS_KEY: &str = "settings"; type Wake = Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>;  fn main() -> ExitCode {+    // An elevated helper for the system installer: no window, logging or single-instance claim,+    // and it must stay ahead of everything else here. Linux starts it through polkit; Windows+    // through the one UAC prompt.+    #[cfg(target_os = "linux")]+    if let Some(code) = artcraft_launcher_native::install::root_helper::run_if_requested() {+        return code;+    }+    #[cfg(windows)]+    if let Some(code) = artcraft_launcher_native::install::windows_helper::run_if_requested() {+        return code;+    }     // First, so the panic report and every start-up warning are recorded (`logging`).     let logs = logging::install();     logging::install_panic_hook();diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml--- a/crates/native/Cargo.toml+++ b/crates/native/Cargo.toml@@ -38,6 +38,12 @@ flate2 = "1" flate2 = "1" tar = { version = "0.4", default-features = false } +[target.'cfg(target_os = "linux")'.dependencies]+# Safe SO_PEERCRED, so the launcher can prove the helper is the process it started.+rustix = { version = "1.1", features = ["net"] }+# O_NOFOLLOW for the root-owned staging copy.+libc = "0.2"+ [target.'cfg(target_os = "macos")'.dependencies] plist = "1" @@ -47,6 +53,6 @@ winreg = "0.55" dunce = "1" zip = { version = "8", default-features = false, features = ["deflate"] } # Safe wrappers for waiting for another process and reading a file's VERSIONINFO.-winsafe = { version = "0.0.29", features = ["kernel", "version"] }+winsafe = { version = "0.0.29", features = ["kernel", "version", "shell", "advapi"] } # 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--- a/crates/native/src/install.rs+++ b/crates/native/src/install.rs@@ -16,6 +16,11 @@ //!   (`<data>/apps/<app>`) and run inside the same sandbox with `HOME` pointed at a `home/` //!   folder beside it, so the app's settings stay in the sandbox too. //!+//! The first system install or removal asks for permission — the polkit password prompt on+//! Linux, the one UAC prompt on Windows — and starts a short-lived helper as the privileged+//! user for the rest of the run (see `root_helper` and `windows_helper`), so updating several+//! apps at once asks only once. FreeBSD keeps one `pkexec` per command.+//! //! Every download is checked against its published SHA-256 before anything is unpacked, a //! running app is never replaced, and a new copy is fully in place before the old one goes. @@ -30,6 +35,13 @@ use crate::manifest::Record; use crate::paths::Paths; use crate::process; +#[cfg(target_os = "linux")]+pub mod root_helper;+#[cfg(windows)]+pub mod windows_helper;+#[cfg(any(windows, test))]+pub(crate) mod windows_wire;+ /// What an install needs from the backend. pub struct Job<'a> {     pub http: &'a Http,@@ -90,7 +102,12 @@ pub fn install_at(         return Err(format!("Cannot update {} to {}: installed version {} is newer", app.name, release.version_label(), inst_v));     }     log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());-    let file = download(release, asset, job)?;+    #[allow(unused_variables)]+    let (file, sha) = download(release, asset, job)?;+    // Linux stages a root-owned copy and checks it against this verified checksum; Windows+    // carries it to the elevated helper. Other platforms don't need it past the download.+    #[cfg(target_os = "linux")]+    let _expected = root_helper::expect_hash(&file, &sha);     log::info!("{}: download ready ({}), running installer...", app.name, file.display());     // Past this point the install can't be interrupted halfway.     (job.phase)(JobPhase::Installing);@@ -103,7 +120,7 @@ pub fn install_at(                 .map(|path| Some(record(&version, kind, path, Vec::new(), false)))         }         #[cfg(windows)]-        PackageKind::Msi => windows::run_msi(&file, destination.as_deref(), job.stop, &mut *job.phase).map(|_| None),+        PackageKind::Msi => windows::run_msi(&file, destination.as_deref(), job.stop, &mut *job.phase, Some(sha.as_str())).map(|_| None),         #[cfg(windows)]         PackageKind::PortableZip => {             crate::portable::install(app, &file, &version, previous, job.paths, destination.as_deref(), job.replace, &mut |record| {@@ -372,7 +389,7 @@ pub fn available_kinds() -> Vec<PackageKind> { /// Fetches `asset` into the download folder, checked against its published SHA-256: the feed's /// or GitHub's per-file digest, else the release's `SHA256SUMS.txt`. A release with neither is /// refused before anything is fetched: a file nothing vouches for is never installed (#90).-pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<PathBuf, String> {+pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<(PathBuf, String), String> {     if !release::valid_file_name(&asset.name) {         return Err("the release has an unexpected file name".into());     }@@ -393,7 +410,7 @@ pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> R         Transfer::Reconnecting { done, total } => phase(JobPhase::Reconnecting { done, total }),     })?;     (job.phase)(JobPhase::Verifying);-    Ok(dest)+    Ok((dest, sha)) }  /// The file's line in the release's `SHA256SUMS.txt`. `Ok(None)` only when the release really@@ -720,7 +737,16 @@ fn pkexec() -> Option<PathBuf> { }  /// Runs a command as root through polkit (`pkexec`), which shows the desktop's password prompt.-#[cfg(any(target_os = "linux", target_os = "freebsd"))]+/// The first one also starts the helper that later commands share, so the prompt is shown once+/// for a run of installs and removals (see `root_helper`).+#[cfg(target_os = "linux")]+fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {+    root_helper::run(args)+}++/// FreeBSD has no shared helper: its two root commands (`cp` and `rm`) are not package-manager+/// calls worth a session, so each uses its own `pkexec` prompt.+#[cfg(target_os = "freebsd")] fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {     let args = with_absolute_program(&SYSTEM_BIN_DIRS, args)?;     let pkexec = pkexec().ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?;@@ -779,10 +805,6 @@ pub(crate) mod linux {         cancelled, find_in, pkexec, process, run_as_root, swap_into_place, tree, turn,     }; -    /// apt waits this long for another package tool (a software centre, automatic updates) to-    /// let go of its lock, instead of failing at once.-    const APT_WAITS: &str = "DPkg::Lock::Timeout=60";-     /// A package tool that root commands can run, from the same directories `run_as_root` uses.     fn system_tool(name: &str) -> Option<PathBuf> {         find_in(&SYSTEM_BIN_DIRS, OsStr::new(name))@@ -824,13 +846,14 @@ pub(crate) mod linux {      /// The command that installs (or upgrades) a downloaded system package.     pub fn install_command(kind: PackageKind) -> Option<&'static [&'static str]> {+        use super::root_helper::{APT_INSTALL, DNF_INSTALL, PACMAN_INSTALL, RPM_INSTALL, ZYPPER_INSTALL};         match kind {             // apt resolves dependencies and records the package like any other.-            PackageKind::Deb => Some(&["apt-get", "-o", APT_WAITS, "install", "-y", "--allow-downgrades"]),-            PackageKind::Rpm if system_tool("dnf").is_some() => Some(&["dnf", "install", "-y"]),-            PackageKind::Rpm if system_tool("zypper").is_some() => Some(&["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"]),-            PackageKind::Rpm => Some(&["rpm", "-U", "--replacepkgs"]),-            PackageKind::ArchPkg => Some(&["pacman", "-U", "--noconfirm"]),+            PackageKind::Deb => Some(APT_INSTALL),+            PackageKind::Rpm if system_tool("dnf").is_some() => Some(DNF_INSTALL),+            PackageKind::Rpm if system_tool("zypper").is_some() => Some(ZYPPER_INSTALL),+            PackageKind::Rpm => Some(RPM_INSTALL),+            PackageKind::ArchPkg => Some(PACMAN_INSTALL),             _ => None,         }     }@@ -955,19 +978,20 @@ pub(crate) mod linux {     }      pub fn remove_package(app: &CraftApp, installed: &Installed, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {+        use super::root_helper::{APT_REMOVE, DNF_REMOVE, DPKG_REMOVE, FLATPAK_SYSTEM_UNINSTALL, PACMAN_REMOVE, RPM_REMOVE};         let mut remove = |command: &[&str], package: &str| as_root(&SYSTEM, command, OsStr::new(package), stop, phase, JobPhase::Removing);         match installed.source {-            InstallSource::Deb if system_tool("apt-get").is_some() => remove(&["apt-get", "-o", APT_WAITS, "remove", "-y"], app.id),-            InstallSource::Deb => remove(&["dpkg", "-r"], app.id),+            InstallSource::Deb if system_tool("apt-get").is_some() => remove(APT_REMOVE, app.id),+            InstallSource::Deb => remove(DPKG_REMOVE, app.id),             InstallSource::Rpm => {                 if system_tool("dnf").is_some() {-                    remove(&["dnf", "remove", "-y"], app.id)+                    remove(DNF_REMOVE, app.id)                 } else {-                    remove(&["rpm", "-e"], app.id)+                    remove(RPM_REMOVE, app.id)                 }             }             // The package detected (photocraft, photocraft-bin or photocraft-git).-            InstallSource::Pacman => remove(&["pacman", "-R", "--noconfirm"], installed.package.as_deref().unwrap_or(app.id)),+            InstallSource::Pacman => remove(PACMAN_REMOVE, installed.package.as_deref().unwrap_or(app.id)),             InstallSource::Flatpak => {                 let id = app.bundle_id();                 let user = process::flatpak_command().args(["uninstall", "--user", "-y", "--noninteractive", &id]).status();@@ -981,7 +1005,7 @@ pub(crate) mod linux {                     }                     return Err(format!("couldn't uninstall {id}"));                 }-                remove(&["flatpak", "uninstall", "--system", "-y", "--noninteractive"], &id)+                remove(FLATPAK_SYSTEM_UNINSTALL, &id)             }             other => Err(format!("{} isn't a system package", other.label())),         }@@ -1679,13 +1703,14 @@ pub(crate) fn msi_path(msi: &Path) -> Result<PathBuf, String> { pub mod windows {     use artcraft_launcher_core::protocol::CANCELLED; -    use super::{CraftApp, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, msi_folder_argument, msi_log, process, turn};+    use super::{CraftApp, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, turn}; -    fn msiexec() -> PathBuf {+    /// The Windows Installer, which the elevated helper runs once permission has been granted.+    pub(super) fn msiexec() -> PathBuf {         std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from).join(r"System32\msiexec.exe")     } -    fn explain(code: Option<i32>) -> Result<(), String> {+    pub(super) fn explain(code: Option<i32>) -> Result<(), String> {         match code {             Some(0 | 3010 | 1641) => Ok(()),             Some(1602) => Err("cancelled".into()),@@ -1696,20 +1721,17 @@ pub mod windows {         }     } -    /// Runs an MSI with a progress window and no restart. Windows asks for permission itself.-    pub fn run_msi(msi: &Path, destination: Option<&Path>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {-        in_turn(stop, phase, JobPhase::Installing, || {-            use std::os::windows::process::CommandExt;-            let mut command = msi_command(&msiexec(), msi)?;-            if let Some(dir) = destination {-                command.raw_arg(msi_folder_argument(dir)?);-            }-            let status = command.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;-            explain(status.code()).map_err(|e| match status.code() {-                Some(1602) | None => e,-                _ => format!("{e}. Its log is at {}", msi_log(msi).display()),-            })-        })+    /// Runs an MSI with a progress window and no restart. The first one asks for administrator+    /// permission once; the helper keeps that permission for the rest of the run (see+    /// [`super::windows_helper`]). `expected` is the checksum the download was verified against.+    pub fn run_msi(+        msi: &Path,+        destination: Option<&Path>,+        stop: &Stop,+        phase: &mut dyn FnMut(JobPhase),+        expected: Option<&str>,+    ) -> Result<(), String> {+        in_turn(stop, phase, JobPhase::Installing, || super::windows_helper::install(msi, destination, expected))     }      /// Runs the launcher's own new MSI for a self-update, with the same progress window and no@@ -1732,11 +1754,7 @@ pub mod windows {         }         in_turn(stop, phase, JobPhase::Removing, || {             for m in installs {-                let status = process::command(msiexec())-                    .args(["/x", &m.product_code, "/passive", "/norestart"])-                    .status()-                    .map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;-                explain(status.code())?;+                super::windows_helper::uninstall(&m.product_code)?;             }             Ok(())         })diff --git a/crates/native/src/install/root_helper.rs b/crates/native/src/install/root_helper.rsnew file mode 100644--- /dev/null+++ b/crates/native/src/install/root_helper.rs@@ -0,0 +1,798 @@+//! One password prompt for a run of system installs.+//!+//! `pkexec` authenticates every call on its own, so updating several apps at once asked for the+//! password once per app. The first call instead starts this program again as root, through a+//! single `pkexec`, and keeps it running as a helper. Later installs and removals go through+//! that helper over a private Unix socket, until it has been idle for fifteen minutes or the+//! launcher exits.+//!+//! The socket is inside a root-visible, user-private temporary folder. The launcher checks the+//! connecting process's pid against the helper it started, so another process of the same user+//! cannot use the helper without its own permission prompt.+//!+//! The helper runs only a fixed set of package-manager commands. Install targets are copied to a+//! root-owned staging folder first and checked against the release's SHA-256, and removal names+//! are limited to Craft app packages. A package can still run its own maintainer scripts as root+//! while it is installed; that is the point of installing it, and the user approved the install.++use std::collections::HashMap;+use std::ffi::{OsStr, OsString};+use std::fs::OpenOptions;+use std::io::{Read, Write};+use std::os::unix::ffi::{OsStrExt, OsStringExt};+use std::os::unix::fs::{MetadataExt, OpenOptionsExt};+use std::os::unix::net::{UnixListener, UnixStream};+use std::path::{Path, PathBuf};+use std::process::{Child, ExitCode, Stdio};+use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError};+use std::time::{Duration, Instant};++use artcraft_launcher_core::SUITE;+use artcraft_launcher_core::protocol::CANCELLED;++use super::{SYSTEM_BIN_DIRS, find_in, pkexec, process, with_absolute_program};++/// The argument that turns this program into the privileged helper, with the socket path.+pub const FLAG: &str = "--artcraft-root-helper=";++/// How long the helper waits for another install before it exits. Every request restarts it.+const IDLE: Duration = Duration::from_secs(15 * 60);++/// How long the desktop may take to collect the password before the first call gives up.+const START_LIMIT: Duration = Duration::from_secs(5 * 60);++/// The helper's first line once it is running as root with the desktop's permission. The number+/// is the protocol version, so a launcher never talks to a helper from another build.+const READY: &str = "READY 1\n";++/// The helper's executable was replaced (a package upgrade) after this process started; the+/// privileged helper cannot be started from it, so the caller falls back to a direct prompt.+const REPLACED: &str = "this program was replaced on disk";++const SOCKET_NAME: &str = "helper.sock";+const MAX_TARGETS: usize = 64;+const MAX_TARGET: usize = 4096;+const MAX_PAYLOAD: usize = 1 << 20;+const MAX_MESSAGE_CHARS: usize = 1000;+const HASH_LENGTH: usize = 32;++// Every command the helper may run, with the fixed arguments before the targets. The+// launcher's package commands are these same constants, so the two can't drift apart.+// `DPkg::Lock::Timeout=60` makes apt wait for another package tool (a software centre,+// automatic updates) to let go of its lock instead of failing at once.+pub const APT_INSTALL: &[&str] = &["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"];+pub const APT_REMOVE: &[&str] = &["apt-get", "-o", "DPkg::Lock::Timeout=60", "remove", "-y"];+pub const DPKG_REMOVE: &[&str] = &["dpkg", "-r"];+pub const DNF_INSTALL: &[&str] = &["dnf", "install", "-y"];+pub const DNF_REMOVE: &[&str] = &["dnf", "remove", "-y"];+pub const ZYPPER_INSTALL: &[&str] = &["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"];+pub const RPM_INSTALL: &[&str] = &["rpm", "-U", "--replacepkgs"];+pub const RPM_REMOVE: &[&str] = &["rpm", "-e"];+pub const PACMAN_INSTALL: &[&str] = &["pacman", "-U", "--noconfirm"];+pub const PACMAN_REMOVE: &[&str] = &["pacman", "-R", "--noconfirm"];+pub const FLATPAK_SYSTEM_UNINSTALL: &[&str] = &["flatpak", "uninstall", "--system", "-y", "--noninteractive"];++/// A command the helper may run.+#[repr(u8)]+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum RootCommand {+    AptInstall = 0,+    AptRemove = 1,+    DpkgRemove = 2,+    DnfInstall = 3,+    DnfRemove = 4,+    ZypperInstall = 5,+    RpmInstall = 6,+    RpmRemove = 7,+    PacmanInstall = 8,+    PacmanRemove = 9,+    FlatpakSystemUninstall = 10,+}++impl RootCommand {+    const ALL: [Self; 11] = [+        Self::AptInstall,+        Self::AptRemove,+        Self::DpkgRemove,+        Self::DnfInstall,+        Self::DnfRemove,+        Self::ZypperInstall,+        Self::RpmInstall,+        Self::RpmRemove,+        Self::PacmanInstall,+        Self::PacmanRemove,+        Self::FlatpakSystemUninstall,+    ];++    fn argv(self) -> &'static [&'static str] {+        match self {+            Self::AptInstall => APT_INSTALL,+            Self::AptRemove => APT_REMOVE,+            Self::DpkgRemove => DPKG_REMOVE,+            Self::DnfInstall => DNF_INSTALL,+            Self::DnfRemove => DNF_REMOVE,+            Self::ZypperInstall => ZYPPER_INSTALL,+            Self::RpmInstall => RPM_INSTALL,+            Self::RpmRemove => RPM_REMOVE,+            Self::PacmanInstall => PACMAN_INSTALL,+            Self::PacmanRemove => PACMAN_REMOVE,+            Self::FlatpakSystemUninstall => FLATPAK_SYSTEM_UNINSTALL,+        }+    }++    fn targets(self) -> Targets {+        match self {+            Self::AptInstall | Self::DnfInstall | Self::ZypperInstall | Self::RpmInstall | Self::PacmanInstall => Targets::Files,+            Self::AptRemove | Self::DpkgRemove | Self::DnfRemove | Self::RpmRemove | Self::PacmanRemove | Self::FlatpakSystemUninstall => {+                Targets::Names+            }+        }+    }++    fn from_index(index: u8) -> Option<Self> {+        Self::ALL.get(usize::from(index)).copied()+    }++    /// The command whose fixed arguments start `args`, with the rest as its targets. The+    /// longest match wins, so no shorter command can claim a longer one's call.+    fn of<'a>(args: &'a [&'a OsStr]) -> Option<(Self, Vec<&'a OsStr>)> {+        let mut found: Option<(Self, Vec<&OsStr>)> = None;+        for command in Self::ALL {+            let argv = command.argv();+            if args.len() <= argv.len() {+                continue;+            }+            if argv.iter().zip(args).all(|(expected, actual)| actual.as_bytes() == expected.as_bytes())+                && found.as_ref().is_none_or(|(best, _)| argv.len() > best.argv().len())+            {+                found = Some((command, args[argv.len()..].to_vec()));+            }+        }+        found+    }+}++/// What a command puts after its fixed arguments.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum Targets {+    /// Downloaded package files.+    Files,+    /// Package names.+    Names,+}++/// Runs the privileged helper when this program was started as one. The launcher calls this+/// before it sets anything else up; every other start has no [`FLAG`] and returns `None`.+pub fn run_if_requested() -> Option<ExitCode> {+    let socket = std::env::args_os().find_map(|arg| arg.to_str().and_then(|text| text.strip_prefix(FLAG)).map(PathBuf::from))?;+    Some(ExitCode::from(serve(&socket)))+}++/// The checksum the launcher verified `path` against before installing it. Dropping the guard+/// forgets it again.+pub(crate) struct ExpectedHash {+    path: PathBuf,+}++static EXPECTED: LazyLock<Mutex<HashMap<PathBuf, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));++/// Remembers the release checksum for `path` until the returned guard is dropped.+pub(crate) fn expect_hash(path: &Path, sha: &str) -> ExpectedHash {+    if let Ok(mut expected) = EXPECTED.lock() {+        expected.insert(path.to_path_buf(), sha.to_string());+    }+    ExpectedHash { path: path.to_path_buf() }+}++impl Drop for ExpectedHash {+    fn drop(&mut self) {+        if let Ok(mut expected) = EXPECTED.lock() {+            expected.remove(&self.path);+        }+    }+}++fn expected_hash(target: &OsStr) -> Option<[u8; HASH_LENGTH]> {+    let expected = EXPECTED.lock().ok()?;+    parse_hash(expected.get(Path::new(target))?)+}++/// The helper's side: connect to the waiting launcher and serve requests until the launcher goes+/// away or nothing has been asked for a while.+fn serve(socket: &Path) -> u8 {+    // `pkexec` sets this to the user who approved the prompt. Without it this wasn't started+    // by a permission prompt, so it is not allowed to install anything.+    let Some(uid) = std::env::var("PKEXEC_UID").ok().and_then(|uid| uid.parse::<u32>().ok()) else {+        return 1;+    };+    let Ok(mut stream) = UnixStream::connect(socket) else { return 1 };+    let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));+    if stream.write_all(READY.as_bytes()).and_then(|()| stream.flush()).is_err() {+        return 1;+    }+    let _ = stream.set_read_timeout(Some(IDLE));+    loop {+        let request = match read_request(&mut stream) {+            Ok(request) => request,+            Err(()) => return 0,+        };+        let outcome = execute(request, uid);+        if write_response(&mut stream, &outcome).is_err() {+            return 1;+        }+    }+}++struct Request {+    command: RootCommand,+    targets: Vec<(OsString, Option<[u8; HASH_LENGTH]>)>,+}++fn read_request(input: &mut impl Read) -> Result<Request, ()> {+    let mut header = [0u8; 4];+    input.read_exact(&mut header).map_err(|_| ())?;+    let length = u32::from_be_bytes(header) as usize;+    if length == 0 || length > MAX_PAYLOAD {+        return Err(());+    }+    let mut payload = vec![0u8; length];+    input.read_exact(&mut payload).map_err(|_| ())?;+    decode(&payload).map_err(|_| ())+}++fn decode(payload: &[u8]) -> Result<Request, String> {+    if payload.len() > MAX_PAYLOAD {+        return Err("the install request is too large".into());+    }+    let (&index, rest) = payload.split_first().ok_or("empty request")?;+    let command = RootCommand::from_index(index).ok_or("unknown command")?;+    let mut at = 0;+    let count = take_u32(rest, &mut at)?;+    if count == 0 || count > MAX_TARGETS as u32 {+        return Err("wrong number of targets".into());+    }+    let mut targets = Vec::with_capacity(count as usize);+    for _ in 0..count {+        let length = take_u32(rest, &mut at)? as usize;+        if length == 0 || length > MAX_TARGET {+            return Err("invalid target".into());+        }+        let end = at.checked_add(length).ok_or("truncated request")?;+        let bytes = rest.get(at..end).ok_or("truncated request")?;+        at = end;+        let flag = *rest.get(at).ok_or("truncated request")?;+        at += 1;+        let expected = match flag {+            0 => None,+            1 => {+                let end = at.checked_add(HASH_LENGTH).ok_or("truncated checksum")?;+                let bytes = rest.get(at..end).ok_or("truncated checksum")?;+                at = end;+                Some(<[u8; HASH_LENGTH]>::try_from(bytes).map_err(|_| "truncated checksum")?)+            }+            _ => return Err("unknown checksum flag".into()),+        };+        targets.push((OsString::from_vec(bytes.to_vec()), expected));+    }+    if at != rest.len() {+        return Err("trailing request data".into());+    }+    Ok(Request { command, targets })+}++fn take_u32(bytes: &[u8], at: &mut usize) -> Result<u32, String> {+    let end = at.checked_add(4).ok_or("truncated request")?;+    let part = bytes.get(*at..end).ok_or("truncated request")?;+    let value = <[u8; 4]>::try_from(part).map_err(|_| "truncated request")?;+    *at = end;+    Ok(u32::from_be_bytes(value))+}++fn execute(request: Request, uid: u32) -> Result<(), String> {+    validate(request.command, &request.targets, Some(uid))?;+    let argv = request.command.argv();+    let program = find_in(&SYSTEM_BIN_DIRS, OsStr::new(argv[0])).ok_or_else(|| format!("{} isn't installed", argv[0]))?;+    let (targets, _staging) = stage_targets(request.command, &request.targets, uid)?;+    let status = process::command(program)+        .args(&argv[1..])+        .args(&targets)+        .stdin(Stdio::null())+        .stdout(Stdio::null())+        .stderr(Stdio::null())+        .status()+        .map_err(|e| format!("couldn't start {} ({e})", argv[0]))?;+    match status.code() {+        Some(0) => Ok(()),+        Some(code) => Err(format!("the system installer stopped with error {code}")),+        None => Err("the system installer was interrupted".into()),+    }+}++/// Copies the packages the installer is about to read into a root-owned folder, checking each+/// copy against the release checksum the launcher verified. The package manager then reads a+/// file no other process can change while it runs; names are passed through unchanged.+fn stage_targets(+    command: RootCommand,+    targets: &[(OsString, Option<[u8; HASH_LENGTH]>)],+    uid: u32,+) -> Result<(Vec<OsString>, Option<tempfile::TempDir>), String> {+    if command.targets() != Targets::Files {+        return Ok((targets.iter().map(|(target, _)| target.clone()).collect(), None));+    }+    let staging = tempfile::Builder::new().prefix("artcraft-pkg-").tempdir().map_err(|error| format!("couldn't prepare the package ({error})"))?;+    let mut copies = Vec::with_capacity(targets.len());+    for (index, (target, expected)) in targets.iter().enumerate() {+        let source_path = Path::new(target);+        let mut source = OpenOptions::new()+            .read(true)+            .custom_flags(libc::O_NOFOLLOW)+            .open(source_path)+            .map_err(|error| format!("couldn't read {} ({error})", source_path.display()))?;+        let metadata = source.metadata().map_err(|error| format!("couldn't read {} ({error})", source_path.display()))?;+        if !metadata.file_type().is_file() || metadata.uid() != uid {+            return Err(format!("{} isn't a downloaded package of the user who approved the prompt", source_path.display()));+        }+        let name = source_path.file_name().ok_or_else(|| format!("{} isn't a package file", source_path.display()))?;+        let mut copy_name = OsString::from(format!("{index}-"));+        copy_name.push(name);+        let copy_path = staging.path().join(copy_name);+        let mut copy = OpenOptions::new()+            .write(true)+            .create_new(true)+            .open(&copy_path)+            .map_err(|error| format!("couldn't prepare {} ({error})", copy_path.display()))?;+        std::io::copy(&mut source, &mut copy).map_err(|error| format!("couldn't copy {} ({error})", source_path.display()))?;+        copy.flush().map_err(|error| format!("couldn't save {} ({error})", copy_path.display()))?;+        drop(copy);+        if let Some(expected) = expected {+            let actual = crate::http::sha256_file(&copy_path)?;+            if actual != crate::http::hex(expected) {+                return Err(format!("{} doesn't match the release checksum", source_path.display()));+            }+        }+        copies.push(copy_path.into_os_string());+    }+    Ok((copies, Some(staging)))+}++fn write_response(output: &mut impl Write, outcome: &Result<(), String>) -> std::io::Result<()> {+    let (status, message) = match outcome {+        Ok(()) => (0u8, String::new()),+        Err(error) => (1u8, error.chars().take(MAX_MESSAGE_CHARS).collect()),+    };+    let message = message.as_bytes();+    let length = u32::try_from(message.len()).unwrap_or(u32::MAX).to_be_bytes();+    output.write_all(&[status])?;+    output.write_all(&length)?;+    output.write_all(message)?;+    output.flush()+}++/// Whether `targets` is something `command` may run, checked without root before a prompt is+/// shown. The helper repeats the check with the file owner's user id.+fn validate(command: RootCommand, targets: &[(OsString, Option<[u8; HASH_LENGTH]>)], uid: Option<u32>) -> Result<(), String> {+    if targets.is_empty() || targets.len() > MAX_TARGETS {+        return Err("the install request has the wrong number of files".into());+    }+    for (target, _) in targets {+        match command.targets() {+            Targets::Files => check_file(target, uid)?,+            Targets::Names => check_name(target)?,+        }+    }+    Ok(())+}++fn check_file(target: &OsStr, uid: Option<u32>) -> Result<(), String> {+    let path = Path::new(target);+    if !path.is_absolute() {+        return Err("a package to install must be an absolute path".into());+    }+    let metadata = std::fs::symlink_metadata(path).map_err(|_| format!("{} can't be installed", path.display()))?;+    if !metadata.file_type().is_file() || uid.is_some_and(|uid| metadata.uid() != uid) {+        return Err(format!("{} isn't a downloaded package of the user who approved the prompt", path.display()));+    }+    Ok(())+}++fn check_name(target: &OsStr) -> Result<(), String> {+    let Some(name) = target.to_str() else {+        return Err("a package name must be text".into());+    };+    let known = SUITE.iter().any(|app| {+        std::iter::once(app.id)+            .chain(app.former_ids.iter().copied())+            .any(|id| name == id || name == format!("{id}-bin") || name == format!("{id}-git") || name == format!("ai.storyteller.{id}"))+    });+    if known { Ok(()) } else { Err(format!("{name} isn't a Craft app package")) }+}++fn parse_hash(text: &str) -> Option<[u8; HASH_LENGTH]> {+    if text.len() != HASH_LENGTH * 2 {+        return None;+    }+    let mut hash = [0u8; HASH_LENGTH];+    for (index, pair) in text.as_bytes().as_chunks::<2>().0.iter().enumerate() {+        hash[index] = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;+    }+    Some(hash)+}++/// The launcher's side of the helper.+struct RootSession {+    child: Child,+    stream: UnixStream,+    _socket_dir: tempfile::TempDir,+}++/// The helper of this launcher, when it has one.+static SESSION: Mutex<Option<RootSession>> = Mutex::new(None);++fn lock() -> MutexGuard<'static, Option<RootSession>> {+    SESSION.lock().unwrap_or_else(PoisonError::into_inner)+}++/// Runs a command that needs root. A command the helper knows is sent to it, starting it (and+/// with it the desktop's one password prompt) if needed; anything else goes through `pkexec`+/// on its own, as before.+pub fn run(args: &[&OsStr]) -> Result<(), String> {+    let Some((command, targets)) = RootCommand::of(args) else {+        return direct(args);+    };+    let hashes: Vec<Option<[u8; HASH_LENGTH]>> =+        if command.targets() == Targets::Files { targets.iter().map(|target| expected_hash(target)).collect() } else { vec![None; targets.len()] };+    let with_hashes: Vec<(OsString, Option<[u8; HASH_LENGTH]>)> =+        targets.iter().map(|target| target.to_os_string()).zip(hashes.iter().copied()).collect();+    validate(command, &with_hashes, None)?;+    // An AppImage's own binary lives in a temporary mount, and inside a Flatpak sandbox root+    // means the sandbox's own root: neither is a place to keep a privileged helper. Those keep+    // the per-install prompt.+    if std::env::var_os("APPIMAGE").is_some() || process::in_sandbox() {+        return direct(args);+    }+    let mut session = lock();+    if session.as_mut().is_some_and(|session| session.finished()) {+        *session = None;+    }+    if session.is_none() {+        match RootSession::start() {+            Ok(started) => *session = Some(started),+            Err(error) if error == REPLACED => return direct(args),+            Err(error) => return Err(error),+        }+    }+    let exchanged = match session.as_mut() {+        Some(active) => active.exchange(command, &targets, &hashes),+        None => Exchange::Broken("the elevated install session wasn't available; try again".into()),+    };+    match exchanged {+        Exchange::Installed(outcome) => outcome,+        Exchange::Broken(error) => {+            *session = None;+            Err(error)+        }+    }+}++/// What one exchange with the helper says.+enum Exchange {+    /// The helper answered: the command's own outcome. The helper is still usable.+    Installed(Result<(), String>),+    /// The helper could not be reached. It is dropped; the next call asks for permission again.+    Broken(String),+}++impl RootSession {+    /// Starts the helper through polkit. `Err("cancelled")` when the prompt was dismissed.+    fn start() -> Result<Self, String> {+        let executable = std::env::current_exe().map_err(|e| format!("couldn't find this program ({e})"))?;+        if !executable.is_file() {+            return Err(REPLACED.into());+        }+        let pkexec = pkexec().ok_or_else(|| "installing system-wide needs polkit (pkexec), which isn't installed".to_string())?;+        let socket_dir =+            tempfile::Builder::new().prefix("artcraft-helper-").tempdir().map_err(|e| format!("couldn't prepare the permission request ({e})"))?;+        let socket = socket_dir.path().join(SOCKET_NAME);+        let listener = UnixListener::bind(&socket).map_err(|e| format!("couldn't prepare the permission request ({e})"))?;+        listener.set_nonblocking(true).map_err(|e| format!("couldn't prepare the permission request ({e})"))?;+        let socket_text = socket.to_str().ok_or_else(|| "the permission socket path isn't text".to_string())?;+        let mut child = process::command(pkexec)+            .arg(&executable)+            .arg(format!("{FLAG}{socket_text}"))+            .stdin(Stdio::null())+            .stdout(Stdio::null())+            .stderr(Stdio::null())+            .spawn()+            .map_err(|e| format!("couldn't ask for permission ({e})"))?;+        let deadline = Instant::now() + START_LIMIT;+        loop {+            if let Ok(Some(status)) = child.try_wait() {+                return Err(start_failed_code(status.code()));+            }+            match listener.accept() {+                Ok((mut stream, _)) => {+                    let pid = i32::try_from(child.id()).ok();+                    let peer = match rustix::net::sockopt::socket_peercred(&stream) {+                        Ok(peer) => peer,+                        Err(error) => {+                            let reason = format!("couldn't verify the permission helper ({error})");+                            return Err(end(&mut child, reason));+                        }+                    };+                    if Some(peer.pid.as_raw_pid()) != pid {+                        continue;+                    }+                    let _ = stream.set_nonblocking(false);+                    let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));+                    let mut ready = Vec::new();+                    if read_line(&mut stream, &mut ready).is_err() || ready != READY.trim_end().as_bytes() {+                        return Err(end(&mut child, "the permission helper didn't start".into()));+                    }+                    let _ = stream.set_read_timeout(None);+                    let _ = stream.set_write_timeout(Some(Duration::from_secs(300)));+                    return Ok(Self { child, stream, _socket_dir: socket_dir });+                }+                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {+                    if Instant::now() >= deadline {+                        return Err(end(&mut child, "the permission prompt wasn't answered".into()));+                    }+                    std::thread::sleep(Duration::from_millis(50));+                }+                Err(error) => return Err(end(&mut child, format!("couldn't ask for permission ({error})"))),+            }+        }+    }++    fn finished(&mut self) -> bool {+        !matches!(self.child.try_wait(), Ok(None))+    }++    fn exchange(&mut self, command: RootCommand, targets: &[&OsStr], hashes: &[Option<[u8; HASH_LENGTH]>]) -> Exchange {+        let payload = match encode(command, targets, hashes) {+            Ok(payload) => payload,+            Err(()) => return Exchange::Broken("the install request is too large".into()),+        };+        let length = u32::try_from(payload.len()).unwrap_or(u32::MAX).to_be_bytes();+        let written = self.stream.write_all(&length).and_then(|()| self.stream.write_all(&payload)).and_then(|()| self.stream.flush());+        if let Err(e) = written {+            return Exchange::Broken(gone(e));+        }+        let mut status = [0u8; 1];+        if let Err(e) = self.stream.read_exact(&mut status) {+            return Exchange::Broken(gone(e));+        }+        let mut length = [0u8; 4];+        if let Err(e) = self.stream.read_exact(&mut length) {+            return Exchange::Broken(gone(e));+        }+        let size = u32::from_be_bytes(length) as usize;+        if size > MAX_PAYLOAD {+            return Exchange::Broken("the elevated install session sent an invalid answer; try again".into());+        }+        let mut message = vec![0u8; size];+        if let Err(e) = self.stream.read_exact(&mut message) {+            return Exchange::Broken(gone(e));+        }+        match status[0] {+            0 => Exchange::Installed(Ok(())),+            1 => Exchange::Installed(Err(String::from_utf8_lossy(&message).into_owned())),+            _ => Exchange::Broken("the elevated install session sent an invalid answer; try again".into()),+        }+    }+}++/// A session that stopped answering has no useful answers left; end it. A healthy helper is+/// never dropped: it sees each command through and exits once its socket closes or it is idle.+impl Drop for RootSession {+    fn drop(&mut self) {+        let _ = self.child.kill();+        let _ = self.child.wait();+    }+}++fn read_line(stream: &mut UnixStream, out: &mut Vec<u8>) -> std::io::Result<()> {+    let mut byte = [0u8; 1];+    while out.len() < 256 {+        match stream.read(&mut byte) {+            Ok(0) => return Ok(()),+            Ok(_) => match byte[0] {+                b'\n' => return Ok(()),+                b'\r' => {}+                other => out.push(other),+            },+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}+            Err(error) => return Err(error),+        }+    }+    Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "handshake line too long"))+}++fn gone(error: std::io::Error) -> String {+    format!("the elevated install session ended ({error}); try again")+}++/// Stops a helper that will not be used and reports why.+fn end(child: &mut Child, reason: String) -> String {+    let _ = child.kill();+    let _ = child.wait();+    reason+}++fn start_failed_code(code: Option<i32>) -> String {+    match code {+        Some(126 | 127) => CANCELLED.to_string(),+        Some(code) => format!("couldn't ask for permission (exit {code})"),+        None => "couldn't ask for permission (interrupted)".into(),+    }+}++/// The helper's request: the command number, then every target with an optional release hash.+fn encode(command: RootCommand, targets: &[&OsStr], hashes: &[Option<[u8; HASH_LENGTH]>]) -> Result<Vec<u8>, ()> {+    if targets.is_empty() || targets.len() > MAX_TARGETS || hashes.len() != targets.len() {+        return Err(());+    }+    let count = u32::try_from(targets.len()).map_err(|_| ())?;+    let mut payload = Vec::new();+    payload.push(command as u8);+    payload.extend_from_slice(&count.to_be_bytes());+    for (target, expected) in targets.iter().zip(hashes) {+        let bytes = target.as_bytes();+        if bytes.is_empty() || bytes.len() > MAX_TARGET {+            return Err(());+        }+        payload.extend_from_slice(&u32::try_from(bytes.len()).map_err(|_| ())?.to_be_bytes());+        payload.extend_from_slice(bytes);+        match expected {+            Some(hash) => {+                payload.push(1);+                payload.extend_from_slice(hash);+            }+            None => payload.push(0),+        }+    }+    if payload.len() > MAX_PAYLOAD { Err(()) } else { Ok(payload) }+}++/// A command that needs root, run on its own through `pkexec` (used where the helper can't).+fn direct(args: &[&OsStr]) -> Result<(), String> {+    let args = with_absolute_program(&SYSTEM_BIN_DIRS, args)?;+    let pkexec = pkexec().ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?;+    let status = process::command(pkexec).args(&args).status().map_err(|e| format!("couldn't ask for permission ({e})"))?;+    super::root_outcome(status.code())+}++#[cfg(test)]+mod tests {+    use super::*;++    fn os(value: &str) -> OsString {+        OsString::from(value)+    }++    #[test]+    fn the_package_commands_are_the_ones_the_launcher_builds() {+        use artcraft_launcher_core::PackageKind;+        assert_eq!(crate::install::linux::install_command(PackageKind::Deb), Some(APT_INSTALL));+        for command in RootCommand::ALL {+            let argv = command.argv();+            let mut args: Vec<&OsStr> = argv.iter().map(OsStr::new).collect();+            args.push(OsStr::new("/downloads/app.deb"));+            let (found, targets) = RootCommand::of(&args).expect("every command is recognized");+            assert_eq!(found, command);+            assert_eq!(targets, vec![OsStr::new("/downloads/app.deb")]);+        }+    }++    #[test]+    fn the_longest_command_claims_the_call() {+        let mut args: Vec<&OsStr> = DNF_INSTALL.iter().map(OsStr::new).collect();+        args.push(OsStr::new("/downloads/app.rpm"));+        assert_eq!(RootCommand::of(&args).map(|(command, _)| command), Some(RootCommand::DnfInstall));+    }++    #[test]+    fn a_call_without_targets_is_not_shared() {+        let args: Vec<&OsStr> = APT_INSTALL.iter().map(OsStr::new).collect();+        assert_eq!(RootCommand::of(&args), None);+    }++    #[test]+    fn an_unknown_command_is_not_shared() {+        let args = [OsStr::new("/bin/sh"), OsStr::new("-c"), OsStr::new("id")];+        assert_eq!(RootCommand::of(&args), None);+    }++    #[test]+    fn requests_round_trip() {+        let targets = [os("/downloads/a.deb"), os("/downloads/b.deb")];+        let borrowed: Vec<&OsStr> = targets.iter().map(OsString::as_os_str).collect();+        let hashes = [Some([0x11; HASH_LENGTH]), None];+        let payload = encode(RootCommand::AptInstall, &borrowed, &hashes).expect("a small request");+        let request = decode(&payload).expect("the helper reads it back");+        assert_eq!(request.command, RootCommand::AptInstall);+        assert_eq!(request.targets, vec![(targets[0].clone(), hashes[0]), (targets[1].clone(), hashes[1])]);+    }++    #[test]+    fn malformed_requests_are_refused() {+        assert!(decode(&[]).is_err());+        assert!(decode(&[99]).is_err());+        assert!(decode(&[0, 0, 0, 0, 0]).is_err(), "no targets");+        // A target that claims to be longer than the request.+        assert!(decode(&[0, 0, 0, 0, 1, 0, 0, 0, 9, b'a']).is_err());+        // Data after the last target.+        let mut payload = encode(RootCommand::AptRemove, &[OsStr::new("photocraft")], &[None]).expect("a small request");+        payload.push(0);+        assert!(decode(&payload).is_err());+        // An unknown checksum flag.+        let mut payload = encode(RootCommand::AptInstall, &[OsStr::new("/tmp/a.deb")], &[None]).expect("a small request");+        let last = payload.len() - 1;+        payload[last] = 2;+        assert!(decode(&payload).is_err());+    }++    #[test]+    fn oversized_requests_are_refused() {+        let big = "a".repeat(MAX_TARGET + 1);+        assert!(encode(RootCommand::AptInstall, &[OsStr::new(&big)], &[None]).is_err());+        assert!(encode(RootCommand::AptInstall, &[], &[]).is_err());+        assert!(encode(RootCommand::AptInstall, &[OsStr::new("/tmp/a.deb")], &[]).is_err());+    }++    #[test]+    fn package_files_must_be_absolute_and_exist() {+        assert!(check_file(OsStr::new("relative.deb"), None).is_err());+        assert!(check_file(OsStr::new("/no/such/package.deb"), None).is_err());+        let dir = tempfile::tempdir().unwrap();+        let file = dir.path().join("app.deb");+        std::fs::write(&file, b"package").unwrap();+        assert_eq!(check_file(file.as_os_str(), None), Ok(()));+        // The helper only trusts the user who approved the prompt.+        assert!(check_file(file.as_os_str(), Some(u32::MAX)).is_err());+    }++    #[test]+    fn package_names_are_craft_packages() {+        for name in ["photocraft", "photocraft-bin", "photocraft-git", "ai.storyteller.photocraft", "printcraft"] {+            assert_eq!(check_name(OsStr::new(name)), Ok(()), "{name}");+        }+        for name in ["", "--option", "a/b", "a b", "a\nb", "gcc@12"] {+            assert!(check_name(OsStr::new(name)).is_err(), "{name}");+        }+    }++    #[test]+    fn removal_commands_take_names_and_installs_take_files() {+        assert_eq!(RootCommand::AptInstall.targets(), Targets::Files);+        assert_eq!(RootCommand::PacmanInstall.targets(), Targets::Files);+        assert_eq!(RootCommand::AptRemove.targets(), Targets::Names);+        assert_eq!(RootCommand::FlatpakSystemUninstall.targets(), Targets::Names);+    }++    #[test]+    fn checksums_are_read_from_hex() {+        let text = "5a".repeat(HASH_LENGTH);+        assert_eq!(parse_hash(&text), Some([0x5a; HASH_LENGTH]));+        assert_eq!(parse_hash(&text[..text.len() - 1]), None);+        assert_eq!(parse_hash(&"zz".repeat(HASH_LENGTH)), None);+    }++    #[test]+    fn packages_are_copied_and_checked_before_install() {+        use std::os::unix::fs::MetadataExt;+        let dir = tempfile::tempdir().unwrap();+        let file = dir.path().join("app.deb");+        std::fs::write(&file, b"package").unwrap();+        let uid = std::fs::metadata(&file).unwrap().uid();+        let expected = crate::http::sha256_file(&file).unwrap();+        let hash = parse_hash(&expected).unwrap();+        let targets = vec![(file.as_os_str().to_os_string(), Some(hash))];+        let (copies, staging) = stage_targets(RootCommand::AptInstall, &targets, uid).unwrap();+        assert_eq!(copies.len(), 1);+        assert_ne!(Path::new(&copies[0]).parent(), Some(dir.path()));+        assert_eq!(std::fs::read(&copies[0]).unwrap(), b"package");+        drop(staging);+        assert!(!Path::new(&copies[0]).exists(), "the staging copy is removed with the staging folder");+        assert!(stage_targets(RootCommand::AptInstall, &[(file.as_os_str().to_os_string(), Some([0; HASH_LENGTH]))], uid).is_err());+    }+}diff --git a/crates/native/src/install/windows_helper.rs b/crates/native/src/install/windows_helper.rsnew file mode 100644--- /dev/null+++ b/crates/native/src/install/windows_helper.rs@@ -0,0 +1,553 @@+//! One UAC prompt for a run of Windows Installer packages.+//!+//! The Craft apps' MSIs are per-machine, so Windows asks for administrator permission for each+//! one. The first install instead starts this program as an elevated helper through a single+//! `runas` prompt, and later installs and removals are sent to it over a loopback connection.+//! The helper stays alive until it has been idle for fifteen minutes or the launcher exits.+//!+//! The package bytes are streamed to the helper and checked against their SHA-256, so the+//! elevated process never needs to read the launcher's cache, and the package cannot change+//! between the launcher's download check and the installer. The helper accepts only installs of+//! streamed MSIs and removals of product codes; nothing it receives can run another program.++use std::io::{Read, Write};+use std::net::{SocketAddr, TcpListener, TcpStream};+use std::path::{Path, PathBuf};+use std::process::ExitCode;+use std::sync::{Mutex, MutexGuard, PoisonError};+use std::time::{Duration, Instant};++use artcraft_launcher_core::protocol::CANCELLED;+use sha2::{Digest, Sha256};++use super::windows_wire::{self, Request};++/// The launcher passes this to the helper it starts elevated.+const FLAG: &str = "--artcraft-elevated-helper=";++/// How long the helper waits for another install before it exits.+const IDLE: Duration = Duration::from_secs(15 * 60);++/// How long the launcher waits for the permission helper to connect after UAC has been answered.+const CONNECT_LIMIT: Duration = Duration::from_secs(60);++const MAX_MESSAGE: usize = 4 << 10;+const HASH_LENGTH: usize = 32;++/// Runs the elevated helper when this program was started as one. The launcher calls this before+/// it sets anything else up; every other start has no [`FLAG`] and returns `None`.+pub fn run_if_requested() -> Option<ExitCode> {+    let value = std::env::args_os().find_map(|arg| arg.to_str().and_then(|text| text.strip_prefix(FLAG)).map(str::to_string))?;+    let Some((port, token)) = value.split_once(':') else { return Some(ExitCode::FAILURE) };+    let Ok(port) = port.parse::<u16>() else { return Some(ExitCode::FAILURE) };+    let token_ok = !token.is_empty() && token.len() <= 128 && token.bytes().all(|byte| byte.is_ascii_hexdigit());+    if !token_ok {+        return Some(ExitCode::FAILURE);+    }+    Some(ExitCode::from(serve(port, token.to_string())))+}++/// Installs `msi` as an administrator. The caller is already past the download check; this only+/// fetches the install permission.+pub fn install(msi: &Path, destination: Option<&Path>, expected: Option<&str>) -> Result<(), String> {+    if !msi.is_file() {+        return Err(format!("{} isn't a downloaded package", msi.display()));+    }+    let expected = match expected {+        Some(text) => Some(windows_wire::parse_sha256(text).ok_or_else(|| "the release checksum isn't a SHA-256".to_string())?),+        None => None,+    };+    with_session(|session| session.install(msi, destination, expected))+}++/// Removes one MSI product as an administrator.+pub fn uninstall(product: &str) -> Result<(), String> {+    windows_wire::validate_product(product)?;+    with_session(|session| session.uninstall(product))+}++/// The helper's side: connect to the waiting launcher and serve requests until the launcher goes+/// away or nothing has been asked for a while.+fn serve(port: u16, token: String) -> u8 {+    let Some(mut stream) = connect(port) else { return 1 };+    let _ = stream.set_nodelay(true);+    let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));+    if writeln!(stream, "HELLO {token}").and_then(|()| stream.flush()).is_err() {+        return 1;+    }+    let _ = stream.set_read_timeout(Some(Duration::from_secs(60)));+    let mut answer = Vec::new();+    if read_line(&mut stream, &mut answer).is_err() || answer != b"OK" {+        return 1;+    }+    let _ = stream.set_read_timeout(Some(IDLE));+    let mut counter = 0u32;+    loop {+        let header = match read_frame(&mut stream) {+            Ok(Some(header)) => header,+            Ok(None) => return 0,+            Err(_) => return 1,+        };+        let (outcome, intact) = match windows_wire::decode(&header) {+            Ok(Request::Install { destination, length, expected }) => install_package(&mut stream, destination, length, expected, &token, counter),+            Ok(Request::Uninstall { product }) => (uninstall_package(&product), true),+            Err(_) => return 1,+        };+        if write_response(&mut stream, &outcome).is_err() || !intact {+            return 1;+        }+        counter = counter.wrapping_add(1);+    }+}++fn connect(port: u16) -> Option<TcpStream> {+    let address = SocketAddr::from(([127, 0, 0, 1], port));+    let deadline = Instant::now() + Duration::from_secs(10);+    loop {+        match TcpStream::connect_timeout(&address, Duration::from_secs(2)) {+            Ok(stream) => return Some(stream),+            Err(_) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)),+            Err(_) => return None,+        }+    }+}++/// Reads one line, for the small plain-text handshake.+fn read_line(stream: &mut TcpStream, out: &mut Vec<u8>) -> std::io::Result<()> {+    let mut byte = [0u8; 1];+    while out.len() < 256 {+        match stream.read(&mut byte) {+            Ok(0) => return Ok(()),+            Ok(_) => match byte[0] {+                b'\n' => return Ok(()),+                b'\r' => {}+                other => out.push(other),+            },+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}+            Err(error) => return Err(error),+        }+    }+    Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "handshake line too long"))+}++/// Reads a length-prefixed request header. `None` means the launcher is gone or idle.+fn read_frame(stream: &mut TcpStream) -> Result<Option<Vec<u8>>, String> {+    let mut length = [0u8; 4];+    match stream.read_exact(&mut length) {+        Ok(()) => {}+        Err(error) if matches!(error.kind(), std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {+            return Ok(None);+        }+        Err(error) => return Err(format!("couldn't read the next request ({error})")),+    }+    let length = u32::from_be_bytes(length) as usize;+    if length == 0 || length > windows_wire::MAX_HEADER {+        return Err("the install request is too large".into());+    }+    let mut header = vec![0u8; length];+    stream.read_exact(&mut header).map_err(|error| format!("couldn't read the next request ({error})"))?;+    Ok(Some(header))+}++/// Receives one MSI into the elevated account's temp folder, checks it and runs it. The second+/// value says whether the connection is still at a request boundary; a short or failed transfer+/// leaves it out of step, so the helper then exits.+fn install_package(+    stream: &mut TcpStream,+    destination: Option<String>,+    length: u64,+    expected: Option<[u8; HASH_LENGTH]>,+    token: &str,+    counter: u32,+) -> (Result<(), String>, bool) {+    let (temp, mut file) = match TempMsi::create(token, counter) {+        Ok(pair) => pair,+        Err(error) => return (Err(error), false),+    };+    let mut hasher = Sha256::new();+    let mut buffer = [0u8; 64 << 10];+    let mut remaining = length;+    while remaining > 0 {+        let want = remaining.min(buffer.len() as u64) as usize;+        match stream.read(&mut buffer[..want]) {+            Ok(0) => return (Err("the installer transfer ended early".into()), false),+            Ok(read) => {+                hasher.update(&buffer[..read]);+                if let Err(error) = file.write_all(&buffer[..read]) {+                    return (Err(format!("couldn't save the installer ({error})")), false);+                }+                remaining -= read as u64;+            }+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}+            Err(error) => return (Err(format!("couldn't receive the installer ({error})")), false),+        }+    }+    if let Err(error) = file.flush() {+        return (Err(format!("couldn't save the installer ({error})")), false);+    }+    drop(file);+    // Reopen read-only with `FILE_SHARE_READ` and keep that handle open while `msiexec` reads+    // the package: no same-account process can replace it in the meantime.+    let mut lock = match lock_msi(&temp.path) {+        Ok(file) => file,+        Err(error) => return (Err(error), false),+    };+    let stored = match hash_file(&mut lock) {+        Ok(hash) => hash,+        Err(error) => return (Err(error), false),+    };+    let mut digest = [0u8; HASH_LENGTH];+    if let Err(error) = stream.read_exact(&mut digest) {+        return (Err(format!("couldn't verify the installer ({error})")), false);+    }+    let received = hasher.finalize();+    if received.as_slice() != &digest[..] {+        return (Err("the installer changed on its way to the permission helper".into()), true);+    }+    if stored.as_slice() != received.as_slice() {+        return (Err("the installer changed on disk before it could run".into()), true);+    }+    if let Some(expected) = expected+        && stored != expected+    {+        return (Err("the installer doesn't match the release checksum".into()), true);+    }+    let destination = match destination {+        Some(destination) => {+            let path = PathBuf::from(destination);+            if !path.is_absolute() {+                return (Err("the install folder must be an absolute path".into()), true);+            }+            Some(path)+        }+        None => None,+    };+    let result = install_msi(&temp.path, destination.as_deref());+    // A cancelled install must stay exactly `cancelled`, so the launcher can tell it from an+    // error; everything else gets the end of the installer's log to explain it.+    let result = result.map_err(|error| if error == CANCELLED { error } else { with_log_tail(error, &log_path(&temp.path)) });+    (result, true)+}++/// `msi_command` canonicalizes the package path (junctions and symlinks, #338), so its log sits+/// beside the canonical path rather than the path this module created.+fn log_path(msi: &Path) -> PathBuf {+    super::msi_path(msi).map_or_else(|_| super::msi_log(msi), |path| super::msi_log(path.as_path()))+}++/// Opens the staged MSI read-only and keeps other processes from writing or deleting it.+fn lock_msi(path: &Path) -> Result<std::fs::File, String> {+    use std::os::windows::fs::OpenOptionsExt;+    const FILE_SHARE_READ: u32 = 0x1;+    std::fs::OpenOptions::new().read(true).share_mode(FILE_SHARE_READ).open(path).map_err(|error| format!("couldn't lock the installer ({error})"))+}++fn hash_file(file: &mut std::fs::File) -> Result<[u8; HASH_LENGTH], String> {+    use std::io::Seek;+    file.seek(std::io::SeekFrom::Start(0)).map_err(|error| format!("couldn't verify the installer ({error})"))?;+    let mut hasher = Sha256::new();+    let mut buffer = [0u8; 64 << 10];+    loop {+        match file.read(&mut buffer) {+            Ok(0) => break,+            Ok(read) => hasher.update(&buffer[..read]),+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}+            Err(error) => return Err(format!("couldn't verify the installer ({error})")),+        }+    }+    let mut hash = [0u8; HASH_LENGTH];+    hash.copy_from_slice(hasher.finalize().as_slice());+    Ok(hash)+}++fn install_msi(temp: &Path, destination: Option<&Path>) -> Result<(), String> {+    use std::os::windows::process::CommandExt;+    let mut command = super::msi_command(&super::windows::msiexec(), temp)?;+    if let Some(destination) = destination {+        command.raw_arg(super::msi_folder_argument(destination)?);+    }+    let status = command.status().map_err(|error| format!("couldn't start the Windows Installer ({error})"))?;+    super::windows::explain(status.code())+}++fn uninstall_package(product: &str) -> Result<(), String> {+    let status = crate::process::command(super::windows::msiexec())+        .args(["/x", product, "/passive", "/norestart"])+        .status()+        .map_err(|error| format!("couldn't start the Windows Installer ({error})"))?;+    super::windows::explain(status.code())+}++fn write_response(stream: &mut TcpStream, outcome: &Result<(), String>) -> std::io::Result<()> {+    let (status, message) = match outcome {+        Ok(()) => (0u8, String::new()),+        Err(error) => (1u8, error.chars().take(1000).collect()),+    };+    let message = message.as_bytes();+    let length = u32::try_from(message.len()).unwrap_or(u32::MAX).to_be_bytes();+    stream.write_all(&[status])?;+    stream.write_all(&length)?;+    stream.write_all(message)?;+    stream.flush()+}++/// The end of the installer's verbose log, for an error the user can act on.+fn with_log_tail(error: String, log: &Path) -> String {+    use std::io::{Seek, SeekFrom};+    let Ok(metadata) = std::fs::metadata(log) else { return error };+    let Ok(mut file) = std::fs::File::open(log) else { return error };+    let start = metadata.len().saturating_sub(4096);+    if file.seek(SeekFrom::Start(start)).is_err() {+        return error;+    }+    let mut bytes = Vec::new();+    if file.take(4096).read_to_end(&mut bytes).is_err() {+        return error;+    }+    let text = String::from_utf8_lossy(&bytes);+    let tail: String = text.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("").trim().chars().take(500).collect();+    if tail.is_empty() { error } else { format!("{error}: {tail}") }+}++/// A downloaded MSI on the elevated account's temp folder, removed however its install ends.+struct TempMsi {+    path: PathBuf,+}++impl TempMsi {+    fn create(token: &str, counter: u32) -> Result<(Self, std::fs::File), String> {+        use std::os::windows::fs::OpenOptionsExt;+        // Only readers may share this file while the helper holds it open: no writer or deleter+        // can replace the package between the digest check and `msiexec`.+        const FILE_SHARE_READ: u32 = 0x1;+        let directory = std::env::temp_dir();+        let prefix: String = token.chars().take(16).collect();+        for attempt in 0..16u32 {+            let path = directory.join(format!("artcraft-update-{prefix}-{counter}-{attempt}.msi"));+            match std::fs::OpenOptions::new().write(true).create_new(true).share_mode(FILE_SHARE_READ).open(&path) {+                Ok(file) => return Ok((Self { path }, file)),+                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,+                Err(error) => return Err(format!("couldn't prepare the installer ({error})")),+            }+        }+        Err("couldn't prepare the installer".into())+    }+}++impl Drop for TempMsi {+    fn drop(&mut self) {+        // Resolve the log while the package still exists, so canonicalization can succeed.+        let log = log_path(&self.path);+        let _ = std::fs::remove_file(&self.path);+        let _ = std::fs::remove_file(log);+    }+}++/// The launcher's side of the helper.+struct Session {+    stream: TcpStream,+}++/// The helper of this launcher, when it has one.+static SESSION: Mutex<Option<Session>> = Mutex::new(None);++fn lock() -> MutexGuard<'static, Option<Session>> {+    SESSION.lock().unwrap_or_else(PoisonError::into_inner)+}++/// What one exchange with the helper says.+enum Reply {+    /// The helper answered: the installer's own outcome. The helper is still usable.+    Done(Result<(), String>),+    /// The helper could not be reached. It is dropped; the next call asks for permission again.+    Broken(String),+}++/// Runs `work` against the helper, starting it (and with it the one UAC prompt) if needed.+fn with_session(work: impl FnOnce(&mut Session) -> Reply) -> Result<(), String> {+    let mut slot = lock();+    if slot.as_ref().is_some_and(|session| !session.alive()) {+        *slot = None;+    }+    if slot.is_none() {+        *slot = Some(Session::start()?);+    }+    let reply = match slot.as_mut() {+        Some(session) => work(session),+        None => Reply::Broken("the elevated install session wasn't available; try again".into()),+    };+    match reply {+        Reply::Done(outcome) => outcome,+        Reply::Broken(error) => {+            *slot = None;+            Err(error)+        }+    }+}++impl Session {+    /// Starts the helper through UAC. `Err("cancelled")` when the prompt was dismissed.+    fn start() -> Result<Self, String> {+        let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| format!("couldn't prepare the permission request ({error})"))?;+        listener.set_nonblocking(true).map_err(|error| format!("couldn't prepare the permission request ({error})"))?;+        let port = listener.local_addr().map_err(|error| format!("couldn't prepare the permission request ({error})"))?.port();+        let token = random_token()?;+        let executable = std::env::current_exe().map_err(|error| format!("couldn't find this program ({error})"))?;+        let executable = executable.to_str().ok_or_else(|| "this program's path isn't text".to_string())?;+        let parameters = format!("{FLAG}{port}:{token}");+        let info = winsafe::SHELLEXECUTEINFO {+            mask: winsafe::co::SEE_MASK::NOASYNC | winsafe::co::SEE_MASK::FLAG_NO_UI,+            verb: Some("runas"),+            file: executable,+            parameters: Some(&parameters),+            show: winsafe::co::SW::HIDE,+            ..Default::default()+        };+        winsafe::ShellExecuteEx(&info).map_err(|error| {+            if error == winsafe::co::ERROR::CANCELLED { CANCELLED.to_string() } else { format!("couldn't ask for permission ({error})") }+        })?;+        let deadline = Instant::now() + CONNECT_LIMIT;+        loop {+            match listener.accept() {+                Ok((mut stream, _)) => {+                    let _ = stream.set_nonblocking(false);+                    let _ = stream.set_nodelay(true);+                    let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));+                    let mut hello = Vec::new();+                    if read_line(&mut stream, &mut hello).is_err() || hello != format!("HELLO {token}").into_bytes() {+                        continue;+                    }+                    if stream.write_all(b"OK\n").and_then(|()| stream.flush()).is_err() {+                        continue;+                    }+                    let _ = stream.set_read_timeout(None);+                    let _ = stream.set_write_timeout(Some(Duration::from_secs(300)));+                    return Ok(Self { stream });+                }+                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {+                    if Instant::now() >= deadline {+                        return Err("the permission helper didn't start".into());+                    }+                    std::thread::sleep(Duration::from_millis(50));+                }+                Err(error) => return Err(format!("couldn't ask for permission ({error})")),+            }+        }+    }++    /// Whether the helper is still connected (it exits after being idle for `IDLE`).+    fn alive(&self) -> bool {+        if self.stream.set_nonblocking(true).is_err() {+            return false;+        }+        let mut probe = [0u8; 1];+        let alive = matches!(self.stream.peek(&mut probe), Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock);+        let _ = self.stream.set_nonblocking(false);+        alive+    }++    fn install(&mut self, msi: &Path, destination: Option<&Path>, expected: Option<[u8; HASH_LENGTH]>) -> Reply {+        let destination = match destination {+            Some(path) => match path.to_str() {+                Some(text) => Some(text),+                None => return Reply::Done(Err("the install folder isn't text".into())),+            },+            None => None,+        };+        let mut file = match std::fs::File::open(msi) {+            Ok(file) => file,+            Err(error) => return Reply::Done(Err(format!("couldn't read the installer ({error})"))),+        };+        let length = match file.metadata() {+            Ok(metadata) => metadata.len(),+            Err(error) => return Reply::Done(Err(format!("couldn't read the installer ({error})"))),+        };+        let header = match windows_wire::encode_install(destination, length, expected.as_ref()) {+            Ok(header) => header,+            Err(error) => return Reply::Done(Err(error)),+        };+        if let Err(error) = self.write_header(&header) {+            return Reply::Broken(gone(error));+        }+        let mut hasher = Sha256::new();+        let mut buffer = [0u8; 64 << 10];+        let mut sent = 0u64;+        loop {+            match file.read(&mut buffer) {+                Ok(0) => break,+                Ok(read) => {+                    if sent.saturating_add(read as u64) > length {+                        return Reply::Broken("the installer changed while it was being sent; try again".into());+                    }+                    hasher.update(&buffer[..read]);+                    if let Err(error) = self.stream.write_all(&buffer[..read]) {+                        return Reply::Broken(gone(error));+                    }+                    sent += read as u64;+                }+                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}+                Err(error) => return Reply::Broken(gone(error)),+            }+        }+        if sent != length {+            return Reply::Broken("the installer changed while it was being sent; try again".into());+        }+        let digest = hasher.finalize();+        if let Err(error) = self.stream.write_all(&digest).and_then(|()| self.stream.flush()) {+            return Reply::Broken(gone(error));+        }+        self.read_reply()+    }++    fn uninstall(&mut self, product: &str) -> Reply {+        let header = match windows_wire::encode_uninstall(product) {+            Ok(header) => header,+            Err(error) => return Reply::Done(Err(error)),+        };+        if let Err(error) = self.write_header(&header) {+            return Reply::Broken(gone(error));+        }+        self.read_reply()+    }++    fn write_header(&mut self, header: &[u8]) -> std::io::Result<()> {+        let length = u32::try_from(header.len()).map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "header too large"))?;+        self.stream.write_all(&length.to_be_bytes())?;+        self.stream.write_all(header)?;+        self.stream.flush()+    }++    fn read_reply(&mut self) -> Reply {+        let mut status = [0u8; 1];+        if let Err(error) = self.stream.read_exact(&mut status) {+            return Reply::Broken(gone(error));+        }+        let mut length = [0u8; 4];+        if let Err(error) = self.stream.read_exact(&mut length) {+            return Reply::Broken(gone(error));+        }+        let length = u32::from_be_bytes(length) as usize;+        if length > MAX_MESSAGE {+            return Reply::Broken("the elevated install session sent an invalid answer; try again".into());+        }+        let mut message = vec![0u8; length];+        if let Err(error) = self.stream.read_exact(&mut message) {+            return Reply::Broken(gone(error));+        }+        match status[0] {+            0 => Reply::Done(Ok(())),+            1 => Reply::Done(Err(String::from_utf8_lossy(&message).into_owned())),+            _ => Reply::Broken("the elevated install session sent an invalid answer; try again".into()),+        }+    }+}++fn random_token() -> Result<String, String> {+    let mut bytes = [0u8; 32];+    getrandom::fill(&mut bytes).map_err(|error| format!("couldn't prepare the permission request ({error})"))?;+    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())+}++fn gone(error: std::io::Error) -> String {+    format!("the elevated install session ended ({error}); try again")+}diff --git a/crates/native/src/install/windows_wire.rs b/crates/native/src/install/windows_wire.rsnew file mode 100644--- /dev/null+++ b/crates/native/src/install/windows_wire.rs@@ -0,0 +1,239 @@+//! The frames the launcher sends its elevated Windows helper.+//!+//! A request is a short header (`u32` length, then the header itself). An install follows the+//! header with the package bytes and their SHA-256, so the helper can stream a large MSI to disk+//! instead of holding it in memory. Keeping this code free of Windows APIs lets it be tested on+//! every platform.++pub(crate) const KIND_INSTALL: u8 = 1;+pub(crate) const KIND_UNINSTALL: u8 = 2;++pub(crate) const MAX_HEADER: usize = 64 << 10;+pub(crate) const MAX_DESTINATION: usize = 32 << 10;+pub(crate) const MAX_PRODUCT: usize = 64;+pub(crate) const MAX_MSI: u64 = 2 << 30; // 2 GiB++/// One thing the helper may be asked to do.+#[derive(Debug, PartialEq, Eq)]+pub(crate) enum Request {+    Install { destination: Option<String>, length: u64, expected: Option<[u8; 32]> },+    Uninstall { product: String },+}++/// An install header: the optional install folder, the package length, and the release checksum+/// the launcher verified the download against.+pub(crate) fn encode_install(destination: Option<&str>, length: u64, expected: Option<&[u8; 32]>) -> Result<Vec<u8>, String> {+    if length == 0 || length > MAX_MSI {+        return Err("the package is too large".into());+    }+    let mut out = vec![KIND_INSTALL];+    match destination {+        Some(destination) => {+            let bytes = destination.as_bytes();+            if bytes.is_empty() || bytes.len() > MAX_DESTINATION || destination.contains('\0') {+                return Err("the install folder isn't usable".into());+            }+            out.push(1);+            out.extend_from_slice(&u32::try_from(bytes.len()).map_err(|_| "the install folder is too long")?.to_be_bytes());+            out.extend_from_slice(bytes);+        }+        None => out.push(0),+    }+    out.extend_from_slice(&length.to_be_bytes());+    match expected {+        Some(expected) => {+            out.push(1);+            out.extend_from_slice(expected);+        }+        None => out.push(0),+    }+    Ok(out)+}++/// An uninstall header: the MSI product code.+pub(crate) fn encode_uninstall(product: &str) -> Result<Vec<u8>, String> {+    validate_product(product)?;+    let mut out = vec![KIND_UNINSTALL];+    out.extend_from_slice(&u32::try_from(product.len()).map_err(|_| "the product code is too long")?.to_be_bytes());+    out.extend_from_slice(product.as_bytes());+    Ok(out)+}++/// Reads a header the helper was sent.+pub(crate) fn decode(header: &[u8]) -> Result<Request, String> {+    if header.len() > MAX_HEADER {+        return Err("the install request is too large".into());+    }+    let mut reader = Reader { bytes: header, at: 0 };+    let request = match reader.u8()? {+        KIND_INSTALL => {+            let destination = match reader.u8()? {+                0 => None,+                1 => {+                    let length = reader.u32()? as usize;+                    if length == 0 || length > MAX_DESTINATION {+                        return Err("the install folder isn't usable".into());+                    }+                    let destination = std::str::from_utf8(reader.bytes(length)?).map_err(|_| "the install folder isn't text")?;+                    if destination.contains('\0') {+                        return Err("the install folder isn't usable".into());+                    }+                    Some(destination.to_string())+                }+                _ => return Err("unknown install header".into()),+            };+            let length = reader.u64()?;+            if length == 0 || length > MAX_MSI {+                return Err("the package is too large".into());+            }+            let expected = match reader.u8()? {+                0 => None,+                1 => {+                    let bytes = reader.bytes(32)?;+                    Some(<[u8; 32]>::try_from(bytes).map_err(|_| "truncated installer checksum")?)+                }+                _ => return Err("unknown installer checksum".into()),+            };+            Request::Install { destination, length, expected }+        }+        KIND_UNINSTALL => {+            let length = reader.u32()? as usize;+            if length == 0 || length > MAX_PRODUCT {+                return Err("the product code isn't usable".into());+            }+            let product = std::str::from_utf8(reader.bytes(length)?).map_err(|_| "the product code isn't text")?.to_string();+            validate_product(&product)?;+            Request::Uninstall { product }+        }+        _ => return Err("unknown request".into()),+    };+    if reader.at != header.len() {+        return Err("trailing request data".into());+    }+    Ok(request)+}++/// The release's SHA-256 as hex, for the header.+pub(crate) fn parse_sha256(text: &str) -> Option<[u8; 32]> {+    if text.len() != 64 {+        return None;+    }+    let mut out = [0u8; 32];+    for (index, chunk) in text.as_bytes().as_chunks::<2>().0.iter().enumerate() {+        out[index] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;+    }+    Some(out)+}++/// Product codes are GUIDs from the Windows Installer registry; nothing else may reach `msiexec+/// /x`.+pub(crate) fn validate_product(product: &str) -> Result<(), String> {+    let ok = !product.is_empty()+        && product.len() <= MAX_PRODUCT+        && !product.starts_with('-')+        && product.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'{' | b'}' | b'-' | b'(' | b')'));+    if ok { Ok(()) } else { Err(format!("{product} isn't a product code")) }+}++struct Reader<'a> {+    bytes: &'a [u8],+    at: usize,+}++impl<'a> Reader<'a> {+    fn bytes(&mut self, length: usize) -> Result<&'a [u8], String> {+        let end = self.at.checked_add(length).ok_or("truncated header")?;+        let part = self.bytes.get(self.at..end).ok_or("truncated header")?;+        self.at = end;+        Ok(part)+    }++    fn u8(&mut self) -> Result<u8, String> {+        self.bytes(1)?.first().copied().ok_or_else(|| "truncated header".to_string())+    }++    fn u32(&mut self) -> Result<u32, String> {+        let bytes = self.bytes(4)?;+        let value = <[u8; 4]>::try_from(bytes).map_err(|_| "truncated header")?;+        Ok(u32::from_be_bytes(value))+    }++    fn u64(&mut self) -> Result<u64, String> {+        let bytes = self.bytes(8)?;+        let value = <[u8; 8]>::try_from(bytes).map_err(|_| "truncated header")?;+        Ok(u64::from_be_bytes(value))+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn installs_round_trip() {+        let checksum = [0x5a; 32];+        for destination in [None, Some(r"C:\Program Files\PhotoCraft")] {+            for expected in [None, Some(&checksum)] {+                let header = encode_install(destination, 12345, expected).expect("a small install request");+                assert_eq!(+                    decode(&header),+                    Ok(Request::Install { destination: destination.map(str::to_string), length: 12345, expected: expected.copied() })+                );+            }+        }+    }++    #[test]+    fn checksums_are_read_from_hex() {+        let text = "5a".repeat(32);+        assert_eq!(parse_sha256(&text), Some([0x5a; 32]));+        assert_eq!(parse_sha256(&text[..63]), None);+        assert_eq!(parse_sha256(&"zz".repeat(32)), None);+    }++    #[test]+    fn uninstalls_round_trip() {+        let product = "{A6DE41B7-A80F-4C59-A17E-BB68065BF67E}";+        let header = encode_uninstall(product).expect("a product code");+        assert_eq!(decode(&header), Ok(Request::Uninstall { product: product.to_string() }));+    }++    #[test]+    fn malformed_headers_are_refused() {+        assert!(decode(&[]).is_err());+        assert!(decode(&[99]).is_err());+        assert!(decode(&[KIND_INSTALL, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).is_err(), "unknown destination flag");+        assert!(decode(&[KIND_UNINSTALL, 0, 0, 0, 1, b'-']).is_err(), "an option isn't a product code");+        assert!(decode(&[KIND_UNINSTALL, 0, 0, 0, 1, b'a', 0]).is_err(), "trailing data");+        let mut header = encode_install(None, 1, None).expect("a small request");+        header.push(0);+        assert!(decode(&header).is_err());+        assert!(decode(&[KIND_UNINSTALL, 0, 0, 0, 0]).is_err(), "no product code");+        // An install header that ends before its length.+        assert!(decode(&[KIND_INSTALL, 0, 0, 0, 0, 0]).is_err());+        // An unknown checksum flag, and a checksum that ends early.+        assert!(decode(&[KIND_INSTALL, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2]).is_err());+        assert!(decode(&[KIND_INSTALL, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 2]).is_err());+    }++    #[test]+    fn oversized_requests_are_refused() {+        assert!(encode_install(None, 0, None).is_err());+        assert!(encode_install(None, MAX_MSI + 1, None).is_err());+        let long = "a".repeat(MAX_DESTINATION + 1);+        assert!(encode_install(Some(&long), 1, None).is_err());+        assert!(encode_install(Some("bad\0folder"), 1, None).is_err());+        let long = "a".repeat(MAX_PRODUCT + 1);+        assert!(encode_uninstall(&long).is_err());+    }++    #[test]+    fn product_codes_are_checked() {+        for product in ["{A6DE41B7-A80F-4C59-A17E-BB68065BF67E}", "photocraft", "(A)"] {+            assert!(validate_product(product).is_ok(), "{product}");+        }+        for product in ["", "-o", "a;b", "a b", "a\nb", "a/b"] {+            assert!(validate_product(product).is_err(), "{product}");+        }+    }+}