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//! Concurrent apt runs are prevented by the dispatcher's exclusion gate, not
9//! by a pre-flight probe: every action whose argv names an apt/dpkg tool maps
10//! to [`ExclusiveResource::Dpkg`](super::ExclusiveResource::Dpkg), and a second
11//! one submitted while the first holds that lock gets a `ConflictResponse`.
12//!
13//! An earlier version of this doc claimed the executor ran
14//! `fuser /var/lib/dpkg/lock` before each mutating command. It never did —
15//! there is no such probe anywhere in the daemon. The gate above covers
16//! contention *between SysKnife actions*, which is what SysKnife can actually
17//! control; it does not detect a dpkg lock held by something outside SysKnife
18//! (an operator's own `apt` session, or unattended-upgrades). In that case apt
19//! itself reports the lock and the action fails with apt's own message.
20//!
21//! ## Environment variables
22//!
23//! `DEBIAN_FRONTEND=noninteractive` suppresses all debconf prompts.
24//! `NEEDRESTART_MODE=a` tells the `needrestart` post-install hook to
25//! automatically restart services instead of prompting the operator.
26//! Both are required for daemon-driven apt invocations; omitting them
27//! causes apt to hang waiting for terminal input that never arrives.
28
29use super::{command_mechanism, ActionSpec};
30use sysknife_types::RiskLevel;
31
32// ---------------------------------------------------------------------------
33// Constants
34// ---------------------------------------------------------------------------
35
36/// apt binary path.
37///
38/// **Must stay absolute.** These actions run as
39/// `sudo env DEBIAN_FRONTEND=… NEEDRESTART_MODE=a <APT_GET> …`, and sudo
40/// PATH-resolves only its *primary* command (`env`) — every later token is
41/// matched against the sudoers rule as a literal string. The grant in
42/// `packaging/sysknife-sudoers` spells `/usr/bin/apt-get`, so a bare
43/// `apt-get` here does not match the rule and sudo falls through to
44/// "a password is required", which a non-interactive daemon can never
45/// supply. Verified on Ubuntu 24.04 with sudo 1.9.15p5: the bare form is
46/// denied, the absolute form is permitted.
47///
48/// `tests/sudoers_grants_match_argv.rs` pins this against the packaged
49/// sudoers file so the two cannot drift apart again.
50const APT_GET: &str = "/usr/bin/apt-get";
51
52/// `DEBIAN_FRONTEND` value that suppresses all debconf interactive prompts.
53/// Required for non-interactive daemon invocations.
54const DEBIAN_FRONTEND_VALUE: &str = "DEBIAN_FRONTEND=noninteractive";
55
56/// `NEEDRESTART_MODE` value that makes needrestart automatically restart
57/// services. Without this, apt post-install hooks spawn an interactive TUI
58/// that blocks the daemon indefinitely.
59const NEEDRESTART_MODE_VALUE: &str = "NEEDRESTART_MODE=a";
60
61// ---------------------------------------------------------------------------
62// specs() — for action_consistency tests
63// ---------------------------------------------------------------------------
64
65/// Return one representative `ActionSpec` per action name so the consistency
66/// test can verify policy + preview + executor coverage.
67pub 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
86/// Installed path of the privileged unattended-upgrades helper script.
87/// See `packaging/sysknife-unattended-upgrades-edit` and the matching NOPASSWD
88/// grant in `packaging/sysknife-sudoers`.
89const UNATTENDED_UPGRADES_HELPER: &str = "/usr/lib/sysknife/unattended-upgrades-edit";
90
91/// Enable or disable Ubuntu automatic (unattended) security updates.
92///
93/// Risk: High. Delegates to the root-owned helper, which writes the fixed
94/// `/etc/apt/apt.conf.d/20auto-upgrades` content (no free-form input) and, when
95/// enabling, ensures the `unattended-upgrades` package is installed. Changing
96/// the automatic-update posture is a security-relevant, system-wide operation.
97pub 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
108// ---------------------------------------------------------------------------
109// Action constructors
110// ---------------------------------------------------------------------------
111
112/// Refresh the apt package index (`apt-get update`).
113///
114/// Risk: Low. No packages are changed, no lock contention for writes.
115/// Requires network access to reach configured APT sources.
116pub 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
135/// Upgrade all installed packages (`apt-get dist-upgrade -y`).
136///
137/// Risk: High. `dist-upgrade` may remove packages to resolve dependency
138/// conflicts (unlike `upgrade`). Can trigger `needrestart` service restarts.
139pub 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
159/// Install a single package (`apt-get install -y <package>`).
160///
161/// Risk: Medium. Installs a package and its dependencies; reversible with
162/// `apt-get remove` but not transactionally atomic.
163pub 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
184/// Remove a package but keep its configuration files (`apt-get remove -y <package>`).
185///
186/// Risk: Medium. Configuration files remain in `/etc`; reversible by
187/// reinstalling the package. Use `apt_purge` to also remove config files.
188pub 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
209/// Remove a package AND its configuration files (`apt-get purge -y <package>`).
210///
211/// Risk: Medium. Configuration files are deleted; harder to reverse than
212/// `remove`. Choose `purge` when a clean reinstall is the goal.
213pub 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
234/// Remove automatically-installed packages no longer needed (`apt-get autoremove -y`).
235///
236/// Risk: Medium. The set of packages actually removed is chosen by the
237/// dependency resolver at execution time, not named by the caller, so the
238/// approved preview (`apt-get autoremove -y`) cannot show what will go. In
239/// practice that set has been known to include old kernels and driver or
240/// bootloader metapackages that were marked auto-installed. It stays below
241/// `AptUpgrade`'s High only because it never *installs* or upgrades anything.
242pub 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
262/// Pin a package at its current version (`apt-mark hold <package>`).
263///
264/// Risk: Medium. Prevents upgrades for the named package until unheld.
265/// Useful to freeze a known-good version when upstream upgrades are risky.
266pub 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
276/// Remove the hold on a package, allowing upgrades again (`apt-mark unhold <package>`).
277///
278/// Risk: Medium. After unholding, the next `apt upgrade` can update the package.
279pub 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
289/// Search apt repositories for packages matching a term (`apt-cache search <term>`).
290///
291/// Risk: Low. Read-only query; no system changes.
292pub 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
302/// List all installed packages (`dpkg -l`).
303///
304/// Risk: Low. Read-only query; no system changes.
305pub 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
315/// Show detailed information about a package (`apt-cache show <package>`).
316///
317/// Risk: Low. Read-only query showing version, dependencies, description.
318pub 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
328/// List packages that have upgrades available (`apt list --upgradable`).
329///
330/// Risk: Low. Read-only query; no packages are installed or changed.
331/// Stderr suppressed via `2>/dev/null` to hide the "WARNING: apt does not
332/// have a stable CLI interface" notice that apt emits on non-interactive
333/// invocations. The script is tee'd through `bash -c` because `apt list`
334/// writes its "Listing..." header to stderr.
335pub 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
345/// Show recent apt transaction history from `/var/log/apt/history.log`.
346///
347/// Risk: Low. Read-only file inspection; no packages are changed.
348/// Retrieves the 80 most recent log lines covering the last few Start-Date
349/// entries so the user can audit what was installed, removed, or upgraded.
350pub 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
366/// The set of packages `apt-get -s autoremove` would remove, parsed from its
367/// simulate (`-s`) output.
368///
369/// apt prints one `Remv <package> [<version>]` line per package it would remove
370/// (the package token may carry an `:arch` suffix, which we keep verbatim). This
371/// is used to bind `AptAutoremove`'s approval to the exact set the operator saw
372/// at preview time — `apt-get autoremove -y` otherwise resolves the deletion set
373/// at run time, so the executed effect is unbound by the approval (#151).
374pub 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// ---------------------------------------------------------------------------
385// Tests
386// ---------------------------------------------------------------------------
387
388#[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        // A BTreeSet gives a stable, deduplicated order regardless of apt's.
435        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    // ── apt_update ───────────────────────────────────────────────────────────
449
450    #[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    // ── apt_upgrade ──────────────────────────────────────────────────────────
482
483    #[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    // ── apt_install ──────────────────────────────────────────────────────────
509
510    #[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    // ── apt_remove ───────────────────────────────────────────────────────────
532
533    #[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        // Must NOT contain purge — that's a different action.
544        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    // ── apt_purge ────────────────────────────────────────────────────────────
556
557    #[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    // ── apt_autoremove ───────────────────────────────────────────────────────
578
579    #[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    // ── apt_hold ─────────────────────────────────────────────────────────────
601
602    #[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    // ── apt_unhold ───────────────────────────────────────────────────────────
623
624    #[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    // ── apt_search ───────────────────────────────────────────────────────────
644
645    #[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        // Read-only search must not require sudo.
667        let spec = apt_search("docker");
668        let (prog, _) = extract_args(&spec);
669        assert_ne!(prog, "sudo");
670    }
671
672    // ── apt_list_installed ───────────────────────────────────────────────────
673
674    #[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    // ── apt_show ─────────────────────────────────────────────────────────────
693
694    #[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    // ── apt_list_upgradable ──────────────────────────────────────────────────
714
715    #[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    // ── apt_history_list ─────────────────────────────────────────────────────
739
740    #[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    // ── specs() completeness ─────────────────────────────────────────────────
767
768    #[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    // ── env var injection regression ─────────────────────────────────────────
795
796    #[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}