Skip to main content

sysknife_daemon/actions/
apt.rs

1//! apt-family package management actions (Ubuntu / Debian).
2//!
3//! All apt commands run with `DEBIAN_FRONTEND=noninteractive` and
4//! `NEEDRESTART_MODE=a` so they never block waiting for a TUI prompt.
5//!
6//! ## Lock contention
7//!
8//! Before any mutating apt command the executor should detect dpkg lock
9//! contention via `fuser /var/lib/dpkg/lock`. That check lives in the
10//! executor; this module only builds the `ActionSpec` for the apt
11//! command itself. The executor layer is responsible for the retry-after
12//! hint when the lock is held.
13//!
14//! ## Environment variables
15//!
16//! `DEBIAN_FRONTEND=noninteractive` suppresses all debconf prompts.
17//! `NEEDRESTART_MODE=a` tells the `needrestart` post-install hook to
18//! automatically restart services instead of prompting the operator.
19//! Both are required for daemon-driven apt invocations; omitting them
20//! causes apt to hang waiting for terminal input that never arrives.
21
22use super::{command_mechanism, ActionSpec};
23use sysknife_types::RiskLevel;
24
25// ---------------------------------------------------------------------------
26// Constants
27// ---------------------------------------------------------------------------
28
29/// apt binary path.
30const APT_GET: &str = "apt-get";
31
32/// `DEBIAN_FRONTEND` value that suppresses all debconf interactive prompts.
33/// Required for non-interactive daemon invocations.
34const DEBIAN_FRONTEND_VALUE: &str = "DEBIAN_FRONTEND=noninteractive";
35
36/// `NEEDRESTART_MODE` value that makes needrestart automatically restart
37/// services. Without this, apt post-install hooks spawn an interactive TUI
38/// that blocks the daemon indefinitely.
39const NEEDRESTART_MODE_VALUE: &str = "NEEDRESTART_MODE=a";
40
41// ---------------------------------------------------------------------------
42// specs() — for action_consistency tests
43// ---------------------------------------------------------------------------
44
45/// Return one representative `ActionSpec` per action name so the consistency
46/// test can verify policy + preview + executor coverage.
47pub fn specs() -> Vec<ActionSpec> {
48    vec![
49        apt_update(),
50        apt_upgrade(),
51        apt_install("curl"),
52        apt_remove("curl"),
53        apt_purge("curl"),
54        apt_autoremove(),
55        apt_hold("curl"),
56        apt_unhold("curl"),
57        apt_search("curl"),
58        apt_list_installed(),
59        apt_show("curl"),
60        apt_list_upgradable(),
61        apt_history_list(),
62        configure_unattended_upgrades(true),
63    ]
64}
65
66/// Installed path of the privileged unattended-upgrades helper script.
67/// See `packaging/sysknife-unattended-upgrades-edit` and the matching NOPASSWD
68/// grant in `packaging/sysknife-sudoers`.
69const UNATTENDED_UPGRADES_HELPER: &str = "/usr/lib/sysknife/unattended-upgrades-edit";
70
71/// Enable or disable Ubuntu automatic (unattended) security updates.
72///
73/// Risk: High. Delegates to the root-owned helper, which writes the fixed
74/// `/etc/apt/apt.conf.d/20auto-upgrades` content (no free-form input) and, when
75/// enabling, ensures the `unattended-upgrades` package is installed. Changing
76/// the automatic-update posture is a security-relevant, system-wide operation.
77pub fn configure_unattended_upgrades(enabled: bool) -> ActionSpec {
78    let flag = if enabled { "--enable" } else { "--disable" };
79    ActionSpec {
80        action_name: "ConfigureUnattendedUpgrades",
81        mechanism: command_mechanism("sudo", [UNATTENDED_UPGRADES_HELPER, flag]),
82        risk_level: RiskLevel::High,
83        reboot_required: false,
84        rollback_available: false,
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Action constructors
90// ---------------------------------------------------------------------------
91
92/// Refresh the apt package index (`apt-get update`).
93///
94/// Risk: Low. No packages are changed, no lock contention for writes.
95/// Requires network access to reach configured APT sources.
96pub fn apt_update() -> ActionSpec {
97    ActionSpec {
98        action_name: "AptUpdate",
99        mechanism: command_mechanism(
100            "sudo",
101            [
102                "env",
103                DEBIAN_FRONTEND_VALUE,
104                NEEDRESTART_MODE_VALUE,
105                APT_GET,
106                "update",
107            ],
108        ),
109        risk_level: RiskLevel::Low,
110        reboot_required: false,
111        rollback_available: false,
112    }
113}
114
115/// Upgrade all installed packages (`apt-get dist-upgrade -y`).
116///
117/// Risk: High. `dist-upgrade` may remove packages to resolve dependency
118/// conflicts (unlike `upgrade`). Can trigger `needrestart` service restarts.
119pub fn apt_upgrade() -> ActionSpec {
120    ActionSpec {
121        action_name: "AptUpgrade",
122        mechanism: command_mechanism(
123            "sudo",
124            [
125                "env",
126                DEBIAN_FRONTEND_VALUE,
127                NEEDRESTART_MODE_VALUE,
128                APT_GET,
129                "dist-upgrade",
130                "-y",
131            ],
132        ),
133        risk_level: RiskLevel::High,
134        reboot_required: false,
135        rollback_available: false,
136    }
137}
138
139/// Install a single package (`apt-get install -y <package>`).
140///
141/// Risk: Medium. Installs a package and its dependencies; reversible with
142/// `apt-get remove` but not transactionally atomic.
143pub fn apt_install(package: &str) -> ActionSpec {
144    ActionSpec {
145        action_name: "AptInstall",
146        mechanism: command_mechanism(
147            "sudo",
148            [
149                "env",
150                DEBIAN_FRONTEND_VALUE,
151                NEEDRESTART_MODE_VALUE,
152                APT_GET,
153                "install",
154                "-y",
155                package,
156            ],
157        ),
158        risk_level: RiskLevel::Medium,
159        reboot_required: false,
160        rollback_available: false,
161    }
162}
163
164/// Remove a package but keep its configuration files (`apt-get remove -y <package>`).
165///
166/// Risk: Medium. Configuration files remain in `/etc`; reversible by
167/// reinstalling the package. Use `apt_purge` to also remove config files.
168pub fn apt_remove(package: &str) -> ActionSpec {
169    ActionSpec {
170        action_name: "AptRemove",
171        mechanism: command_mechanism(
172            "sudo",
173            [
174                "env",
175                DEBIAN_FRONTEND_VALUE,
176                NEEDRESTART_MODE_VALUE,
177                APT_GET,
178                "remove",
179                "-y",
180                package,
181            ],
182        ),
183        risk_level: RiskLevel::Medium,
184        reboot_required: false,
185        rollback_available: false,
186    }
187}
188
189/// Remove a package AND its configuration files (`apt-get purge -y <package>`).
190///
191/// Risk: Medium. Configuration files are deleted; harder to reverse than
192/// `remove`. Choose `purge` when a clean reinstall is the goal.
193pub fn apt_purge(package: &str) -> ActionSpec {
194    ActionSpec {
195        action_name: "AptPurge",
196        mechanism: command_mechanism(
197            "sudo",
198            [
199                "env",
200                DEBIAN_FRONTEND_VALUE,
201                NEEDRESTART_MODE_VALUE,
202                APT_GET,
203                "purge",
204                "-y",
205                package,
206            ],
207        ),
208        risk_level: RiskLevel::Medium,
209        reboot_required: false,
210        rollback_available: false,
211    }
212}
213
214/// Remove automatically-installed packages no longer needed (`apt-get autoremove -y`).
215///
216/// Risk: Low. Only removes packages that were installed as dependencies and
217/// are no longer required by any explicitly-installed package.
218pub fn apt_autoremove() -> ActionSpec {
219    ActionSpec {
220        action_name: "AptAutoremove",
221        mechanism: command_mechanism(
222            "sudo",
223            [
224                "env",
225                DEBIAN_FRONTEND_VALUE,
226                NEEDRESTART_MODE_VALUE,
227                APT_GET,
228                "autoremove",
229                "-y",
230            ],
231        ),
232        risk_level: RiskLevel::Medium,
233        reboot_required: false,
234        rollback_available: false,
235    }
236}
237
238/// Pin a package at its current version (`apt-mark hold <package>`).
239///
240/// Risk: Medium. Prevents upgrades for the named package until unheld.
241/// Useful to freeze a known-good version when upstream upgrades are risky.
242pub fn apt_hold(package: &str) -> ActionSpec {
243    ActionSpec {
244        action_name: "AptHold",
245        mechanism: command_mechanism("sudo", ["apt-mark", "hold", package]),
246        risk_level: RiskLevel::Medium,
247        reboot_required: false,
248        rollback_available: false,
249    }
250}
251
252/// Remove the hold on a package, allowing upgrades again (`apt-mark unhold <package>`).
253///
254/// Risk: Medium. After unholding, the next `apt upgrade` can update the package.
255pub fn apt_unhold(package: &str) -> ActionSpec {
256    ActionSpec {
257        action_name: "AptUnhold",
258        mechanism: command_mechanism("sudo", ["apt-mark", "unhold", package]),
259        risk_level: RiskLevel::Medium,
260        reboot_required: false,
261        rollback_available: false,
262    }
263}
264
265/// Search apt repositories for packages matching a term (`apt-cache search <term>`).
266///
267/// Risk: Low. Read-only query; no system changes.
268pub fn apt_search(term: &str) -> ActionSpec {
269    ActionSpec {
270        action_name: "AptSearch",
271        mechanism: command_mechanism("apt-cache", ["search", term]),
272        risk_level: RiskLevel::Low,
273        reboot_required: false,
274        rollback_available: false,
275    }
276}
277
278/// List all installed packages (`dpkg -l`).
279///
280/// Risk: Low. Read-only query; no system changes.
281pub fn apt_list_installed() -> ActionSpec {
282    ActionSpec {
283        action_name: "AptListInstalled",
284        mechanism: command_mechanism("dpkg", ["-l"]),
285        risk_level: RiskLevel::Low,
286        reboot_required: false,
287        rollback_available: false,
288    }
289}
290
291/// Show detailed information about a package (`apt-cache show <package>`).
292///
293/// Risk: Low. Read-only query showing version, dependencies, description.
294pub fn apt_show(package: &str) -> ActionSpec {
295    ActionSpec {
296        action_name: "AptShow",
297        mechanism: command_mechanism("apt-cache", ["show", package]),
298        risk_level: RiskLevel::Low,
299        reboot_required: false,
300        rollback_available: false,
301    }
302}
303
304/// List packages that have upgrades available (`apt list --upgradable`).
305///
306/// Risk: Low. Read-only query; no packages are installed or changed.
307/// Stderr suppressed via `2>/dev/null` to hide the "WARNING: apt does not
308/// have a stable CLI interface" notice that apt emits on non-interactive
309/// invocations. The script is tee'd through `bash -c` because `apt list`
310/// writes its "Listing..." header to stderr.
311pub fn apt_list_upgradable() -> ActionSpec {
312    ActionSpec {
313        action_name: "AptListUpgradable",
314        mechanism: command_mechanism("bash", ["-c", "apt list --upgradable 2>/dev/null"]),
315        risk_level: RiskLevel::Low,
316        reboot_required: false,
317        rollback_available: false,
318    }
319}
320
321/// Show recent apt transaction history from `/var/log/apt/history.log`.
322///
323/// Risk: Low. Read-only file inspection; no packages are changed.
324/// Retrieves the 80 most recent log lines covering the last few Start-Date
325/// entries so the user can audit what was installed, removed, or upgraded.
326pub fn apt_history_list() -> ActionSpec {
327    ActionSpec {
328        action_name: "AptHistoryList",
329        mechanism: command_mechanism(
330            "bash",
331            [
332                "-c",
333                "grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80",
334            ],
335        ),
336        risk_level: RiskLevel::Low,
337        reboot_required: false,
338        rollback_available: false,
339    }
340}
341
342// ---------------------------------------------------------------------------
343// Tests
344// ---------------------------------------------------------------------------
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::actions::ActionMechanism;
350
351    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
352        match &spec.mechanism {
353            ActionMechanism::Command { program, args } => {
354                (*program, args.iter().map(String::as_str).collect())
355            }
356            _ => panic!("expected Command mechanism"),
357        }
358    }
359
360    // ── apt_update ───────────────────────────────────────────────────────────
361
362    #[test]
363    fn apt_update_action_name() {
364        assert_eq!(apt_update().action_name, "AptUpdate");
365    }
366
367    #[test]
368    fn apt_update_argv_correct() {
369        let spec = apt_update();
370        let (prog, args) = extract_args(&spec);
371        assert_eq!(prog, "sudo");
372        assert!(args.contains(&"apt-get"));
373        assert!(args.contains(&"update"));
374        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
375        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
376    }
377
378    #[test]
379    fn apt_update_risk_is_low() {
380        assert_eq!(apt_update().risk_level, RiskLevel::Low);
381    }
382
383    #[test]
384    fn apt_update_no_reboot_no_rollback() {
385        let spec = apt_update();
386        assert!(!spec.reboot_required);
387        assert!(!spec.rollback_available);
388    }
389
390    // ── apt_upgrade ──────────────────────────────────────────────────────────
391
392    #[test]
393    fn apt_upgrade_action_name() {
394        assert_eq!(apt_upgrade().action_name, "AptUpgrade");
395    }
396
397    #[test]
398    fn apt_upgrade_argv_correct() {
399        let spec = apt_upgrade();
400        let (prog, args) = extract_args(&spec);
401        assert_eq!(prog, "sudo");
402        assert!(args.contains(&"apt-get"));
403        assert!(args.contains(&"dist-upgrade"));
404        assert!(args.contains(&"-y"));
405        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
406        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
407    }
408
409    #[test]
410    fn apt_upgrade_risk_is_high() {
411        assert_eq!(apt_upgrade().risk_level, RiskLevel::High);
412    }
413
414    // ── apt_install ──────────────────────────────────────────────────────────
415
416    #[test]
417    fn apt_install_action_name() {
418        assert_eq!(apt_install("vim").action_name, "AptInstall");
419    }
420
421    #[test]
422    fn apt_install_includes_package_name() {
423        let spec = apt_install("vim");
424        let (_, args) = extract_args(&spec);
425        assert!(args.contains(&"vim"));
426        assert!(args.contains(&"install"));
427        assert!(args.contains(&"-y"));
428        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
429        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
430    }
431
432    #[test]
433    fn apt_install_risk_is_medium() {
434        assert_eq!(apt_install("vim").risk_level, RiskLevel::Medium);
435    }
436
437    // ── apt_remove ───────────────────────────────────────────────────────────
438
439    #[test]
440    fn apt_remove_action_name() {
441        assert_eq!(apt_remove("vim").action_name, "AptRemove");
442    }
443
444    #[test]
445    fn apt_remove_argv_contains_remove_not_purge() {
446        let spec = apt_remove("vim");
447        let (_, args) = extract_args(&spec);
448        assert!(args.contains(&"remove"));
449        // Must NOT contain purge — that's a different action.
450        assert!(!args.contains(&"purge"));
451        assert!(args.contains(&"vim"));
452        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
453        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
454    }
455
456    #[test]
457    fn apt_remove_risk_is_medium() {
458        assert_eq!(apt_remove("vim").risk_level, RiskLevel::Medium);
459    }
460
461    // ── apt_purge ────────────────────────────────────────────────────────────
462
463    #[test]
464    fn apt_purge_action_name() {
465        assert_eq!(apt_purge("vim").action_name, "AptPurge");
466    }
467
468    #[test]
469    fn apt_purge_argv_uses_purge_subcommand() {
470        let spec = apt_purge("vim");
471        let (_, args) = extract_args(&spec);
472        assert!(args.contains(&"purge"));
473        assert!(args.contains(&"vim"));
474        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
475        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
476    }
477
478    #[test]
479    fn apt_purge_risk_is_medium() {
480        assert_eq!(apt_purge("vim").risk_level, RiskLevel::Medium);
481    }
482
483    // ── apt_autoremove ───────────────────────────────────────────────────────
484
485    #[test]
486    fn apt_autoremove_action_name() {
487        assert_eq!(apt_autoremove().action_name, "AptAutoremove");
488    }
489
490    #[test]
491    fn apt_autoremove_argv_correct() {
492        let spec = apt_autoremove();
493        let (prog, args) = extract_args(&spec);
494        assert_eq!(prog, "sudo");
495        assert!(args.contains(&"autoremove"));
496        assert!(args.contains(&"-y"));
497        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
498        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
499    }
500
501    #[test]
502    fn apt_autoremove_risk_is_medium() {
503        assert_eq!(apt_autoremove().risk_level, RiskLevel::Medium);
504    }
505
506    // ── apt_hold ─────────────────────────────────────────────────────────────
507
508    #[test]
509    fn apt_hold_action_name() {
510        assert_eq!(apt_hold("nginx").action_name, "AptHold");
511    }
512
513    #[test]
514    fn apt_hold_uses_apt_mark_hold() {
515        let spec = apt_hold("nginx");
516        let (prog, args) = extract_args(&spec);
517        assert_eq!(prog, "sudo");
518        assert!(args.contains(&"apt-mark"));
519        assert!(args.contains(&"hold"));
520        assert!(args.contains(&"nginx"));
521    }
522
523    #[test]
524    fn apt_hold_risk_is_medium() {
525        assert_eq!(apt_hold("nginx").risk_level, RiskLevel::Medium);
526    }
527
528    // ── apt_unhold ───────────────────────────────────────────────────────────
529
530    #[test]
531    fn apt_unhold_action_name() {
532        assert_eq!(apt_unhold("nginx").action_name, "AptUnhold");
533    }
534
535    #[test]
536    fn apt_unhold_uses_apt_mark_unhold() {
537        let spec = apt_unhold("nginx");
538        let (_, args) = extract_args(&spec);
539        assert!(args.contains(&"apt-mark"));
540        assert!(args.contains(&"unhold"));
541        assert!(args.contains(&"nginx"));
542    }
543
544    #[test]
545    fn apt_unhold_risk_is_medium() {
546        assert_eq!(apt_unhold("nginx").risk_level, RiskLevel::Medium);
547    }
548
549    // ── apt_search ───────────────────────────────────────────────────────────
550
551    #[test]
552    fn apt_search_action_name() {
553        assert_eq!(apt_search("docker").action_name, "AptSearch");
554    }
555
556    #[test]
557    fn apt_search_uses_apt_cache_search() {
558        let spec = apt_search("docker");
559        let (prog, args) = extract_args(&spec);
560        assert_eq!(prog, "apt-cache");
561        assert!(args.contains(&"search"));
562        assert!(args.contains(&"docker"));
563    }
564
565    #[test]
566    fn apt_search_risk_is_low() {
567        assert_eq!(apt_search("docker").risk_level, RiskLevel::Low);
568    }
569
570    #[test]
571    fn apt_search_no_sudo() {
572        // Read-only search must not require sudo.
573        let spec = apt_search("docker");
574        let (prog, _) = extract_args(&spec);
575        assert_ne!(prog, "sudo");
576    }
577
578    // ── apt_list_installed ───────────────────────────────────────────────────
579
580    #[test]
581    fn apt_list_installed_action_name() {
582        assert_eq!(apt_list_installed().action_name, "AptListInstalled");
583    }
584
585    #[test]
586    fn apt_list_installed_uses_dpkg() {
587        let spec = apt_list_installed();
588        let (prog, args) = extract_args(&spec);
589        assert_eq!(prog, "dpkg");
590        assert!(args.contains(&"-l"));
591    }
592
593    #[test]
594    fn apt_list_installed_risk_is_low() {
595        assert_eq!(apt_list_installed().risk_level, RiskLevel::Low);
596    }
597
598    // ── apt_show ─────────────────────────────────────────────────────────────
599
600    #[test]
601    fn apt_show_action_name() {
602        assert_eq!(apt_show("openssh-server").action_name, "AptShow");
603    }
604
605    #[test]
606    fn apt_show_uses_apt_cache_show() {
607        let spec = apt_show("openssh-server");
608        let (prog, args) = extract_args(&spec);
609        assert_eq!(prog, "apt-cache");
610        assert!(args.contains(&"show"));
611        assert!(args.contains(&"openssh-server"));
612    }
613
614    #[test]
615    fn apt_show_risk_is_low() {
616        assert_eq!(apt_show("openssh-server").risk_level, RiskLevel::Low);
617    }
618
619    // ── apt_list_upgradable ──────────────────────────────────────────────────
620
621    #[test]
622    fn apt_list_upgradable_action_name() {
623        assert_eq!(apt_list_upgradable().action_name, "AptListUpgradable");
624    }
625
626    #[test]
627    fn apt_list_upgradable_uses_bash_and_apt_list() {
628        let spec = apt_list_upgradable();
629        match &spec.mechanism {
630            ActionMechanism::Command { program, args } => {
631                assert_eq!(*program, "bash");
632                let joined = args.join(" ");
633                assert!(joined.contains("apt list --upgradable"), "argv: {joined}");
634            }
635            _ => panic!("expected Command mechanism"),
636        }
637    }
638
639    #[test]
640    fn apt_list_upgradable_risk_is_low() {
641        assert_eq!(apt_list_upgradable().risk_level, RiskLevel::Low);
642    }
643
644    // ── apt_history_list ─────────────────────────────────────────────────────
645
646    #[test]
647    fn apt_history_list_action_name() {
648        assert_eq!(apt_history_list().action_name, "AptHistoryList");
649    }
650
651    #[test]
652    fn apt_history_list_uses_bash_and_grep() {
653        let spec = apt_history_list();
654        match &spec.mechanism {
655            ActionMechanism::Command { program, args } => {
656                assert_eq!(*program, "bash");
657                let joined = args.join(" ");
658                assert!(
659                    joined.contains("/var/log/apt/history.log"),
660                    "argv: {joined}"
661                );
662            }
663            _ => panic!("expected Command mechanism"),
664        }
665    }
666
667    #[test]
668    fn apt_history_list_risk_is_low() {
669        assert_eq!(apt_history_list().risk_level, RiskLevel::Low);
670    }
671
672    // ── specs() completeness ─────────────────────────────────────────────────
673
674    #[test]
675    fn specs_covers_all_action_names() {
676        let expected_names = [
677            "AptUpdate",
678            "AptUpgrade",
679            "AptInstall",
680            "AptRemove",
681            "AptPurge",
682            "AptAutoremove",
683            "AptHold",
684            "AptUnhold",
685            "AptSearch",
686            "AptListInstalled",
687            "AptShow",
688            "AptListUpgradable",
689            "AptHistoryList",
690        ];
691        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
692        for name in &expected_names {
693            assert!(
694                spec_names.contains(name),
695                "specs() is missing action {name}"
696            );
697        }
698    }
699
700    // ── env var injection regression ─────────────────────────────────────────
701
702    #[test]
703    fn mutating_apt_commands_carry_env_vars() {
704        let mutating = [
705            apt_upgrade(),
706            apt_install("vim"),
707            apt_remove("vim"),
708            apt_purge("vim"),
709            apt_autoremove(),
710        ];
711        for spec in &mutating {
712            let (_, args) = extract_args(spec);
713            assert!(
714                args.contains(&DEBIAN_FRONTEND_VALUE),
715                "{} missing DEBIAN_FRONTEND",
716                spec.action_name
717            );
718            assert!(
719                args.contains(&NEEDRESTART_MODE_VALUE),
720                "{} missing NEEDRESTART_MODE",
721                spec.action_name
722            );
723        }
724    }
725}