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
@@ -316,8 +316,10 @@ dependencies = [ "dunce", "flate2", "getrandom 0.3.4",+ "libc", "log", "plist",+ "rustix 1.1.5", "serde", "serde_json", "sha2",@@ -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();@@ -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"@@ -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(()) })new file mode 100644@@ -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(©_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(©_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());+ }+}new file mode 100644@@ -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(¶meters),+ 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")+}new file mode 100644@@ -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}");+ }+ }+}