1use super::{command_mechanism, ActionSpec};
19use sysknife_types::RiskLevel;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum SnapInstallError {
28 InvalidArg { param: &'static str, value: String },
36}
37
38impl std::fmt::Display for SnapInstallError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::InvalidArg { param, value } => write!(f, "invalid {param}: '{value}'"),
42 }
43 }
44}
45
46impl std::error::Error for SnapInstallError {}
47
48fn safe_snap_arg_is_valid(s: &str) -> bool {
54 if s.is_empty() || s.len() > 254 || s.starts_with('-') {
55 return false;
56 }
57 s.chars()
58 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-'))
59}
60
61pub fn specs() -> Vec<ActionSpec> {
67 vec![
68 snap_install("firefox", None, false).expect("firefox is a valid snap arg"),
69 snap_remove("firefox"),
70 snap_refresh(Some("firefox")),
71 snap_hold("firefox"),
72 snap_unhold("firefox"),
73 snap_list(),
74 snap_info("firefox"),
75 snap_revert("firefox"),
76 snap_classic_install("code"),
77 ]
78}
79
80pub fn snap_install(
95 name: &str,
96 channel: Option<&str>,
97 auto_update: bool,
98) -> Result<ActionSpec, SnapInstallError> {
99 if !safe_snap_arg_is_valid(name) {
100 return Err(SnapInstallError::InvalidArg {
101 param: "name",
102 value: name.to_string(),
103 });
104 }
105 if let Some(ch) = channel {
106 if !safe_snap_arg_is_valid(ch) {
107 return Err(SnapInstallError::InvalidArg {
108 param: "channel",
109 value: ch.to_string(),
110 });
111 }
112 }
113
114 if auto_update {
115 let mut args = vec!["install".to_string()];
117 if let Some(ch) = channel {
118 args.push(format!("--channel={}", ch));
119 }
120 args.push(name.to_string());
121 Ok(ActionSpec {
122 action_name: "SnapInstall",
123 mechanism: command_mechanism(
124 "sudo",
125 std::iter::once("snap").chain(args.iter().map(String::as_str)),
126 ),
127 risk_level: RiskLevel::Medium,
128 reboot_required: false,
129 rollback_available: false,
130 })
131 } else {
132 let channel_arg = channel.unwrap_or("stable");
134 Ok(ActionSpec {
135 action_name: "SnapInstall",
136 mechanism: super::command_mechanism(
137 "sudo",
138 [
139 "/usr/lib/sysknife/action-steps",
140 "snap-install-hold",
141 name,
142 channel_arg,
143 ],
144 ),
145 risk_level: RiskLevel::Medium,
146 reboot_required: false,
147 rollback_available: false,
148 })
149 }
150}
151
152pub fn snap_remove(name: &str) -> ActionSpec {
156 ActionSpec {
157 action_name: "SnapRemove",
158 mechanism: command_mechanism("sudo", ["snap", "remove", name]),
159 risk_level: RiskLevel::Medium,
160 reboot_required: false,
161 rollback_available: false,
162 }
163}
164
165pub fn snap_refresh(name: Option<&str>) -> ActionSpec {
172 match name {
173 Some(n) => ActionSpec {
174 action_name: "SnapRefresh",
175 mechanism: command_mechanism("sudo", ["snap", "refresh", n]),
176 risk_level: RiskLevel::Medium,
177 reboot_required: false,
178 rollback_available: false,
179 },
180 None => ActionSpec {
181 action_name: "SnapRefresh",
182 mechanism: command_mechanism("sudo", ["snap", "refresh"]),
183 risk_level: RiskLevel::Medium,
184 reboot_required: false,
185 rollback_available: false,
186 },
187 }
188}
189
190pub fn snap_hold(name: &str) -> ActionSpec {
194 ActionSpec {
195 action_name: "SnapHold",
196 mechanism: command_mechanism("sudo", ["snap", "refresh", "--hold", name]),
197 risk_level: RiskLevel::Medium,
198 reboot_required: false,
199 rollback_available: false,
200 }
201}
202
203pub fn snap_unhold(name: &str) -> ActionSpec {
208 ActionSpec {
209 action_name: "SnapUnhold",
210 mechanism: command_mechanism("sudo", ["snap", "refresh", "--unhold", name]),
211 risk_level: RiskLevel::Medium,
212 reboot_required: false,
213 rollback_available: false,
214 }
215}
216
217pub fn snap_list() -> ActionSpec {
221 ActionSpec {
222 action_name: "SnapList",
223 mechanism: command_mechanism("snap", ["list"]),
224 risk_level: RiskLevel::Low,
225 reboot_required: false,
226 rollback_available: false,
227 }
228}
229
230pub fn snap_info(name: &str) -> ActionSpec {
234 ActionSpec {
235 action_name: "SnapInfo",
236 mechanism: command_mechanism("snap", ["info", name]),
237 risk_level: RiskLevel::Low,
238 reboot_required: false,
239 rollback_available: false,
240 }
241}
242
243pub fn snap_revert(name: &str) -> ActionSpec {
248 ActionSpec {
249 action_name: "SnapRevert",
250 mechanism: command_mechanism("sudo", ["snap", "revert", name]),
251 risk_level: RiskLevel::Medium,
252 reboot_required: false,
253 rollback_available: false,
254 }
255}
256
257pub fn snap_classic_install(name: &str) -> ActionSpec {
262 ActionSpec {
263 action_name: "SnapClassicInstall",
264 mechanism: command_mechanism("sudo", ["snap", "install", "--classic", name]),
265 risk_level: RiskLevel::Medium,
266 reboot_required: false,
267 rollback_available: false,
268 }
269}
270
271#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::actions::ActionMechanism;
279
280 fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
281 match &spec.mechanism {
282 ActionMechanism::Command { program, args } => (*program, args.clone()),
283 _ => panic!("expected Command mechanism"),
284 }
285 }
286
287 #[test]
290 fn snap_install_action_name() {
291 assert_eq!(
292 snap_install("firefox", None, false).unwrap().action_name,
293 "SnapInstall"
294 );
295 }
296
297 #[test]
298 fn snap_install_default_includes_hold() {
299 let spec = snap_install("firefox", None, false).unwrap();
300 let (prog, args) = extract_args(&spec);
301 assert_eq!(prog, "sudo");
302 assert_eq!(
303 args,
304 [
305 "/usr/lib/sysknife/action-steps",
306 "snap-install-hold",
307 "firefox",
308 "stable"
309 ]
310 );
311 assert_eq!(
312 crate::actions::exclusive_resource(&spec),
313 Some(crate::actions::ExclusiveResource::Snap)
314 );
315 }
316
317 #[test]
318 fn snap_install_auto_update_no_hold() {
319 let spec = snap_install("firefox", None, true).unwrap();
320 let (prog, args) = extract_args(&spec);
321 assert_eq!(prog, "sudo");
322 let full = args.join(" ");
323 assert!(full.contains("install"), "missing install: {full}");
325 assert!(
326 !full.contains("hold"),
327 "unexpected hold in auto_update=true: {full}"
328 );
329 }
330
331 #[test]
332 fn snap_install_custom_channel() {
333 let spec = snap_install("firefox", Some("beta"), false).unwrap();
334 let (_, args) = extract_args(&spec);
335 let full = args.join(" ");
336 assert!(full.contains("beta"), "channel not present: {full}");
337 }
338
339 #[test]
340 fn snap_install_risk_medium() {
341 assert_eq!(
342 snap_install("vim", None, false).unwrap().risk_level,
343 RiskLevel::Medium
344 );
345 }
346
347 #[test]
348 fn snap_install_rejects_shell_metacharacters_in_name() {
349 let err = snap_install("firefox; rm -rf /", None, false).unwrap_err();
350 assert!(matches!(
351 err,
352 SnapInstallError::InvalidArg { param: "name", .. }
353 ));
354 }
355
356 #[test]
357 fn snap_install_rejects_shell_metacharacters_in_channel() {
358 let err = snap_install("firefox", Some("beta && evil"), false).unwrap_err();
359 assert!(matches!(
360 err,
361 SnapInstallError::InvalidArg {
362 param: "channel",
363 ..
364 }
365 ));
366 }
367
368 #[test]
369 fn snap_install_rejects_leading_dash_name() {
370 let err = snap_install("--classic", None, false).unwrap_err();
372 assert!(matches!(
373 err,
374 SnapInstallError::InvalidArg { param: "name", .. }
375 ));
376 }
377
378 #[test]
381 fn snap_remove_action_name() {
382 assert_eq!(snap_remove("vim").action_name, "SnapRemove");
383 }
384
385 #[test]
386 fn snap_remove_argv() {
387 let spec = snap_remove("vim");
388 let (prog, args) = extract_args(&spec);
389 assert_eq!(prog, "sudo");
390 let a: Vec<&str> = args.iter().map(String::as_str).collect();
391 assert!(a.contains(&"snap"));
392 assert!(a.contains(&"remove"));
393 assert!(a.contains(&"vim"));
394 }
395
396 #[test]
397 fn snap_remove_risk_medium() {
398 assert_eq!(snap_remove("vim").risk_level, RiskLevel::Medium);
399 }
400
401 #[test]
404 fn snap_refresh_named() {
405 let spec = snap_refresh(Some("firefox"));
406 let (_, args) = extract_args(&spec);
407 let a: Vec<&str> = args.iter().map(String::as_str).collect();
408 assert!(a.contains(&"firefox"));
409 }
410
411 #[test]
412 fn snap_refresh_all_no_extra_args() {
413 let spec = snap_refresh(None);
414 let (_, args) = extract_args(&spec);
415 assert_eq!(
417 args.iter()
418 .filter(|a| a.as_str() != "snap" && a.as_str() != "refresh")
419 .count(),
420 0
421 );
422 }
423
424 #[test]
427 fn snap_hold_action_name() {
428 assert_eq!(snap_hold("vim").action_name, "SnapHold");
429 }
430
431 #[test]
432 fn snap_hold_argv_uses_refresh_hold() {
433 let spec = snap_hold("vim");
434 let (_, args) = extract_args(&spec);
435 let a: Vec<&str> = args.iter().map(String::as_str).collect();
436 assert!(a.contains(&"refresh"));
437 assert!(a.contains(&"--hold"));
438 assert!(a.contains(&"vim"));
439 }
440
441 #[test]
442 fn snap_unhold_action_name() {
443 assert_eq!(snap_unhold("vim").action_name, "SnapUnhold");
444 }
445
446 #[test]
447 fn snap_unhold_argv_uses_refresh_unhold() {
448 let spec = snap_unhold("vim");
449 let (_, args) = extract_args(&spec);
450 let a: Vec<&str> = args.iter().map(String::as_str).collect();
451 assert!(a.contains(&"refresh"));
452 assert!(a.contains(&"--unhold"));
453 assert!(a.contains(&"vim"));
454 }
455
456 #[test]
459 fn snap_list_action_name() {
460 assert_eq!(snap_list().action_name, "SnapList");
461 }
462
463 #[test]
464 fn snap_list_no_sudo() {
465 let spec = snap_list();
466 let (prog, _) = extract_args(&spec);
467 assert_ne!(prog, "sudo");
468 }
469
470 #[test]
471 fn snap_list_risk_low() {
472 assert_eq!(snap_list().risk_level, RiskLevel::Low);
473 }
474
475 #[test]
478 fn snap_info_action_name() {
479 assert_eq!(snap_info("firefox").action_name, "SnapInfo");
480 }
481
482 #[test]
483 fn snap_info_no_sudo() {
484 let spec = snap_info("firefox");
485 let (prog, _) = extract_args(&spec);
486 assert_ne!(prog, "sudo");
487 }
488
489 #[test]
490 fn snap_info_risk_low() {
491 assert_eq!(snap_info("firefox").risk_level, RiskLevel::Low);
492 }
493
494 #[test]
495 fn snap_info_includes_name() {
496 let spec = snap_info("code");
497 let (_, args) = extract_args(&spec);
498 let a: Vec<&str> = args.iter().map(String::as_str).collect();
499 assert!(a.contains(&"code"));
500 }
501
502 #[test]
505 fn snap_revert_action_name() {
506 assert_eq!(snap_revert("firefox").action_name, "SnapRevert");
507 }
508
509 #[test]
510 fn snap_revert_argv_correct() {
511 let spec = snap_revert("firefox");
512 let (prog, args) = extract_args(&spec);
513 assert_eq!(prog, "sudo");
514 let a: Vec<&str> = args.iter().map(String::as_str).collect();
515 assert!(a.contains(&"snap"));
516 assert!(a.contains(&"revert"));
517 assert!(a.contains(&"firefox"));
518 }
519
520 #[test]
521 fn snap_revert_risk_medium() {
522 assert_eq!(snap_revert("firefox").risk_level, RiskLevel::Medium);
523 }
524
525 #[test]
528 fn snap_classic_install_action_name() {
529 assert_eq!(
530 snap_classic_install("code").action_name,
531 "SnapClassicInstall"
532 );
533 }
534
535 #[test]
536 fn snap_classic_install_argv_contains_classic_flag() {
537 let spec = snap_classic_install("code");
538 let (prog, args) = extract_args(&spec);
539 assert_eq!(prog, "sudo");
540 let a: Vec<&str> = args.iter().map(String::as_str).collect();
541 assert!(a.contains(&"snap"));
542 assert!(a.contains(&"install"));
543 assert!(a.contains(&"--classic"));
544 assert!(a.contains(&"code"));
545 }
546
547 #[test]
548 fn snap_classic_install_risk_medium() {
549 assert_eq!(snap_classic_install("code").risk_level, RiskLevel::Medium);
550 }
551
552 #[test]
555 fn specs_covers_all_action_names() {
556 let expected = [
557 "SnapInstall",
558 "SnapRemove",
559 "SnapRefresh",
560 "SnapHold",
561 "SnapUnhold",
562 "SnapList",
563 "SnapInfo",
564 "SnapRevert",
565 "SnapClassicInstall",
566 ];
567 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
568 for name in &expected {
569 assert!(spec_names.contains(name), "specs() missing {name}");
570 }
571 }
572}