1use super::{command_mechanism, ActionSpec};
20use sysknife_types::RiskLevel;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum SnapInstallError {
29 InvalidArg { param: &'static str, value: String },
39}
40
41impl std::fmt::Display for SnapInstallError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::InvalidArg { param, value } => write!(f, "invalid {param}: '{value}'"),
45 }
46 }
47}
48
49impl std::error::Error for SnapInstallError {}
50
51fn safe_snap_arg_is_valid(s: &str) -> bool {
57 if s.is_empty() || s.len() > 254 || s.starts_with('-') {
58 return false;
59 }
60 s.chars()
61 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-'))
62}
63
64pub fn specs() -> Vec<ActionSpec> {
70 vec![
71 snap_install("firefox", None, false).expect("firefox is a valid snap arg"),
72 snap_remove("firefox"),
73 snap_refresh(Some("firefox")),
74 snap_hold("firefox"),
75 snap_unhold("firefox"),
76 snap_list(),
77 snap_info("firefox"),
78 snap_revert("firefox"),
79 snap_classic_install("code"),
80 ]
81}
82
83pub fn snap_install(
98 name: &str,
99 channel: Option<&str>,
100 auto_update: bool,
101) -> Result<ActionSpec, SnapInstallError> {
102 if !safe_snap_arg_is_valid(name) {
103 return Err(SnapInstallError::InvalidArg {
104 param: "name",
105 value: name.to_string(),
106 });
107 }
108 if let Some(ch) = channel {
109 if !safe_snap_arg_is_valid(ch) {
110 return Err(SnapInstallError::InvalidArg {
111 param: "channel",
112 value: ch.to_string(),
113 });
114 }
115 }
116
117 if auto_update {
118 let mut args = vec!["install".to_string()];
120 if let Some(ch) = channel {
121 args.push(format!("--channel={}", ch));
122 }
123 args.push(name.to_string());
124 Ok(ActionSpec {
125 action_name: "SnapInstall",
126 mechanism: command_mechanism(
127 "sudo",
128 std::iter::once("snap").chain(args.iter().map(String::as_str)),
129 ),
130 risk_level: RiskLevel::Medium,
131 reboot_required: false,
132 rollback_available: false,
133 })
134 } else {
135 let channel_arg = channel.unwrap_or("stable");
138 let cmd = format!(
139 "snap install --channel={} {} && snap refresh --hold {}",
140 channel_arg, name, name
141 );
142 Ok(ActionSpec {
143 action_name: "SnapInstall",
144 mechanism: super::command_mechanism("sudo", ["sh", "-c", &cmd]),
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 let full = args.join(" ");
304 assert!(
305 full.contains("snap install"),
306 "missing 'snap install': {full}"
307 );
308 assert!(
309 full.contains("snap refresh --hold firefox"),
310 "missing hold: {full}"
311 );
312 }
313
314 #[test]
315 fn snap_install_auto_update_no_hold() {
316 let spec = snap_install("firefox", None, true).unwrap();
317 let (prog, args) = extract_args(&spec);
318 assert_eq!(prog, "sudo");
319 let full = args.join(" ");
320 assert!(full.contains("install"), "missing install: {full}");
322 assert!(
323 !full.contains("hold"),
324 "unexpected hold in auto_update=true: {full}"
325 );
326 }
327
328 #[test]
329 fn snap_install_custom_channel() {
330 let spec = snap_install("firefox", Some("beta"), false).unwrap();
331 let (_, args) = extract_args(&spec);
332 let full = args.join(" ");
333 assert!(full.contains("beta"), "channel not present: {full}");
334 }
335
336 #[test]
337 fn snap_install_risk_medium() {
338 assert_eq!(
339 snap_install("vim", None, false).unwrap().risk_level,
340 RiskLevel::Medium
341 );
342 }
343
344 #[test]
345 fn snap_install_rejects_shell_metacharacters_in_name() {
346 let err = snap_install("firefox; rm -rf /", None, false).unwrap_err();
347 assert!(matches!(
348 err,
349 SnapInstallError::InvalidArg { param: "name", .. }
350 ));
351 }
352
353 #[test]
354 fn snap_install_rejects_shell_metacharacters_in_channel() {
355 let err = snap_install("firefox", Some("beta && evil"), false).unwrap_err();
356 assert!(matches!(
357 err,
358 SnapInstallError::InvalidArg {
359 param: "channel",
360 ..
361 }
362 ));
363 }
364
365 #[test]
366 fn snap_install_rejects_leading_dash_name() {
367 let err = snap_install("--classic", None, false).unwrap_err();
369 assert!(matches!(
370 err,
371 SnapInstallError::InvalidArg { param: "name", .. }
372 ));
373 }
374
375 #[test]
378 fn snap_remove_action_name() {
379 assert_eq!(snap_remove("vim").action_name, "SnapRemove");
380 }
381
382 #[test]
383 fn snap_remove_argv() {
384 let spec = snap_remove("vim");
385 let (prog, args) = extract_args(&spec);
386 assert_eq!(prog, "sudo");
387 let a: Vec<&str> = args.iter().map(String::as_str).collect();
388 assert!(a.contains(&"snap"));
389 assert!(a.contains(&"remove"));
390 assert!(a.contains(&"vim"));
391 }
392
393 #[test]
394 fn snap_remove_risk_medium() {
395 assert_eq!(snap_remove("vim").risk_level, RiskLevel::Medium);
396 }
397
398 #[test]
401 fn snap_refresh_named() {
402 let spec = snap_refresh(Some("firefox"));
403 let (_, args) = extract_args(&spec);
404 let a: Vec<&str> = args.iter().map(String::as_str).collect();
405 assert!(a.contains(&"firefox"));
406 }
407
408 #[test]
409 fn snap_refresh_all_no_extra_args() {
410 let spec = snap_refresh(None);
411 let (_, args) = extract_args(&spec);
412 assert_eq!(
414 args.iter()
415 .filter(|a| a.as_str() != "snap" && a.as_str() != "refresh")
416 .count(),
417 0
418 );
419 }
420
421 #[test]
424 fn snap_hold_action_name() {
425 assert_eq!(snap_hold("vim").action_name, "SnapHold");
426 }
427
428 #[test]
429 fn snap_hold_argv_uses_refresh_hold() {
430 let spec = snap_hold("vim");
431 let (_, args) = extract_args(&spec);
432 let a: Vec<&str> = args.iter().map(String::as_str).collect();
433 assert!(a.contains(&"refresh"));
434 assert!(a.contains(&"--hold"));
435 assert!(a.contains(&"vim"));
436 }
437
438 #[test]
439 fn snap_unhold_action_name() {
440 assert_eq!(snap_unhold("vim").action_name, "SnapUnhold");
441 }
442
443 #[test]
444 fn snap_unhold_argv_uses_refresh_unhold() {
445 let spec = snap_unhold("vim");
446 let (_, args) = extract_args(&spec);
447 let a: Vec<&str> = args.iter().map(String::as_str).collect();
448 assert!(a.contains(&"refresh"));
449 assert!(a.contains(&"--unhold"));
450 assert!(a.contains(&"vim"));
451 }
452
453 #[test]
456 fn snap_list_action_name() {
457 assert_eq!(snap_list().action_name, "SnapList");
458 }
459
460 #[test]
461 fn snap_list_no_sudo() {
462 let spec = snap_list();
463 let (prog, _) = extract_args(&spec);
464 assert_ne!(prog, "sudo");
465 }
466
467 #[test]
468 fn snap_list_risk_low() {
469 assert_eq!(snap_list().risk_level, RiskLevel::Low);
470 }
471
472 #[test]
475 fn snap_info_action_name() {
476 assert_eq!(snap_info("firefox").action_name, "SnapInfo");
477 }
478
479 #[test]
480 fn snap_info_no_sudo() {
481 let spec = snap_info("firefox");
482 let (prog, _) = extract_args(&spec);
483 assert_ne!(prog, "sudo");
484 }
485
486 #[test]
487 fn snap_info_risk_low() {
488 assert_eq!(snap_info("firefox").risk_level, RiskLevel::Low);
489 }
490
491 #[test]
492 fn snap_info_includes_name() {
493 let spec = snap_info("code");
494 let (_, args) = extract_args(&spec);
495 let a: Vec<&str> = args.iter().map(String::as_str).collect();
496 assert!(a.contains(&"code"));
497 }
498
499 #[test]
502 fn snap_revert_action_name() {
503 assert_eq!(snap_revert("firefox").action_name, "SnapRevert");
504 }
505
506 #[test]
507 fn snap_revert_argv_correct() {
508 let spec = snap_revert("firefox");
509 let (prog, args) = extract_args(&spec);
510 assert_eq!(prog, "sudo");
511 let a: Vec<&str> = args.iter().map(String::as_str).collect();
512 assert!(a.contains(&"snap"));
513 assert!(a.contains(&"revert"));
514 assert!(a.contains(&"firefox"));
515 }
516
517 #[test]
518 fn snap_revert_risk_medium() {
519 assert_eq!(snap_revert("firefox").risk_level, RiskLevel::Medium);
520 }
521
522 #[test]
525 fn snap_classic_install_action_name() {
526 assert_eq!(
527 snap_classic_install("code").action_name,
528 "SnapClassicInstall"
529 );
530 }
531
532 #[test]
533 fn snap_classic_install_argv_contains_classic_flag() {
534 let spec = snap_classic_install("code");
535 let (prog, args) = extract_args(&spec);
536 assert_eq!(prog, "sudo");
537 let a: Vec<&str> = args.iter().map(String::as_str).collect();
538 assert!(a.contains(&"snap"));
539 assert!(a.contains(&"install"));
540 assert!(a.contains(&"--classic"));
541 assert!(a.contains(&"code"));
542 }
543
544 #[test]
545 fn snap_classic_install_risk_medium() {
546 assert_eq!(snap_classic_install("code").risk_level, RiskLevel::Medium);
547 }
548
549 #[test]
552 fn specs_covers_all_action_names() {
553 let expected = [
554 "SnapInstall",
555 "SnapRemove",
556 "SnapRefresh",
557 "SnapHold",
558 "SnapUnhold",
559 "SnapList",
560 "SnapInfo",
561 "SnapRevert",
562 "SnapClassicInstall",
563 ];
564 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
565 for name in &expected {
566 assert!(spec_names.contains(name), "specs() missing {name}");
567 }
568 }
569}