use super::{command_mechanism, ActionSpec};
use sysknife_types::RiskLevel;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SnapInstallError {
InvalidArg { param: &'static str, value: String },
}
impl std::fmt::Display for SnapInstallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidArg { param, value } => write!(f, "invalid {param}: '{value}'"),
}
}
}
impl std::error::Error for SnapInstallError {}
fn safe_snap_arg_is_valid(s: &str) -> bool {
if s.is_empty() || s.len() > 254 || s.starts_with('-') {
return false;
}
s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-'))
}
pub fn specs() -> Vec<ActionSpec> {
vec![
snap_install("firefox", None, false).expect("firefox is a valid snap arg"),
snap_remove("firefox"),
snap_refresh(Some("firefox")),
snap_hold("firefox"),
snap_unhold("firefox"),
snap_list(),
snap_info("firefox"),
snap_revert("firefox"),
snap_classic_install("code"),
]
}
pub fn snap_install(
name: &str,
channel: Option<&str>,
auto_update: bool,
) -> Result<ActionSpec, SnapInstallError> {
if !safe_snap_arg_is_valid(name) {
return Err(SnapInstallError::InvalidArg {
param: "name",
value: name.to_string(),
});
}
if let Some(ch) = channel {
if !safe_snap_arg_is_valid(ch) {
return Err(SnapInstallError::InvalidArg {
param: "channel",
value: ch.to_string(),
});
}
}
if auto_update {
let mut args = vec!["install".to_string()];
if let Some(ch) = channel {
args.push(format!("--channel={}", ch));
}
args.push(name.to_string());
Ok(ActionSpec {
action_name: "SnapInstall",
mechanism: command_mechanism(
"sudo",
std::iter::once("snap").chain(args.iter().map(String::as_str)),
),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
})
} else {
let channel_arg = channel.unwrap_or("stable");
Ok(ActionSpec {
action_name: "SnapInstall",
mechanism: super::command_mechanism(
"sudo",
[
"/usr/lib/sysknife/action-steps",
"snap-install-hold",
name,
channel_arg,
],
),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
})
}
}
pub fn snap_remove(name: &str) -> ActionSpec {
ActionSpec {
action_name: "SnapRemove",
mechanism: command_mechanism("sudo", ["snap", "remove", name]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn snap_refresh(name: Option<&str>) -> ActionSpec {
match name {
Some(n) => ActionSpec {
action_name: "SnapRefresh",
mechanism: command_mechanism("sudo", ["snap", "refresh", n]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
},
None => ActionSpec {
action_name: "SnapRefresh",
mechanism: command_mechanism("sudo", ["snap", "refresh"]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
},
}
}
pub fn snap_hold(name: &str) -> ActionSpec {
ActionSpec {
action_name: "SnapHold",
mechanism: command_mechanism("sudo", ["snap", "refresh", "--hold", name]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn snap_unhold(name: &str) -> ActionSpec {
ActionSpec {
action_name: "SnapUnhold",
mechanism: command_mechanism("sudo", ["snap", "refresh", "--unhold", name]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn snap_list() -> ActionSpec {
ActionSpec {
action_name: "SnapList",
mechanism: command_mechanism("snap", ["list"]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn snap_info(name: &str) -> ActionSpec {
ActionSpec {
action_name: "SnapInfo",
mechanism: command_mechanism("snap", ["info", name]),
risk_level: RiskLevel::Low,
reboot_required: false,
rollback_available: false,
}
}
pub fn snap_revert(name: &str) -> ActionSpec {
ActionSpec {
action_name: "SnapRevert",
mechanism: command_mechanism("sudo", ["snap", "revert", name]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
pub fn snap_classic_install(name: &str) -> ActionSpec {
ActionSpec {
action_name: "SnapClassicInstall",
mechanism: command_mechanism("sudo", ["snap", "install", "--classic", name]),
risk_level: RiskLevel::Medium,
reboot_required: false,
rollback_available: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::ActionMechanism;
fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
match &spec.mechanism {
ActionMechanism::Command { program, args } => (*program, args.clone()),
_ => panic!("expected Command mechanism"),
}
}
#[test]
fn snap_install_action_name() {
assert_eq!(
snap_install("firefox", None, false).unwrap().action_name,
"SnapInstall"
);
}
#[test]
fn snap_install_default_includes_hold() {
let spec = snap_install("firefox", None, false).unwrap();
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
assert_eq!(
args,
[
"/usr/lib/sysknife/action-steps",
"snap-install-hold",
"firefox",
"stable"
]
);
assert_eq!(
crate::actions::exclusive_resource(&spec),
Some(crate::actions::ExclusiveResource::Snap)
);
}
#[test]
fn snap_install_auto_update_no_hold() {
let spec = snap_install("firefox", None, true).unwrap();
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
let full = args.join(" ");
assert!(full.contains("install"), "missing install: {full}");
assert!(
!full.contains("hold"),
"unexpected hold in auto_update=true: {full}"
);
}
#[test]
fn snap_install_custom_channel() {
let spec = snap_install("firefox", Some("beta"), false).unwrap();
let (_, args) = extract_args(&spec);
let full = args.join(" ");
assert!(full.contains("beta"), "channel not present: {full}");
}
#[test]
fn snap_install_risk_medium() {
assert_eq!(
snap_install("vim", None, false).unwrap().risk_level,
RiskLevel::Medium
);
}
#[test]
fn snap_install_rejects_shell_metacharacters_in_name() {
let err = snap_install("firefox; rm -rf /", None, false).unwrap_err();
assert!(matches!(
err,
SnapInstallError::InvalidArg { param: "name", .. }
));
}
#[test]
fn snap_install_rejects_shell_metacharacters_in_channel() {
let err = snap_install("firefox", Some("beta && evil"), false).unwrap_err();
assert!(matches!(
err,
SnapInstallError::InvalidArg {
param: "channel",
..
}
));
}
#[test]
fn snap_install_rejects_leading_dash_name() {
let err = snap_install("--classic", None, false).unwrap_err();
assert!(matches!(
err,
SnapInstallError::InvalidArg { param: "name", .. }
));
}
#[test]
fn snap_remove_action_name() {
assert_eq!(snap_remove("vim").action_name, "SnapRemove");
}
#[test]
fn snap_remove_argv() {
let spec = snap_remove("vim");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
let a: Vec<&str> = args.iter().map(String::as_str).collect();
assert!(a.contains(&"snap"));
assert!(a.contains(&"remove"));
assert!(a.contains(&"vim"));
}
#[test]
fn snap_remove_risk_medium() {
assert_eq!(snap_remove("vim").risk_level, RiskLevel::Medium);
}
#[test]
fn snap_refresh_named() {
let spec = snap_refresh(Some("firefox"));
let (_, args) = extract_args(&spec);
let a: Vec<&str> = args.iter().map(String::as_str).collect();
assert!(a.contains(&"firefox"));
}
#[test]
fn snap_refresh_all_no_extra_args() {
let spec = snap_refresh(None);
let (_, args) = extract_args(&spec);
assert_eq!(
args.iter()
.filter(|a| a.as_str() != "snap" && a.as_str() != "refresh")
.count(),
0
);
}
#[test]
fn snap_hold_action_name() {
assert_eq!(snap_hold("vim").action_name, "SnapHold");
}
#[test]
fn snap_hold_argv_uses_refresh_hold() {
let spec = snap_hold("vim");
let (_, args) = extract_args(&spec);
let a: Vec<&str> = args.iter().map(String::as_str).collect();
assert!(a.contains(&"refresh"));
assert!(a.contains(&"--hold"));
assert!(a.contains(&"vim"));
}
#[test]
fn snap_unhold_action_name() {
assert_eq!(snap_unhold("vim").action_name, "SnapUnhold");
}
#[test]
fn snap_unhold_argv_uses_refresh_unhold() {
let spec = snap_unhold("vim");
let (_, args) = extract_args(&spec);
let a: Vec<&str> = args.iter().map(String::as_str).collect();
assert!(a.contains(&"refresh"));
assert!(a.contains(&"--unhold"));
assert!(a.contains(&"vim"));
}
#[test]
fn snap_list_action_name() {
assert_eq!(snap_list().action_name, "SnapList");
}
#[test]
fn snap_list_no_sudo() {
let spec = snap_list();
let (prog, _) = extract_args(&spec);
assert_ne!(prog, "sudo");
}
#[test]
fn snap_list_risk_low() {
assert_eq!(snap_list().risk_level, RiskLevel::Low);
}
#[test]
fn snap_info_action_name() {
assert_eq!(snap_info("firefox").action_name, "SnapInfo");
}
#[test]
fn snap_info_no_sudo() {
let spec = snap_info("firefox");
let (prog, _) = extract_args(&spec);
assert_ne!(prog, "sudo");
}
#[test]
fn snap_info_risk_low() {
assert_eq!(snap_info("firefox").risk_level, RiskLevel::Low);
}
#[test]
fn snap_info_includes_name() {
let spec = snap_info("code");
let (_, args) = extract_args(&spec);
let a: Vec<&str> = args.iter().map(String::as_str).collect();
assert!(a.contains(&"code"));
}
#[test]
fn snap_revert_action_name() {
assert_eq!(snap_revert("firefox").action_name, "SnapRevert");
}
#[test]
fn snap_revert_argv_correct() {
let spec = snap_revert("firefox");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
let a: Vec<&str> = args.iter().map(String::as_str).collect();
assert!(a.contains(&"snap"));
assert!(a.contains(&"revert"));
assert!(a.contains(&"firefox"));
}
#[test]
fn snap_revert_risk_medium() {
assert_eq!(snap_revert("firefox").risk_level, RiskLevel::Medium);
}
#[test]
fn snap_classic_install_action_name() {
assert_eq!(
snap_classic_install("code").action_name,
"SnapClassicInstall"
);
}
#[test]
fn snap_classic_install_argv_contains_classic_flag() {
let spec = snap_classic_install("code");
let (prog, args) = extract_args(&spec);
assert_eq!(prog, "sudo");
let a: Vec<&str> = args.iter().map(String::as_str).collect();
assert!(a.contains(&"snap"));
assert!(a.contains(&"install"));
assert!(a.contains(&"--classic"));
assert!(a.contains(&"code"));
}
#[test]
fn snap_classic_install_risk_medium() {
assert_eq!(snap_classic_install("code").risk_level, RiskLevel::Medium);
}
#[test]
fn specs_covers_all_action_names() {
let expected = [
"SnapInstall",
"SnapRemove",
"SnapRefresh",
"SnapHold",
"SnapUnhold",
"SnapList",
"SnapInfo",
"SnapRevert",
"SnapClassicInstall",
];
let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
for name in &expected {
assert!(spec_names.contains(name), "specs() missing {name}");
}
}
}