refactor(update): one signed-manifest checker, shared by the host and the client

The host has known how to answer "is a newer build available for this box's
channel?" since the update-from-web-console work. The Linux client is about to
need the same answer from the same signed document, and a trust rule that lives
in two places is a trust rule that will drift.

So the parts where being wrong is a security bug now exist exactly once, in the
new `pf-update-check`: Ed25519 verification against pinned keys, the manifest
schema and its fail-closed validation, the post-redirect fetch, the version
comparison that has to reconcile four different canary spellings, and the
install-kind ladder — the last parameterised by which product is asking, since
the delivery channels are the same ones but the markers are not.

The pinned key list moves with them. Two lists could have disagreed about who
may announce a release, and the one that drifted is the one nobody would have
noticed; `publish-update-manifest.sh` follows to the new path, and a MISSING
keys file there is now fatal rather than a warning that silently skips the
cross-check the step exists to perform.

Host call sites are unchanged throughout: `store::index` and `update::manifest`
re-export from the shared crate under their old names, and `update::detect`
keeps its cached `detect()` and the host's command hints. Verified with clippy
-D warnings on Linux and on Windows (nvenc,amf-qsv,qsv), 340 host tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
enricobuehler
2026-07-31 09:41:26 +02:00
committed by enricobuehler
co-authored by Claude Opus 5
parent 84c6938562
commit f6cfe382fd
15 changed files with 977 additions and 593 deletions
Generated
+14
View File
@@ -2880,6 +2880,7 @@ dependencies = [
"mdns-sd",
"opus",
"pf-ffvk",
"pf-update-check",
"pipewire",
"punktfunk-core",
"pyrowave-sys",
@@ -3055,6 +3056,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "pf-update-check"
version = "0.22.3"
dependencies = [
"anyhow",
"base64",
"ring",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "pf-vdisplay"
version = "0.22.3"
@@ -3457,6 +3470,7 @@ dependencies = [
"pf-host-config",
"pf-inject",
"pf-paths",
"pf-update-check",
"pf-vdisplay",
"pf-win-display",
"pf-zerocopy",
+1
View File
@@ -13,6 +13,7 @@ members = [
"crates/pf-driver-proto",
"crates/pf-paths",
"crates/pf-update",
"crates/pf-update-check",
"crates/pf-host-config",
"crates/pf-gpu",
"crates/pf-zerocopy",
+34
View File
@@ -0,0 +1,34 @@
# The update-CHECK core, shared by the host and the Linux client (planning:
# host-update-from-web-console.md §3). It exists because both products answer the same
# question — "does a newer build exist for this box's channel?" — from the same signed
# manifest, and a trust rule that lives in two places is a trust rule that will diverge.
#
# Deliberately check-only: no apply legs, no package managers, no privileged anything. The
# host keeps its Windows/Linux apply code; the client keeps its own. What is shared here is
# exactly the part where being wrong is a security bug: signature verification, manifest
# validation, and the version comparison that decides whether to tell a user to update.
[package]
name = "pf-update-check"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Signed update-manifest fetch + verification, install-kind detection and version comparison, shared by the punktfunk host and client."
publish = false
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Ed25519 over the exact manifest bytes. The workspace is ring-only (no aws-lc-sys — it fails
# on the Windows CI runner), and this is the same primitive the plugin-store index uses.
ring = "0.17"
base64 = "0.22"
# Small, sync, bundles webpki roots — no system cert store dependency, which matters on the
# Deck (Decky's embedded Python has no usable roots either; see clients/decky/main.py).
ureq = "2"
[lints]
workspace = true
+391
View File
@@ -0,0 +1,391 @@
//! **How was this installed, and on which channel?** (design §4.1)
//!
//! The apply strategy — and, where no apply leg exists, the command hint a UI shows — hangs
//! off the install kind. Detection is a ladder over facts nothing request-side can influence:
//! packaging writes a root-owned marker, a sysext self-identifies via its merged
//! extension-release, a flatpak by its sandbox, Nix by store path, and so on.
//!
//! The ladder is shared between the host and the client because the delivery channels are the
//! same ones; what differs is spelled out in [`Product`] — which marker file to read, whether
//! a flatpak rung exists at all, and what a user-owned binary means. The ladder itself is a
//! pure function over a [`Probe`], so every rung is unit-testable without a box.
use crate::version::{conf_channel, windows_channel_of, Channel};
use std::path::{Path, PathBuf};
/// Which punktfunk program is asking. The rungs differ (see the module docs), and mixing them
/// up would misreport a client-only box as an un-updatable host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Product {
Host,
Client,
}
impl Product {
/// Where this product's packaging stamps how it was installed. First word = kind
/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`).
///
/// The two products use SEPARATE files on purpose: a box can carry both packages, and
/// every packaging format we ship treats two packages owning one path as a hard conflict.
///
/// The client's lives in its own DIRECTORY, not just under its own name, because the host
/// RPM claims `%{_datadir}/punktfunk/*` with a glob — a sibling file in there would be
/// owned by both subpackages and `dnf install punktfunk punktfunk-client` would refuse.
pub fn marker_path(self) -> &'static str {
match self {
Product::Host => "/usr/share/punktfunk/install-kind",
Product::Client => "/usr/share/punktfunk-client/install-kind",
}
}
/// The merged sysext names itself here (written by the sysext build scripts); its presence
/// means the running `/usr` overlay came from that image, regardless of any leftover marker.
pub fn sysext_marker(self) -> &'static str {
match self {
Product::Host => "/usr/lib/extension-release.d/extension-release.punktfunk",
Product::Client => "/usr/lib/extension-release.d/extension-release.punktfunk-client",
}
}
/// The binary name, for the command hints.
pub fn binary(self) -> &'static str {
match self {
Product::Host => "punktfunk-host",
Product::Client => "punktfunk-client",
}
}
}
/// The sysext updater's own config (`CHANNEL=stable|canary`).
const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallKind {
WindowsInstaller,
/// Client only — the sandboxed GTK app (`io.unom.Punktfunk`).
Flatpak,
Sysext,
RpmOstree,
Apt,
Dnf,
Pacman,
SteamosSource,
Nix,
Source,
}
impl InstallKind {
pub fn as_str(self) -> &'static str {
match self {
InstallKind::WindowsInstaller => "windows-installer",
InstallKind::Flatpak => "flatpak",
InstallKind::Sysext => "sysext",
InstallKind::RpmOstree => "rpm-ostree",
InstallKind::Apt => "apt",
InstallKind::Dnf => "dnf",
InstallKind::Pacman => "pacman",
InstallKind::SteamosSource => "steamos-source",
InstallKind::Nix => "nix",
InstallKind::Source => "source",
}
}
}
/// The facts the ladder reads, gathered once by [`gather`] (tests build these directly).
#[derive(Debug, Default)]
pub struct Probe {
/// Running on Windows (cfg, not a file).
pub windows: bool,
/// The running exe's path.
pub exe: PathBuf,
/// `$HOME`, if any.
pub home: Option<PathBuf>,
/// This process is inside a flatpak sandbox.
pub flatpak: bool,
/// Contents of [`Product::marker_path`], if present.
pub marker: Option<String>,
/// [`Product::sysext_marker`] exists (merged sysext overlay).
pub sysext: bool,
/// Contents of [`SYSEXT_CONF`], if present.
pub sysext_conf: Option<String>,
/// `/run/ostree-booted` exists (rpm-ostree / bootc family).
pub ostree_booted: bool,
/// The asking binary's own version string, for the Windows channel heuristic.
pub version: String,
}
/// Gather the real probe for `product`. Cheap (a few `stat`s and two small reads); consumers
/// cache the classification, not this.
pub fn gather(product: Product, version: &str) -> Probe {
Probe {
windows: cfg!(target_os = "windows"),
exe: std::env::current_exe().unwrap_or_default(),
home: std::env::var_os("HOME").map(PathBuf::from),
// Both are set by flatpak inside the sandbox; the exe path is the belt to that
// env-var braces, since a portal-spawned process can inherit a stripped environment.
flatpak: product == Product::Client
&& (std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists()),
marker: std::fs::read_to_string(product.marker_path()).ok(),
sysext: Path::new(product.sysext_marker()).exists(),
sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(),
ostree_booted: Path::new("/run/ostree-booted").exists(),
version: version.to_string(),
}
}
/// The ladder (design §4.1). Order matters and each rung is a fact the caller can't forge:
/// flatpak sandbox > sysext overlay > Nix store path > dev/source tree > user-owned Deck
/// build > package marker (flipped to rpm-ostree when the box is ostree-booted) > `source`.
pub fn classify(p: &Probe, product: Product) -> (InstallKind, Channel) {
if p.windows {
// The installer is the only supported Windows delivery; a loose cargo build shows
// itself by not living under Program Files. Channel: canary installers carry the CI
// run as the third component (`M.m.<run>`), see `windows_channel_of`.
let installed = p
.exe
.to_string_lossy()
.to_ascii_lowercase()
.contains("\\program files\\punktfunk");
return if installed {
(
InstallKind::WindowsInstaller,
windows_channel_of(&p.version),
)
} else {
(InstallKind::Source, Channel::Stable)
};
}
// Inside the sandbox `/usr` is the runtime's, so every file rung below would read the
// WRONG box's facts. This must stay first.
if p.flatpak {
return (InstallKind::Flatpak, Channel::Stable);
}
if p.sysext {
let channel = p
.sysext_conf
.as_deref()
.and_then(conf_channel)
.unwrap_or(Channel::Stable);
return (InstallKind::Sysext, channel);
}
if p.exe.starts_with("/nix/store") {
return (InstallKind::Nix, Channel::Stable);
}
// A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the
// Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is
// user-owned but NOT a plain `target/` dir — that distinction is the marker here.
let exe_str = p.exe.to_string_lossy().to_string();
if exe_str.contains("/target/") {
return (InstallKind::Source, Channel::Stable);
}
if let Some(home) = &p.home {
if p.exe.starts_with(home) {
// Only the HOST has an on-device Deck build (scripts/steamdeck/update.sh builds
// the host). A client binary under $HOME is someone's own build or a copy into
// ~/.local/bin — nothing knows how to update it, so say `source` and mean it.
return match product {
Product::Host => (InstallKind::SteamosSource, Channel::Canary),
Product::Client => (InstallKind::Source, Channel::Stable),
};
}
}
if let Some(marker) = &p.marker {
let mut words = marker.split_whitespace();
let kind = words.next().unwrap_or("");
let channel = match words.next() {
Some("canary") => Channel::Canary,
_ => Channel::Stable,
};
let kind = match kind {
"apt" => Some(InstallKind::Apt),
// An ostree-booted box consumed the RPM by layering (or an image build); either
// way `dnf upgrade` is not how it updates.
"dnf" if p.ostree_booted => Some(InstallKind::RpmOstree),
"dnf" => Some(InstallKind::Dnf),
"pacman" => Some(InstallKind::Pacman),
_ => None,
};
if let Some(kind) = kind {
return (kind, channel);
}
}
(InstallKind::Source, Channel::Stable)
}
/// The per-kind "how to update" command a UI shows while (or instead of) an apply path
/// existing (design §5). One line, copy-pastable, no placeholders.
pub fn update_command(kind: InstallKind, product: Product) -> String {
let bin = product.binary();
match (kind, product) {
(InstallKind::WindowsInstaller, _) => {
"winget upgrade unom.PunktfunkHost (or re-run the newer installer)".into()
}
(InstallKind::Flatpak, _) => "flatpak update --user io.unom.Punktfunk".into(),
// The signed sysext feed carries the HOST image only; a client sysext is the local
// `packaging/arch/build-sysext.sh` wrapper, which has no feed to update from.
(InstallKind::Sysext, Product::Host) => "sudo punktfunk-sysext update".into(),
(InstallKind::Sysext, Product::Client) => {
"rebuild the client sysext: bash packaging/arch/build-sysext.sh <new .pkg.tar.zst> \
&& sudo cp punktfunk-client.raw /var/lib/extensions/ && sudo systemd-sysext refresh"
.into()
}
(InstallKind::RpmOstree, Product::Host) => {
"sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)".into()
}
(InstallKind::RpmOstree, Product::Client) => {
format!("sudo rpm-ostree update --uninstall {bin} --install {bin} (staged; reboot to finish)")
}
(InstallKind::Apt, _) => {
format!("sudo apt update && sudo apt install --only-upgrade {bin}")
}
(InstallKind::Dnf, Product::Host) => "sudo dnf upgrade punktfunk".into(),
(InstallKind::Dnf, Product::Client) => "sudo dnf upgrade punktfunk-client".into(),
(InstallKind::Pacman, _) => "sudo pacman -Syu".into(),
(InstallKind::SteamosSource, _) => {
"bash ~/punktfunk/scripts/steamdeck/update.sh --pull".into()
}
(InstallKind::Nix, _) => "nix flake update punktfunk (then rebuild your system)".into(),
(InstallKind::Source, Product::Host) => {
"git pull && cargo build --release -p punktfunk-host".into()
}
(InstallKind::Source, Product::Client) => {
"git pull && cargo build --release -p punktfunk-client-linux".into()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(exe: &str) -> Probe {
Probe {
windows: false,
exe: PathBuf::from(exe),
home: Some(PathBuf::from("/home/deck")),
..Default::default()
}
}
fn host_probe() -> Probe {
probe("/usr/bin/punktfunk-host")
}
#[test]
fn ladder_sysext_beats_marker() {
let mut p = host_probe();
p.sysext = true;
p.marker = Some("dnf canary".into());
p.sysext_conf = Some("CHANNEL=canary\n".into());
assert_eq!(
classify(&p, Product::Host),
(InstallKind::Sysext, Channel::Canary)
);
p.sysext_conf = None;
assert_eq!(
classify(&p, Product::Host),
(InstallKind::Sysext, Channel::Stable)
);
}
#[test]
fn ladder_nix_store_path() {
let mut p = host_probe();
p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host");
assert_eq!(classify(&p, Product::Host).0, InstallKind::Nix);
}
#[test]
fn ladder_cargo_target_is_source_even_under_home() {
let mut p = host_probe();
p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host");
assert_eq!(classify(&p, Product::Host).0, InstallKind::Source);
}
#[test]
fn ladder_deck_build_is_steamos_source() {
let mut p = host_probe();
p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host");
assert_eq!(classify(&p, Product::Host).0, InstallKind::SteamosSource);
}
/// The same user-owned path means something else for the client: there is no on-device
/// client build script, so it must not claim the Deck source-rebuild leg.
#[test]
fn ladder_user_owned_client_is_plain_source() {
let mut p = probe("/home/deck/.local/bin/punktfunk-client");
p.marker = None;
assert_eq!(classify(&p, Product::Client).0, InstallKind::Source);
}
#[test]
fn ladder_markers() {
for (marker, ostree, kind, channel) in [
("apt stable", false, InstallKind::Apt, Channel::Stable),
("apt canary", false, InstallKind::Apt, Channel::Canary),
("dnf stable", false, InstallKind::Dnf, Channel::Stable),
("dnf stable", true, InstallKind::RpmOstree, Channel::Stable),
("pacman canary", false, InstallKind::Pacman, Channel::Canary),
] {
let mut p = host_probe();
p.marker = Some(marker.into());
p.ostree_booted = ostree;
assert_eq!(
classify(&p, Product::Host),
(kind, channel),
"marker `{marker}`"
);
}
}
#[test]
fn ladder_unknown_marker_falls_through_to_source() {
let mut p = host_probe();
p.marker = Some("snap stable".into());
assert_eq!(classify(&p, Product::Host).0, InstallKind::Source);
}
/// Inside the sandbox every /usr fact belongs to the flatpak runtime, not the box — so a
/// leftover host marker on the real system must not win.
#[test]
fn flatpak_wins_over_every_file_rung() {
let mut p = probe("/app/bin/punktfunk-client");
p.flatpak = true;
p.sysext = true;
p.marker = Some("pacman canary".into());
assert_eq!(
classify(&p, Product::Client),
(InstallKind::Flatpak, Channel::Stable)
);
}
#[test]
fn windows_installed_vs_loose_build() {
let mut p = Probe {
windows: true,
exe: PathBuf::from("C:\\Program Files\\Punktfunk\\punktfunk-host.exe"),
version: "0.23.10118".into(),
..Default::default()
};
assert_eq!(
classify(&p, Product::Host),
(InstallKind::WindowsInstaller, Channel::Canary)
);
p.exe = PathBuf::from("C:\\src\\punktfunk\\target\\release\\punktfunk-host.exe");
assert_eq!(classify(&p, Product::Host).0, InstallKind::Source);
}
/// Command hints are user-facing copy — they must name the right package per product.
#[test]
fn hints_are_product_specific() {
assert!(update_command(InstallKind::Apt, Product::Client).contains("punktfunk-client"));
assert!(update_command(InstallKind::Apt, Product::Host).contains("punktfunk-host"));
assert!(update_command(InstallKind::Flatpak, Product::Client).contains("flatpak update"));
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Fetching the per-channel manifest + its detached signature.
//!
//! Blocking (`ureq`) on purpose: both consumers call it off a background thread, and a sync
//! client with bundled webpki roots avoids depending on a system cert store — which the Deck
//! is exactly the wrong box to depend on.
//!
//! The signature is verified over the FINAL response bytes, after redirects. Our registry
//! answers a file GET with a 303 to object storage; a check that verified the pre-redirect
//! body would be verifying a redirect stub (the sysext-feed lesson, `publish-sysext-feed.sh`).
use crate::manifest::{self, Manifest, MAX_MANIFEST_BYTES};
use crate::sig::PublicKey;
use std::time::Duration;
/// Feed base — `<base>/<channel>/manifest.json` + `.sig`.
pub const DEFAULT_FEED_BASE: &str =
"https://git.unom.io/api/packages/unom/generic/punktfunk-update";
/// One fetch's wall-clock budget.
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
/// The feed base, with a `PUNKTFUNK_UPDATE_FEED` override for tests and dev feeds. This is
/// operator config (an env var on the process), never request-time input; the `https://` (or
/// loopback) requirement keeps a stray value from silently downgrading the transport.
pub fn feed_base() -> String {
std::env::var("PUNKTFUNK_UPDATE_FEED")
.ok()
.filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1"))
.unwrap_or_else(|| DEFAULT_FEED_BASE.to_string())
}
/// Fetch + verify the channel manifest. `keys` are the caller's pinned Ed25519 keys; an empty
/// list is refused rather than treated as "skip verification".
pub fn fetch_manifest_blocking(
base: &str,
channel: &str,
keys: &[PublicKey],
user_agent: &str,
) -> Result<Manifest, String> {
if keys.is_empty() {
return Err("no update key is pinned in this build".into());
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
.redirects(3)
.user_agent(user_agent)
.build();
let url = format!("{base}/{channel}/manifest.json");
let sig_url = format!("{url}.sig");
let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?;
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?;
manifest::verify_and_parse(&body, &sig_text, keys, channel).map_err(|e| format!("{e:#}"))
}
fn fetch_err(e: ureq::Error) -> String {
match e {
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
other => format!("feed fetch failed: {other}"),
}
}
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1);
reader
.read_to_end(&mut buf)
.map_err(|e| format!("read failed: {e}"))?;
if buf.len() > MAX_MANIFEST_BYTES {
return Err("response exceeds the manifest size cap".into());
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_keys_never_fetches() {
// Fail-closed before any network call: an empty pin list is a broken build, not a
// licence to trust whatever the feed serves.
let err =
fetch_manifest_blocking("https://127.0.0.1:1", "stable", &[], "test").unwrap_err();
assert!(err.contains("no update key"), "{err}");
}
#[test]
fn feed_override_must_be_https_or_loopback() {
// Not a full env test (env is process-global and tests run in parallel) — just the
// predicate the override is filtered by.
let ok = |s: &str| s.starts_with("https://") || s.starts_with("http://127.0.0.1");
assert!(ok("https://example.test/feed"));
assert!(ok("http://127.0.0.1:8080/feed"));
assert!(!ok("http://example.test/feed"));
assert!(!ok("file:///tmp/feed"));
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Shared update-**check** core for the punktfunk host and the Linux client.
//!
//! Both products answer the same question — *does a newer build exist for this box's
//! channel?* — from the same Ed25519-signed per-channel manifest. This crate owns the part
//! where being wrong is a security bug, so that it exists exactly once:
//!
//! * [`sig`] — detached Ed25519 verification against pinned keys.
//! * [`manifest`] — the signed document's schema and its fail-closed validation rules.
//! * [`feed`] — fetching the document (and verifying over the *final*, post-redirect bytes).
//! * [`version`] — channels, and the comparison that decides "newer" across packaging formats.
//! * [`detect`] — the install-kind ladder, parameterised by [`detect::Product`].
//!
//! There is deliberately **no apply code here**. Applying an update is privileged, per-product
//! and per-platform; it lives with the product that does it (`punktfunk-host::update`,
//! `pf-client-core::update`, and the root helper in `pf-update`).
/// The Ed25519 public keys trusted for update manifests — two slots, so a key rotation is
/// "sign with the new one, ship builds trusting both, retire the old" (the plugin-store
/// `OFFICIAL_KEYS` drill) rather than a flag day. The private half is the
/// `UPDATE_MANIFEST_KEY` CI secret and the operator's offline backup.
///
/// It lives here, not in either binary, because the host and the client consume the SAME
/// signed manifest: two pin lists could disagree about who may announce a release, and the
/// one that drifted would be the one nobody noticed. `scripts/ci/publish-update-manifest.sh`
/// cross-checks the signing key against this file before it signs anything.
pub const OFFICIAL_UPDATE_KEYS: [&str; 2] = [
"ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=",
"", // rotation slot
];
pub mod detect;
pub mod feed;
pub mod manifest;
pub mod sig;
pub mod version;
pub use detect::{InstallKind, Product};
pub use manifest::{Manifest, MAX_MANIFEST_BYTES, SCHEMA};
pub use sig::{verify_signature, PublicKey};
pub use version::{canary_run, is_newer, triple, Channel};
@@ -1,10 +1,14 @@
//! The signed **update manifest**: the check truth for "a newer host exists"
//! The signed **update manifest**: the check truth for "a newer build exists"
//! (design `host-update-from-web-console.md` §3).
//!
//! One small JSON document per channel, Ed25519-signed with keys pinned in this binary
//! ([`super::UPDATE_KEYS`]) and verified by the exact code path the plugin store already
//! trusts ([`crate::store::index::verify_signature`]). TLS and the registry that serves the
//! document are transport, never trust.
//! One small JSON document per channel, Ed25519-signed with keys pinned in the consuming
//! binary and verified by the exact code path the plugin store already trusts ([`crate::sig`]).
//! TLS and the registry that serves the document are transport, never trust.
//!
//! The document describes a *release*, not a product: the host and the Linux client ship from
//! one repo at one version, so both compare their own installed version against the same
//! `version`/`ci_run` here. Only the per-product payload legs (today: `windows_host`) are
//! product-specific, and a consumer that doesn't need one simply ignores it.
//!
//! Rules, all fail-closed (the sysext 303 lesson encoded):
//! 1. **Signature before parse** — over the exact fetched bytes, then strict JSON. An HTML
@@ -12,27 +16,27 @@
//! 2. **Channel binding** — the document names its channel and it must match the one we
//! asked for, so a validly-signed canary manifest replayed onto the stable URL is refused.
//! 3. **Monotonic serial** — the publish-time serial can never go backwards for a channel
//! (the anti-downgrade/anti-replay floor, persisted by [`super`]).
//! 4. **Pinned notes origin** — the release-notes link the console renders must live on our
//! forge, so a signed-but-wrong document can't send the operator to a lookalike page.
//! (the anti-downgrade/anti-replay floor, persisted by the consumer).
//! 4. **Pinned notes origin** — the release-notes link a UI renders must live on our forge,
//! so a signed-but-wrong document can't send the operator to a lookalike page.
use crate::store::index::{verify_signature, PublicKey};
use crate::sig::{verify_signature, PublicKey};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
/// The only manifest schema this host understands. A breaking change bumps this; old hosts
/// The only manifest schema this build understands. A breaking change bumps this; old builds
/// report "unsupported schema" instead of guessing.
pub(crate) const SCHEMA: u32 = 1;
pub const SCHEMA: u32 = 1;
/// Hard cap on a fetched manifest (and its signature). The real document is <1 KB.
pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024;
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// The only origin a manifest may point the operator at for release notes.
const NOTES_ORIGIN: &str = "https://git.unom.io/";
/// The signed update manifest, as served (and signed) per channel.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Manifest {
pub struct Manifest {
/// Document schema — must equal [`SCHEMA`].
pub schema: u32,
/// The channel this document was published for (`stable` | `canary`). Bound-checked
@@ -44,24 +48,24 @@ pub(crate) struct Manifest {
/// RFC-3339 publish time. Display only.
#[serde(default)]
pub published_at: String,
/// The released host version this manifest announces.
/// The released version this manifest announces.
pub version: String,
/// Release-notes link the console renders. Must be on [`NOTES_ORIGIN`].
/// Release-notes link a UI renders. Must be on [`NOTES_ORIGIN`].
#[serde(default)]
pub notes_url: String,
/// Canary only: the CI run number, the definitive "newer" axis where per-channel version
/// strings differ (`~ciN`, `0.ciN`, a padded pkgrel, `M.m.run`).
#[serde(default)]
pub ci_run: Option<u64>,
/// The Windows installer leg (design §6) — parsed and carried now so a U0 host is already
/// schema-complete, consumed by the U1 apply path.
/// The Windows installer leg (design §6) — consumed by the host's Windows apply path and
/// ignored everywhere else.
#[serde(default)]
pub windows_host: Option<WindowsHostAsset>,
}
/// Where the Windows host installer for [`Manifest::version`] lives and how to verify it.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct WindowsHostAsset {
pub struct WindowsHostAsset {
/// Immutable per-version download URL — never a mutable `latest/` alias, so the hash
/// below can't race an alias re-upload.
pub url: String,
@@ -79,7 +83,7 @@ pub(crate) struct WindowsHostAsset {
/// Verify `sig_text` over the exact `bytes` against `keys`, then strictly parse and validate
/// the document for `expected_channel`. The only constructor — there is no unsigned path.
pub(crate) fn verify_and_parse(
pub fn verify_and_parse(
bytes: &[u8],
sig_text: &str,
keys: &[PublicKey],
@@ -91,20 +95,20 @@ pub(crate) fn verify_and_parse(
/// Parse + validate a document whose signature has already been checked. Split out so tests
/// can exercise validation without minting signatures for every case.
pub(crate) fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result<Manifest> {
pub fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result<Manifest> {
if bytes.len() > MAX_MANIFEST_BYTES {
bail!("manifest is larger than the {MAX_MANIFEST_BYTES}-byte cap");
}
let m: Manifest = serde_json::from_slice(bytes).context("update manifest is not valid JSON")?;
if m.schema != SCHEMA {
bail!(
"unsupported manifest schema {} (this host understands {SCHEMA})",
"unsupported manifest schema {} (this build understands {SCHEMA})",
m.schema
);
}
if m.channel != expected_channel {
bail!(
"manifest is for channel `{}` but this host asked for `{expected_channel}`",
"manifest is for channel `{}` but this build asked for `{expected_channel}`",
m.channel
);
}
@@ -159,14 +163,7 @@ mod tests {
#[test]
fn signed_roundtrip_and_tamper() {
use base64::Engine as _;
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_str = format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
);
let (key_str, kp) = crate::sig::tests::keypair();
let keys = vec![PublicKey::parse(&key_str).unwrap()];
let body = bytes(&doc());
+106
View File
@@ -0,0 +1,106 @@
//! Detached Ed25519 signatures over an exact document (plugin-store index, update manifest).
//!
//! Moved here from `punktfunk-host::store::index` when the client grew its own update check:
//! one implementation, one set of tests, one place to fix if the format ever moves. The host
//! re-exports it, so its call sites are unchanged.
use anyhow::{bail, Context, Result};
/// An ed25519 public key pinned by a source record, spelled `ed25519:<base64 of the 32 raw bytes>`.
#[derive(Debug, Clone)]
pub struct PublicKey(Vec<u8>);
impl PublicKey {
pub fn parse(s: &str) -> Result<Self> {
use base64::Engine as _;
let b64 = s
.strip_prefix("ed25519:")
.context("public key must be spelled `ed25519:<base64>`")?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.context("public key is not valid base64")?;
if raw.len() != 32 {
bail!("ed25519 public key must be 32 bytes, got {}", raw.len());
}
Ok(Self(raw))
}
}
/// Verify a detached ed25519 signature over the **exact** document bytes against any of the
/// pinned keys (two slots, so a key rotation is "sign with the new one, ship a build that
/// trusts both, retire the old" rather than a flag day).
///
/// `sig_text` is the `.sig` file's contents: base64, whitespace-tolerant.
pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Result<()> {
use base64::Engine as _;
if keys.is_empty() {
bail!("no public key pinned for this source");
}
let sig = base64::engine::general_purpose::STANDARD
.decode(sig_text.trim())
.context("signature file is not valid base64")?;
for key in keys {
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
if pk.verify(bytes, &sig).is_ok() {
return Ok(());
}
}
bail!("signature does not verify against any pinned key")
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A fresh ring keypair as `(pinned key string, signer)` — the format contract with the
/// CI signers (raw 32-byte key, `ed25519:<base64>`; raw 64-byte signature, base64).
pub(crate) fn keypair() -> (String, ring::signature::Ed25519KeyPair) {
use base64::Engine as _;
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key_str = format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
);
(key_str, kp)
}
#[test]
fn roundtrip_and_tamper() {
use base64::Engine as _;
let (key_str, kp) = keypair();
let keys = vec![PublicKey::parse(&key_str).unwrap()];
let body = b"the exact bytes";
let sig = base64::engine::general_purpose::STANDARD.encode(kp.sign(body));
assert!(verify_signature(body, &sig, &keys).is_ok());
// A `.sig` file written by a shell redirect ends in a newline — tolerated.
assert!(verify_signature(body, &format!("{sig}\n"), &keys).is_ok());
assert!(verify_signature(b"other bytes", &sig, &keys).is_err());
// No pinned key at all fails closed rather than skipping verification.
assert!(verify_signature(body, &sig, &[]).is_err());
}
#[test]
fn key_format_is_enforced() {
assert!(PublicKey::parse("6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=").is_err());
assert!(PublicKey::parse("ed25519:not base64!!").is_err());
// Right encoding, wrong length.
assert!(PublicKey::parse("ed25519:AAAA").is_err());
}
/// A signature from a key we do NOT pin must fail even though it is perfectly valid —
/// "verifies" and "verifies as us" are the same statement or the pinning is decorative.
#[test]
fn other_signer_refused() {
use base64::Engine as _;
let (ours, _) = keypair();
let (_, theirs) = keypair();
let keys = vec![PublicKey::parse(&ours).unwrap()];
let body = b"the exact bytes";
let sig = base64::engine::general_purpose::STANDARD.encode(theirs.sign(body));
assert!(verify_signature(body, &sig, &keys).is_err());
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Release channels and the "is the manifest newer than me?" comparison.
//!
//! The hard part is canary: every packaging channel spells the same CI build differently
//! (`0.23.0~ci10250.gab12cd34` on deb, `0.23.0-0.ci10250.g…` on rpm, a zero-padded pkgrel on
//! pacman, `0.23.10250` on Windows/decky). Comparing those strings to each other is
//! meaningless, so canary compares `(major, minor)` and then the **CI run number**, which is
//! the one monotonic axis every channel carries. Stable compares the plain triple.
//!
//! Everything here is definitive-or-false: an unparseable pair never flags an update. A UI
//! still shows both version strings — the badge just doesn't light up on guesswork.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Stable,
Canary,
}
impl Channel {
pub fn as_str(self) -> &'static str {
match self {
Channel::Stable => "stable",
Channel::Canary => "canary",
}
}
}
/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`).
pub fn triple(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty());
// Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three
// ONLY if the string actually starts with digits (else it's not a version at all).
if !v.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
Some((
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}
/// The CI run number embedded in a canary version string, wherever the channel's format hid
/// it: `0.23.0~ci10250.g<sha>` (deb), `0.23.0-0.ci10250.g<sha>` (rpm), `0.23.10250`
/// (Windows/decky style, run-as-patch). A stable string yields `None`.
pub fn canary_run(version: &str) -> Option<u64> {
// `ci` immediately followed by digits, anywhere.
let mut rest = version;
while let Some(pos) = rest.find("ci") {
let digits: String = rest[pos + 2..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits.is_empty() {
return digits.parse().ok();
}
rest = &rest[pos + 2..];
}
match triple(version) {
Some((_, _, patch)) if patch >= 1000 => Some(patch),
_ => None,
}
}
/// Is the manifest's release newer than `current`? See the module docs for why canary
/// compares run numbers rather than patch fields.
pub fn is_newer(
manifest_version: &str,
manifest_ci_run: Option<u64>,
current: &str,
channel: Channel,
) -> bool {
let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else {
return false;
};
match channel {
Channel::Stable => m > c,
Channel::Canary => {
if (m.0, m.1) != (c.0, c.1) {
return (m.0, m.1) > (c.0, c.1);
}
let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version));
match (manifest_run, canary_run(current)) {
(Some(mr), Some(cr)) => mr > cr,
_ => false,
}
}
}
}
/// Windows canary installers are versioned `M.m.<run>` where `<run>` is a 4+ digit CI run
/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10).
pub fn windows_channel_of(version: &str) -> Channel {
match triple(version) {
Some((_, _, patch)) if patch >= 1000 => Channel::Canary,
_ => Channel::Stable,
}
}
/// `CHANNEL=canary` in a shell-style conf (the sysext updater's own format).
pub fn conf_channel(conf: &str) -> Option<Channel> {
for line in conf.lines() {
if let Some(v) = line.trim().strip_prefix("CHANNEL=") {
return Some(match v.trim() {
"canary" => Channel::Canary,
_ => Channel::Stable,
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn triples() {
assert_eq!(triple("0.23.0"), Some((0, 23, 0)));
assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0)));
assert_eq!(triple("0.23.10250"), Some((0, 23, 10250)));
assert_eq!(triple("garbage"), None);
assert_eq!(triple("1.2"), None);
}
#[test]
fn canary_runs() {
assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250));
assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777));
assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky)
assert_eq!(canary_run("0.23.0"), None); // stable string
assert_eq!(canary_run("0.23.0-1"), None);
}
#[test]
fn newer_stable() {
assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable));
assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable));
assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags
assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable));
}
#[test]
fn newer_canary_compares_runs_not_patch() {
// deb canary current vs Windows-style manifest version, same run ⇒ NOT newer,
// even though a naive triple compare says 10250 > 0.
assert!(!is_newer(
"0.23.10250",
Some(10250),
"0.23.0~ci10250.gab12cd34",
Channel::Canary
));
assert!(is_newer(
"0.23.10251",
Some(10251),
"0.23.0~ci10250.gab12cd34",
Channel::Canary
));
// Minor bump wins outright.
assert!(is_newer(
"0.24.100",
Some(100),
"0.23.0~ci10250.g12",
Channel::Canary
));
// No run extractable on either side ⇒ conservative false.
assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary));
}
#[test]
fn windows_channel_heuristic() {
assert_eq!(windows_channel_of("0.22.2"), Channel::Stable);
assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary);
}
#[test]
fn conf_channels() {
assert_eq!(conf_channel("CHANNEL=canary\n"), Some(Channel::Canary));
assert_eq!(
conf_channel("# a comment\nCHANNEL=stable"),
Some(Channel::Stable)
);
assert_eq!(conf_channel("KEEP=6\n"), None);
}
}
+2
View File
@@ -10,6 +10,8 @@ repository.workspace = true
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# Signed update-manifest fetch/verify + install-kind detection, shared with the Linux client.
pf-update-check = { path = "../pf-update-check" }
# Config-dir + owner-private file helpers (moved out of the gamestream junk drawer, plan §W6).
pf-paths = { path = "../pf-paths" }
# Process-wide host config global, extracted to a leaf crate (plan §W6).
+1 -1
View File
@@ -130,7 +130,7 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus {
channel: channel.as_str().into(),
current_version: current.into(),
apply: update::apply_support().into(),
channel_hint: detect::channel_hint(kind).into(),
channel_hint: detect::channel_hint(kind),
check_disabled: update::check_disabled(),
available,
manifest: snap.checked.as_ref().map(|c| UpdateManifestInfo {
+5 -91
View File
@@ -121,47 +121,11 @@ pub(crate) struct Advisory {
// ---------------------------------------------------------------- signature
/// An ed25519 public key pinned by a source record, spelled `ed25519:<base64 of the 32 raw bytes>`.
#[derive(Debug, Clone)]
pub(crate) struct PublicKey(Vec<u8>);
impl PublicKey {
pub(crate) fn parse(s: &str) -> Result<Self> {
use base64::Engine as _;
let b64 = s
.strip_prefix("ed25519:")
.context("public key must be spelled `ed25519:<base64>`")?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.context("public key is not valid base64")?;
if raw.len() != 32 {
bail!("ed25519 public key must be 32 bytes, got {}", raw.len());
}
Ok(Self(raw))
}
}
/// Verify a detached ed25519 signature over the **exact** index bytes against any of the pinned
/// keys (two slots, so a key rotation is "sign with the new one, ship a host that trusts both,
/// retire the old" rather than a flag day).
///
/// `sig_text` is the `.sig` file's contents: base64, whitespace-tolerant.
pub(crate) fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Result<()> {
use base64::Engine as _;
if keys.is_empty() {
bail!("no public key pinned for this source");
}
let sig = base64::engine::general_purpose::STANDARD
.decode(sig_text.trim())
.context("signature file is not valid base64")?;
for key in keys {
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
if pk.verify(bytes, &sig).is_ok() {
return Ok(());
}
}
bail!("index signature does not verify against any pinned key")
}
// Ed25519 verification moved to `pf-update-check` when the Linux client grew its own update
// check: the host's plugin-store index and both products' update manifests are all "verify a
// detached signature over exact bytes against pinned keys", and that rule must exist once.
// Re-exported under the old path so every call site here is unchanged.
pub(crate) use pf_update_check::sig::{verify_signature, PublicKey};
// ---------------------------------------------------------------- parse + validate
@@ -511,56 +475,6 @@ mod tests {
assert!(!valid_integrity("sha512"));
}
#[test]
fn signature_verifies_only_over_exact_bytes_and_pinned_keys() {
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key = {
use base64::Engine as _;
PublicKey::parse(&format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
))
.unwrap()
};
let body = br#"{"schema":1,"plugins":[]}"#;
let sig = {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(kp.sign(body).as_ref())
};
assert!(verify_signature(body, &sig, std::slice::from_ref(&key)).is_ok());
// whitespace in the .sig file is tolerated
assert!(verify_signature(body, &format!("{sig}\n"), std::slice::from_ref(&key)).is_ok());
// one byte of tampering fails
assert!(verify_signature(
br#"{"schema":1,"plugins":[ ]}"#,
&sig,
std::slice::from_ref(&key)
)
.is_err());
// a different key fails
let other = ring::signature::Ed25519KeyPair::from_pkcs8(
ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
.unwrap()
.as_ref(),
)
.unwrap();
let other_key = {
use base64::Engine as _;
PublicKey::parse(&format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(other.public_key().as_ref())
))
.unwrap()
};
assert!(verify_signature(body, &sig, &[other_key]).is_err());
// no pinned key at all fails closed
assert!(verify_signature(body, &sig, &[]).is_err());
}
/// The real published index must parse here, field for field.
///
/// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index
+17 -70
View File
@@ -20,28 +20,22 @@ pub(crate) mod detect;
pub(crate) mod jobs;
#[cfg(target_os = "linux")]
mod linux;
pub(crate) mod manifest;
// The signed manifest's schema + validation live in `pf-update-check` (shared with the
// client's check). Re-exported so `manifest::…` call sites below are unchanged.
pub(crate) use pf_update_check::manifest;
#[cfg(target_os = "windows")]
pub(crate) mod windows;
use crate::store::index::PublicKey;
use manifest::Manifest;
use pf_update_check::PublicKey;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
/// The Ed25519 public keys this binary trusts for update manifests — two slots so a key
/// rotation is "sign with the new one, ship a host trusting both, retire the old" (the
/// plugin-store `OFFICIAL_KEYS` drill). The private half is the `UPDATE_MANIFEST_KEY` CI
/// secret; it also lives in the operator's offline backup (plan U0.1 DoD).
pub(crate) const UPDATE_KEYS: [&str; 2] = [
"ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=",
"", // rotation slot
];
/// Feed base — `<base>/<channel>/manifest.json` + `.sig`. Override for tests/dev feeds via
/// `PUNKTFUNK_UPDATE_FEED` (a base URL, not request-time input: env is operator config).
const DEFAULT_FEED_BASE: &str = "https://git.unom.io/api/packages/unom/generic/punktfunk-update";
/// The Ed25519 public keys this binary trusts for update manifests. The list itself lives in
/// `pf-update-check` (the client verifies the same manifest and must trust the same signers);
/// this alias keeps the host's call sites and the plan's vocabulary intact.
pub(crate) use pf_update_check::OFFICIAL_UPDATE_KEYS as UPDATE_KEYS;
/// A cache older than this is refreshed in the background on the next status read.
const AUTO_REFRESH_AFTER: Duration = Duration::from_secs(6 * 60 * 60);
@@ -53,9 +47,6 @@ pub(crate) const FORCE_MIN_INTERVAL: Duration = Duration::from_secs(30);
/// freeze-detection hint (design §3.2), not an error.
const STALE_AFTER: Duration = Duration::from_secs(45 * 24 * 60 * 60);
/// One fetch's wall-clock budget (mirrors the store catalog fetch).
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
/// Update checks disabled by operator config (env or `host.env`).
pub(crate) fn check_disabled() -> bool {
matches!(
@@ -129,13 +120,6 @@ pub(crate) fn opt_in_hint() -> Option<String> {
None
}
fn feed_base() -> String {
std::env::var("PUNKTFUNK_UPDATE_FEED")
.ok()
.filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1"))
.unwrap_or_else(|| DEFAULT_FEED_BASE.to_string())
}
fn pinned_keys() -> Vec<PublicKey> {
UPDATE_KEYS
.iter()
@@ -228,55 +212,18 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
// ---------------------------------------------------------------- refresh
/// Fetch + verify the channel manifest. Blocking (`ureq`) — call from a blocking thread.
/// Fetch + verify the channel manifest through the shared checker. Blocking — call from a
/// blocking thread.
fn fetch_manifest_blocking(channel: &str) -> Result<Manifest, String> {
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
// Follow the registry's 303-to-object-storage redirect; the signature is verified
// over the FINAL bytes (the sysext-feed lesson).
.redirects(3)
.user_agent(&format!(
pf_update_check::feed::fetch_manifest_blocking(
&pf_update_check::feed::feed_base(),
channel,
&pinned_keys(),
&format!(
"punktfunk-host/{} (update-check)",
env!("PUNKTFUNK_VERSION")
))
.build();
let base = feed_base();
let url = format!("{base}/{channel}/manifest.json");
let sig_url = format!("{url}.sig");
let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?;
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?;
let keys = pinned_keys();
if keys.is_empty() {
// Both slots empty would mean a build with the feature disarmed; refuse rather than
// silently skipping verification.
return Err("no update key is pinned in this build".into());
}
manifest::verify_and_parse(&body, &sig_text, &keys, channel).map_err(|e| format!("{e:#}"))
}
fn fetch_err(e: ureq::Error) -> String {
match e {
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
other => format!("feed fetch failed: {other}"),
}
}
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut reader = resp
.into_reader()
.take(manifest::MAX_MANIFEST_BYTES as u64 + 1);
reader
.read_to_end(&mut buf)
.map_err(|e| format!("read failed: {e}"))?;
if buf.len() > manifest::MAX_MANIFEST_BYTES {
return Err("response exceeds the manifest size cap".into());
}
Ok(buf)
),
)
}
/// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache,
+37 -392
View File
@@ -1,420 +1,65 @@
//! **How was this host installed, and on which channel?** (design §4.1)
//!
//! The apply strategy — and, until apply lands, the command hint the console shows — hangs
//! off the install kind. Detection is a ladder over root-owned facts: packaging writes a
//! marker (`/usr/share/punktfunk/install-kind`, e.g. `apt stable`), the sysext self-identifies
//! via its merged extension-release, Nix by store path, and so on. The API only ever *reads*
//! this; nothing request-side can influence it.
//! The ladder itself, the version comparison and the per-kind command hints moved to
//! `pf-update-check` when the Linux client grew the same surface — the delivery channels are
//! the same ones, and two copies of "is this newer?" would have drifted. What stays here is
//! the host's binding of that shared machinery: [`Product::Host`], this binary's own version,
//! and the process-wide cache.
//!
//! The ladder itself is a pure function over a [`Probe`] so every branch is unit-testable;
//! [`detect`] gathers the real probe once per process.
//! Everything is re-exported under the names the rest of the host already uses, so call sites
//! (`detect::InstallKind::Apt`, `detect::is_newer(…)`, …) are unchanged.
use std::path::{Path, PathBuf};
// Only what the host actually names — an unused re-export is a warning, and CI runs
// `clippy -D warnings`. Tests reach for the rest through `pf_update_check::…` directly.
pub(crate) use pf_update_check::detect::InstallKind;
pub(crate) use pf_update_check::version::{is_newer, Channel};
use pf_update_check::detect::{classify as classify_shared, gather, Product};
use std::sync::OnceLock;
/// Where the Linux packages stamp how they were installed. First word = kind
/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`).
const MARKER_PATH: &str = "/usr/share/punktfunk/install-kind";
/// The merged sysext names itself here (written by `build-sysext.sh`); its presence means the
/// running `/usr` overlay came from the sysext image, regardless of any leftover marker.
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
/// The sysext updater's own config (`CHANNEL=stable|canary`).
const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallKind {
WindowsInstaller,
Sysext,
RpmOstree,
Apt,
Dnf,
Pacman,
SteamosSource,
Nix,
Source,
}
impl InstallKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
InstallKind::WindowsInstaller => "windows-installer",
InstallKind::Sysext => "sysext",
InstallKind::RpmOstree => "rpm-ostree",
InstallKind::Apt => "apt",
InstallKind::Dnf => "dnf",
InstallKind::Pacman => "pacman",
InstallKind::SteamosSource => "steamos-source",
InstallKind::Nix => "nix",
InstallKind::Source => "source",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Channel {
Stable,
Canary,
}
impl Channel {
pub(crate) fn as_str(self) -> &'static str {
match self {
Channel::Stable => "stable",
Channel::Canary => "canary",
}
}
}
/// The root-owned facts the ladder reads, gathered once by [`gather`] (tests build these
/// directly).
#[derive(Debug, Default)]
pub(crate) struct Probe {
/// Running on Windows (cfg, not a file).
pub windows: bool,
/// The running exe's path.
pub exe: PathBuf,
/// `$HOME`, if any.
pub home: Option<PathBuf>,
/// Contents of [`MARKER_PATH`], if present.
pub marker: Option<String>,
/// [`SYSEXT_MARKER`] exists (merged sysext overlay).
pub sysext: bool,
/// Contents of [`SYSEXT_CONF`], if present.
pub sysext_conf: Option<String>,
/// `/run/ostree-booted` exists (rpm-ostree / bootc family).
pub ostree_booted: bool,
}
fn gather() -> Probe {
Probe {
windows: cfg!(target_os = "windows"),
exe: std::env::current_exe().unwrap_or_default(),
home: std::env::var_os("HOME").map(PathBuf::from),
marker: std::fs::read_to_string(MARKER_PATH).ok(),
sysext: Path::new(SYSEXT_MARKER).exists(),
sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(),
ostree_booted: Path::new("/run/ostree-booted").exists(),
}
}
/// The ladder (design §4.1). Order matters and each rung is a root-owned fact:
/// sysext overlay > Nix store path > dev/source tree > user-owned Deck build > package
/// marker (flipped to rpm-ostree when the box is ostree-booted) > `source` fallback.
pub(crate) fn classify(p: &Probe) -> (InstallKind, Channel) {
if p.windows {
// The installer is the only supported Windows delivery; a loose cargo build shows
// itself by not living under Program Files. Channel: canary installers carry the CI
// run as the third component (`M.m.<run>`), see `windows_channel_of`.
let installed = p
.exe
.to_string_lossy()
.to_ascii_lowercase()
.contains("\\program files\\punktfunk");
return if installed {
(
InstallKind::WindowsInstaller,
windows_channel_of(env!("PUNKTFUNK_VERSION")),
)
} else {
(InstallKind::Source, Channel::Stable)
};
}
if p.sysext {
let channel = p
.sysext_conf
.as_deref()
.and_then(conf_channel)
.unwrap_or(Channel::Stable);
return (InstallKind::Sysext, channel);
}
if p.exe.starts_with("/nix/store") {
return (InstallKind::Nix, Channel::Stable);
}
// A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the
// Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is
// user-owned but NOT a plain `target/` dir — that distinction is the marker here.
let exe_str = p.exe.to_string_lossy().to_string();
if exe_str.contains("/target/") {
return (InstallKind::Source, Channel::Stable);
}
if let Some(home) = &p.home {
if p.exe.starts_with(home) {
return (InstallKind::SteamosSource, Channel::Canary);
}
}
if let Some(marker) = &p.marker {
let mut words = marker.split_whitespace();
let kind = words.next().unwrap_or("");
let channel = match words.next() {
Some("canary") => Channel::Canary,
_ => Channel::Stable,
};
let kind = match kind {
"apt" => Some(InstallKind::Apt),
// An ostree-booted box consumed the RPM by layering (or an image build); either
// way `dnf upgrade` is not how it updates. bootc-vs-layered is refined in U2 via
// `rpm-ostree status` — until then both report `rpm-ostree` (notify text is
// identical in U0).
"dnf" if p.ostree_booted => Some(InstallKind::RpmOstree),
"dnf" => Some(InstallKind::Dnf),
"pacman" => Some(InstallKind::Pacman),
_ => None,
};
if let Some(kind) = kind {
return (kind, channel);
}
}
(InstallKind::Source, Channel::Stable)
}
/// `CHANNEL=canary` in `/etc/punktfunk-sysext.conf` (the sysext updater's own format).
fn conf_channel(conf: &str) -> Option<Channel> {
for line in conf.lines() {
if let Some(v) = line.trim().strip_prefix("CHANNEL=") {
return Some(match v.trim() {
"canary" => Channel::Canary,
_ => Channel::Stable,
});
}
}
None
}
/// Windows canary installers are versioned `M.m.<run>` where `<run>` is a 4+ digit CI run
/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10).
fn windows_channel_of(version: &str) -> Channel {
match triple(version) {
Some((_, _, patch)) if patch >= 1000 => Channel::Canary,
_ => Channel::Stable,
}
}
/// The process-wide answer, computed once.
pub(crate) fn detect() -> (InstallKind, Channel) {
static DETECTED: OnceLock<(InstallKind, Channel)> = OnceLock::new();
*DETECTED.get_or_init(|| classify(&gather()))
*DETECTED.get_or_init(|| {
classify_shared(
&gather(Product::Host, env!("PUNKTFUNK_VERSION")),
Product::Host,
)
})
}
// ---------------------------------------------------------------- version comparison
/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`).
pub(crate) fn triple(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty());
// Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three
// ONLY if the string actually starts with digits (else it's not a version at all).
if !v.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
Some((
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}
/// The CI run number embedded in a canary version string, wherever the channel's format hid
/// it: `0.23.0~ci10250.g<sha>` (deb), `0.23.0-0.ci10250.g<sha>` (rpm), `0.23.10250`
/// (Windows/decky style, run-as-patch). A stable string yields `None`.
pub(crate) fn canary_run(version: &str) -> Option<u64> {
// `ci` immediately followed by digits, anywhere.
let mut rest = version;
while let Some(pos) = rest.find("ci") {
let digits: String = rest[pos + 2..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits.is_empty() {
return digits.parse().ok();
}
rest = &rest[pos + 2..];
}
match triple(version) {
Some((_, _, patch)) if patch >= 1000 => Some(patch),
_ => None,
}
}
/// Is the manifest's release newer than what this process runs? Definitive-or-false: an
/// unparseable pair never flags (the console still shows both version strings — the badge
/// just doesn't light up on guesswork). Canary compares `(major, minor)` then the CI run,
/// because canary patch fields mean different things per channel (R10).
pub(crate) fn is_newer(
manifest_version: &str,
manifest_ci_run: Option<u64>,
current: &str,
channel: Channel,
) -> bool {
let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else {
return false;
};
match channel {
Channel::Stable => m > c,
Channel::Canary => {
if (m.0, m.1) != (c.0, c.1) {
return (m.0, m.1) > (c.0, c.1);
}
let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version));
match (manifest_run, canary_run(current)) {
(Some(mr), Some(cr)) => mr > cr,
_ => false,
}
}
}
/// The host ladder over an explicit probe — the seam the tests use.
#[cfg(test)]
fn classify(p: &pf_update_check::detect::Probe) -> (InstallKind, Channel) {
classify_shared(p, Product::Host)
}
/// The per-kind "how to update" command the console shows while (or instead of) an apply
/// path existing (design §5). One line, copy-pastable, no placeholders.
pub(crate) fn channel_hint(kind: InstallKind) -> &'static str {
match kind {
InstallKind::WindowsInstaller => {
"winget upgrade unom.PunktfunkHost (or re-run the newer installer)"
}
InstallKind::Sysext => "sudo punktfunk-sysext update",
InstallKind::RpmOstree => {
"sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)"
}
InstallKind::Apt => "sudo apt update && sudo apt install --only-upgrade punktfunk-host",
InstallKind::Dnf => "sudo dnf upgrade punktfunk",
InstallKind::Pacman => "sudo pacman -Syu",
InstallKind::SteamosSource => "bash ~/punktfunk/scripts/steamdeck/update.sh --pull",
InstallKind::Nix => "nix flake update punktfunk (then rebuild your system)",
InstallKind::Source => "git pull && cargo build --release -p punktfunk-host",
}
pub(crate) fn channel_hint(kind: InstallKind) -> String {
pf_update_check::detect::update_command(kind, Product::Host)
}
#[cfg(test)]
mod tests {
use super::*;
fn probe() -> Probe {
Probe {
windows: false,
exe: PathBuf::from("/usr/bin/punktfunk-host"),
home: Some(PathBuf::from("/home/deck")),
/// The shared crate owns the ladder's own tests; this one guards the host's BINDING of
/// it — that we ask as `Product::Host`, which is what makes a Deck build report the
/// source-rebuild leg instead of the client's plain `source`.
#[test]
fn host_binding_reports_the_deck_source_leg() {
let p = pf_update_check::detect::Probe {
exe: "/home/deck/punktfunk/target-steamos/release/punktfunk-host".into(),
home: Some("/home/deck".into()),
..Default::default()
}
}
#[test]
fn ladder_sysext_beats_marker() {
let mut p = probe();
p.sysext = true;
p.marker = Some("dnf canary".into());
p.sysext_conf = Some("CHANNEL=canary\n".into());
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Canary));
p.sysext_conf = None;
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Stable));
}
#[test]
fn ladder_nix_store_path() {
let mut p = probe();
p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host");
assert_eq!(classify(&p).0, InstallKind::Nix);
}
#[test]
fn ladder_cargo_target_is_source_even_under_home() {
let mut p = probe();
p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host");
assert_eq!(classify(&p).0, InstallKind::Source);
}
#[test]
fn ladder_deck_build_is_steamos_source() {
let mut p = probe();
p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host");
};
assert_eq!(classify(&p).0, InstallKind::SteamosSource);
}
#[test]
fn ladder_markers() {
for (marker, ostree, kind, channel) in [
("apt stable", false, InstallKind::Apt, Channel::Stable),
("apt canary", false, InstallKind::Apt, Channel::Canary),
("dnf stable", false, InstallKind::Dnf, Channel::Stable),
("dnf stable", true, InstallKind::RpmOstree, Channel::Stable),
("pacman canary", false, InstallKind::Pacman, Channel::Canary),
] {
let mut p = probe();
p.marker = Some(marker.into());
p.ostree_booted = ostree;
assert_eq!(classify(&p), (kind, channel), "marker `{marker}`");
}
}
#[test]
fn ladder_unknown_marker_falls_through_to_source() {
let mut p = probe();
p.marker = Some("snap stable".into());
assert_eq!(classify(&p).0, InstallKind::Source);
}
#[test]
fn triples() {
assert_eq!(triple("0.23.0"), Some((0, 23, 0)));
assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0)));
assert_eq!(triple("0.23.10250"), Some((0, 23, 10250)));
assert_eq!(triple("garbage"), None);
assert_eq!(triple("1.2"), None);
}
#[test]
fn canary_runs() {
assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250));
assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777));
assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky)
assert_eq!(canary_run("0.23.0"), None); // stable string
assert_eq!(canary_run("0.23.0-1"), None);
}
#[test]
fn newer_stable() {
assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable));
assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable));
assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags
assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable));
}
#[test]
fn newer_canary_compares_runs_not_patch() {
// deb canary current vs Windows-style manifest version, same run ⇒ NOT newer,
// even though a naive triple compare says 10250 > 0.
assert!(!is_newer(
"0.23.10250",
Some(10250),
"0.23.0~ci10250.gab12cd34",
Channel::Canary
));
assert!(is_newer(
"0.23.10251",
Some(10251),
"0.23.0~ci10250.gab12cd34",
Channel::Canary
));
// Minor bump wins outright.
assert!(is_newer(
"0.24.100",
Some(100),
"0.23.0~ci10250.g12",
Channel::Canary
));
// No run extractable on either side ⇒ conservative false.
assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary));
}
#[test]
fn windows_channel_heuristic() {
assert_eq!(windows_channel_of("0.22.2"), Channel::Stable);
assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary);
fn hints_name_the_host_package() {
assert!(channel_hint(InstallKind::Apt).contains("punktfunk-host"));
assert!(channel_hint(InstallKind::Sysext).contains("punktfunk-sysext update"));
}
}
+15 -9
View File
@@ -7,7 +7,7 @@
#
# The signature is a raw 64-byte Ed25519 over the EXACT manifest bytes, base64 in the .sig —
# the same format the plugin index uses and `store::index::verify_signature` checks. The
# public half is pinned in the host binary (UPDATE_KEYS in crates/punktfunk-host/src/update.rs);
# public half is pinned in the shared checker (OFFICIAL_UPDATE_KEYS in crates/pf-update-check);
# before signing, this script cross-checks the signing key against that constant and refuses
# on mismatch — the most likely deploy mistake is signing with a key no host trusts (the
# sysext publisher's fingerprint-crosscheck drill).
@@ -68,14 +68,20 @@ fi
# Cross-check: the key we are about to sign with must be one the host binary pins.
PUB="ed25519:$(openssl pkey -in "$KEY" -pubout -outform DER | tail -c 32 | base64)"
KEYS_FILE="crates/punktfunk-host/src/update.rs"
if [ -f "$KEYS_FILE" ]; then
if ! grep -qF "\"$PUB\"" "$KEYS_FILE"; then
echo "ERROR: signing key $PUB is not pinned in $KEYS_FILE (UPDATE_KEYS) — wrong key?" >&2
exit 1
fi
else
echo "WARN: $KEYS_FILE not in this checkout — skipping the pinned-key cross-check" >&2
# The pin list moved to the shared checker when the Linux client started verifying the same
# manifest (crates/pf-update-check/src/lib.rs, OFFICIAL_UPDATE_KEYS) — one list, so the host
# and the client can never disagree about who may announce a release.
KEYS_FILE="crates/pf-update-check/src/lib.rs"
# A MISSING file is fatal, not a warning. This check is the guard against signing with a key
# no build trusts; if its path ever goes stale the old `else` branch would have skipped it
# silently and published an unverifiable manifest — the exact failure it exists to prevent.
if [ ! -f "$KEYS_FILE" ]; then
echo "ERROR: $KEYS_FILE not in this checkout — refusing to sign without the pinned-key cross-check" >&2
exit 1
fi
if ! grep -qF "\"$PUB\"" "$KEYS_FILE"; then
echo "ERROR: signing key $PUB is not pinned in $KEYS_FILE (OFFICIAL_UPDATE_KEYS) — wrong key?" >&2
exit 1
fi
# ---- build the manifest ---------------------------------------------------------------------