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// ---------------------------------------------------------------------------
367// Tests
368// ---------------------------------------------------------------------------
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::actions::ActionMechanism;
374
375    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
376        match &spec.mechanism {
377            ActionMechanism::Command { program, args } => {
378                (*program, args.iter().map(String::as_str).collect())
379            }
380            _ => panic!("expected Command mechanism"),
381        }
382    }
383
384    // ── apt_update ───────────────────────────────────────────────────────────
385
386    #[test]
387    fn apt_update_action_name() {
388        assert_eq!(apt_update().action_name, "AptUpdate");
389    }
390
391    #[test]
392    fn apt_update_argv_correct() {
393        let spec = apt_update();
394        let (prog, args) = extract_args(&spec);
395        assert_eq!(prog, "sudo");
396        assert!(
397            args.contains(&APT_GET),
398            "argv must carry the absolute apt-get path: {args:?}"
399        );
400        assert!(args.contains(&"update"));
401        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
402        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
403    }
404
405    #[test]
406    fn apt_update_risk_is_low() {
407        assert_eq!(apt_update().risk_level, RiskLevel::Low);
408    }
409
410    #[test]
411    fn apt_update_no_reboot_no_rollback() {
412        let spec = apt_update();
413        assert!(!spec.reboot_required);
414        assert!(!spec.rollback_available);
415    }
416
417    // ── apt_upgrade ──────────────────────────────────────────────────────────
418
419    #[test]
420    fn apt_upgrade_action_name() {
421        assert_eq!(apt_upgrade().action_name, "AptUpgrade");
422    }
423
424    #[test]
425    fn apt_upgrade_argv_correct() {
426        let spec = apt_upgrade();
427        let (prog, args) = extract_args(&spec);
428        assert_eq!(prog, "sudo");
429        assert!(
430            args.contains(&APT_GET),
431            "argv must carry the absolute apt-get path: {args:?}"
432        );
433        assert!(args.contains(&"dist-upgrade"));
434        assert!(args.contains(&"-y"));
435        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
436        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
437    }
438
439    #[test]
440    fn apt_upgrade_risk_is_high() {
441        assert_eq!(apt_upgrade().risk_level, RiskLevel::High);
442    }
443
444    // ── apt_install ──────────────────────────────────────────────────────────
445
446    #[test]
447    fn apt_install_action_name() {
448        assert_eq!(apt_install("vim").action_name, "AptInstall");
449    }
450
451    #[test]
452    fn apt_install_includes_package_name() {
453        let spec = apt_install("vim");
454        let (_, args) = extract_args(&spec);
455        assert!(args.contains(&"vim"));
456        assert!(args.contains(&"install"));
457        assert!(args.contains(&"-y"));
458        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
459        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
460    }
461
462    #[test]
463    fn apt_install_risk_is_medium() {
464        assert_eq!(apt_install("vim").risk_level, RiskLevel::Medium);
465    }
466
467    // ── apt_remove ───────────────────────────────────────────────────────────
468
469    #[test]
470    fn apt_remove_action_name() {
471        assert_eq!(apt_remove("vim").action_name, "AptRemove");
472    }
473
474    #[test]
475    fn apt_remove_argv_contains_remove_not_purge() {
476        let spec = apt_remove("vim");
477        let (_, args) = extract_args(&spec);
478        assert!(args.contains(&"remove"));
479        // Must NOT contain purge — that's a different action.
480        assert!(!args.contains(&"purge"));
481        assert!(args.contains(&"vim"));
482        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
483        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
484    }
485
486    #[test]
487    fn apt_remove_risk_is_medium() {
488        assert_eq!(apt_remove("vim").risk_level, RiskLevel::Medium);
489    }
490
491    // ── apt_purge ────────────────────────────────────────────────────────────
492
493    #[test]
494    fn apt_purge_action_name() {
495        assert_eq!(apt_purge("vim").action_name, "AptPurge");
496    }
497
498    #[test]
499    fn apt_purge_argv_uses_purge_subcommand() {
500        let spec = apt_purge("vim");
501        let (_, args) = extract_args(&spec);
502        assert!(args.contains(&"purge"));
503        assert!(args.contains(&"vim"));
504        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
505        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
506    }
507
508    #[test]
509    fn apt_purge_risk_is_medium() {
510        assert_eq!(apt_purge("vim").risk_level, RiskLevel::Medium);
511    }
512
513    // ── apt_autoremove ───────────────────────────────────────────────────────
514
515    #[test]
516    fn apt_autoremove_action_name() {
517        assert_eq!(apt_autoremove().action_name, "AptAutoremove");
518    }
519
520    #[test]
521    fn apt_autoremove_argv_correct() {
522        let spec = apt_autoremove();
523        let (prog, args) = extract_args(&spec);
524        assert_eq!(prog, "sudo");
525        assert!(args.contains(&"autoremove"));
526        assert!(args.contains(&"-y"));
527        assert!(args.contains(&DEBIAN_FRONTEND_VALUE));
528        assert!(args.contains(&NEEDRESTART_MODE_VALUE));
529    }
530
531    #[test]
532    fn apt_autoremove_risk_is_medium() {
533        assert_eq!(apt_autoremove().risk_level, RiskLevel::Medium);
534    }
535
536    // ── apt_hold ─────────────────────────────────────────────────────────────
537
538    #[test]
539    fn apt_hold_action_name() {
540        assert_eq!(apt_hold("nginx").action_name, "AptHold");
541    }
542
543    #[test]
544    fn apt_hold_uses_apt_mark_hold() {
545        let spec = apt_hold("nginx");
546        let (prog, args) = extract_args(&spec);
547        assert_eq!(prog, "sudo");
548        assert!(args.contains(&"apt-mark"));
549        assert!(args.contains(&"hold"));
550        assert!(args.contains(&"nginx"));
551    }
552
553    #[test]
554    fn apt_hold_risk_is_medium() {
555        assert_eq!(apt_hold("nginx").risk_level, RiskLevel::Medium);
556    }
557
558    // ── apt_unhold ───────────────────────────────────────────────────────────
559
560    #[test]
561    fn apt_unhold_action_name() {
562        assert_eq!(apt_unhold("nginx").action_name, "AptUnhold");
563    }
564
565    #[test]
566    fn apt_unhold_uses_apt_mark_unhold() {
567        let spec = apt_unhold("nginx");
568        let (_, args) = extract_args(&spec);
569        assert!(args.contains(&"apt-mark"));
570        assert!(args.contains(&"unhold"));
571        assert!(args.contains(&"nginx"));
572    }
573
574    #[test]
575    fn apt_unhold_risk_is_medium() {
576        assert_eq!(apt_unhold("nginx").risk_level, RiskLevel::Medium);
577    }
578
579    // ── apt_search ───────────────────────────────────────────────────────────
580
581    #[test]
582    fn apt_search_action_name() {
583        assert_eq!(apt_search("docker").action_name, "AptSearch");
584    }
585
586    #[test]
587    fn apt_search_uses_apt_cache_search() {
588        let spec = apt_search("docker");
589        let (prog, args) = extract_args(&spec);
590        assert_eq!(prog, "apt-cache");
591        assert!(args.contains(&"search"));
592        assert!(args.contains(&"docker"));
593    }
594
595    #[test]
596    fn apt_search_risk_is_low() {
597        assert_eq!(apt_search("docker").risk_level, RiskLevel::Low);
598    }
599
600    #[test]
601    fn apt_search_no_sudo() {
602        // Read-only search must not require sudo.
603        let spec = apt_search("docker");
604        let (prog, _) = extract_args(&spec);
605        assert_ne!(prog, "sudo");
606    }
607
608    // ── apt_list_installed ───────────────────────────────────────────────────
609
610    #[test]
611    fn apt_list_installed_action_name() {
612        assert_eq!(apt_list_installed().action_name, "AptListInstalled");
613    }
614
615    #[test]
616    fn apt_list_installed_uses_dpkg() {
617        let spec = apt_list_installed();
618        let (prog, args) = extract_args(&spec);
619        assert_eq!(prog, "dpkg");
620        assert!(args.contains(&"-l"));
621    }
622
623    #[test]
624    fn apt_list_installed_risk_is_low() {
625        assert_eq!(apt_list_installed().risk_level, RiskLevel::Low);
626    }
627
628    // ── apt_show ─────────────────────────────────────────────────────────────
629
630    #[test]
631    fn apt_show_action_name() {
632        assert_eq!(apt_show("openssh-server").action_name, "AptShow");
633    }
634
635    #[test]
636    fn apt_show_uses_apt_cache_show() {
637        let spec = apt_show("openssh-server");
638        let (prog, args) = extract_args(&spec);
639        assert_eq!(prog, "apt-cache");
640        assert!(args.contains(&"show"));
641        assert!(args.contains(&"openssh-server"));
642    }
643
644    #[test]
645    fn apt_show_risk_is_low() {
646        assert_eq!(apt_show("openssh-server").risk_level, RiskLevel::Low);
647    }
648
649    // ── apt_list_upgradable ──────────────────────────────────────────────────
650
651    #[test]
652    fn apt_list_upgradable_action_name() {
653        assert_eq!(apt_list_upgradable().action_name, "AptListUpgradable");
654    }
655
656    #[test]
657    fn apt_list_upgradable_uses_bash_and_apt_list() {
658        let spec = apt_list_upgradable();
659        match &spec.mechanism {
660            ActionMechanism::Command { program, args } => {
661                assert_eq!(*program, "bash");
662                let joined = args.join(" ");
663                assert!(joined.contains("apt list --upgradable"), "argv: {joined}");
664            }
665            _ => panic!("expected Command mechanism"),
666        }
667    }
668
669    #[test]
670    fn apt_list_upgradable_risk_is_low() {
671        assert_eq!(apt_list_upgradable().risk_level, RiskLevel::Low);
672    }
673
674    // ── apt_history_list ─────────────────────────────────────────────────────
675
676    #[test]
677    fn apt_history_list_action_name() {
678        assert_eq!(apt_history_list().action_name, "AptHistoryList");
679    }
680
681    #[test]
682    fn apt_history_list_uses_bash_and_grep() {
683        let spec = apt_history_list();
684        match &spec.mechanism {
685            ActionMechanism::Command { program, args } => {
686                assert_eq!(*program, "bash");
687                let joined = args.join(" ");
688                assert!(
689                    joined.contains("/var/log/apt/history.log"),
690                    "argv: {joined}"
691                );
692            }
693            _ => panic!("expected Command mechanism"),
694        }
695    }
696
697    #[test]
698    fn apt_history_list_risk_is_low() {
699        assert_eq!(apt_history_list().risk_level, RiskLevel::Low);
700    }
701
702    // ── specs() completeness ─────────────────────────────────────────────────
703
704    #[test]
705    fn specs_covers_all_action_names() {
706        let expected_names = [
707            "AptUpdate",
708            "AptUpgrade",
709            "AptInstall",
710            "AptRemove",
711            "AptPurge",
712            "AptAutoremove",
713            "AptHold",
714            "AptUnhold",
715            "AptSearch",
716            "AptListInstalled",
717            "AptShow",
718            "AptListUpgradable",
719            "AptHistoryList",
720        ];
721        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
722        for name in &expected_names {
723            assert!(
724                spec_names.contains(name),
725                "specs() is missing action {name}"
726            );
727        }
728    }
729
730    // ── env var injection regression ─────────────────────────────────────────
731
732    #[test]
733    fn mutating_apt_commands_carry_env_vars() {
734        let mutating = [
735            apt_upgrade(),
736            apt_install("vim"),
737            apt_remove("vim"),
738            apt_purge("vim"),
739            apt_autoremove(),
740        ];
741        for spec in &mutating {
742            let (_, args) = extract_args(spec);
743            assert!(
744                args.contains(&DEBIAN_FRONTEND_VALUE),
745                "{} missing DEBIAN_FRONTEND",
746                spec.action_name
747            );
748            assert!(
749                args.contains(&NEEDRESTART_MODE_VALUE),
750                "{} missing NEEDRESTART_MODE",
751                spec.action_name
752            );
753        }
754    }
755}