1use super::{command_mechanism, ActionSpec};
30use sysknife_types::RiskLevel;
31
32const APT_GET: &str = "/usr/bin/apt-get";
51
52const DEBIAN_FRONTEND_VALUE: &str = "DEBIAN_FRONTEND=noninteractive";
55
56const NEEDRESTART_MODE_VALUE: &str = "NEEDRESTART_MODE=a";
60
61pub fn specs() -> Vec<ActionSpec> {
68 vec![
69 apt_update(),
70 apt_upgrade(),
71 apt_install("curl"),
72 apt_remove("curl"),
73 apt_purge("curl"),
74 apt_autoremove(),
75 apt_hold("curl"),
76 apt_unhold("curl"),
77 apt_search("curl"),
78 apt_list_installed(),
79 apt_show("curl"),
80 apt_list_upgradable(),
81 apt_history_list(),
82 configure_unattended_upgrades(true),
83 ]
84}
85
86const UNATTENDED_UPGRADES_HELPER: &str = "/usr/lib/sysknife/unattended-upgrades-edit";
90
91pub fn configure_unattended_upgrades(enabled: bool) -> ActionSpec {
98 let flag = if enabled { "--enable" } else { "--disable" };
99 ActionSpec {
100 action_name: "ConfigureUnattendedUpgrades",
101 mechanism: command_mechanism("sudo", [UNATTENDED_UPGRADES_HELPER, flag]),
102 risk_level: RiskLevel::High,
103 reboot_required: false,
104 rollback_available: false,
105 }
106}
107
108pub fn apt_update() -> ActionSpec {
117 ActionSpec {
118 action_name: "AptUpdate",
119 mechanism: command_mechanism(
120 "sudo",
121 [
122 "env",
123 DEBIAN_FRONTEND_VALUE,
124 NEEDRESTART_MODE_VALUE,
125 APT_GET,
126 "update",
127 ],
128 ),
129 risk_level: RiskLevel::Low,
130 reboot_required: false,
131 rollback_available: false,
132 }
133}
134
135pub fn apt_upgrade() -> ActionSpec {
140 ActionSpec {
141 action_name: "AptUpgrade",
142 mechanism: command_mechanism(
143 "sudo",
144 [
145 "env",
146 DEBIAN_FRONTEND_VALUE,
147 NEEDRESTART_MODE_VALUE,
148 APT_GET,
149 "dist-upgrade",
150 "-y",
151 ],
152 ),
153 risk_level: RiskLevel::High,
154 reboot_required: false,
155 rollback_available: false,
156 }
157}
158
159pub fn apt_install(package: &str) -> ActionSpec {
164 ActionSpec {
165 action_name: "AptInstall",
166 mechanism: command_mechanism(
167 "sudo",
168 [
169 "env",
170 DEBIAN_FRONTEND_VALUE,
171 NEEDRESTART_MODE_VALUE,
172 APT_GET,
173 "install",
174 "-y",
175 package,
176 ],
177 ),
178 risk_level: RiskLevel::Medium,
179 reboot_required: false,
180 rollback_available: false,
181 }
182}
183
184pub fn apt_remove(package: &str) -> ActionSpec {
189 ActionSpec {
190 action_name: "AptRemove",
191 mechanism: command_mechanism(
192 "sudo",
193 [
194 "env",
195 DEBIAN_FRONTEND_VALUE,
196 NEEDRESTART_MODE_VALUE,
197 APT_GET,
198 "remove",
199 "-y",
200 package,
201 ],
202 ),
203 risk_level: RiskLevel::Medium,
204 reboot_required: false,
205 rollback_available: false,
206 }
207}
208
209pub fn apt_purge(package: &str) -> ActionSpec {
214 ActionSpec {
215 action_name: "AptPurge",
216 mechanism: command_mechanism(
217 "sudo",
218 [
219 "env",
220 DEBIAN_FRONTEND_VALUE,
221 NEEDRESTART_MODE_VALUE,
222 APT_GET,
223 "purge",
224 "-y",
225 package,
226 ],
227 ),
228 risk_level: RiskLevel::Medium,
229 reboot_required: false,
230 rollback_available: false,
231 }
232}
233
234pub fn apt_autoremove() -> ActionSpec {
243 ActionSpec {
244 action_name: "AptAutoremove",
245 mechanism: command_mechanism(
246 "sudo",
247 [
248 "env",
249 DEBIAN_FRONTEND_VALUE,
250 NEEDRESTART_MODE_VALUE,
251 APT_GET,
252 "autoremove",
253 "-y",
254 ],
255 ),
256 risk_level: RiskLevel::Medium,
257 reboot_required: false,
258 rollback_available: false,
259 }
260}
261
262pub fn apt_hold(package: &str) -> ActionSpec {
267 ActionSpec {
268 action_name: "AptHold",
269 mechanism: command_mechanism("sudo", ["apt-mark", "hold", package]),
270 risk_level: RiskLevel::Medium,
271 reboot_required: false,
272 rollback_available: false,
273 }
274}
275
276pub fn apt_unhold(package: &str) -> ActionSpec {
280 ActionSpec {
281 action_name: "AptUnhold",
282 mechanism: command_mechanism("sudo", ["apt-mark", "unhold", package]),
283 risk_level: RiskLevel::Medium,
284 reboot_required: false,
285 rollback_available: false,
286 }
287}
288
289pub fn apt_search(term: &str) -> ActionSpec {
293 ActionSpec {
294 action_name: "AptSearch",
295 mechanism: command_mechanism("apt-cache", ["search", term]),
296 risk_level: RiskLevel::Low,
297 reboot_required: false,
298 rollback_available: false,
299 }
300}
301
302pub fn apt_list_installed() -> ActionSpec {
306 ActionSpec {
307 action_name: "AptListInstalled",
308 mechanism: command_mechanism("dpkg", ["-l"]),
309 risk_level: RiskLevel::Low,
310 reboot_required: false,
311 rollback_available: false,
312 }
313}
314
315pub fn apt_show(package: &str) -> ActionSpec {
319 ActionSpec {
320 action_name: "AptShow",
321 mechanism: command_mechanism("apt-cache", ["show", package]),
322 risk_level: RiskLevel::Low,
323 reboot_required: false,
324 rollback_available: false,
325 }
326}
327
328pub fn apt_list_upgradable() -> ActionSpec {
336 ActionSpec {
337 action_name: "AptListUpgradable",
338 mechanism: command_mechanism("bash", ["-c", "apt list --upgradable 2>/dev/null"]),
339 risk_level: RiskLevel::Low,
340 reboot_required: false,
341 rollback_available: false,
342 }
343}
344
345pub fn apt_history_list() -> ActionSpec {
351 ActionSpec {
352 action_name: "AptHistoryList",
353 mechanism: command_mechanism(
354 "bash",
355 [
356 "-c",
357 "grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80",
358 ],
359 ),
360 risk_level: RiskLevel::Low,
361 reboot_required: false,
362 rollback_available: false,
363 }
364}
365
366pub fn parse_autoremove_removals(simulate_stdout: &str) -> std::collections::BTreeSet<String> {
375 simulate_stdout
376 .lines()
377 .filter_map(|line| {
378 let rest = line.trim_start().strip_prefix("Remv ")?;
379 rest.split_whitespace().next().map(str::to_string)
380 })
381 .collect()
382}
383
384#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::actions::ActionMechanism;
392
393 fn removals(s: &str) -> Vec<String> {
394 parse_autoremove_removals(s).into_iter().collect()
395 }
396
397 #[test]
398 fn parse_autoremove_extracts_the_remv_package_set() {
399 let out = "Reading package lists... Done\n\
400 Building dependency tree... Done\n\
401 The following packages will be REMOVED:\n\
402 \x20 linux-image-5.4.0-100 linux-modules-5.4.0-100\n\
403 0 upgraded, 0 newly installed, 2 to remove and 0 not upgraded.\n\
404 Remv linux-image-5.4.0-100 [5.4.0-100.113]\n\
405 Remv linux-modules-5.4.0-100 [5.4.0-100.113]\n";
406 assert_eq!(
407 removals(out),
408 vec![
409 "linux-image-5.4.0-100".to_string(),
410 "linux-modules-5.4.0-100".to_string()
411 ]
412 );
413 }
414
415 #[test]
416 fn parse_autoremove_keeps_arch_suffix_and_ignores_non_remv_lines() {
417 let out = "Inst libkeep [1.0] (2.0 amd64)\n\
418 Remv libfoo:amd64 [1.2-3]\n\
419 Conf libbar [4.5]\n";
420 assert_eq!(removals(out), vec!["libfoo:amd64".to_string()]);
421 }
422
423 #[test]
424 fn parse_autoremove_on_empty_or_nothing_to_remove_is_empty() {
425 assert!(parse_autoremove_removals("").is_empty());
426 assert!(parse_autoremove_removals(
427 "Reading package lists... Done\n0 to remove and 0 not upgraded.\n"
428 )
429 .is_empty());
430 }
431
432 #[test]
433 fn parse_autoremove_deduplicates_and_sorts() {
434 let out = "Remv b [1]\nRemv a [1]\nRemv b [1]\n";
436 assert_eq!(removals(out), vec!["a".to_string(), "b".to_string()]);
437 }
438
439 fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
440 match &spec.mechanism {
441 ActionMechanism::Command { program, args } => {
442 (*program, args.iter().map(String::as_str).collect())
443 }
444 _ => panic!("expected Command mechanism"),
445 }
446 }
447
448 #[test]
451 fn apt_update_action_name() {
452 assert_eq!(apt_update().action_name, "AptUpdate");
453 }
454
455 #[test]
456 fn apt_update_argv_correct() {
457 let spec = apt_update();
458 let (prog, args) = extract_args(&spec);
459 assert_eq!(prog, "sudo");
460 assert!(
461 args.contains(&APT_GET),
462 "argv must carry the absolute apt-get path: {args:?}"
463 );
464 assert!(args.contains(&"update"));
465 assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
466 assert!(args.contains(&NEEDRESTART_MODE_VALUE));
467 }
468
469 #[test]
470 fn apt_update_risk_is_low() {
471 assert_eq!(apt_update().risk_level, RiskLevel::Low);
472 }
473
474 #[test]
475 fn apt_update_no_reboot_no_rollback() {
476 let spec = apt_update();
477 assert!(!spec.reboot_required);
478 assert!(!spec.rollback_available);
479 }
480
481 #[test]
484 fn apt_upgrade_action_name() {
485 assert_eq!(apt_upgrade().action_name, "AptUpgrade");
486 }
487
488 #[test]
489 fn apt_upgrade_argv_correct() {
490 let spec = apt_upgrade();
491 let (prog, args) = extract_args(&spec);
492 assert_eq!(prog, "sudo");
493 assert!(
494 args.contains(&APT_GET),
495 "argv must carry the absolute apt-get path: {args:?}"
496 );
497 assert!(args.contains(&"dist-upgrade"));
498 assert!(args.contains(&"-y"));
499 assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
500 assert!(args.contains(&NEEDRESTART_MODE_VALUE));
501 }
502
503 #[test]
504 fn apt_upgrade_risk_is_high() {
505 assert_eq!(apt_upgrade().risk_level, RiskLevel::High);
506 }
507
508 #[test]
511 fn apt_install_action_name() {
512 assert_eq!(apt_install("vim").action_name, "AptInstall");
513 }
514
515 #[test]
516 fn apt_install_includes_package_name() {
517 let spec = apt_install("vim");
518 let (_, args) = extract_args(&spec);
519 assert!(args.contains(&"vim"));
520 assert!(args.contains(&"install"));
521 assert!(args.contains(&"-y"));
522 assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
523 assert!(args.contains(&NEEDRESTART_MODE_VALUE));
524 }
525
526 #[test]
527 fn apt_install_risk_is_medium() {
528 assert_eq!(apt_install("vim").risk_level, RiskLevel::Medium);
529 }
530
531 #[test]
534 fn apt_remove_action_name() {
535 assert_eq!(apt_remove("vim").action_name, "AptRemove");
536 }
537
538 #[test]
539 fn apt_remove_argv_contains_remove_not_purge() {
540 let spec = apt_remove("vim");
541 let (_, args) = extract_args(&spec);
542 assert!(args.contains(&"remove"));
543 assert!(!args.contains(&"purge"));
545 assert!(args.contains(&"vim"));
546 assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
547 assert!(args.contains(&NEEDRESTART_MODE_VALUE));
548 }
549
550 #[test]
551 fn apt_remove_risk_is_medium() {
552 assert_eq!(apt_remove("vim").risk_level, RiskLevel::Medium);
553 }
554
555 #[test]
558 fn apt_purge_action_name() {
559 assert_eq!(apt_purge("vim").action_name, "AptPurge");
560 }
561
562 #[test]
563 fn apt_purge_argv_uses_purge_subcommand() {
564 let spec = apt_purge("vim");
565 let (_, args) = extract_args(&spec);
566 assert!(args.contains(&"purge"));
567 assert!(args.contains(&"vim"));
568 assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
569 assert!(args.contains(&NEEDRESTART_MODE_VALUE));
570 }
571
572 #[test]
573 fn apt_purge_risk_is_medium() {
574 assert_eq!(apt_purge("vim").risk_level, RiskLevel::Medium);
575 }
576
577 #[test]
580 fn apt_autoremove_action_name() {
581 assert_eq!(apt_autoremove().action_name, "AptAutoremove");
582 }
583
584 #[test]
585 fn apt_autoremove_argv_correct() {
586 let spec = apt_autoremove();
587 let (prog, args) = extract_args(&spec);
588 assert_eq!(prog, "sudo");
589 assert!(args.contains(&"autoremove"));
590 assert!(args.contains(&"-y"));
591 assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
592 assert!(args.contains(&NEEDRESTART_MODE_VALUE));
593 }
594
595 #[test]
596 fn apt_autoremove_risk_is_medium() {
597 assert_eq!(apt_autoremove().risk_level, RiskLevel::Medium);
598 }
599
600 #[test]
603 fn apt_hold_action_name() {
604 assert_eq!(apt_hold("nginx").action_name, "AptHold");
605 }
606
607 #[test]
608 fn apt_hold_uses_apt_mark_hold() {
609 let spec = apt_hold("nginx");
610 let (prog, args) = extract_args(&spec);
611 assert_eq!(prog, "sudo");
612 assert!(args.contains(&"apt-mark"));
613 assert!(args.contains(&"hold"));
614 assert!(args.contains(&"nginx"));
615 }
616
617 #[test]
618 fn apt_hold_risk_is_medium() {
619 assert_eq!(apt_hold("nginx").risk_level, RiskLevel::Medium);
620 }
621
622 #[test]
625 fn apt_unhold_action_name() {
626 assert_eq!(apt_unhold("nginx").action_name, "AptUnhold");
627 }
628
629 #[test]
630 fn apt_unhold_uses_apt_mark_unhold() {
631 let spec = apt_unhold("nginx");
632 let (_, args) = extract_args(&spec);
633 assert!(args.contains(&"apt-mark"));
634 assert!(args.contains(&"unhold"));
635 assert!(args.contains(&"nginx"));
636 }
637
638 #[test]
639 fn apt_unhold_risk_is_medium() {
640 assert_eq!(apt_unhold("nginx").risk_level, RiskLevel::Medium);
641 }
642
643 #[test]
646 fn apt_search_action_name() {
647 assert_eq!(apt_search("docker").action_name, "AptSearch");
648 }
649
650 #[test]
651 fn apt_search_uses_apt_cache_search() {
652 let spec = apt_search("docker");
653 let (prog, args) = extract_args(&spec);
654 assert_eq!(prog, "apt-cache");
655 assert!(args.contains(&"search"));
656 assert!(args.contains(&"docker"));
657 }
658
659 #[test]
660 fn apt_search_risk_is_low() {
661 assert_eq!(apt_search("docker").risk_level, RiskLevel::Low);
662 }
663
664 #[test]
665 fn apt_search_no_sudo() {
666 let spec = apt_search("docker");
668 let (prog, _) = extract_args(&spec);
669 assert_ne!(prog, "sudo");
670 }
671
672 #[test]
675 fn apt_list_installed_action_name() {
676 assert_eq!(apt_list_installed().action_name, "AptListInstalled");
677 }
678
679 #[test]
680 fn apt_list_installed_uses_dpkg() {
681 let spec = apt_list_installed();
682 let (prog, args) = extract_args(&spec);
683 assert_eq!(prog, "dpkg");
684 assert!(args.contains(&"-l"));
685 }
686
687 #[test]
688 fn apt_list_installed_risk_is_low() {
689 assert_eq!(apt_list_installed().risk_level, RiskLevel::Low);
690 }
691
692 #[test]
695 fn apt_show_action_name() {
696 assert_eq!(apt_show("openssh-server").action_name, "AptShow");
697 }
698
699 #[test]
700 fn apt_show_uses_apt_cache_show() {
701 let spec = apt_show("openssh-server");
702 let (prog, args) = extract_args(&spec);
703 assert_eq!(prog, "apt-cache");
704 assert!(args.contains(&"show"));
705 assert!(args.contains(&"openssh-server"));
706 }
707
708 #[test]
709 fn apt_show_risk_is_low() {
710 assert_eq!(apt_show("openssh-server").risk_level, RiskLevel::Low);
711 }
712
713 #[test]
716 fn apt_list_upgradable_action_name() {
717 assert_eq!(apt_list_upgradable().action_name, "AptListUpgradable");
718 }
719
720 #[test]
721 fn apt_list_upgradable_uses_bash_and_apt_list() {
722 let spec = apt_list_upgradable();
723 match &spec.mechanism {
724 ActionMechanism::Command { program, args } => {
725 assert_eq!(*program, "bash");
726 let joined = args.join(" ");
727 assert!(joined.contains("apt list --upgradable"), "argv: {joined}");
728 }
729 _ => panic!("expected Command mechanism"),
730 }
731 }
732
733 #[test]
734 fn apt_list_upgradable_risk_is_low() {
735 assert_eq!(apt_list_upgradable().risk_level, RiskLevel::Low);
736 }
737
738 #[test]
741 fn apt_history_list_action_name() {
742 assert_eq!(apt_history_list().action_name, "AptHistoryList");
743 }
744
745 #[test]
746 fn apt_history_list_uses_bash_and_grep() {
747 let spec = apt_history_list();
748 match &spec.mechanism {
749 ActionMechanism::Command { program, args } => {
750 assert_eq!(*program, "bash");
751 let joined = args.join(" ");
752 assert!(
753 joined.contains("/var/log/apt/history.log"),
754 "argv: {joined}"
755 );
756 }
757 _ => panic!("expected Command mechanism"),
758 }
759 }
760
761 #[test]
762 fn apt_history_list_risk_is_low() {
763 assert_eq!(apt_history_list().risk_level, RiskLevel::Low);
764 }
765
766 #[test]
769 fn specs_covers_all_action_names() {
770 let expected_names = [
771 "AptUpdate",
772 "AptUpgrade",
773 "AptInstall",
774 "AptRemove",
775 "AptPurge",
776 "AptAutoremove",
777 "AptHold",
778 "AptUnhold",
779 "AptSearch",
780 "AptListInstalled",
781 "AptShow",
782 "AptListUpgradable",
783 "AptHistoryList",
784 ];
785 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
786 for name in &expected_names {
787 assert!(
788 spec_names.contains(name),
789 "specs() is missing action {name}"
790 );
791 }
792 }
793
794 #[test]
797 fn mutating_apt_commands_carry_env_vars() {
798 let mutating = [
799 apt_upgrade(),
800 apt_install("vim"),
801 apt_remove("vim"),
802 apt_purge("vim"),
803 apt_autoremove(),
804 ];
805 for spec in &mutating {
806 let (_, args) = extract_args(spec);
807 assert!(
808 args.contains(&DEBIAN_FRONTEND_VALUE),
809 "{} missing DEBIAN_FRONTEND",
810 spec.action_name
811 );
812 assert!(
813 args.contains(&NEEDRESTART_MODE_VALUE),
814 "{} missing NEEDRESTART_MODE",
815 spec.action_name
816 );
817 }
818 }
819}