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:
parent
1231cbb827
commit
ccc79fefed
9 changed files with 889 additions and 37 deletions
111
docs/tasks/CLOSURE-001-closure-backed-operations.md
Normal file
111
docs/tasks/CLOSURE-001-closure-backed-operations.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
218
updater-core/src/closure.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
119
updater-core/src/refusal.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue