use super::{command_mechanism, ActionSpec};
use sysknife_types::RiskLevel;
const ACTION_STEPS: &str = "/usr/lib/sysknife/action-steps";
pub fn specs() -> Vec<ActionSpec> {
vec![
apt_update(),
apt_upgrade(),
apt_install("curl"),
apt_remove("curl"),
apt_purge("curl"),
apt_autoremove(),
apt_hold("curl"),
apt_unhold("curl"),
apt_search("curl"),
apt_list_installed(),
apt_show("curl"),
apt_list_upgradable(),
apt_history_list(),
configure_unattended_upgrades(true),
]
}
const UNATTENDED_UPGRADES_HELPER: &str = "/usr/lib/sysknife/unattended-upgrades-edit";
pub fn configure_unattended_upgrades(enabled: bool) -> ActionSpec {
let flag = if enabled { "--enable" } else { "--disable" };
ActionSpec {
action_name: "ConfigureUnattendedUpgrades",
mechanism: command_mechanism("sudo", [UNATTENDED_UPGRADES_HELPER, flag]),
risk_level: RiskLevel::High,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_update() -> ActionSpec {
ActionSpec {
action_name: "AptUpdate",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "update"]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_upgrade() -> ActionSpec {
ActionSpec {
action_name: "AptUpgrade",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "dist-upgrade"]),
risk_level: RiskLevel::High,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_install(package: &str) -> ActionSpec {
ActionSpec {
action_name: "AptInstall",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "install", package]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_remove(package: &str) -> ActionSpec {
ActionSpec {
action_name: "AptRemove",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "remove", package]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_purge(package: &str) -> ActionSpec {
ActionSpec {
action_name: "AptPurge",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "purge", package]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_autoremove() -> ActionSpec {
ActionSpec {
action_name: "AptAutoremove",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "autoremove"]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_hold(package: &str) -> ActionSpec {
ActionSpec {
action_name: "AptHold",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "hold", package]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_unhold(package: &str) -> ActionSpec {
ActionSpec {
action_name: "AptUnhold",
mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "unhold", package]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_search(term: &str) -> ActionSpec {
ActionSpec {
action_name: "AptSearch",
mechanism: command_mechanism("apt-cache", ["search", term]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_list_installed() -> ActionSpec {
ActionSpec {
action_name: "AptListInstalled",
mechanism: command_mechanism("dpkg", ["-l"]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_show(package: &str) -> ActionSpec {
ActionSpec {
action_name: "AptShow",
mechanism: command_mechanism("apt-cache", ["show", package]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_list_upgradable() -> ActionSpec {
ActionSpec {
action_name: "AptListUpgradable",
mechanism: command_mechanism("bash", ["-c", "apt list --upgradable 2>/dev/null"]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn apt_history_list() -> ActionSpec {
ActionSpec {
action_name: "AptHistoryList",
mechanism: command_mechanism(
"bash",
[
"-c",
"grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80",
],
),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn parse_autoremove_removals(simulate_stdout: &str) -> std::collections::BTreeSet<String> {
simulate_stdout
.lines()
.filter_map(|line| {
let rest = line.trim_start().strip_prefix("Remv ")?;
rest.split_whitespace().next().map(str::to_string)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::ActionMechanism;
fn removals(s: &str) -> Vec<String> {
parse_autoremove_removals(s).into_iter().collect()
}
#[test]
fn parse_autoremove_extracts_the_remv_package_set() {
let out = "Reading package lists... Done\n\
Building dependency tree... Done\n\
The following packages will be REMOVED:\n\
\x20 linux-image-5.4.0-100 linux-modules-5.4.0-100\n\
0 upgraded, 0 newly installed, 2 to remove and 0 not upgraded.\n\
Remv linux-image-5.4.0-100 [5.4.0-100.113]\n\
Remv linux-modules-5.4.0-100 [5.4.0-100.113]\n";
assert_eq!(
removals(out),
vec![
"linux-image-5.4.0-100".to_string(),
"linux-modules-5.4.0-100".to_string()
]
);
}
#[test]
fn parse_autoremove_keeps_arch_suffix_and_ignores_non_remv_lines() {
let out = "Inst libkeep [1.0] (2.0 amd64)\n\
Remv libfoo:amd64 [1.2-3]\n\
Conf libbar [4.5]\n";
assert_eq!(removals(out), vec!["libfoo:amd64".to_string()]);
}
#[test]
fn parse_autoremove_on_empty_or_nothing_to_remove_is_empty() {
assert!(parse_autoremove_removals("").is_empty());
assert!(parse_autoremove_removals(
"Reading package lists... Done\n0 to remove and 0 not upgraded.\n"
)
.is_empty());
}
#[test]
fn parse_autoremove_deduplicates_and_sorts() {
let out = "Remv b [1]\nRemv a [1]\nRemv b [1]\n";
assert_eq!(removals(out), vec!["a".to_string(), "b".to_string()]);
}
fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
match &spec.mechanism {
ActionMechanism::Command { program, args } => {
(*program, args.iter().map(String::as_str).collect())
}
_ => panic!("expected Command mechanism"),
}
}
#[test]
fn apt_update_action_name() {
assert_eq!(apt_update().action_name, "AptUpdate");
}
#[test]
fn apt_update_argv_correct() {
let spec = apt_update();
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(args, vec![ACTION_STEPS, "apt", "update"]);
}
#[test]
fn apt_update_risk_is_low() {
assert_eq!(apt_update().risk_level, RiskLevel::Low);
}
#[test]
fn apt_update_no_reboot_no_rollback() {
let spec = apt_update();
assert!(!spec.reboot_required);
assert!(!spec.rollback_available);
}
#[test]
fn apt_upgrade_action_name() {
assert_eq!(apt_upgrade().action_name, "AptUpgrade");
}
#[test]
fn apt_upgrade_argv_correct() {
let spec = apt_upgrade();
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(args, vec![ACTION_STEPS, "apt", "dist-upgrade"]);
}
#[test]
fn apt_upgrade_risk_is_high() {
assert_eq!(apt_upgrade().risk_level, RiskLevel::High);
}
#[test]
fn apt_install_action_name() {
assert_eq!(apt_install("vim").action_name, "AptInstall");
}
#[test]
fn apt_install_includes_package_name() {
let spec = apt_install("vim");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(args, vec![ACTION_STEPS, "apt", "install", "vim"]);
}
#[test]
fn apt_install_risk_is_medium() {
assert_eq!(apt_install("vim").risk_level, RiskLevel::Medium);
}
#[test]
fn apt_remove_action_name() {
assert_eq!(apt_remove("vim").action_name, "AptRemove");
}
#[test]
fn apt_remove_argv_contains_remove_not_purge() {
let spec = apt_remove("vim");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(args, vec![ACTION_STEPS, "apt", "remove", "vim"]);
}
#[test]
fn apt_remove_risk_is_medium() {
assert_eq!(apt_remove("vim").risk_level, RiskLevel::Medium);
}
#[test]
fn apt_purge_action_name() {
assert_eq!(apt_purge("vim").action_name, "AptPurge");
}
#[test]
fn apt_purge_argv_uses_purge_subcommand() {
let spec = apt_purge("vim");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(args, vec![ACTION_STEPS, "apt", "purge", "vim"]);
}
#[test]
fn apt_purge_risk_is_medium() {
assert_eq!(apt_purge("vim").risk_level, RiskLevel::Medium);
}
#[test]
fn apt_autoremove_action_name() {
assert_eq!(apt_autoremove().action_name, "AptAutoremove");
}
#[test]
fn apt_autoremove_argv_correct() {
let spec = apt_autoremove();
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(args, vec![ACTION_STEPS, "apt", "autoremove"]);
}
#[test]
fn apt_autoremove_risk_is_medium() {
assert_eq!(apt_autoremove().risk_level, RiskLevel::Medium);
}
#[test]
fn apt_hold_action_name() {
assert_eq!(apt_hold("nginx").action_name, "AptHold");
}
#[test]
fn apt_hold_uses_apt_mark_hold() {
let spec = apt_hold("nginx");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(args, vec![ACTION_STEPS, "apt", "hold", "nginx"]);
}
#[test]
fn apt_hold_risk_is_medium() {
assert_eq!(apt_hold("nginx").risk_level, RiskLevel::Medium);
}
#[test]
fn apt_unhold_action_name() {
assert_eq!(apt_unhold("nginx").action_name, "AptUnhold");
}
#[test]
fn apt_unhold_uses_apt_mark_unhold() {
let spec = apt_unhold("nginx");
let (_, args) = extract_args(&spec);
assert_eq!(args, vec![ACTION_STEPS, "apt", "unhold", "nginx"]);
}
#[test]
fn apt_unhold_risk_is_medium() {
assert_eq!(apt_unhold("nginx").risk_level, RiskLevel::Medium);
}
#[test]
fn apt_search_action_name() {
assert_eq!(apt_search("docker").action_name, "AptSearch");
}
#[test]
fn apt_search_uses_apt_cache_search() {
let spec = apt_search("docker");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "apt-cache");
assert!(args.contains(&"search"));
assert!(args.contains(&"docker"));
}
#[test]
fn apt_search_risk_is_low() {
assert_eq!(apt_search("docker").risk_level, RiskLevel::Low);
}
#[test]
fn apt_search_no_sudo() {
let spec = apt_search("docker");
let (prog, _) = extract_args(&spec);
assert_ne!(prog, "sudo");
}
#[test]
fn apt_list_installed_action_name() {
assert_eq!(apt_list_installed().action_name, "AptListInstalled");
}
#[test]
fn apt_list_installed_uses_dpkg() {
let spec = apt_list_installed();
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "dpkg");
assert!(args.contains(&"-l"));
}
#[test]
fn apt_list_installed_risk_is_low() {
assert_eq!(apt_list_installed().risk_level, RiskLevel::Low);
}
#[test]
fn apt_show_action_name() {
assert_eq!(apt_show("openssh-server").action_name, "AptShow");
}
#[test]
fn apt_show_uses_apt_cache_show() {
let spec = apt_show("openssh-server");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "apt-cache");
assert!(args.contains(&"show"));
assert!(args.contains(&"openssh-server"));
}
#[test]
fn apt_show_risk_is_low() {
assert_eq!(apt_show("openssh-server").risk_level, RiskLevel::Low);
}
#[test]
fn apt_list_upgradable_action_name() {
assert_eq!(apt_list_upgradable().action_name, "AptListUpgradable");
}
#[test]
fn apt_list_upgradable_uses_bash_and_apt_list() {
let spec = apt_list_upgradable();
match &spec.mechanism {
ActionMechanism::Command { program, args } => {
assert_eq!(*program, "bash");
let joined = args.join(" ");
assert!(joined.contains("apt list --upgradable"), "argv: {joined}");
}
_ => panic!("expected Command mechanism"),
}
}
#[test]
fn apt_list_upgradable_risk_is_low() {
assert_eq!(apt_list_upgradable().risk_level, RiskLevel::Low);
}
#[test]
fn apt_history_list_action_name() {
assert_eq!(apt_history_list().action_name, "AptHistoryList");
}
#[test]
fn apt_history_list_uses_bash_and_grep() {
let spec = apt_history_list();
match &spec.mechanism {
ActionMechanism::Command { program, args } => {
assert_eq!(*program, "bash");
let joined = args.join(" ");
assert!(
joined.contains("/var/log/apt/history.log"),
"argv: {joined}"
);
}
_ => panic!("expected Command mechanism"),
}
}
#[test]
fn apt_history_list_risk_is_low() {
assert_eq!(apt_history_list().risk_level, RiskLevel::Low);
}
#[test]
fn specs_covers_all_action_names() {
let expected_names = [
"AptUpdate",
"AptUpgrade",
"AptInstall",
"AptRemove",
"AptPurge",
"AptAutoremove",
"AptHold",
"AptUnhold",
"AptSearch",
"AptListInstalled",
"AptShow",
"AptListUpgradable",
"AptHistoryList",
];
let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
for name in &expected_names {
assert!(
spec_names.contains(name),
"specs() is missing action {name}"
);
}
}
fn apt_get_actions() -> Vec<ActionSpec> {
vec![
apt_update(),
apt_upgrade(),
apt_install("vim"),
apt_remove("vim"),
apt_purge("vim"),
apt_autoremove(),
apt_hold("vim"),
apt_unhold("vim"),
]
}
#[test]
fn apt_get_actions_go_through_the_helper_and_carry_no_option() {
for spec in apt_get_actions() {
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo", "{}", spec.action_name);
assert_eq!(
args[..2],
[ACTION_STEPS, "apt"],
"{} must go through the helper verb: {args:?}",
spec.action_name
);
for arg in &args {
assert!(
!arg.starts_with('-'),
"{} sends an option {arg:?}; the helper adds the flags itself",
spec.action_name
);
assert!(
!arg.contains("apt-get")
&& !arg.contains("apt-mark")
&& *arg != "env"
&& !arg.contains('='),
"{} still builds a direct apt-get or apt-mark argv: {args:?}",
spec.action_name
);
}
}
}
#[test]
fn apt_get_actions_still_contend_for_the_dpkg_lock() {
for spec in apt_get_actions() {
assert_eq!(
crate::actions::exclusive_resource(&spec),
Some(crate::actions::ExclusiveResource::Dpkg),
"{} lost the dpkg lock when it moved to the helper",
spec.action_name
);
}
}
#[test]
fn a_package_named_like_another_tool_does_not_change_the_lock() {
for operand in ["snap", "flatpak", "rpm-ostree"] {
assert_eq!(
crate::actions::exclusive_resource(&apt_install(operand)),
Some(crate::actions::ExclusiveResource::Dpkg),
"apt install {operand} must hold the dpkg lock"
);
}
}
}