diff --git a/clients/linux/build.rs b/clients/linux/build.rs index 36b8a1ab..c93a2213 100644 --- a/clients/linux/build.rs +++ b/clients/linux/build.rs @@ -1,7 +1,21 @@ //! Compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic icons) -//! into a gresource bundle, registered at startup via `gio::resources_register_include!`. +//! into a gresource bundle, registered at startup via `gio::resources_register_include!`, +//! and stamp the build version the update check compares against. fn main() { + // Build provenance, identical to the host's (crates/punktfunk-host/build.rs): the packaging + // jobs set PUNKTFUNK_BUILD_VERSION to the full package version (`0.23.0~ci10250.gab12cd34` + // on deb, `0.23.0-0.ci10250.g…` on rpm, …), a plain `cargo build` falls back to the crate + // version. This is what `--version` prints and what `--check-update` compares against the + // signed manifest — and the canary suffix is load-bearing there, because canary channels + // compare CI run numbers rather than patch fields (pf_update_check::version). + let version = std::env::var("PUNKTFUNK_BUILD_VERSION") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into())); + println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}"); + println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION"); + // Host cfg gate mirrors this crate's `#[cfg(target_os = "linux")]` modules: on any other // host the crate compiles to an empty stub and `glib-compile-resources` may not exist. #[cfg(target_os = "linux")] diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 0c7feeff..181cbf36 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -446,6 +446,106 @@ pub fn headless_reset() -> glib::ExitCode { } } +/// The version this binary was built as — the CI-stamped string (`0.23.0~ci10250.gab12cd34` +/// and friends) when built by a packaging job, the crate version otherwise. See `build.rs`. +pub fn version_string() -> &'static str { + env!("PUNKTFUNK_VERSION") +} + +/// `--version` — one line, so a packaging script's run-the-binary gate and the Decky plugin +/// can both ask "what is installed here?" without a display or a config dir. +fn headless_version() -> glib::ExitCode { + println!("punktfunk-client {}", version_string()); + glib::ExitCode::SUCCESS +} + +/// `--check-update [--json]` — is a newer client available for this box's channel, and can +/// anything here install it? The answer comes from the same Ed25519-signed manifest the host +/// checks (`pf_client_core::update`); this is only its presentation. +/// +/// Exit code carries the verdict for scripts: 0 = up to date, 10 = an update is available, +/// 1 = the check could not be completed (offline, bad signature, disabled). The distinction +/// matters — "could not tell" must never be scripted as "up to date". +fn headless_check_update() -> glib::ExitCode { + let status = pf_client_core::update::check(version_string()); + if arg_flag("--json") { + match serde_json::to_string(&status) { + Ok(s) => println!("{s}"), + Err(e) => { + eprintln!("check-update: {e}"); + return glib::ExitCode::FAILURE; + } + } + } else { + println!( + "installed {} ({}, {})", + status.current, status.kind, status.channel + ); + println!("available {}", status.latest); + if let Some(err) = &status.error { + eprintln!("check-update: {err}"); + } else if status.update_available { + println!("update yes"); + println!("apply {}", update_apply_line(&status)); + } else { + println!("update no"); + } + if let Some(hint) = &status.opt_in_hint { + println!("opt-in {hint}"); + } + } + if status.error.is_some() { + glib::ExitCode::FAILURE + } else if status.update_available { + // Distinct from both success and failure so `--check-update` is scriptable. + glib::ExitCode::from(10u8) + } else { + glib::ExitCode::SUCCESS + } +} + +/// The human line describing how an update would be applied. +fn update_apply_line(status: &pf_client_core::update::Status) -> String { + use pf_client_core::update::Applier; + match status.applier { + Applier::Helper => format!("punktfunk-client --apply-update ({})", status.command), + Applier::Flatpak => status.command.clone(), + Applier::None => status.command.clone(), + } +} + +/// `--apply-update [--json]` — drive the packaged root helper for this install. Refuses (with +/// the command to run instead) for every kind it does not own; see +/// `pf_client_core::update::apply`. +fn headless_apply_update() -> glib::ExitCode { + let outcome = pf_client_core::update::apply(version_string()); + if arg_flag("--json") { + match serde_json::to_string(&outcome) { + Ok(s) => println!("{s}"), + Err(e) => { + eprintln!("apply-update: {e}"); + return glib::ExitCode::FAILURE; + } + } + } else if let Some(err) = &outcome.error { + eprintln!("apply-update: {err}"); + } else if outcome.staged { + println!( + "staged {} -> {} (reboot to finish)", + outcome.before, outcome.after + ); + } else if outcome.changed { + println!("updated {} -> {}", outcome.before, outcome.after); + } else { + println!("already up to date ({})", outcome.before); + } + if outcome.ok { + glib::ExitCode::SUCCESS + } else { + glib::ExitCode::FAILURE + } +} + /// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the /// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the /// dispatch in `app.rs` is a single line. @@ -468,6 +568,15 @@ pub fn headless_host_command() -> Option { if arg_flag("--reset") { return Some(headless_reset()); } + if arg_flag("--version") { + return Some(headless_version()); + } + if arg_flag("--check-update") { + return Some(headless_check_update()); + } + if arg_flag("--apply-update") { + return Some(headless_apply_update()); + } None } diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index da91cb08..c37e09fe 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -36,6 +36,9 @@ ash = { version = "0.38", optional = true } # `ureq` is small + sync (the host uses it too) and its rustls unifies with the # workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`. ureq = "2" +# Signed update-manifest fetch/verify + the install-kind ladder, shared with the host so one +# trust rule serves both (crates/pf-update-check). +pf-update-check = { path = "../pf-update-check" } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 90b35b1a..6a147114 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -53,6 +53,12 @@ pub mod profiles; pub mod session; #[cfg(any(target_os = "linux", windows))] pub mod trust; +// "Is a newer client available, and can this box install it?" — the client half of the +// signed-manifest update check the host already runs (design: host-update-from-web-console.md). +// Linux only: the Windows client ships inside the host installer and the Mac one through +// clients/apple, so neither has a package to reason about here. +#[cfg(target_os = "linux")] +pub mod update; #[cfg(any(target_os = "linux", windows))] pub mod video; #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-client-core/src/update.rs b/crates/pf-client-core/src/update.rs new file mode 100644 index 00000000..5d788087 --- /dev/null +++ b/crates/pf-client-core/src/update.rs @@ -0,0 +1,668 @@ +//! **Is a newer client available, and can this box install it?** (Linux) +//! +//! The counterpart to `punktfunk-host::update`, sharing its trust machinery through +//! [`pf_update_check`]: the same per-channel Ed25519-signed manifest, the same validation +//! rules, the same "is this newer?" comparison. The host and the client ship from one repo at +//! one version, so one manifest answers for both — only the install kind and what can be done +//! about it differ, and that is what this module works out. +//! +//! **Why this lives in the client and not in the Decky plugin.** The plugin's backend is +//! unprivileged Python with no crypto dependency it could verify a signature with, and the +//! plugin is not the only surface that wants this (the GTK About page and a bare +//! `punktfunk-client --check-update` want the same answer). So the client is the engine and +//! every UI is a caller — exactly how `--pair`, `--library` and `--list-hosts` already work. +//! +//! **Privilege.** Nothing here is privileged. A flatpak updates through flatpak; a +//! system-packaged client updates through the root helper (`pf-update`, started as a fixed, +//! parameterless systemd oneshot that polkit authorises for members of the `punktfunk-update` +//! group); everything else reports the command to run and stops. The request we can make +//! carries no version, URL or package name — the helper derives all of it from root-owned +//! state, so being able to trigger it grants only "run this box's normal update". + +#![cfg(target_os = "linux")] + +use pf_update_check::detect::{self, InstallKind, Product}; +use pf_update_check::version::{is_newer, Channel}; +use pf_update_check::PublicKey; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +/// The unit the client-side polkit rule scopes to. Its presence is the "root helper is +/// installed" probe — the client packages ship unit + helper + rule together. +const HELPER_UNIT: &str = "punktfunk-client-update.service"; +const HELPER_UNIT_PATH: &str = "/usr/lib/systemd/system/punktfunk-client-update.service"; + +/// Where the helper's `apply-client` verb writes its outcome. Separate from the host's record +/// so a box running both never reads the other product's run as its own. +const HELPER_RESULT: &str = "/var/lib/punktfunk/client-update-result.json"; + +/// The pacman escape hatch (root-owned; see the design's §5 pacman stance). Shared with the +/// host — one box, one answer to "may something run a full sysupgrade unattended". +const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf"; + +/// The opt-in group. Mirrors `packaging/linux/49-punktfunk-client-update.rules`. +const OPT_IN_GROUP: &str = "punktfunk-update"; + +/// Wall-clock cap on one helper run (a stale box's package manager is slow; a stuck one must +/// still surface as an error eventually). +const HELPER_TIMEOUT: Duration = Duration::from_secs(30 * 60); + +/// What the box can do about a pending update. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Apply { + /// One action installs it now. + Full, + /// One action stages it; a reboot finishes (rpm-ostree). + Staged, + /// Nothing here can install it — show [`Status::command`]. + Notify, +} + +/// Who performs the apply. The client can drive the root helper itself, but it can never +/// update its own flatpak (inside the sandbox there is no host `flatpak` to run) — that one +/// belongs to whoever launched it, which on a Deck is the Decky plugin. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Applier { + /// The caller runs `flatpak update --user io.unom.Punktfunk`. + Flatpak, + /// `punktfunk-client --apply-update` drives the packaged root helper. + Helper, + /// Manual only. + None, +} + +/// The whole answer, as the CLI serialises it for the Decky plugin and the GTK About page. +#[derive(Debug, Clone, Serialize)] +pub struct Status { + /// Install kind (`flatpak`, `apt`, `dnf`, `sysext`, …). + pub kind: String, + /// `stable` | `canary`. + pub channel: String, + /// The running client's version. + pub current: String, + /// The channel's newest version, or `current` when the check couldn't run. + pub latest: String, + pub update_available: bool, + pub apply: Apply, + pub applier: Applier, + /// One copy-pastable line that updates this install, always populated. + pub command: String, + /// Set when one-click COULD work but the operator hasn't joined the group yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub opt_in_hint: Option, + /// Release-notes link from the manifest (validated to our forge), empty when unknown. + pub notes_url: String, + /// Why the check couldn't complete. `update_available` is always false when set. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// The Ed25519 keys trusted for update manifests — pinned once in [`pf_update_check`] so the +/// host and the client can never disagree about who may announce a release. +fn pinned_keys() -> Vec { + pf_update_check::OFFICIAL_UPDATE_KEYS + .iter() + .filter(|k| !k.is_empty()) + .filter_map(|k| PublicKey::parse(k).ok()) + .collect() +} + +/// Update checks disabled by operator config — same switch name the host honours. +pub fn check_disabled() -> bool { + matches!( + std::env::var("PUNKTFUNK_UPDATE_CHECK").as_deref(), + Ok("0") | Ok("false") | Ok("off") + ) +} + +/// One-click apply disabled by operator config (the kill switch): the box still reports what +/// is available and how to install it by hand. +pub fn apply_disabled() -> bool { + matches!( + std::env::var("PUNKTFUNK_UPDATE_APPLY").as_deref(), + Ok("0") | Ok("false") | Ok("off") + ) +} + +/// This box's install kind + channel for the CLIENT (not the host — separate marker files; +/// see [`Product`]). +/// +/// `current` is the calling binary's own version string. It is a parameter rather than an +/// `env!` here because only the BINARY is version-stamped (`clients/linux/build.rs` reads +/// `PUNKTFUNK_BUILD_VERSION`, which is what carries the canary `~ciN` suffix the channel +/// comparison needs); a library crate baking in its own build-time constant would quietly +/// report the workspace version instead. +pub fn detect_install(current: &str) -> (InstallKind, Channel) { + detect::classify(&detect::gather(Product::Client, current), Product::Client) +} + +/// The root helper (+ its unit) is installed on this box. +fn helper_installed() -> bool { + Path::new(HELPER_UNIT_PATH).exists() +} + +/// The pacman full-sysupgrade escape hatch is explicitly enabled (root-owned config). +fn pacman_opted_in() -> bool { + std::fs::read_to_string(PACMAN_OPTIN_CONF) + .map(|c| c.lines().any(|l| l.trim() == "PACMAN_FULL_SYSUPGRADE=1")) + .unwrap_or(false) +} + +/// Is this user in the opt-in group, **by NSS** (matching how polkit will decide) rather than +/// by our possibly-stale process credentials — so a fresh `usermod -aG` counts without a +/// re-login. Mirrors `punktfunk-host::update::linux::opted_in`. +fn opted_in() -> bool { + let Some(user) = capture(Command::new("id").arg("-un")) else { + return false; + }; + let Some(groups) = capture(Command::new("id").args(["-nG", user.trim()])) else { + return false; + }; + groups.split_whitespace().any(|g| g == OPT_IN_GROUP) +} + +fn capture(cmd: &mut Command) -> Option { + let out = cmd.output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// The opt-in instruction shown instead of an apply button. +pub fn opt_in_hint() -> String { + format!("sudo usermod -aG {OPT_IN_GROUP} $USER # enables one-tap client updates on this box") +} + +/// Everything about the BOX that decides what an apply may do — gathered once, so the routing +/// below is a pure function of (kind, caps). Env vars and root-owned files are read here and +/// nowhere else, which is what lets the routing rules be tested exhaustively without a box. +#[derive(Debug, Clone, Copy)] +struct Caps { + /// Operator kill switch (`PUNKTFUNK_UPDATE_APPLY=0`). + apply_disabled: bool, + /// The packaged root helper + its unit are installed. + helper: bool, + /// This user is in the opt-in group. + opted_in: bool, + /// The root-owned pacman full-sysupgrade escape hatch is set. + pacman_optin: bool, +} + +impl Caps { + fn probe() -> Self { + let helper = helper_installed(); + Self { + apply_disabled: apply_disabled(), + helper, + // Both of these shell out or read root-owned config; skip them entirely when no + // helper is installed, since nothing they could say would change the answer. + opted_in: helper && opted_in(), + pacman_optin: helper && pacman_opted_in(), + } + } +} + +/// What this install can do about an update, and who would do it. +fn apply_route(kind: InstallKind, c: Caps) -> (Apply, Applier) { + if c.apply_disabled { + return (Apply::Notify, Applier::None); + } + let helper_ready = c.helper && c.opted_in; + match kind { + // Always available and needs no opt-in: it is a per-user install the user already owns. + InstallKind::Flatpak => (Apply::Full, Applier::Flatpak), + InstallKind::Apt | InstallKind::Dnf if helper_ready => (Apply::Full, Applier::Helper), + InstallKind::RpmOstree if helper_ready => (Apply::Staged, Applier::Helper), + InstallKind::Pacman if helper_ready && c.pacman_optin => (Apply::Full, Applier::Helper), + // The signed sysext feed carries the HOST image only — there is nothing for a client + // sysext to update FROM, so this is honestly notify-only rather than a button that + // would fail. Same for nix, source builds and the Deck's own tree. + _ => (Apply::Notify, Applier::None), + } +} + +/// Would this box one-click apply if the operator joined the group? (Drives the hint.) +fn opt_in_would_help(kind: InstallKind, c: Caps) -> bool { + !c.apply_disabled + && c.helper + && !c.opted_in + && matches!( + kind, + InstallKind::Apt | InstallKind::Dnf | InstallKind::RpmOstree | InstallKind::Pacman + ) +} + +// ---------------------------------------------------------------- serial floor + +fn state_path() -> Option { + crate::trust::config_dir() + .ok() + .map(|d| d.join("client-update-state.json")) +} + +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct FloorFile { + #[serde(default)] + serial_floor: std::collections::BTreeMap, +} + +fn load_floor(path: &Path, channel: &str) -> u64 { + std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|f| f.serial_floor.get(channel).copied()) + .unwrap_or(0) +} + +/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it. +fn store_floor(path: &Path, channel: &str, serial: u64) { + let mut file: FloorFile = std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + let slot = file.serial_floor.entry(channel.to_string()).or_insert(0); + if serial <= *slot { + return; + } + *slot = serial; + let Ok(bytes) = serde_json::to_vec_pretty(&file) else { + return; + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, &bytes).is_ok() { + let _ = std::fs::rename(&tmp, path); + } +} + +// ---------------------------------------------------------------- check + +/// Detect the install, fetch + verify the channel manifest, and report. Blocking (one HTTPS +/// round trip); never panics — a failed check reports `error` and `update_available: false`, +/// because "we could not tell" must never render as "you are up to date". +pub fn check(current: &str) -> Status { + let (kind, channel) = detect_install(current); + let caps = Caps::probe(); + let current = current.to_string(); + let mut status = Status { + kind: kind.as_str().to_string(), + channel: channel.as_str().to_string(), + current: current.clone(), + latest: current.clone(), + update_available: false, + apply: Apply::Notify, + applier: Applier::None, + command: detect::update_command(kind, Product::Client), + opt_in_hint: opt_in_would_help(kind, caps).then(opt_in_hint), + notes_url: String::new(), + error: None, + }; + let (apply, applier) = apply_route(kind, caps); + status.apply = apply; + status.applier = applier; + + if check_disabled() { + status.error = Some("update checks are disabled (PUNKTFUNK_UPDATE_CHECK=0)".into()); + return status; + } + + let manifest = match pf_update_check::feed::fetch_manifest_blocking( + &pf_update_check::feed::feed_base(), + channel.as_str(), + &pinned_keys(), + &format!("punktfunk-client/{current} (update-check)"), + ) { + Ok(m) => m, + Err(e) => { + status.error = Some(e); + return status; + } + }; + + // Anti-rollback: a validly-signed but OLDER manifest replayed at us is an error, not a + // silent downgrade of what we believe the channel holds. + if let Some(path) = state_path() { + let floor = load_floor(&path, channel.as_str()); + if manifest.serial < floor { + status.error = Some(format!( + "manifest serial {} is older than the last accepted {} — refusing rollback", + manifest.serial, floor + )); + return status; + } + store_floor(&path, channel.as_str(), manifest.serial); + } + + status.latest = manifest.version.clone(); + status.notes_url = manifest.notes_url.clone(); + status.update_available = is_newer(&manifest.version, manifest.ci_run, ¤t, channel); + status +} + +// ---------------------------------------------------------------- apply + +/// What an apply attempt did. +#[derive(Debug, Clone, Serialize)] +pub struct ApplyOutcome { + pub ok: bool, + /// The package set actually changed on disk. + pub changed: bool, + /// Installed, but a reboot activates it (rpm-ostree). + pub staged: bool, + pub before: String, + pub after: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ApplyOutcome { + fn failed(error: impl Into) -> Self { + Self { + ok: false, + changed: false, + staged: false, + before: String::new(), + after: String::new(), + error: Some(error.into()), + } + } +} + +#[derive(serde::Deserialize)] +struct HelperResult { + ok: bool, + #[serde(default)] + before_version: String, + #[serde(default)] + after_version: String, + #[serde(default)] + changed: bool, + #[serde(default)] + staged: bool, + #[serde(default)] + error: Option, + #[serde(default)] + finished_unix: u64, +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Run the packaged root helper for this install, if that is the applier for it. Blocking for +/// as long as the package manager takes. +/// +/// Deliberately refuses every kind it does not own rather than trying something clever: a +/// flatpak can only be updated from outside its sandbox, and there is no feed behind a client +/// sysext or a source build. Callers read [`Status::applier`] first. +pub fn apply(current: &str) -> ApplyOutcome { + let (kind, _) = detect_install(current); + let caps = Caps::probe(); + let (_, applier) = apply_route(kind, caps); + match applier { + Applier::Helper => {} + Applier::Flatpak => { + return ApplyOutcome::failed( + "a flatpak client updates from outside its sandbox — run `flatpak update --user \ + io.unom.Punktfunk` (the Decky plugin does this for you)", + ) + } + Applier::None => { + let hint = opt_in_would_help(kind, caps) + .then(opt_in_hint) + .unwrap_or_else(|| detect::update_command(kind, Product::Client)); + return ApplyOutcome::failed(format!( + "no one-tap update for a `{}` install — {hint}", + kind.as_str() + )); + } + } + + let started = now_unix(); + let output = Command::new("systemctl") + .args(["start", HELPER_UNIT]) + .output(); + let output = match output { + Ok(o) => o, + Err(e) => return ApplyOutcome::failed(format!("launch systemctl: {e}")), + }; + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + let denied = err.contains("interactive authentication") + || err.contains("Access denied") + || err.contains("Permission denied"); + return ApplyOutcome::failed(if denied { + format!( + "not authorized to start the update helper — enable one-tap updates first: {}", + opt_in_hint() + ) + } else { + format!( + "update helper failed ({}) — see `journalctl -u {HELPER_UNIT}`. {}", + output.status, + err.trim() + ) + }); + } + + // The unit succeeded; its record is the ground truth. A record older than this run means + // the helper never wrote one — surface that instead of reporting a previous run's success. + let result: HelperResult = match std::fs::read(HELPER_RESULT) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(r) => r, + None => { + return ApplyOutcome::failed(format!( + "the update helper wrote no readable result at {HELPER_RESULT}" + )) + } + }; + if result.finished_unix + 5 < started { + return ApplyOutcome::failed(format!( + "the update helper's result record predates this run ({} < {started}) — it never \ + wrote one", + result.finished_unix + )); + } + if !result.ok { + return ApplyOutcome::failed( + result + .error + .unwrap_or_else(|| "the update helper reported failure without detail".into()), + ); + } + ApplyOutcome { + ok: true, + changed: result.changed, + staged: result.staged, + before: result.before_version, + after: result.after_version, + error: None, + } +} + +/// The helper's wall-clock cap, exposed so a caller can size its own timeout above ours. +pub const fn helper_timeout() -> Duration { + HELPER_TIMEOUT +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fully-enabled box: helper installed, user opted in, pacman hatch open. + fn ready() -> Caps { + Caps { + apply_disabled: false, + helper: true, + opted_in: true, + pacman_optin: true, + } + } + + /// The kill switch must reach BOTH halves of the answer: no applier, and the notify tier. + /// It is the operator's "stop offering this" and has to beat every kind, including the + /// flatpak one that needs no other permission. + #[test] + fn kill_switch_forces_notify() { + let off = Caps { + apply_disabled: true, + ..ready() + }; + for kind in [ + InstallKind::Flatpak, + InstallKind::Apt, + InstallKind::Dnf, + InstallKind::RpmOstree, + InstallKind::Pacman, + ] { + assert_eq!( + apply_route(kind, off), + (Apply::Notify, Applier::None), + "{}", + kind.as_str() + ); + } + } + + /// Kinds with no feed behind them must never claim an apply route, however permissive the + /// box is — a button that cannot work is worse than a command the user can run. + #[test] + fn feedless_kinds_are_notify_only() { + for kind in [ + InstallKind::Sysext, + InstallKind::Nix, + InstallKind::Source, + InstallKind::SteamosSource, + InstallKind::WindowsInstaller, + ] { + assert_eq!( + apply_route(kind, ready()), + (Apply::Notify, Applier::None), + "{}", + kind.as_str() + ); + } + } + + /// The package-manager kinds are gated on BOTH the helper being installed and the group + /// opt-in; neither alone may produce a Helper applier. + #[test] + fn helper_kinds_need_helper_and_opt_in() { + for kind in [InstallKind::Apt, InstallKind::Dnf, InstallKind::RpmOstree] { + for caps in [ + Caps { + helper: false, + ..ready() + }, + Caps { + opted_in: false, + ..ready() + }, + ] { + assert_eq!( + apply_route(kind, caps), + (Apply::Notify, Applier::None), + "{}", + kind.as_str() + ); + } + let (tier, applier) = apply_route(kind, ready()); + assert_eq!(applier, Applier::Helper, "{}", kind.as_str()); + // rpm-ostree activates on reboot; the others land immediately. + let want = if kind == InstallKind::RpmOstree { + Apply::Staged + } else { + Apply::Full + }; + assert_eq!(tier, want, "{}", kind.as_str()); + } + } + + /// pacman carries the extra root-owned hatch (a full `-Syu` is a whole-system action). + #[test] + fn pacman_needs_its_own_opt_in() { + assert_eq!( + apply_route( + InstallKind::Pacman, + Caps { + pacman_optin: false, + ..ready() + } + ), + (Apply::Notify, Applier::None) + ); + assert_eq!( + apply_route(InstallKind::Pacman, ready()), + (Apply::Full, Applier::Helper) + ); + } + + /// A flatpak is a per-user install the user already owns — no group, no helper, and the + /// caller (not the sandboxed client) performs it. + #[test] + fn flatpak_needs_no_opt_in_and_is_applied_by_the_caller() { + let bare = Caps { + apply_disabled: false, + helper: false, + opted_in: false, + pacman_optin: false, + }; + assert_eq!( + apply_route(InstallKind::Flatpak, bare), + (Apply::Full, Applier::Flatpak) + ); + } + + /// The opt-in hint is only worth showing when joining the group would actually change the + /// answer: a helper must already be installed, and the kind must be one it serves. + #[test] + fn opt_in_hint_only_where_it_would_help() { + let not_opted = Caps { + opted_in: false, + ..ready() + }; + assert!(opt_in_would_help(InstallKind::Apt, not_opted)); + assert!(!opt_in_would_help(InstallKind::Sysext, not_opted)); + assert!(!opt_in_would_help(InstallKind::Flatpak, not_opted)); + assert!(!opt_in_would_help( + InstallKind::Apt, + Caps { + helper: false, + ..not_opted + } + )); + // Already in the group ⇒ nothing to hint at. + assert!(!opt_in_would_help(InstallKind::Apt, ready())); + } + + #[test] + fn serial_floor_never_lowers() { + let dir = std::env::temp_dir().join(format!("pf-update-floor-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("state.json"); + store_floor(&path, "stable", 100); + assert_eq!(load_floor(&path, "stable"), 100); + store_floor(&path, "stable", 50); + assert_eq!( + load_floor(&path, "stable"), + 100, + "a replay must not lower it" + ); + store_floor(&path, "stable", 101); + assert_eq!(load_floor(&path, "stable"), 101); + // Channels are independent floors. + assert_eq!(load_floor(&path, "canary"), 0); + std::fs::remove_dir_all(&dir).ok(); + } +}