Watch
1
0
Fork
You've already forked souveraine-updater
0

resolve what a mutation changes before elevating

pkexec pacman -Syu authorises everything pacman would do. Operations carry
the resolved set now, hashed on RedFlag's own byte contract so a token
minted there verifies here, and re-derived before elevation: a repository
that moves while you read the prompt refuses instead of installing.
This commit is contained in:
Fimeg 2026-08-26 15:14:53 -04:00
commit ccc79fefed
9 changed files with 889 additions and 37 deletions

View file

@ -0,0 +1,111 @@
# CLOSURE-001: closure-backed operations
**Status:** phase 1 landed. Phases 2–4 open.
**Why it exists:** the Updater and RedFlag are one mutation protocol at two
authority placements. The seam between them is the closure — the resolved set a
mutation is authorised to change. Until an operation carries one, `pkexec
pacman -Syu` authorises *everything pacman would do*, which is RedFlag's
forbidden shape: sign the top level, not the resolved closure.
## What landed (phase 1)
- `closure.rs` — `Artifact`, `Source`, `Closure`, and the canonical hash:
`hex(sha256("\n".join(sorted("{name}@{version}#{sha256}"))))`. This is
RedFlag's capability-token contract byte for byte; the Go server mints
against it and the Rust helper re-derives it. Pinned by vector in tests, not
re-derived from the formula — the value *is* the contract.
- `Source` is three classes, not two. Arch has official repositories and
locally built packages; the edge archive is neither. It is signed by a pinned
key, produced by a job named in the distribution manifest, from a commit that
can be walked back to.
- `refusal.rs` — eight typed refusals with stable codes. Previously six
distinct outcomes crossed as one bool and an `anyhow` string.
- `pacman::resolve` — `pacman -Sp --print-format` for the resolved set,
unprivileged; artifact hashes read from the signed sync databases, never from
a file already in the cache.
- `Operation::Install` and `Operation::FullUpgrade` carry a `Closure`.
`auth::run` re-resolves and compares hashes before elevating, so a repository
that moves while a human is reading the prompt is `repositories-moved`, not a
silently different install.
- The operation sheet shows the closure — count, download size, per-source
split, short hash — with pacman's output as diagnostics beneath it.
## What is not true yet
**The closure is a statement, not an enforcement.** Nothing verifies an artifact
hash before installation; `pacman -Syu` still resolves and downloads on its own
behalf once elevated. Phase 2 closes that.
## Phase 2 — download and verify before mutation
1. **Split the refresh from the upgrade.** `-Syu` refreshes the databases
*after* the closure was resolved, so the set it installs is not provably the
set that was authorised. Sequence must become: `-Sy` (elevated) → resolve →
present → authorise → `-Su`. Two elevations is the wrong answer; a single
authorisation covering both is the design question.
2. **Pre-download.** `pacman -Sw` the closure into the cache, then hash each
`.pkg.tar.zst` and compare against the closure's recorded hashes. A mismatch
is a full stop, not a warning.
3. **Execute from verified cache** — `pacman -S --needed <names> --` with the
cache already populated and verified.
4. The `--` separator before every user-derived value is not optional. It is
why `package_names_are_argv_entries_not_shell_text` exists.
## Phase 3 — authority providers
`authorize(Closure) -> Result<Authorization, Refusal>` as a trait, with the
existing polkit path as the first implementation. Then `LocalMintAuthority`
(RedFlag's `redflag-helper --mint`, root-owned key), `RemoteFleetAuthority`
(a fleet server holds the key off-host), `FederatedBodyAuthority` (another of
this owner's bodies).
The refusal taxonomy is already the shared vocabulary — a vulnerability
full-stop, a stale evidence window and an expired token are new variants, not a
new mechanism.
## Phase 4 — the second body
Not settled architecture, and not to be built before phase 3. The point of a
second body is not that it holds a key somewhere else; it is that it renders
the closure on a screen the first body does not control. If a compromised host
constructs the closure *and* writes the words describing it, the human is
approving the attacker's summary.
So the second body must distinguish authoritative inputs from derived
presentation: identity, version and hash checked against signed repository
material; provenance class checked against the distribution manifest; advisory
status recomputed against whatever advisory source is authoritative; sizes from
authenticated metadata. Only then is a fresh step-up there worth anything.
## Open decisions
- **Partial upgrades.** Arch does not support them; the detail pane offers
per-package installs. Options: individual approval with closure expansion,
full-sync only, or batch approval where the operator sees the expanded
closure before authorising. Owner's call.
- **The journal.** Every authorisation and every refusal should be recorded
once, in one place, in the source/package/installed/exercised vocabulary.
Whether that is a local append-only file, the substrate's memory, or both is
undecided.
- **`proposed_by`.** An agent may resolve a closure and propose it; a human
authorises it. Until per-agent Unix principals exist the origin cannot be
attested, so it renders as a claim — "origin claims: <name>, not
independently attested" — never as a fact printed beside a hash.
## Adjacent defect
`Repo::from` matches only core, extra, multilib and souveraine, dropping every
other configured sync repository into `Repo::Local`, which renders as `LOCAL`.
Signed third-party repositories are currently badged as local files. The badge
taxonomy and `closure::Source` want to become one thing.
## Acceptance
- An install and a full upgrade both resolve, display and authorise a closure
before any elevation.
- No artifact installs whose hash was not verified against the closure.
- A repository that moves between resolution and execution refuses with
`repositories-moved` and re-resolves on retry.
- Every refusal reaches the surface as a code, not as prose.
- The closure hash computed here verifies byte-identically against RedFlag's
Go implementation over the same artifact set.

View file

@ -9,3 +9,6 @@ description = "SouveraineOS Updater — pacman backend library"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
# The closure hash is a cross-language contract with RedFlag; both sides must
# compute the same SHA-256 over the same bytes.
sha2 = "0.10"

View file

@ -6,19 +6,29 @@
//! outright when the session is locked, and reports honestly when there is no
//! agent to prompt with.
//!
//! A mutating operation carries its [`Closure`]: the resolved set it is
//! authorised to change, decided before anything elevates. Between resolving
//! and the prompt being answered a human is sitting there, and on a rolling
//! release the repositories do not wait — so the closure is re-derived and
//! compared before the first privileged process starts.
//!
//! See: SESSION-AUTHORITY-DOCTRINE.md, SESSION-TRUST-ARCHITECTURE.md
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use anyhow::{Context, Result};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::closure::Closure;
use crate::refusal::Refusal;
/// SouveraineOS capability tiers. Maps to the shell's LockContentPolicy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Tier {
/// No auth required: reading the catalog, counting updates.
/// No auth required: reading the catalog, counting updates, resolving a
/// closure. Everything that only looks.
Ambient,
/// Fresh re-auth required: anything that writes to the system.
StepUp,
@ -27,10 +37,10 @@ pub enum Tier {
/// A system-changing operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Operation {
Install(Vec<String>),
Install(Closure),
Remove(Vec<String>),
RemoveOrphans(Vec<String>),
FullUpgrade,
FullUpgrade(Closure),
SyncDatabases,
CleanCache,
RateMirrors,
@ -47,10 +57,10 @@ impl Operation {
argv
};
match self {
Operation::Install(names) => pacman(&["-S", "--noconfirm"], names),
Operation::Install(closure) => pacman(&["-S", "--noconfirm"], &closure.requested),
Operation::Remove(names) => pacman(&["-R", "--noconfirm"], names),
Operation::RemoveOrphans(names) => pacman(&["-Rns", "--noconfirm"], names),
Operation::FullUpgrade => pacman(&["-Syu", "--noconfirm"], &[]),
Operation::FullUpgrade(_) => pacman(&["-Syu", "--noconfirm"], &[]),
Operation::SyncDatabases => pacman(&["-Sy"], &[]),
Operation::CleanCache => pacman(&["-Sc", "--noconfirm"], &[]),
Operation::RateMirrors => vec![
@ -65,12 +75,12 @@ impl Operation {
/// Present-tense label for the operation log header.
pub fn label(&self) -> String {
match self {
Operation::Install(names) => format!("Installing {}", names.join(" ")),
Operation::Install(closure) => format!("Installing {}", closure.requested.join(" ")),
Operation::Remove(names) => format!("Removing {}", names.join(" ")),
Operation::RemoveOrphans(names) => {
format!("Removing {} orphaned packages", names.len())
}
Operation::FullUpgrade => "Upgrading the system".into(),
Operation::FullUpgrade(_) => "Upgrading the system".into(),
Operation::SyncDatabases => "Syncing package databases".into(),
Operation::CleanCache => "Cleaning the package cache".into(),
Operation::RateMirrors => "Rating mirrors".into(),
@ -81,11 +91,29 @@ impl Operation {
Tier::StepUp
}
/// The set this operation was authorised against, where it has one.
/// Removals and cache maintenance download nothing, so they have none.
pub fn closure(&self) -> Option<&Closure> {
match self {
Operation::Install(closure) | Operation::FullUpgrade(closure) => Some(closure),
_ => None,
}
}
/// Whether this operation can change what is installed — the caller
/// refreshes the catalog after one of these succeeds.
pub fn mutates_packages(&self) -> bool {
!matches!(self, Operation::RateMirrors)
}
/// Resolve the same request again, against the databases as they are now.
fn reresolve(&self) -> Result<Closure> {
match self {
Operation::Install(closure) => crate::pacman::resolve(&closure.requested, false),
Operation::FullUpgrade(_) => crate::pacman::resolve(&[], true),
_ => Ok(Closure::default()),
}
}
}
/// Session state as logind reports it.
@ -105,11 +133,26 @@ pub fn query_session_state() -> SessionState {
/// Run an operation, streaming each output line to `on_line` as it arrives.
///
/// Returns whether the operation succeeded. Errors are for "could not run it
/// at all" — a non-zero exit from pacman is a normal `Ok(false)`.
pub fn run(op: &Operation, on_line: &mut dyn FnMut(&str)) -> Result<bool> {
/// Every way this can not-happen is a typed [`Refusal`]; success is the only
/// unit value. A non-zero exit from pacman is `Refusal::Failed`, not an error
/// in the sense of something broken.
pub fn run(op: &Operation, on_line: &mut dyn FnMut(&str)) -> Result<(), Refusal> {
if op.tier() == Tier::StepUp && query_session_state().screen_locked {
anyhow::bail!("session is locked — {} needs step-up authentication", op.label());
return Err(Refusal::SessionLocked);
}
// The window this closes is the human one: the closure was shown, and the
// repositories moved while it was being read.
if let Some(authorised) = op.closure() {
let found = op
.reresolve()
.map_err(|error| Refusal::Unresolvable { detail: error.to_string() })?;
if found.hash() != authorised.hash() {
return Err(Refusal::RepositoriesMoved {
authorised: authorised.short_hash(),
found: found.short_hash(),
});
}
}
let argv = op.argv();
@ -119,7 +162,7 @@ pub fn run(op: &Operation, on_line: &mut dyn FnMut(&str)) -> Result<bool> {
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to launch pkexec {}", argv[0]))?;
.map_err(|error| Refusal::LaunchFailed { detail: error.to_string() })?;
// pacman writes progress to stdout and warnings to stderr; the log wants
// both, interleaved in arrival order. One channel, one reader per pipe.
@ -162,20 +205,17 @@ pub fn run(op: &Operation, on_line: &mut dyn FnMut(&str)) -> Result<bool> {
let _ = reader.join();
}
let status = child.wait().context("pkexec did not exit cleanly")?;
let status = child
.wait()
.map_err(|error| Refusal::LaunchFailed { detail: error.to_string() })?;
if no_agent {
// Loud, specific, and actionable: pkexec's own message names no cause,
// and the operation looks like it merely "failed". The session needs a
// polkit agent — on SouveraineOS that is the shell's to run.
anyhow::bail!(
"no polkit authentication agent is running in this session, so nothing \
can be authorised — start the shell's agent and try again"
);
return Err(Refusal::NoAuthAgent);
}
match status.code() {
Some(126) => anyhow::bail!("authorisation was declined"),
Some(127) => anyhow::bail!("{} is not installed", argv[0]),
code => Ok(code == Some(0)),
Some(0) => Ok(()),
Some(126) => Err(Refusal::Declined),
Some(127) => Err(Refusal::MissingExecutable { path: argv[0].clone() }),
code => Err(Refusal::Failed { exit_code: code.unwrap_or(-1) }),
}
}
@ -196,6 +236,20 @@ fn last_frame(bytes: &[u8]) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::closure::{Artifact, Source};
fn closure_of(name: &str) -> Closure {
Closure {
requested: vec![name.into()],
artifacts: vec![Artifact {
name: name.into(),
version: "1-1".into(),
sha256: "00".repeat(32),
source: Source::Official,
download_size: 1,
}],
}
}
#[test]
fn a_redraw_run_collapses_to_its_last_frame() {
@ -213,7 +267,7 @@ mod tests {
#[test]
fn package_names_are_argv_entries_not_shell_text() {
let argv = Operation::Install(vec!["zsh; rm -rf /".into()]).argv();
let argv = Operation::Install(closure_of("zsh; rm -rf /")).argv();
assert_eq!(argv[0], "/usr/bin/pacman");
assert_eq!(argv.last().unwrap(), "zsh; rm -rf /");
}
@ -221,6 +275,14 @@ mod tests {
#[test]
fn rating_mirrors_does_not_touch_the_package_set() {
assert!(!Operation::RateMirrors.mutates_packages());
assert!(Operation::FullUpgrade.mutates_packages());
assert!(Operation::FullUpgrade(Closure::default()).mutates_packages());
}
#[test]
fn only_downloading_operations_carry_a_closure() {
assert!(Operation::Install(closure_of("zsh")).closure().is_some());
assert!(Operation::FullUpgrade(Closure::default()).closure().is_some());
assert!(Operation::Remove(vec!["zsh".into()]).closure().is_none());
assert!(Operation::CleanCache.closure().is_none());
}
}

218
updater-core/src/closure.rs Normal file
View file

@ -0,0 +1,218 @@
//! The resolved closure — exactly what a mutation is authorised to change.
//!
//! The canonical hash is RedFlag's capability-token contract, byte for byte:
//!
//! ```text
//! closure_hash = hex(sha256("\n".join(sorted("{name}@{version}#{sha256}"))))
//! ```
//!
//! Two implementations already agree on those bytes — RedFlag's Go server mints
//! against them and its Rust helper re-derives them before it will execute
//! anything. This is the third. The sort, the separators and the join *are* the
//! contract; changing one silently breaks cross-language verification, so the
//! tests below pin it by vector rather than by re-implementing the formula.
//!
//! See `docs/tasks/CLOSURE-001-closure-backed-operations.md`.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Where an artifact comes from, and therefore what can be said about it.
///
/// Arch's taxonomy has two classes — official repositories, and everything
/// built locally. SouveraineOS needs three: the edge archive is neither an
/// anonymous mirror nor an unverifiable local build. It is signed by a key this
/// machine pins, produced by a job named in the distribution manifest, from a
/// commit that can be walked back to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Source {
Official,
SouveraineEdge,
Foreign,
}
impl Source {
pub fn label(&self) -> &'static str {
match self {
Source::Official => "OFFICIAL",
Source::SouveraineEdge => "SOUVERAINE",
Source::Foreign => "FOREIGN",
}
}
/// Classify a pacman sync-repository name. The archive is published per
/// architecture, so `souveraine-aarch64` and `souveraine-x86_64` are one
/// source — the same reason `Repo::from` strips the suffix.
pub fn from_repo(repo: &str) -> Source {
let base = repo
.strip_suffix("-aarch64")
.or_else(|| repo.strip_suffix("-x86_64"))
.unwrap_or(repo);
match base {
"souveraine" => Source::SouveraineEdge,
_ => Source::Official,
}
}
}
/// One package an operation would fetch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Artifact {
pub name: String,
pub version: String,
/// The hash the *signed sync database* records, not one taken from a file
/// already on disk. pacman's keyring is the floor; this is the value an
/// executor verifies a download against before it is allowed to install.
/// Empty when the database offered none — see [`Closure::unverifiable`].
pub sha256: String,
pub source: Source,
pub download_size: u64,
}
impl Artifact {
/// This artifact's line of the canonical message.
fn canonical(&self) -> String {
format!("{}@{}#{}", self.name, self.version, self.sha256)
}
}
/// The full set an operation touches — top-level and every transitive
/// dependency pacman resolved with it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Closure {
/// What the human asked for. Empty for a full upgrade: the request is the
/// system, not a list of names.
pub requested: Vec<String>,
pub artifacts: Vec<Artifact>,
}
impl Closure {
/// The canonical digest. Artifacts are sorted before hashing, so the order
/// pacman happened to print them in cannot change the answer.
pub fn hash(&self) -> String {
let mut lines: Vec<String> = self.artifacts.iter().map(Artifact::canonical).collect();
lines.sort();
let mut hasher = Sha256::new();
hasher.update(lines.join("\n").as_bytes());
hex(&hasher.finalize())
}
/// First twelve characters of the hash — enough to read off a screen and
/// compare between two bodies, never enough to verify with.
pub fn short_hash(&self) -> String {
self.hash().chars().take(12).collect()
}
pub fn is_empty(&self) -> bool {
self.artifacts.is_empty()
}
pub fn download_size(&self) -> u64 {
self.artifacts.iter().map(|a| a.download_size).sum()
}
pub fn count(&self, source: Source) -> usize {
self.artifacts.iter().filter(|a| a.source == source).count()
}
/// Artifacts the signed database gave no hash for. An executor cannot
/// verify these, so they are named rather than quietly carried along.
pub fn unverifiable(&self) -> Vec<&str> {
self.artifacts
.iter()
.filter(|a| a.sha256.is_empty())
.map(|a| a.name.as_str())
.collect()
}
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut out, b| {
let _ = write!(out, "{b:02x}");
out
})
}
#[cfg(test)]
mod tests {
use super::*;
fn artifact(name: &str, version: &str, sha: &str) -> Artifact {
Artifact {
name: name.into(),
version: version.into(),
sha256: sha.into(),
source: Source::Official,
download_size: 0,
}
}
const ACL: &str = "1111111111111111111111111111111111111111111111111111111111111111";
const ZSH: &str = "2222222222222222222222222222222222222222222222222222222222222222";
/// Pinned vector. If this changes, every RedFlag token minted against the
/// old bytes stops verifying — the value is the contract, not the formula.
#[test]
fn closure_hash_matches_the_cross_language_vector() {
let closure = Closure {
requested: vec!["zsh".into()],
artifacts: vec![artifact("acl", "2.3.2-1", ACL), artifact("zsh", "5.9.2-1", ZSH)],
};
assert_eq!(
closure.hash(),
"4f0ed7367270fec8fb249afea85813e8bd9064048354bd9540b43a9d01d43175"
);
}
#[test]
fn resolve_order_cannot_change_the_hash() {
let forward = Closure {
requested: vec![],
artifacts: vec![artifact("acl", "2.3.2-1", ACL), artifact("zsh", "5.9.2-1", ZSH)],
};
let reversed = Closure {
requested: vec![],
artifacts: vec![artifact("zsh", "5.9.2-1", ZSH), artifact("acl", "2.3.2-1", ACL)],
};
assert_eq!(forward.hash(), reversed.hash());
}
#[test]
fn an_empty_closure_hashes_the_empty_message() {
assert_eq!(
Closure::default().hash(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn a_moved_version_is_a_different_closure() {
let before = Closure {
requested: vec![],
artifacts: vec![artifact("zsh", "5.9.2-1", ZSH)],
};
let after = Closure {
requested: vec![],
artifacts: vec![artifact("zsh", "5.9.3-1", ZSH)],
};
assert_ne!(before.hash(), after.hash());
}
#[test]
fn the_archive_is_its_own_source_on_both_architectures() {
assert_eq!(Source::from_repo("souveraine-aarch64"), Source::SouveraineEdge);
assert_eq!(Source::from_repo("souveraine-x86_64"), Source::SouveraineEdge);
assert_eq!(Source::from_repo("core"), Source::Official);
assert_eq!(Source::from_repo("endeavouros"), Source::Official);
}
#[test]
fn an_artifact_with_no_recorded_hash_is_named() {
let closure = Closure {
requested: vec![],
artifacts: vec![artifact("zsh", "5.9.2-1", ZSH), artifact("mystery", "1-1", "")],
};
assert_eq!(closure.unverifiable(), vec!["mystery"]);
}
}

View file

@ -1,5 +1,7 @@
pub mod auth;
pub mod cache;
pub mod catalog;
pub mod closure;
pub mod models;
pub mod pacman;
pub mod refusal;

View file

@ -5,10 +5,12 @@
//! interpolated name is a command-injection hole in a program whose whole
//! purpose is to then run pacman as root.
use std::collections::HashMap;
use std::process::Command;
use anyhow::{Context, Result};
use crate::closure::{Artifact, Closure, Source};
use crate::models::*;
/// Run pacman with an argv, returning (stdout, exit code). stderr is dropped:
@ -328,7 +330,7 @@ fn read_trimmed(path: &str) -> String {
.unwrap_or_default()
}
fn human_bytes(bytes: u64) -> String {
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
@ -419,10 +421,197 @@ pub fn system_info() -> Result<SystemInfo> {
})
}
// ─── Closure resolution ──────────────────────────────────────────────────────
/// One package's record in a sync database.
#[derive(Default)]
struct Desc {
name: String,
version: String,
sha256: String,
}
/// Parse the concatenated `desc` entries of a sync database.
///
/// A `.db` is a libarchive tarball of `<name>-<version>/desc` files; each
/// record starts at `%FILENAME%` and is a run of `%KEY%` lines followed by
/// their values. bsdtar ships with the libarchive pacman already depends on,
/// so reading them costs no new dependency and no root.
fn parse_desc(out: &str) -> Vec<Desc> {
let mut records: Vec<Desc> = Vec::new();
let mut current: Option<Desc> = None;
let mut key = String::new();
for line in out.lines() {
let line = line.trim();
if line.len() > 2 && line.starts_with('%') && line.ends_with('%') {
if line == "%FILENAME%" {
if let Some(desc) = current.take() {
records.push(desc);
}
current = Some(Desc::default());
}
key = line.to_string();
continue;
}
if line.is_empty() {
continue;
}
let Some(desc) = current.as_mut() else {
continue;
};
match key.as_str() {
"%NAME%" => desc.name = line.to_string(),
"%VERSION%" => desc.version = line.to_string(),
"%SHA256SUM%" => desc.sha256 = line.to_string(),
_ => {}
}
}
if let Some(desc) = current.take() {
records.push(desc);
}
records
}
/// A repository name reaches this module from pacman's own output and is then
/// part of a path. Nothing but a plain name may become one.
fn is_plain_repo_name(repo: &str) -> bool {
!repo.is_empty()
&& !repo.starts_with('.')
&& repo
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
/// The hash the signed sync databases record, keyed by name and version.
fn sync_hashes(repos: &[String]) -> HashMap<(String, String), String> {
let mut index = HashMap::new();
for repo in repos.iter().filter(|r| is_plain_repo_name(r)) {
let db = format!("/var/lib/pacman/sync/{repo}.db");
let Ok(out) = Command::new("bsdtar").args(["-xOf", &db, "*/desc"]).output() else {
continue;
};
if !out.status.success() {
continue;
}
for desc in parse_desc(&String::from_utf8_lossy(&out.stdout)) {
if !desc.name.is_empty() {
index.insert((desc.name, desc.version), desc.sha256);
}
}
}
index
}
/// One row of `--print-format '%r|%n|%v|%s'`.
struct ResolvedRow {
repo: String,
name: String,
version: String,
size: u64,
}
fn parse_resolve(out: &str) -> Vec<ResolvedRow> {
out.lines()
.filter_map(|line| {
let mut fields = line.trim().splitn(4, '|');
let repo = fields.next()?;
let name = fields.next()?;
let version = fields.next()?;
let size = fields.next()?;
if repo.is_empty() || name.is_empty() {
return None;
}
Some(ResolvedRow {
repo: repo.to_string(),
name: name.to_string(),
version: version.to_string(),
size: size.trim().parse().unwrap_or(0),
})
})
.collect()
}
/// What an operation would download, resolved before anything is elevated.
///
/// `pacman -Sp` runs unprivileged and prints the resolved set: the requested
/// packages plus every transitive dependency not already installed. Hashes come
/// from the signed sync database rather than from a file already in the cache —
/// the keyring is pacman's floor, and this is what an executor would verify a
/// download against.
pub fn resolve(requested: &[String], full_upgrade: bool) -> Result<Closure> {
let mut args: Vec<&str> = vec![if full_upgrade { "-Sup" } else { "-Sp" }, "--print-format"];
args.push("%r|%n|%v|%s");
let names: Vec<&str> = requested.iter().map(String::as_str).collect();
if !full_upgrade {
args.extend(names.iter().copied());
}
let (out, code) = pacman(&args)?;
let rows = parse_resolve(&out);
if rows.is_empty() && code != 0 {
anyhow::bail!("pacman could not resolve the request (exit {code})");
}
let mut repos: Vec<String> = rows.iter().map(|r| r.repo.clone()).collect();
repos.sort();
repos.dedup();
let hashes = sync_hashes(&repos);
let artifacts = rows
.into_iter()
.map(|row| Artifact {
sha256: hashes
.get(&(row.name.clone(), row.version.clone()))
.cloned()
.unwrap_or_default(),
source: Source::from_repo(&row.repo),
name: row.name,
version: row.version,
download_size: row.size,
})
.collect();
Ok(Closure { requested: requested.to_vec(), artifacts })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_desc_record_yields_name_version_and_hash() {
let db = "%FILENAME%\nzsh-5.9.2-1-x86_64.pkg.tar.zst\n\n%NAME%\nzsh\n\n\
%VERSION%\n5.9.2-1\n\n%CSIZE%\n2424190\n\n%SHA256SUM%\nabc123\n\n\
%FILENAME%\nacl-2.3.2-1-x86_64.pkg.tar.zst\n\n%NAME%\nacl\n\n\
%VERSION%\n2.3.2-1\n\n%SHA256SUM%\ndef456\n";
let parsed = parse_desc(db);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].name, "zsh");
assert_eq!(parsed[0].version, "5.9.2-1");
assert_eq!(parsed[0].sha256, "abc123");
assert_eq!(parsed[1].name, "acl");
assert_eq!(parsed[1].sha256, "def456");
}
#[test]
fn a_repository_name_cannot_climb_out_of_the_sync_directory() {
assert!(is_plain_repo_name("souveraine-x86_64"));
assert!(is_plain_repo_name("core"));
assert!(!is_plain_repo_name("../../etc/shadow"));
assert!(!is_plain_repo_name(".hidden"));
assert!(!is_plain_repo_name(""));
}
#[test]
fn resolve_rows_carry_repo_name_version_and_size() {
let rows = parse_resolve("extra|zsh|5.9.2-1|2424190\nsouveraine-x86_64|souveraine|0.1.r574-1|13492728\nnoise\n");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].repo, "extra");
assert_eq!(rows[0].size, 2424190);
assert_eq!(rows[1].name, "souveraine");
assert_eq!(Source::from_repo(&rows[1].repo), Source::SouveraineEdge);
}
#[test]
fn search_entries_carry_descriptions_and_install_state() {
let out = "core/acl 2.3.2-1 [installed]\n Access control list utilities\n\

119
updater-core/src/refusal.rs Normal file
View file

@ -0,0 +1,119 @@
//! Why an operation did not happen.
//!
//! Refusal is data. A locked session, a declined prompt, a repository that
//! moved under a pending decision and a pacman that exited non-zero are four
//! different facts; a surface handed one boolean has to guess which, and it
//! guesses wrong. The codes are stable — they are what a face keys off, what a
//! journal records, and what a second body would have to agree with.
/// A refusal carries its own reason. Nothing here is an error in the sense of
/// "something broke": each is the system correctly declining to change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
/// The session is locked and the operation writes to the system.
SessionLocked,
/// Nothing in this session can present an authorisation prompt.
NoAuthAgent,
/// A human was asked and said no.
Declined,
MissingExecutable { path: String },
LaunchFailed { detail: String },
/// The closure could not be resolved at all — pacman refused, or the sync
/// databases could not be read.
Unresolvable { detail: String },
/// The repositories moved between resolving the closure and elevating.
/// The authorised set is no longer the set that would install.
RepositoriesMoved { authorised: String, found: String },
/// pacman ran and exited non-zero.
Failed { exit_code: i32 },
}
impl Refusal {
/// Stable identifier. Add variants; never renumber or reword these.
pub fn code(&self) -> &'static str {
match self {
Refusal::SessionLocked => "session-locked",
Refusal::NoAuthAgent => "no-auth-agent",
Refusal::Declined => "declined",
Refusal::MissingExecutable { .. } => "missing-executable",
Refusal::LaunchFailed { .. } => "launch-failed",
Refusal::Unresolvable { .. } => "unresolvable",
Refusal::RepositoriesMoved { .. } => "repositories-moved",
Refusal::Failed { .. } => "failed",
}
}
/// What a person is told. One sentence, and it names the next move where
/// there is one.
pub fn message(&self) -> String {
match self {
Refusal::SessionLocked => {
"The session is locked, so nothing may change the system. Unlock and try again."
.into()
}
Refusal::NoAuthAgent => {
// pkexec's own message names no cause, and the operation merely
// looks like it failed.
"No authentication agent is running in this session, so nothing can be \
authorised — start the shell's agent and try again."
.into()
}
Refusal::Declined => "Authorisation was declined.".into(),
Refusal::MissingExecutable { path } => format!("{path} is not installed."),
Refusal::LaunchFailed { detail } => format!("Could not start the operation: {detail}"),
Refusal::Unresolvable { detail } => {
format!("Could not work out what this would change: {detail}")
}
Refusal::RepositoriesMoved { authorised, found } => format!(
"The repositories changed while you were deciding — {authorised} was authorised, \
{found} is what would install now. Review the new set."
),
Refusal::Failed { exit_code } => {
format!("The operation did not complete (pacman exited {exit_code}).")
}
}
}
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message())
}
}
impl std::error::Error for Refusal {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_refusal_has_a_distinct_code() {
let all = [
Refusal::SessionLocked,
Refusal::NoAuthAgent,
Refusal::Declined,
Refusal::MissingExecutable { path: "/usr/bin/pacman".into() },
Refusal::LaunchFailed { detail: "x".into() },
Refusal::Unresolvable { detail: "x".into() },
Refusal::RepositoriesMoved { authorised: "a".into(), found: "b".into() },
Refusal::Failed { exit_code: 1 },
];
let mut codes: Vec<&str> = all.iter().map(Refusal::code).collect();
codes.sort_unstable();
let count = codes.len();
codes.dedup();
assert_eq!(codes.len(), count);
}
#[test]
fn a_moved_repository_names_both_closures() {
let refusal = Refusal::RepositoriesMoved {
authorised: "4f0ed7367270".into(),
found: "8c31aa0b91de".into(),
};
assert_eq!(refusal.code(), "repositories-moved");
assert!(refusal.message().contains("4f0ed7367270"));
assert!(refusal.message().contains("8c31aa0b91de"));
}
}

View file

@ -89,6 +89,55 @@ Popup {
}
}
// ---- The closure -----------------------------------------------------
// What was resolved before anything elevated. pacman's output below is
// diagnostics underneath this, not the operation itself.
Rectangle {
id: manifestBox
width: parent.width - 2 * Theme.spacing
x: Theme.spacing
height: visible ? manifest.implicitHeight + 2 * Theme.spacingSm : 0
visible: root.pm.closureCount > 0
radius: Theme.radiusSm
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.divider
Column {
id: manifest
anchors.centerIn: parent
width: parent.width - 2 * Theme.spacingSm
spacing: 2
Text {
text: root.pm.closureCount + " packages · " + root.pm.closureDownload
color: Theme.text
font.pixelSize: Theme.fontMeta
font.weight: Theme.weightBold
}
Text {
text: root.pm.closureOfficial + " official · " + root.pm.closureEdge + " Souveraine"
+ (root.pm.closureUnverified > 0
? " · " + root.pm.closureUnverified + " with no recorded hash"
: "")
color: root.pm.closureUnverified > 0 ? Theme.warning : Theme.textDim
font.pixelSize: Theme.fontSmall
width: parent.width
wrapMode: Text.Wrap
}
Text {
text: "closure " + root.pm.closureHash
color: Theme.textFaint
font.pixelSize: Theme.fontSmall
font.family: Theme.monoFamily
}
}
}
Item { width: 1; height: manifestBox.visible ? Theme.spacingSm : 0 }
// ---- Failure banner --------------------------------------------------
Rectangle {
width: parent.width - 2 * Theme.spacing
@ -118,6 +167,7 @@ Popup {
width: parent.width - 2 * Theme.spacing
x: Theme.spacing
height: root.height - Theme.headerHeight - 2 * Theme.spacing
- (manifestBox.visible ? manifestBox.height + Theme.spacingSm : 0)
- (root.failure.length > 0 ? failureText.implicitHeight + 3 * Theme.spacingSm : Theme.spacingSm)
radius: Theme.radiusSm
color: Theme.window

View file

@ -14,7 +14,9 @@ use cxx_qt::CxxQtType;
use cxx_qt::Threading;
use cxx_qt_lib::{QByteArray, QModelIndex, QString, QStringList, QVariant};
use updater_core::auth::Operation;
use updater_core::closure::{Closure, Source};
use updater_core::models::{Package, PkgStatus, Repo};
use updater_core::refusal::Refusal;
#[cxx_qt::bridge]
pub mod ffi {
@ -58,6 +60,16 @@ pub mod ffi {
#[qproperty(bool, operation_running)]
#[qproperty(QString, operation_label)]
#[qproperty(QString, operation_output)]
// The refusal code of the last operation, empty while one is running
// or after it succeeded. A face keys off this, never off the prose.
#[qproperty(QString, operation_refusal)]
// The resolved closure, decided before anything elevates.
#[qproperty(QString, closure_hash)]
#[qproperty(i32, closure_count)]
#[qproperty(QString, closure_download)]
#[qproperty(i32, closure_official)]
#[qproperty(i32, closure_edge)]
#[qproperty(i32, closure_unverified)]
#[qproperty(bool, mirror_rating_available)]
#[qproperty(i32, total_packages)]
#[qproperty(i32, installed_count)]
@ -254,6 +266,13 @@ pub struct PackageManagerRust {
operation_running: bool,
operation_label: QString,
operation_output: QString,
operation_refusal: QString,
closure_hash: QString,
closure_count: i32,
closure_download: QString,
closure_official: i32,
closure_edge: i32,
closure_unverified: i32,
mirror_rating_available: bool,
total_packages: i32,
installed_count: i32,
@ -315,6 +334,13 @@ impl Default for PackageManagerRust {
operation_running: false,
operation_label: QString::default(),
operation_output: QString::default(),
operation_refusal: QString::default(),
closure_hash: QString::default(),
closure_count: 0,
closure_download: QString::default(),
closure_official: 0,
closure_edge: 0,
closure_unverified: 0,
mirror_rating_available: false,
total_packages: 0,
installed_count: 0,
@ -495,7 +521,7 @@ impl ffi::PackageManager {
}
pub fn install_package(self: Pin<&mut Self>, name: &QString) {
self.start(Operation::Install(vec![name.to_string()]));
self.resolve_then_run(vec![name.to_string()], false);
}
pub fn remove_package(self: Pin<&mut Self>, name: &QString) {
@ -503,7 +529,7 @@ impl ffi::PackageManager {
}
pub fn full_upgrade(self: Pin<&mut Self>) {
self.start(Operation::FullUpgrade);
self.resolve_then_run(Vec::new(), true);
}
pub fn sync_databases(self: Pin<&mut Self>) {
@ -666,6 +692,40 @@ impl ffi::PackageManager {
// ─── Operations ──────────────────────────────────────────────────────────────
impl ffi::PackageManager {
/// Resolve what a download would change, then run it.
///
/// Resolution is ambient — it reads the sync databases and asks pacman what
/// it would fetch. Nothing elevates until that set exists and is on screen,
/// which is the whole difference between authorising `pacman -Syu` and
/// authorising a named set of artifacts.
fn resolve_then_run(mut self: Pin<&mut Self>, requested: Vec<String>, full_upgrade: bool) {
if self.operation_running {
return;
}
self.as_mut().set_operation_running(true);
self.as_mut()
.set_operation_label(QString::from("Resolving what this would change"));
self.as_mut().clear_output();
self.as_mut().clear_closure();
let thread = self.qt_thread();
std::thread::spawn(move || {
let resolved = updater_core::pacman::resolve(&requested, full_upgrade);
let _ = thread.queue(move |mut qobject| match resolved {
Ok(closure) => {
qobject.as_mut().apply_closure(&closure);
qobject.launch(if full_upgrade {
Operation::FullUpgrade(closure)
} else {
Operation::Install(closure)
});
}
Err(error) => qobject
.finish_operation(Err(Refusal::Unresolvable { detail: error.to_string() })),
});
});
}
/// Run a privileged operation on a worker thread, streaming its output
/// into the log as it arrives.
fn start(mut self: Pin<&mut Self>, op: Operation) {
@ -673,9 +733,16 @@ impl ffi::PackageManager {
return;
}
self.as_mut().set_operation_running(true);
self.as_mut().clear_output();
self.as_mut().clear_closure();
self.launch(op);
}
/// The privileged half. The caller has already claimed `operation_running`.
fn launch(mut self: Pin<&mut Self>, op: Operation) {
self.as_mut()
.set_operation_label(QString::from(op.label().as_str()));
self.as_mut().clear_output();
self.as_mut().set_operation_refusal(QString::default());
let thread = self.qt_thread();
std::thread::spawn(move || {
@ -684,14 +751,33 @@ impl ffi::PackageManager {
let line = line.to_string();
let _ = sink.queue(move |qobject| qobject.append_output(&line));
});
let refresh_after = op.mutates_packages();
let _ = thread.queue(move |qobject| match outcome {
Ok(success) => qobject.finish_operation(success, String::new(), refresh_after),
Err(error) => qobject.finish_operation(false, error.to_string(), false),
});
let _ = thread.queue(move |qobject| qobject.finish_operation(outcome));
});
}
fn apply_closure(mut self: Pin<&mut Self>, closure: &Closure) {
self.as_mut()
.set_closure_hash(QString::from(closure.short_hash().as_str()));
self.as_mut().set_closure_count(closure.artifacts.len() as i32);
self.as_mut().set_closure_download(QString::from(
updater_core::pacman::human_bytes(closure.download_size()).as_str(),
));
self.as_mut()
.set_closure_official(closure.count(Source::Official) as i32);
self.as_mut()
.set_closure_edge(closure.count(Source::SouveraineEdge) as i32);
self.set_closure_unverified(closure.unverifiable().len() as i32);
}
fn clear_closure(mut self: Pin<&mut Self>) {
self.as_mut().set_closure_hash(QString::default());
self.as_mut().set_closure_count(0);
self.as_mut().set_closure_download(QString::default());
self.as_mut().set_closure_official(0);
self.as_mut().set_closure_edge(0);
self.set_closure_unverified(0);
}
fn append_output(mut self: Pin<&mut Self>, line: &str) {
{
let mut rust = self.as_mut().rust_mut();
@ -710,11 +796,23 @@ impl ffi::PackageManager {
self.set_operation_output(QString::default());
}
fn finish_operation(mut self: Pin<&mut Self>, success: bool, message: String, refresh: bool) {
/// Land an outcome. Success is the only unit value; everything else names
/// itself, and the code goes to the face while the prose goes to the log.
fn finish_operation(mut self: Pin<&mut Self>, outcome: Result<(), Refusal>) {
// A refusal raised before pkexec ran changed nothing, so the catalog is
// still true. Only a pacman that actually ran can have moved it.
let ran = matches!(&outcome, Ok(()) | Err(Refusal::Failed { .. }));
let (success, code, message) = match &outcome {
Ok(()) => (true, String::new(), String::new()),
Err(refusal) => (false, refusal.code().to_string(), refusal.message()),
};
if !message.is_empty() {
self.as_mut().append_output(&message);
}
self.as_mut().set_operation_running(false);
self.as_mut()
.set_operation_refusal(QString::from(code.as_str()));
self.as_mut().set_status_text(QString::from(if success {
"Done"
} else {
@ -722,7 +820,7 @@ impl ffi::PackageManager {
}));
self.as_mut()
.operation_finished(success, QString::from(message.as_str()));
if success && refresh {
if ran {
// The databases moved, so the cache stamp no longer matches; a
// plain refresh already re-reads, but be explicit about it.
updater_core::cache::invalidate();