1use super::{command_mechanism, ActionSpec};
46use sysknife_types::RiskLevel;
47
48const ACTION_STEPS: &str = "/usr/lib/sysknife/action-steps";
59
60pub fn specs() -> Vec<ActionSpec> {
67 vec![
68 apt_update(),
69 apt_upgrade(),
70 apt_install("curl"),
71 apt_remove("curl"),
72 apt_purge("curl"),
73 apt_autoremove(),
74 apt_hold("curl"),
75 apt_unhold("curl"),
76 apt_search("curl"),
77 apt_list_installed(),
78 apt_show("curl"),
79 apt_list_upgradable(),
80 apt_history_list(),
81 configure_unattended_upgrades(true),
82 ]
83}
84
85const UNATTENDED_UPGRADES_HELPER: &str = "/usr/lib/sysknife/unattended-upgrades-edit";
89
90pub fn configure_unattended_upgrades(enabled: bool) -> ActionSpec {
97 let flag = if enabled { "--enable" } else { "--disable" };
98 ActionSpec {
99 action_name: "ConfigureUnattendedUpgrades",
100 mechanism: command_mechanism("sudo", [UNATTENDED_UPGRADES_HELPER, flag]),
101 risk_level: RiskLevel::High,
102 reboot_required: false,
103 rollback_available: false,
104 }
105}
106
107pub fn apt_update() -> ActionSpec {
116 ActionSpec {
117 action_name: "AptUpdate",
118 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "update"]),
119 risk_level: RiskLevel::Low,
120 reboot_required: false,
121 rollback_available: false,
122 }
123}
124
125pub fn apt_upgrade() -> ActionSpec {
131 ActionSpec {
132 action_name: "AptUpgrade",
133 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "dist-upgrade"]),
134 risk_level: RiskLevel::High,
135 reboot_required: false,
136 rollback_available: false,
137 }
138}
139
140pub fn apt_install(package: &str) -> ActionSpec {
149 ActionSpec {
150 action_name: "AptInstall",
151 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "install", package]),
152 risk_level: RiskLevel::Medium,
153 reboot_required: false,
154 rollback_available: false,
155 }
156}
157
158pub fn apt_remove(package: &str) -> ActionSpec {
168 ActionSpec {
169 action_name: "AptRemove",
170 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "remove", package]),
171 risk_level: RiskLevel::Medium,
172 reboot_required: false,
173 rollback_available: false,
174 }
175}
176
177pub fn apt_purge(package: &str) -> ActionSpec {
184 ActionSpec {
185 action_name: "AptPurge",
186 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "purge", package]),
187 risk_level: RiskLevel::Medium,
188 reboot_required: false,
189 rollback_available: false,
190 }
191}
192
193pub fn apt_autoremove() -> ActionSpec {
203 ActionSpec {
204 action_name: "AptAutoremove",
205 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "autoremove"]),
206 risk_level: RiskLevel::Medium,
207 reboot_required: false,
208 rollback_available: false,
209 }
210}
211
212pub fn apt_hold(package: &str) -> ActionSpec {
225 ActionSpec {
226 action_name: "AptHold",
227 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "hold", package]),
228 risk_level: RiskLevel::Medium,
229 reboot_required: false,
230 rollback_available: false,
231 }
232}
233
234pub fn apt_unhold(package: &str) -> ActionSpec {
239 ActionSpec {
240 action_name: "AptUnhold",
241 mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "unhold", package]),
242 risk_level: RiskLevel::Medium,
243 reboot_required: false,
244 rollback_available: false,
245 }
246}
247
248pub fn apt_search(term: &str) -> ActionSpec {
252 ActionSpec {
253 action_name: "AptSearch",
254 mechanism: command_mechanism("apt-cache", ["search", term]),
255 risk_level: RiskLevel::Low,
256 reboot_required: false,
257 rollback_available: false,
258 }
259}
260
261pub fn apt_list_installed() -> ActionSpec {
265 ActionSpec {
266 action_name: "AptListInstalled",
267 mechanism: command_mechanism("dpkg", ["-l"]),
268 risk_level: RiskLevel::Low,
269 reboot_required: false,
270 rollback_available: false,
271 }
272}
273
274pub fn apt_show(package: &str) -> ActionSpec {
278 ActionSpec {
279 action_name: "AptShow",
280 mechanism: command_mechanism("apt-cache", ["show", package]),
281 risk_level: RiskLevel::Low,
282 reboot_required: false,
283 rollback_available: false,
284 }
285}
286
287pub fn apt_list_upgradable() -> ActionSpec {
295 ActionSpec {
296 action_name: "AptListUpgradable",
297 mechanism: command_mechanism("bash", ["-c", "apt list --upgradable 2>/dev/null"]),
298 risk_level: RiskLevel::Low,
299 reboot_required: false,
300 rollback_available: false,
301 }
302}
303
304pub fn apt_history_list() -> ActionSpec {
310 ActionSpec {
311 action_name: "AptHistoryList",
312 mechanism: command_mechanism(
313 "bash",
314 [
315 "-c",
316 "grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80",
317 ],
318 ),
319 risk_level: RiskLevel::Low,
320 reboot_required: false,
321 rollback_available: false,
322 }
323}
324
325pub fn parse_autoremove_removals(simulate_stdout: &str) -> std::collections::BTreeSet<String> {
334 simulate_stdout
335 .lines()
336 .filter_map(|line| {
337 let rest = line.trim_start().strip_prefix("Remv ")?;
338 rest.split_whitespace().next().map(str::to_string)
339 })
340 .collect()
341}
342
343#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::actions::ActionMechanism;
351
352 fn removals(s: &str) -> Vec<String> {
353 parse_autoremove_removals(s).into_iter().collect()
354 }
355
356 #[test]
357 fn parse_autoremove_extracts_the_remv_package_set() {
358 let out = "Reading package lists... Done\n\
359 Building dependency tree... Done\n\
360 The following packages will be REMOVED:\n\
361 \x20 linux-image-5.4.0-100 linux-modules-5.4.0-100\n\
362 0 upgraded, 0 newly installed, 2 to remove and 0 not upgraded.\n\
363 Remv linux-image-5.4.0-100 [5.4.0-100.113]\n\
364 Remv linux-modules-5.4.0-100 [5.4.0-100.113]\n";
365 assert_eq!(
366 removals(out),
367 vec![
368 "linux-image-5.4.0-100".to_string(),
369 "linux-modules-5.4.0-100".to_string()
370 ]
371 );
372 }
373
374 #[test]
375 fn parse_autoremove_keeps_arch_suffix_and_ignores_non_remv_lines() {
376 let out = "Inst libkeep [1.0] (2.0 amd64)\n\
377 Remv libfoo:amd64 [1.2-3]\n\
378 Conf libbar [4.5]\n";
379 assert_eq!(removals(out), vec!["libfoo:amd64".to_string()]);
380 }
381
382 #[test]
383 fn parse_autoremove_on_empty_or_nothing_to_remove_is_empty() {
384 assert!(parse_autoremove_removals("").is_empty());
385 assert!(parse_autoremove_removals(
386 "Reading package lists... Done\n0 to remove and 0 not upgraded.\n"
387 )
388 .is_empty());
389 }
390
391 #[test]
392 fn parse_autoremove_deduplicates_and_sorts() {
393 let out = "Remv b [1]\nRemv a [1]\nRemv b [1]\n";
395 assert_eq!(removals(out), vec!["a".to_string(), "b".to_string()]);
396 }
397
398 fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
399 match &spec.mechanism {
400 ActionMechanism::Command { program, args } => {
401 (*program, args.iter().map(String::as_str).collect())
402 }
403 _ => panic!("expected Command mechanism"),
404 }
405 }
406
407 #[test]
410 fn apt_update_action_name() {
411 assert_eq!(apt_update().action_name, "AptUpdate");
412 }
413
414 #[test]
415 fn apt_update_argv_correct() {
416 let spec = apt_update();
417 let (prog, args) = extract_args(&spec);
418 assert_eq!(prog, "sudo");
419 assert_eq!(args, vec![ACTION_STEPS, "apt", "update"]);
420 }
421
422 #[test]
423 fn apt_update_risk_is_low() {
424 assert_eq!(apt_update().risk_level, RiskLevel::Low);
425 }
426
427 #[test]
428 fn apt_update_no_reboot_no_rollback() {
429 let spec = apt_update();
430 assert!(!spec.reboot_required);
431 assert!(!spec.rollback_available);
432 }
433
434 #[test]
437 fn apt_upgrade_action_name() {
438 assert_eq!(apt_upgrade().action_name, "AptUpgrade");
439 }
440
441 #[test]
442 fn apt_upgrade_argv_correct() {
443 let spec = apt_upgrade();
444 let (prog, args) = extract_args(&spec);
445 assert_eq!(prog, "sudo");
446 assert_eq!(args, vec![ACTION_STEPS, "apt", "dist-upgrade"]);
447 }
448
449 #[test]
450 fn apt_upgrade_risk_is_high() {
451 assert_eq!(apt_upgrade().risk_level, RiskLevel::High);
452 }
453
454 #[test]
457 fn apt_install_action_name() {
458 assert_eq!(apt_install("vim").action_name, "AptInstall");
459 }
460
461 #[test]
462 fn apt_install_includes_package_name() {
463 let spec = apt_install("vim");
464 let (prog, args) = extract_args(&spec);
465 assert_eq!(prog, "sudo");
466 assert_eq!(args, vec![ACTION_STEPS, "apt", "install", "vim"]);
467 }
468
469 #[test]
470 fn apt_install_risk_is_medium() {
471 assert_eq!(apt_install("vim").risk_level, RiskLevel::Medium);
472 }
473
474 #[test]
477 fn apt_remove_action_name() {
478 assert_eq!(apt_remove("vim").action_name, "AptRemove");
479 }
480
481 #[test]
482 fn apt_remove_argv_contains_remove_not_purge() {
483 let spec = apt_remove("vim");
484 let (prog, args) = extract_args(&spec);
485 assert_eq!(prog, "sudo");
486 assert_eq!(args, vec![ACTION_STEPS, "apt", "remove", "vim"]);
488 }
489
490 #[test]
491 fn apt_remove_risk_is_medium() {
492 assert_eq!(apt_remove("vim").risk_level, RiskLevel::Medium);
493 }
494
495 #[test]
498 fn apt_purge_action_name() {
499 assert_eq!(apt_purge("vim").action_name, "AptPurge");
500 }
501
502 #[test]
503 fn apt_purge_argv_uses_purge_subcommand() {
504 let spec = apt_purge("vim");
505 let (prog, args) = extract_args(&spec);
506 assert_eq!(prog, "sudo");
507 assert_eq!(args, vec![ACTION_STEPS, "apt", "purge", "vim"]);
508 }
509
510 #[test]
511 fn apt_purge_risk_is_medium() {
512 assert_eq!(apt_purge("vim").risk_level, RiskLevel::Medium);
513 }
514
515 #[test]
518 fn apt_autoremove_action_name() {
519 assert_eq!(apt_autoremove().action_name, "AptAutoremove");
520 }
521
522 #[test]
523 fn apt_autoremove_argv_correct() {
524 let spec = apt_autoremove();
525 let (prog, args) = extract_args(&spec);
526 assert_eq!(prog, "sudo");
527 assert_eq!(args, vec![ACTION_STEPS, "apt", "autoremove"]);
528 }
529
530 #[test]
531 fn apt_autoremove_risk_is_medium() {
532 assert_eq!(apt_autoremove().risk_level, RiskLevel::Medium);
533 }
534
535 #[test]
538 fn apt_hold_action_name() {
539 assert_eq!(apt_hold("nginx").action_name, "AptHold");
540 }
541
542 #[test]
543 fn apt_hold_uses_apt_mark_hold() {
544 let spec = apt_hold("nginx");
545 let (prog, args) = extract_args(&spec);
546 assert_eq!(prog, "sudo");
547 assert_eq!(args, vec![ACTION_STEPS, "apt", "hold", "nginx"]);
548 }
549
550 #[test]
551 fn apt_hold_risk_is_medium() {
552 assert_eq!(apt_hold("nginx").risk_level, RiskLevel::Medium);
553 }
554
555 #[test]
558 fn apt_unhold_action_name() {
559 assert_eq!(apt_unhold("nginx").action_name, "AptUnhold");
560 }
561
562 #[test]
563 fn apt_unhold_uses_apt_mark_unhold() {
564 let spec = apt_unhold("nginx");
565 let (_, args) = extract_args(&spec);
566 assert_eq!(args, vec![ACTION_STEPS, "apt", "unhold", "nginx"]);
567 }
568
569 #[test]
570 fn apt_unhold_risk_is_medium() {
571 assert_eq!(apt_unhold("nginx").risk_level, RiskLevel::Medium);
572 }
573
574 #[test]
577 fn apt_search_action_name() {
578 assert_eq!(apt_search("docker").action_name, "AptSearch");
579 }
580
581 #[test]
582 fn apt_search_uses_apt_cache_search() {
583 let spec = apt_search("docker");
584 let (prog, args) = extract_args(&spec);
585 assert_eq!(prog, "apt-cache");
586 assert!(args.contains(&"search"));
587 assert!(args.contains(&"docker"));
588 }
589
590 #[test]
591 fn apt_search_risk_is_low() {
592 assert_eq!(apt_search("docker").risk_level, RiskLevel::Low);
593 }
594
595 #[test]
596 fn apt_search_no_sudo() {
597 let spec = apt_search("docker");
599 let (prog, _) = extract_args(&spec);
600 assert_ne!(prog, "sudo");
601 }
602
603 #[test]
606 fn apt_list_installed_action_name() {
607 assert_eq!(apt_list_installed().action_name, "AptListInstalled");
608 }
609
610 #[test]
611 fn apt_list_installed_uses_dpkg() {
612 let spec = apt_list_installed();
613 let (prog, args) = extract_args(&spec);
614 assert_eq!(prog, "dpkg");
615 assert!(args.contains(&"-l"));
616 }
617
618 #[test]
619 fn apt_list_installed_risk_is_low() {
620 assert_eq!(apt_list_installed().risk_level, RiskLevel::Low);
621 }
622
623 #[test]
626 fn apt_show_action_name() {
627 assert_eq!(apt_show("openssh-server").action_name, "AptShow");
628 }
629
630 #[test]
631 fn apt_show_uses_apt_cache_show() {
632 let spec = apt_show("openssh-server");
633 let (prog, args) = extract_args(&spec);
634 assert_eq!(prog, "apt-cache");
635 assert!(args.contains(&"show"));
636 assert!(args.contains(&"openssh-server"));
637 }
638
639 #[test]
640 fn apt_show_risk_is_low() {
641 assert_eq!(apt_show("openssh-server").risk_level, RiskLevel::Low);
642 }
643
644 #[test]
647 fn apt_list_upgradable_action_name() {
648 assert_eq!(apt_list_upgradable().action_name, "AptListUpgradable");
649 }
650
651 #[test]
652 fn apt_list_upgradable_uses_bash_and_apt_list() {
653 let spec = apt_list_upgradable();
654 match &spec.mechanism {
655 ActionMechanism::Command { program, args } => {
656 assert_eq!(*program, "bash");
657 let joined = args.join(" ");
658 assert!(joined.contains("apt list --upgradable"), "argv: {joined}");
659 }
660 _ => panic!("expected Command mechanism"),
661 }
662 }
663
664 #[test]
665 fn apt_list_upgradable_risk_is_low() {
666 assert_eq!(apt_list_upgradable().risk_level, RiskLevel::Low);
667 }
668
669 #[test]
672 fn apt_history_list_action_name() {
673 assert_eq!(apt_history_list().action_name, "AptHistoryList");
674 }
675
676 #[test]
677 fn apt_history_list_uses_bash_and_grep() {
678 let spec = apt_history_list();
679 match &spec.mechanism {
680 ActionMechanism::Command { program, args } => {
681 assert_eq!(*program, "bash");
682 let joined = args.join(" ");
683 assert!(
684 joined.contains("/var/log/apt/history.log"),
685 "argv: {joined}"
686 );
687 }
688 _ => panic!("expected Command mechanism"),
689 }
690 }
691
692 #[test]
693 fn apt_history_list_risk_is_low() {
694 assert_eq!(apt_history_list().risk_level, RiskLevel::Low);
695 }
696
697 #[test]
700 fn specs_covers_all_action_names() {
701 let expected_names = [
702 "AptUpdate",
703 "AptUpgrade",
704 "AptInstall",
705 "AptRemove",
706 "AptPurge",
707 "AptAutoremove",
708 "AptHold",
709 "AptUnhold",
710 "AptSearch",
711 "AptListInstalled",
712 "AptShow",
713 "AptListUpgradable",
714 "AptHistoryList",
715 ];
716 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
717 for name in &expected_names {
718 assert!(
719 spec_names.contains(name),
720 "specs() is missing action {name}"
721 );
722 }
723 }
724
725 fn apt_get_actions() -> Vec<ActionSpec> {
731 vec![
732 apt_update(),
733 apt_upgrade(),
734 apt_install("vim"),
735 apt_remove("vim"),
736 apt_purge("vim"),
737 apt_autoremove(),
738 apt_hold("vim"),
739 apt_unhold("vim"),
740 ]
741 }
742
743 #[test]
749 fn apt_get_actions_go_through_the_helper_and_carry_no_option() {
750 for spec in apt_get_actions() {
751 let (prog, args) = extract_args(&spec);
752 assert_eq!(prog, "sudo", "{}", spec.action_name);
753 assert_eq!(
754 args[..2],
755 [ACTION_STEPS, "apt"],
756 "{} must go through the helper verb: {args:?}",
757 spec.action_name
758 );
759 for arg in &args {
760 assert!(
761 !arg.starts_with('-'),
762 "{} sends an option {arg:?}; the helper adds the flags itself",
763 spec.action_name
764 );
765 assert!(
766 !arg.contains("apt-get")
767 && !arg.contains("apt-mark")
768 && *arg != "env"
769 && !arg.contains('='),
770 "{} still builds a direct apt-get or apt-mark argv: {args:?}",
771 spec.action_name
772 );
773 }
774 }
775 }
776
777 #[test]
780 fn apt_get_actions_still_contend_for_the_dpkg_lock() {
781 for spec in apt_get_actions() {
782 assert_eq!(
783 crate::actions::exclusive_resource(&spec),
784 Some(crate::actions::ExclusiveResource::Dpkg),
785 "{} lost the dpkg lock when it moved to the helper",
786 spec.action_name
787 );
788 }
789 }
790
791 #[test]
794 fn a_package_named_like_another_tool_does_not_change_the_lock() {
795 for operand in ["snap", "flatpak", "rpm-ostree"] {
796 assert_eq!(
797 crate::actions::exclusive_resource(&apt_install(operand)),
798 Some(crate::actions::ExclusiveResource::Dpkg),
799 "apt install {operand} must hold the dpkg lock"
800 );
801 }
802 }
803}