forked from unom/punktfunk
feat(client/linux): the client says whether it is current, and can update itself
`punktfunk-client --check-update` fetches the same Ed25519-signed per-channel manifest the host trusts, works out how this client was installed, and reports what — if anything — can be done about a newer build. `--apply-update` does the part it owns. `--version` prints the CI-stamped build string, which is both what the comparison needs and what a packaging gate can run the binary for. The engine lives here rather than in the Decky plugin for two reasons: verifying a signature needs crypto that Decky's embedded Python does not have, and the plugin is not the only surface that wants the answer. Every UI becomes a caller, exactly as it already is for --pair, --library and --list-hosts. What is possible depends on the install, and the module refuses to pretend otherwise. A flatpak is a per-user install its launcher can update. A .deb/.rpm/ pacman client goes through the packaged root helper — a fixed, parameterless systemd oneshot polkit authorises for members of the punktfunk-update group, so the request carries nothing an attacker could influence and the payload comes from the distro's own signed repositories. A sysext, a nix profile or a source build gets the command and nothing else: the signed sysext feed carries the HOST image, so a client sysext has no feed to update from, and a button that can only fail is worse than one honest line. A failed check reports the failure. "Could not tell" rendering as "up to date" is the one answer that must never happen. The box's capabilities are probed once into a `Caps` struct so the routing rules are a pure function of (install kind, caps) — all seven of them are tested without a box, including that the kill switch beats every kind and that pacman needs its own root-owned full-sysupgrade opt-in on top of the group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f6cfe382fd
commit
a87fbebc0a
+15
-1
@@ -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")]
|
||||
|
||||
@@ -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<glib::ExitCode> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<PublicKey> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<String, u64>,
|
||||
}
|
||||
|
||||
fn load_floor(path: &Path, channel: &str) -> u64 {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<FloorFile>(&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<String>,
|
||||
}
|
||||
|
||||
impl ApplyOutcome {
|
||||
fn failed(error: impl Into<String>) -> 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<String>,
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user