use crate::capabilities::{render, View};
use crate::cli::{Cli, PackagesAction};
use crate::error::{is_service_unknown, FezError, Result};
use crate::protocol::client::BridgeClient;
use serde_json::{json, Value};
const DNF_NAME: &str = "org.rpm.dnf.v0";
const SM_PATH: &str = "/org/rpm/dnf/v0";
const SM_IFACE: &str = "org.rpm.dnf.v0.SessionManager";
const RPM_IFACE: &str = "org.rpm.dnf.v0.rpm.Rpm";
const REPO_IFACE: &str = "org.rpm.dnf.v0.rpm.Repo";
const GOAL_IFACE: &str = "org.rpm.dnf.v0.Goal";
const PKG_ATTRS: &[&str] = &["name", "evr", "arch", "repo_id", "install_size", "summary"];
#[derive(Clone, Copy)]
enum Mutation {
Install,
Remove,
Upgrade,
}
impl Mutation {
fn verb(self) -> &'static str {
match self {
Mutation::Install => "install",
Mutation::Remove => "remove",
Mutation::Upgrade => "upgrade",
}
}
fn method(self) -> &'static str {
match self {
Mutation::Install => "install",
Mutation::Remove => "remove",
Mutation::Upgrade => "upgrade",
}
}
}
enum ReadAction<'a> {
List(ListFilters<'a>),
Info { spec: &'a str },
Search { pattern: &'a str },
CheckUpdate,
Repolist { filter: RepoFilter },
}
#[derive(Clone, Copy)]
struct ListFilters<'a> {
available: bool,
repos: &'a [String],
name: Option<&'a str>,
limit: Option<usize>,
offset: usize,
}
#[derive(Clone, Copy)]
enum RepoFilter {
Enabled,
Disabled,
All,
}
impl RepoFilter {
fn enable_disable(self) -> &'static str {
match self {
RepoFilter::Enabled => "enabled",
RepoFilter::Disabled => "disabled",
RepoFilter::All => "all",
}
}
fn accepts(self, enabled: bool) -> bool {
match self {
RepoFilter::Enabled => enabled,
RepoFilter::Disabled => !enabled,
RepoFilter::All => true,
}
}
}
enum Plan<'a> {
Read(ReadAction<'a>),
Mutate {
mutation: Mutation,
specs: Vec<String>,
},
}
fn classify(action: &PackagesAction) -> Plan<'_> {
match action {
PackagesAction::List {
installed: _installed,
available,
repo,
name,
limit,
offset,
} => Plan::Read(ReadAction::List(ListFilters {
available: *available,
repos: repo,
name: name.as_deref(),
limit: *limit,
offset: *offset,
})),
PackagesAction::Info { spec } => Plan::Read(ReadAction::Info { spec }),
PackagesAction::Search { pattern } => Plan::Read(ReadAction::Search { pattern }),
PackagesAction::CheckUpdate => Plan::Read(ReadAction::CheckUpdate),
PackagesAction::Repolist {
enabled: _enabled,
disabled,
all,
} => {
let filter = if *all {
RepoFilter::All
} else if *disabled {
RepoFilter::Disabled
} else {
RepoFilter::Enabled
};
Plan::Read(ReadAction::Repolist { filter })
}
PackagesAction::Install { specs } => Plan::Mutate {
mutation: Mutation::Install,
specs: specs.clone(),
},
PackagesAction::Remove { specs } => Plan::Mutate {
mutation: Mutation::Remove,
specs: specs.clone(),
},
PackagesAction::Upgrade { specs } => Plan::Mutate {
mutation: Mutation::Upgrade,
specs: specs.clone(),
},
}
}
pub fn dispatch(cli: &Cli, action: &PackagesAction) -> i32 {
let view = match classify(action) {
Plan::Read(read) => run_read(cli, read),
Plan::Mutate { mutation, specs } => run_mutation(cli, mutation, &specs),
};
render(cli, view)
}
fn dependency_missing() -> FezError {
FezError::DependencyMissing {
component: "dnf5daemon".into(),
dbus_name: DNF_NAME.into(),
remediation: "Install the dnf5daemon server on the target (dnf install dnf5daemon-server) and ensure its D-Bus service org.rpm.dnf.v0 is activatable, then retry.".into(),
}
}
fn variant(signature: &str, value: Value) -> Value {
json!({ "t": signature, "v": value })
}
fn options(entries: &[(&str, &str, Value)]) -> Value {
let map: serde_json::Map<String, Value> = entries
.iter()
.map(|(k, sig, v)| ((*k).to_string(), variant(sig, v.clone())))
.collect();
Value::Object(map)
}
fn open_session(client: &mut BridgeClient, privileged: bool) -> Result<(String, String)> {
let channel = if privileged {
client.dbus_open_privileged(DNF_NAME)?
} else {
client.dbus_open(DNF_NAME)?
};
let out = client.dbus_call(
&channel,
SM_PATH,
SM_IFACE,
"open_session",
json!([options(&[
("load_system_repo", "b", json!(true)),
("load_available_repos", "b", json!(true)),
])]),
);
let out = match out {
Ok(v) => v,
Err(FezError::Dbus { name, .. }) if is_service_unknown(&name) => {
return Err(dependency_missing())
}
Err(e) => return Err(e),
};
let session = out.get(0).and_then(Value::as_str).unwrap_or("").to_string();
Ok((channel, session))
}
fn close_session(client: &mut BridgeClient, channel: &str, session: &str) {
let _ = client.dbus_call(
channel,
SM_PATH,
SM_IFACE,
"close_session",
json!([session]),
);
}
fn sv(v: &Value, key: &str) -> String {
v.get(key)
.and_then(|f| f.get("v").unwrap_or(f).as_str())
.unwrap_or("")
.to_string()
}
fn sv_u64(v: &Value, key: &str) -> u64 {
let field = v.get(key).map(|f| f.get("v").unwrap_or(f));
match field {
Some(f) if f.is_u64() => f.as_u64().unwrap_or(0),
Some(f) if f.is_i64() => u64::try_from(f.as_i64().unwrap_or(0)).unwrap_or(0),
Some(f) => f.as_str().and_then(|s| s.parse().ok()).unwrap_or(0),
None => 0,
}
}
fn sv_bool(v: &Value, key: &str) -> bool {
v.get(key)
.and_then(|f| f.get("v").unwrap_or(f).as_bool())
.unwrap_or(false)
}
fn package_json(p: &Value) -> Value {
json!({
"name": sv(p, "name"),
"evr": sv(p, "evr"),
"arch": sv(p, "arch"),
"repo_id": sv(p, "repo_id"),
"install_size": sv_u64(p, "install_size"),
"summary": sv(p, "summary"),
})
}
const PKG_COLUMNS: &[&str] = &["name", "evr", "arch", "repo_id", "install_size", "summary"];
fn package_row(p: &Value) -> Value {
json!([
sv(p, "name"),
sv(p, "evr"),
sv(p, "arch"),
sv(p, "repo_id"),
sv_u64(p, "install_size"),
sv(p, "summary"),
])
}
fn run_read(cli: &Cli, action: ReadAction<'_>) -> Result<View> {
let transport = crate::transport::from_host(cli.host.as_deref());
let mut client = BridgeClient::connect(transport.as_ref())?;
let host = client.host().to_string();
let (channel, session) = match open_session(&mut client, false) {
Ok(pair) => pair,
Err(FezError::DependencyMissing { .. }) => {
return read_via_packagekit(&mut client, host, action);
}
Err(e) => return Err(e),
};
let result = match action {
ReadAction::List(filters) => list(&mut client, &channel, &session, host, filters),
ReadAction::Info { spec } => info(&mut client, &channel, &session, host, spec),
ReadAction::Search { pattern } => search(&mut client, &channel, &session, host, pattern),
ReadAction::CheckUpdate => check_update(&mut client, &channel, &session, host),
ReadAction::Repolist { filter } => repolist(&mut client, &channel, &session, host, filter),
};
close_session(&mut client, &channel, &session);
result
}
fn from_pk(pk: crate::capabilities::packages_pk::PkView, host: String) -> View {
View::new(pk.kind, host, pk.data, pk.human).with_hints_opt(pk.hints)
}
fn both_missing() -> FezError {
FezError::DependencyMissing {
component: "dnf5daemon or PackageKit".into(),
dbus_name: "org.rpm.dnf.v0 / org.freedesktop.PackageKit".into(),
remediation: "Install a package backend: dnf5daemon-server (Fedora) providing org.rpm.dnf.v0, or PackageKit providing org.freedesktop.PackageKit, then retry.".into(),
}
}
fn read_via_packagekit(
client: &mut BridgeClient,
host: String,
action: ReadAction<'_>,
) -> Result<View> {
use crate::capabilities::packages_pk as pk;
let result = match action {
ReadAction::List(filters) => pk::list(
client,
filters.available,
filters.repos,
filters.name,
filters.limit,
filters.offset,
),
ReadAction::Info { spec } => pk::info(client, spec),
ReadAction::Search { pattern } => pk::search(client, pattern),
ReadAction::CheckUpdate => pk::check_update(client),
ReadAction::Repolist { filter } => {
pk::repolist(client, move |enabled| filter.accepts(enabled))
}
};
match result {
Ok(view) => Ok(from_pk(view, host)),
Err(FezError::Dbus { name, .. }) if is_service_unknown(&name) => Err(both_missing()),
Err(e) => Err(e),
}
}
fn rpm_list(
client: &mut BridgeClient,
channel: &str,
session: &str,
scope: &str,
patterns: &[String],
) -> Result<Vec<Value>> {
let out = client.dbus_call(
channel,
session,
RPM_IFACE,
"list",
json!([options(&[
("scope", "s", json!(scope)),
("patterns", "as", json!(patterns)),
("package_attrs", "as", json!(PKG_ATTRS)),
])]),
)?;
Ok(out
.get(0)
.and_then(Value::as_array)
.cloned()
.unwrap_or_default())
}
fn list(
client: &mut BridgeClient,
channel: &str,
session: &str,
host: String,
filters: ListFilters<'_>,
) -> Result<View> {
let scope = if filters.available {
"available"
} else {
"installed"
};
let raw = rpm_list(client, channel, session, scope, &[])?;
let filtered: Vec<&Value> = raw
.iter()
.filter(|p| {
filters.repos.is_empty() || filters.repos.iter().any(|r| r == &sv(p, "repo_id"))
})
.filter(|p| {
filters
.name
.is_none_or(|pattern| sv(p, "name").contains(pattern))
})
.collect();
let total = filtered.len();
let start = filters.offset.min(total);
let end = match filters.limit {
Some(limit) => (start + limit).min(total),
None => total,
};
let page = &filtered[start..end];
let mut human = format!(
"{:<24} {:<20} {:<10} {}\n",
"NAME", "VERSION", "ARCH", "REPO"
);
for p in page {
human.push_str(&format!(
"{:<24} {:<20} {:<10} {}\n",
sv(p, "name"),
sv(p, "evr"),
sv(p, "arch"),
sv(p, "repo_id"),
));
}
let rows: Vec<Value> = page.iter().map(|p| package_row(p)).collect();
let mut data = crate::envelope::table_data(PKG_COLUMNS, rows);
data["scope"] = json!(scope);
data["repos"] = json!(filters.repos);
data["name"] = json!(filters.name);
data["total"] = json!(total);
data["returned"] = json!(end - start);
data["limit"] = json!(filters.limit);
data["offset"] = json!(filters.offset);
data["next_offset"] = json!((end < total).then_some(end));
data["backend"] = json!("dnf5daemon");
let hints = if filters.limit.is_none() && total > 1000 {
Some(json!([format!(
"This response has {total} rows. Prefer packages search <pattern>, use --name, or use --limit."
)]))
} else {
None
};
Ok(View::new("PackageList", host, data, human).with_hints_opt(hints))
}
fn info(
client: &mut BridgeClient,
channel: &str,
session: &str,
host: String,
spec: &str,
) -> Result<View> {
let raw = rpm_list(client, channel, session, "all", &[spec.to_string()])?;
let first = raw
.first()
.ok_or_else(|| FezError::NotFound(spec.to_string()))?;
let mut pkg = package_json(first);
pkg["backend"] = json!("dnf5daemon");
let human = format!(
"Name : {}\nVersion : {}\nArch : {}\nRepo : {}\nInstall size: {}\nSummary : {}\n",
sv(first, "name"),
sv(first, "evr"),
sv(first, "arch"),
sv(first, "repo_id"),
sv_u64(first, "install_size"),
sv(first, "summary"),
);
Ok(View::new("PackageInfo", host, pkg, human))
}
fn search(
client: &mut BridgeClient,
channel: &str,
session: &str,
host: String,
pattern: &str,
) -> Result<View> {
let glob = format!("*{pattern}*");
let raw = rpm_list(client, channel, session, "available", &[glob])?;
let mut human = String::new();
for p in &raw {
human.push_str(&format!("{} - {}\n", sv(p, "name"), sv(p, "summary")));
}
let rows: Vec<Value> = raw.iter().map(package_row).collect();
let mut data = crate::envelope::table_data(PKG_COLUMNS, rows);
data["pattern"] = json!(pattern);
data["backend"] = json!("dnf5daemon");
Ok(View::new("PackageSearch", host, data, human))
}
fn check_update(
client: &mut BridgeClient,
channel: &str,
session: &str,
host: String,
) -> Result<View> {
let raw = rpm_list(client, channel, session, "upgrades", &[])?;
let mut human = format!("{:<24} {:<20} {}\n", "NAME", "VERSION", "REPO");
for p in &raw {
human.push_str(&format!(
"{:<24} {:<20} {}\n",
sv(p, "name"),
sv(p, "evr"),
sv(p, "repo_id"),
));
}
let rows: Vec<Value> = raw.iter().map(package_row).collect();
let mut data = crate::envelope::table_data(PKG_COLUMNS, rows);
data["backend"] = json!("dnf5daemon");
Ok(View::new("PackageUpdates", host, data, human))
}
const REPO_COLUMNS: &[&str] = &["id", "name", "enabled"];
fn repolist(
client: &mut BridgeClient,
channel: &str,
session: &str,
host: String,
filter: RepoFilter,
) -> Result<View> {
let out = client.dbus_call(
channel,
session,
REPO_IFACE,
"list",
json!([options(&[
("enable_disable", "s", json!(filter.enable_disable())),
("repo_attrs", "as", json!(["id", "name", "enabled"])),
])]),
)?;
let raw = out
.get(0)
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut rows = Vec::new();
let mut human = format!("{:<24} {:<10} {}\n", "REPO ID", "ENABLED", "NAME");
for r in &raw {
let enabled = sv_bool(r, "enabled");
if !filter.accepts(enabled) {
continue;
}
let id = sv(r, "id");
let name = sv(r, "name");
human.push_str(&format!("{id:<24} {enabled:<10} {name}\n"));
rows.push(json!([id, name, enabled]));
}
let mut data = crate::envelope::table_data(REPO_COLUMNS, rows);
data["backend"] = json!("dnf5daemon");
Ok(View::new("RepoList", host, data, human))
}
struct ResolvedPlan {
install: Vec<String>,
remove: Vec<String>,
upgrade: Vec<String>,
downgrade: Vec<String>,
install_size_total: u64,
remove_names: Vec<String>,
}
impl ResolvedPlan {
fn data(&self) -> Value {
json!({
"install": self.install,
"remove": self.remove,
"upgrade": self.upgrade,
"downgrade": self.downgrade,
"install_size_total": self.install_size_total,
"counts": {
"install": self.install.len(),
"remove": self.remove.len(),
"upgrade": self.upgrade.len(),
"downgrade": self.downgrade.len(),
},
})
}
}
fn nevra(object: &Value) -> String {
format!(
"{}-{}.{}",
sv(object, "name"),
sv(object, "evr"),
sv(object, "arch")
)
}
fn parse_plan(items: &Value) -> ResolvedPlan {
let mut plan = ResolvedPlan {
install: Vec::new(),
remove: Vec::new(),
upgrade: Vec::new(),
downgrade: Vec::new(),
install_size_total: 0,
remove_names: Vec::new(),
};
let Some(arr) = items.as_array() else {
return plan;
};
for item in arr {
let action = item.get(1).and_then(Value::as_str).unwrap_or("");
let object = item.get(4).cloned().unwrap_or(Value::Null);
let label = nevra(&object);
match action {
"Install" => {
plan.install_size_total += sv_u64(&object, "install_size");
plan.install.push(label);
}
"Upgrade" => plan.upgrade.push(label),
"Downgrade" => plan.downgrade.push(label),
"Remove" | "Replaced" | "Obsoleted" | "Erase" => {
plan.remove_names.push(sv(&object, "name"));
plan.remove.push(label);
}
_ => {}
}
}
plan
}
fn run_mutation(cli: &Cli, m: Mutation, specs: &[String]) -> Result<View> {
let host = cli.resolved_host();
let transport = crate::transport::from_host(cli.host.as_deref());
let mut client = BridgeClient::connect(transport.as_ref())?;
let (channel, session) = match open_session(&mut client, true) {
Ok(pair) => pair,
Err(FezError::DependencyMissing { .. }) => {
return mutate_via_packagekit(&mut client, m, specs, &host, cli.dry_run, cli.force);
}
Err(e) => return Err(e),
};
let result = mutation_inner(cli, &mut client, &channel, &session, m, specs, &host);
close_session(&mut client, &channel, &session);
result
}
fn mutate_via_packagekit(
client: &mut BridgeClient,
m: Mutation,
specs: &[String],
host: &str,
dry_run: bool,
force: bool,
) -> Result<View> {
match crate::capabilities::packages_pk::mutate(client, m.verb(), specs, host, dry_run, force) {
Ok(view) => Ok(from_pk(view, host.to_string())),
Err(FezError::Dbus { name, .. }) if is_service_unknown(&name) => Err(both_missing()),
Err(e) => Err(e),
}
}
#[allow(clippy::too_many_arguments)]
fn mutation_inner(
cli: &Cli,
client: &mut BridgeClient,
channel: &str,
session: &str,
m: Mutation,
specs: &[String],
host: &str,
) -> Result<View> {
client.dbus_call(channel, session, RPM_IFACE, m.method(), json!([specs, {}]))?;
let out = client.dbus_call(channel, session, GOAL_IFACE, "resolve", json!([{}]))?;
let items = out.get(0).cloned().unwrap_or(Value::Null);
let plan = parse_plan(&items);
if cli.dry_run {
return Ok(plan_view(m, host, specs, &plan, true));
}
crate::safety::check_removal_plan(&plan.remove_names, cli.force)?;
crate::audit::run_audited(host, m.verb(), &specs.join(","), || {
client.dbus_call(channel, session, GOAL_IFACE, "do_transaction", json!([{}]))?;
Ok(plan_view(m, host, specs, &plan, false))
})
}
fn plan_view(
m: Mutation,
host: &str,
specs: &[String],
plan: &ResolvedPlan,
dry_run: bool,
) -> View {
let kind = if dry_run {
"PackagePlan"
} else {
"PackageMutation"
};
let mut data = plan.data();
if let Value::Object(map) = &mut data {
map.insert("operation".into(), json!(m.verb()));
map.insert("specs".into(), json!(specs));
map.insert("dry_run".into(), json!(dry_run));
map.insert("backend".into(), json!("dnf5daemon"));
}
let human = if dry_run {
format!(
"DRY-RUN: {} {} on {} would install {}, remove {}, upgrade {}, downgrade {} package(s)\n",
m.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",
m.verb(),
specs.join(" "),
host,
plan.install.len(),
plan.remove.len(),
plan.upgrade.len(),
plan.downgrade.len(),
)
};
View::new(kind, host.to_string(), data, human)
}
#[cfg(test)]
mod tests {
use super::*;
fn item(action: &str, name: &str, size: u64) -> Value {
json!([ "Package", action, "User", {}, {
"name": {"t":"s","v": name},
"evr": {"t":"s","v":"1-1"},
"arch": {"t":"s","v":"x86_64"},
"repo_id": {"t":"s","v":"fedora"},
"install_size": {"t":"t","v": size.to_string()}
}])
}
#[test]
fn variant_wraps_value_with_signature() {
assert_eq!(variant("b", json!(true)), json!({"t": "b", "v": true}));
assert_eq!(variant("s", json!("x")), json!({"t": "s", "v": "x"}));
}
#[test]
fn options_wraps_every_value_as_a_variant() {
let opts = options(&[
("load_system_repo", "b", json!(true)),
("scope", "s", json!("installed")),
("patterns", "as", json!(["htop"])),
]);
assert_eq!(
opts,
json!({
"load_system_repo": {"t": "b", "v": true},
"scope": {"t": "s", "v": "installed"},
"patterns": {"t": "as", "v": ["htop"]},
})
);
}
#[test]
fn parse_plan_buckets_by_action() {
let items = json!([
item("Install", "htop", 100),
item("Remove", "oldpkg", 50),
item("Upgrade", "nginx", 200),
item("Downgrade", "foo", 10)
]);
let plan = parse_plan(&items);
assert_eq!(plan.install, vec!["htop-1-1.x86_64"]);
assert_eq!(plan.remove, vec!["oldpkg-1-1.x86_64"]);
assert_eq!(plan.upgrade, vec!["nginx-1-1.x86_64"]);
assert_eq!(plan.downgrade, vec!["foo-1-1.x86_64"]);
assert_eq!(plan.remove_names, vec!["oldpkg".to_string()]);
}
#[test]
fn parse_plan_counts_replaced_as_removed() {
let items = json!([
item("Install", "newpkg", 100),
item("Replaced", "oldpkg", 50)
]);
assert_eq!(parse_plan(&items).remove_names, vec!["oldpkg".to_string()]);
}
#[test]
fn parse_plan_totals_install_size() {
let items = json!([item("Install", "a", 100), item("Install", "b", 200)]);
assert_eq!(parse_plan(&items).install_size_total, 300);
}
}