use crate::error::{FezError, Result};
use crate::protocol::client::BridgeClient;
use serde_json::{json, Value};
const PK_NAME: &str = "org.freedesktop.PackageKit";
const PK_PATH: &str = "/org/freedesktop/PackageKit";
const PK_IFACE: &str = "org.freedesktop.PackageKit";
const TX_IFACE: &str = "org.freedesktop.PackageKit.Transaction";
const TF_NONE: u64 = 0;
const TF_SIMULATE: u64 = 2;
const FILTER_INSTALLED: u64 = 1 << 2;
const FILTER_NEWEST: u64 = 1 << 16;
const FILTER_NONE: u64 = 0;
const INFO_INSTALLING: u64 = 8;
const INFO_REMOVING: u64 = 9;
const INFO_UPDATING: u64 = 7;
const INFO_OBSOLETING: u64 = 11;
const INFO_DOWNGRADING: u64 = 13;
const PK_ERROR_NOT_AUTHORIZED: u64 = 6;
pub struct PkView {
pub kind: &'static str,
pub data: Value,
pub human: String,
pub hints: Option<Value>,
}
struct PkPackage {
info: u64,
name: String,
version: String,
arch: String,
data: String,
summary: String,
}
impl PkPackage {
fn from_signal(args: &[Value]) -> Option<Self> {
let info = args.first()?.as_u64()?;
let pid = args.get(1)?.as_str()?;
let summary = args
.get(2)
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let mut parts = pid.splitn(4, ';');
let name = parts.next()?.to_string();
let version = parts.next().unwrap_or("").to_string();
let arch = parts.next().unwrap_or("").to_string();
let data = parts.next().unwrap_or("").to_string();
Some(PkPackage {
info,
name,
version,
arch,
data,
summary,
})
}
fn label(&self) -> String {
format!("{}-{}.{}", self.name, self.version, self.arch)
}
fn package_id(&self) -> String {
format!("{};{};{};{}", self.name, self.version, self.arch, self.data)
}
fn repo(&self) -> &str {
&self.data
}
}
fn packages_from(signals: &[(String, Vec<Value>)]) -> Vec<PkPackage> {
signals
.iter()
.filter(|(member, _)| member == "Package")
.filter_map(|(_, args)| PkPackage::from_signal(args))
.collect()
}
fn check_stream(signals: &[(String, Vec<Value>)]) -> Result<()> {
if let Some((_, args)) = signals.iter().find(|(m, _)| m == "ErrorCode") {
let code = args.first().and_then(Value::as_u64).unwrap_or(0);
let details = args
.get(1)
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if code == PK_ERROR_NOT_AUTHORIZED {
return Err(FezError::AccessDenied {
remediation: format!(
"PackageKit denied the operation ({details}). Ensure privilege escalation is available (passwordless sudo or a polkit rule) and retry."
),
});
}
return Err(FezError::Dbus {
name: "org.freedesktop.PackageKit.Error".into(),
message: details,
});
}
Ok(())
}
const PK_COLUMNS: &[&str] = &["name", "evr", "arch", "repo_id", "install_size", "summary"];
const PK_REPO_COLUMNS: &[&str] = &["id", "name", "enabled"];
fn open_pk(client: &mut BridgeClient, privileged: bool) -> Result<String> {
if privileged {
client.dbus_open_privileged(PK_NAME)
} else {
client.dbus_open(PK_NAME)
}
}
fn new_tx(client: &mut BridgeClient, channel: &str) -> Result<String> {
let out = client.dbus_call(channel, PK_PATH, PK_IFACE, "CreateTransaction", json!([]))?;
Ok(out
.as_array()
.and_then(|a| a.first())
.or(Some(&out))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
fn pk_hints() -> Option<Value> {
Some(json!({
"backend": "packagekit",
"note": "Running via the PackageKit fallback backend; install/download sizes are unavailable on this backend.",
}))
}
fn row(p: &PkPackage) -> Value {
json!([p.name, p.version, p.arch, p.repo(), Value::Null, p.summary,])
}
fn human_table(pkgs: &[&PkPackage]) -> String {
let mut s = format!(
"{:<24} {:<20} {:<10} {}\n",
"NAME", "VERSION", "ARCH", "REPO"
);
for p in pkgs {
s.push_str(&format!(
"{:<24} {:<20} {:<10} {}\n",
p.name,
p.version,
p.arch,
p.repo()
));
}
s
}
pub fn list(
client: &mut BridgeClient,
available: bool,
repos: &[String],
name: Option<&str>,
limit: Option<usize>,
offset: usize,
) -> Result<PkView> {
let channel = open_pk(client, false)?;
let tx = new_tx(client, &channel)?;
let filter = if available {
FILTER_NEWEST
} else {
FILTER_INSTALLED
};
let signals =
client.dbus_call_collect(&channel, &tx, TX_IFACE, "GetPackages", json!([filter]))?;
check_stream(&signals)?;
let pkgs = packages_from(&signals);
let filtered: Vec<&PkPackage> = pkgs
.iter()
.filter(|p| repos.is_empty() || repos.iter().any(|r| r == p.repo()))
.filter(|p| name.is_none_or(|pattern| p.name.contains(pattern)))
.collect();
let total = filtered.len();
let start = offset.min(total);
let end = match limit {
Some(limit) => (start + limit).min(total),
None => total,
};
let page = &filtered[start..end];
let rows: Vec<Value> = page.iter().map(|p| row(p)).collect();
let mut data = crate::envelope::table_data(PK_COLUMNS, rows);
data["scope"] = json!(if available { "available" } else { "installed" });
data["repos"] = json!(repos);
data["name"] = json!(name);
data["total"] = json!(total);
data["returned"] = json!(end - start);
data["limit"] = json!(limit);
data["offset"] = json!(offset);
data["next_offset"] = json!((end < total).then_some(end));
data["backend"] = json!("packagekit");
let human = human_table(page);
let hints = if limit.is_none() && total > 1000 {
Some(json!([
pk_hints().expect("PackageKit hint is always present"),
format!(
"This response has {total} rows. Prefer packages search <pattern>, use --name, or use --limit."
)
]))
} else {
pk_hints()
};
Ok(PkView {
kind: "PackageList",
data,
human,
hints,
})
}
pub fn info(client: &mut BridgeClient, spec: &str) -> Result<PkView> {
let channel = open_pk(client, false)?;
let tx = new_tx(client, &channel)?;
let signals = client.dbus_call_collect(
&channel,
&tx,
TX_IFACE,
"Resolve",
json!([FILTER_NEWEST, [spec]]),
)?;
check_stream(&signals)?;
let pkgs = packages_from(&signals);
let p = pkgs
.first()
.ok_or_else(|| FezError::NotFound(spec.to_string()))?;
let data = json!({
"name": p.name,
"evr": p.version,
"arch": p.arch,
"repo_id": p.repo(),
"install_size": Value::Null,
"summary": p.summary,
"backend": "packagekit",
});
let human = format!(
"Name : {}\nVersion : {}\nArch : {}\nRepo : {}\nInstall size: (unavailable)\nSummary : {}\n",
p.name,
p.version,
p.arch,
p.repo(),
p.summary,
);
Ok(PkView {
kind: "PackageInfo",
data,
human,
hints: pk_hints(),
})
}
pub fn search(client: &mut BridgeClient, pattern: &str) -> Result<PkView> {
let channel = open_pk(client, false)?;
let tx = new_tx(client, &channel)?;
let signals = client.dbus_call_collect(
&channel,
&tx,
TX_IFACE,
"SearchNames",
json!([FILTER_NEWEST, [pattern]]),
)?;
check_stream(&signals)?;
let pkgs = packages_from(&signals);
let refs: Vec<&PkPackage> = pkgs.iter().collect();
let rows: Vec<Value> = refs.iter().map(|p| row(p)).collect();
let mut data = crate::envelope::table_data(PK_COLUMNS, rows);
data["pattern"] = json!(pattern);
data["backend"] = json!("packagekit");
let mut human = String::new();
for p in &refs {
human.push_str(&format!("{} - {}\n", p.name, p.summary));
}
Ok(PkView {
kind: "PackageSearch",
data,
human,
hints: pk_hints(),
})
}
pub fn check_update(client: &mut BridgeClient) -> Result<PkView> {
let channel = open_pk(client, false)?;
let tx = new_tx(client, &channel)?;
let signals =
client.dbus_call_collect(&channel, &tx, TX_IFACE, "GetUpdates", json!([FILTER_NONE]))?;
check_stream(&signals)?;
let pkgs = packages_from(&signals);
let refs: Vec<&PkPackage> = pkgs.iter().collect();
let rows: Vec<Value> = refs.iter().map(|p| row(p)).collect();
let mut data = crate::envelope::table_data(PK_COLUMNS, rows);
data["backend"] = json!("packagekit");
let mut human = format!("{:<24} {:<20} {}\n", "NAME", "VERSION", "REPO");
for p in &refs {
human.push_str(&format!("{:<24} {:<20} {}\n", p.name, p.version, p.repo()));
}
Ok(PkView {
kind: "PackageUpdates",
data,
human,
hints: pk_hints(),
})
}
pub fn repolist(client: &mut BridgeClient, accepts: impl Fn(bool) -> bool) -> Result<PkView> {
let channel = open_pk(client, false)?;
let tx = new_tx(client, &channel)?;
let signals =
client.dbus_call_collect(&channel, &tx, TX_IFACE, "GetRepoList", json!([FILTER_NONE]))?;
check_stream(&signals)?;
let mut rows = Vec::new();
let mut human = format!("{:<24} {:<10} {}\n", "REPO ID", "ENABLED", "NAME");
for (member, args) in &signals {
if member != "RepoDetail" {
continue;
}
let id = args.first().and_then(Value::as_str).unwrap_or("");
let name = args.get(1).and_then(Value::as_str).unwrap_or("");
let enabled = args.get(2).and_then(Value::as_bool).unwrap_or(false);
if !accepts(enabled) {
continue;
}
human.push_str(&format!("{id:<24} {enabled:<10} {name}\n"));
rows.push(json!([id, name, enabled]));
}
let mut data = crate::envelope::table_data(PK_REPO_COLUMNS, rows);
data["backend"] = json!("packagekit");
Ok(PkView {
kind: "RepoList",
data,
human,
hints: pk_hints(),
})
}
struct PkPlan {
install: Vec<String>,
remove: Vec<String>,
upgrade: Vec<String>,
downgrade: Vec<String>,
remove_names: Vec<String>,
}
fn plan_from(signals: &[(String, Vec<Value>)]) -> PkPlan {
let mut plan = PkPlan {
install: vec![],
remove: vec![],
upgrade: vec![],
downgrade: vec![],
remove_names: vec![],
};
for p in packages_from(signals) {
match p.info {
INFO_INSTALLING => plan.install.push(p.label()),
INFO_UPDATING => plan.upgrade.push(p.label()),
INFO_DOWNGRADING => plan.downgrade.push(p.label()),
INFO_REMOVING | INFO_OBSOLETING => {
plan.remove_names.push(p.name.clone());
plan.remove.push(p.label());
}
_ => {}
}
}
plan
}
fn method_for(verb: &str, flags: u64, ids: &[String]) -> (&'static str, Value) {
match verb {
"install" => ("InstallPackages", json!([flags, ids])),
"upgrade" => ("UpdatePackages", json!([flags, ids])),
"remove" => ("RemovePackages", json!([flags, ids, true, false])),
other => unreachable!("unknown mutation verb {other}"),
}
}
fn resolve_ids(client: &mut BridgeClient, channel: &str, specs: &[String]) -> Result<Vec<String>> {
let tx = new_tx(client, channel)?;
let signals = client.dbus_call_collect(
channel,
&tx,
TX_IFACE,
"Resolve",
json!([FILTER_NEWEST, specs]),
)?;
check_stream(&signals)?;
Ok(packages_from(&signals)
.iter()
.map(PkPackage::package_id)
.collect())
}
pub fn mutate(
client: &mut BridgeClient,
verb: &str,
specs: &[String],
host: &str,
dry_run: bool,
force: bool,
) -> Result<PkView> {
let channel = open_pk(client, true)?;
let ids = resolve_ids(client, &channel, specs)?;
if ids.is_empty() {
return Err(FezError::NotFound(specs.join(", ")));
}
let sim_tx = new_tx(client, &channel)?;
let (m, args) = method_for(verb, TF_SIMULATE, &ids);
let sim = client.dbus_call_collect(&channel, &sim_tx, TX_IFACE, m, args)?;
check_stream(&sim)?;
let plan = plan_from(&sim);
if dry_run {
return Ok(plan_view(verb, host, specs, &plan, true));
}
crate::safety::check_removal_plan(&plan.remove_names, force)?;
let sink = crate::audit::sink_from_env();
let audit = crate::audit::AuditContext::new(
&crate::audit::actor(),
host,
verb,
&specs.join(","),
&crate::audit::correlation_id(),
);
sink.write(&audit.record(crate::audit::Outcome::Attempt));
let (m, args) = method_for(verb, TF_NONE, &ids);
let exec_tx = new_tx(client, &channel)?;
let exec = client
.dbus_call_collect(&channel, &exec_tx, TX_IFACE, m, args)
.and_then(|s| check_stream(&s));
match &exec {
Ok(()) => sink.write(&audit.record(crate::audit::Outcome::Ok)),
Err(e) => sink.write(&audit.record(crate::audit::Outcome::Error(e.to_string()))),
}
exec?;
Ok(plan_view(verb, host, specs, &plan, false))
}
fn plan_view(verb: &str, host: &str, specs: &[String], plan: &PkPlan, dry_run: bool) -> PkView {
let kind = if dry_run {
"PackagePlan"
} else {
"PackageMutation"
};
let data = json!({
"operation": verb,
"specs": specs,
"dry_run": dry_run,
"backend": "packagekit",
"install": plan.install,
"remove": plan.remove,
"upgrade": plan.upgrade,
"downgrade": plan.downgrade,
"install_size_total": Value::Null,
"counts": {
"install": plan.install.len(),
"remove": plan.remove.len(),
"upgrade": plan.upgrade.len(),
"downgrade": plan.downgrade.len(),
},
});
let human = if dry_run {
format!(
"DRY-RUN: {} {} on {} would install {}, remove {}, upgrade {}, downgrade {} package(s)\n",
verb,
specs.join(" "),
host,
plan.install.len(),
plan.remove.len(),
plan.upgrade.len(),
plan.downgrade.len(),
)
} else {
format!(
"{} {} on {}: installed {}, removed {}, upgraded {}, downgraded {} package(s)\n",
verb,
specs.join(" "),
host,
plan.install.len(),
plan.remove.len(),
plan.upgrade.len(),
plan.downgrade.len(),
)
};
PkView {
kind,
data,
human,
hints: pk_hints(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_package_signal() {
let args = vec![
json!(8),
json!("htop;3.4.1-3.fc44;x86_64;fedora"),
json!("Interactive process viewer"),
];
let p = PkPackage::from_signal(&args).unwrap();
assert_eq!(p.info, 8);
assert_eq!(p.name, "htop");
assert_eq!(p.version, "3.4.1-3.fc44");
assert_eq!(p.arch, "x86_64");
assert_eq!(p.repo(), "fedora");
assert_eq!(p.label(), "htop-3.4.1-3.fc44.x86_64");
assert_eq!(p.package_id(), "htop;3.4.1-3.fc44;x86_64;fedora");
}
#[test]
fn malformed_package_signal_is_none() {
assert!(PkPackage::from_signal(&[]).is_none());
assert!(PkPackage::from_signal(&[json!(8)]).is_none());
}
#[test]
fn error_code_six_is_access_denied() {
let signals = vec![
(
"ErrorCode".to_string(),
vec![json!(6), json!("not authorized")],
),
("Finished".to_string(), vec![json!(4), json!(10)]),
];
let err = check_stream(&signals).unwrap_err();
assert!(matches!(err, FezError::AccessDenied { .. }));
}
#[test]
fn other_error_code_is_dbus() {
let signals = vec![(
"ErrorCode".to_string(),
vec![json!(4), json!("package not found")],
)];
let err = check_stream(&signals).unwrap_err();
assert!(matches!(err, FezError::Dbus { .. }));
}
#[test]
fn clean_stream_is_ok() {
let signals = vec![
(
"Package".to_string(),
vec![json!(8), json!("htop;1;x86_64;fedora"), json!("")],
),
("Finished".to_string(), vec![json!(1), json!(20)]),
];
assert!(check_stream(&signals).is_ok());
}
#[test]
fn plan_buckets_by_info_enum() {
let signals = vec![
(
"Package".to_string(),
vec![json!(8), json!("nginx;1;x86_64;fedora"), json!("")],
),
(
"Package".to_string(),
vec![json!(9), json!("htop;1;x86_64;installed"), json!("")],
),
(
"Package".to_string(),
vec![json!(7), json!("bash;2;x86_64;updates"), json!("")],
),
("Finished".to_string(), vec![json!(1), json!(20)]),
];
let plan = plan_from(&signals);
assert_eq!(plan.install, vec!["nginx-1.x86_64"]);
assert_eq!(plan.remove, vec!["htop-1.x86_64"]);
assert_eq!(plan.remove_names, vec!["htop"]);
assert_eq!(plan.upgrade, vec!["bash-2.x86_64"]);
assert!(plan.downgrade.is_empty());
}
#[test]
fn method_for_maps_verbs() {
let ids = vec!["nginx;1;x86_64;fedora".to_string()];
assert_eq!(method_for("install", TF_NONE, &ids).0, "InstallPackages");
assert_eq!(method_for("upgrade", TF_NONE, &ids).0, "UpdatePackages");
let (m, args) = method_for("remove", TF_SIMULATE, &ids);
assert_eq!(m, "RemovePackages");
assert_eq!(args.as_array().unwrap().len(), 4);
}
#[test]
fn dry_run_plan_view_has_null_size_and_backend() {
let plan = PkPlan {
install: vec!["nginx-1.x86_64".into()],
remove: vec![],
upgrade: vec![],
downgrade: vec![],
remove_names: vec![],
};
let view = plan_view("install", "host", &["nginx".to_string()], &plan, true);
assert_eq!(view.kind, "PackagePlan");
assert_eq!(view.data["backend"], json!("packagekit"));
assert_eq!(view.data["install_size_total"], Value::Null);
assert_eq!(view.data["dry_run"], json!(true));
assert_eq!(view.data["counts"]["install"], json!(1));
}
}