Skip to main content

sysknife_daemon/actions/
apt.rs

1//! apt-family package management actions (Ubuntu / Debian).
2//!
3//! The eight actions that run `apt-get` or `apt-mark` as root (`AptUpdate`,
4//! `AptUpgrade`, `AptInstall`, `AptRemove`, `AptPurge`, `AptAutoremove`,
5//! `AptHold`, `AptUnhold`) run
6//! `sudo /usr/lib/sysknife/action-steps apt <subcommand> [<package>]`. The
7//! helper builds the `apt-get` or `apt-mark` argv from an allowlist and sets
8//! `DEBIAN_FRONTEND=noninteractive` and `NEEDRESTART_MODE=a` itself, so the
9//! commands never block waiting for a TUI prompt. A direct sudoers grant on
10//! `apt-get` or `apt-mark` admitted apt's own options
11//! (`-o APT::Update::Pre-Invoke::=<cmd>` runs `<cmd>` as root), and sudoers
12//! cannot forbid the options that follow a wildcard, so neither has a grant.
13//!
14//! The package operand is validated here by `validated_install_package` and
15//! `validated_unmodified_package` (character set, and no trailing `-`, `+` or
16//! `^`), and again in the helper. A name containing `.` or `+` can still be a
17//! regular expression to apt, so the helper also passes
18//! `-o APT::Cmd::Pattern-Only=1` and refuses an operand that does not name
19//! exactly one existing package, which only the host's package database can say.
20//!
21//! ## Lock contention
22//!
23//! Concurrent apt runs are prevented by the dispatcher's exclusion gate, not
24//! by a pre-flight probe: every action whose argv names an apt/dpkg tool maps
25//! to [`ExclusiveResource::Dpkg`](super::ExclusiveResource::Dpkg), and a second
26//! one submitted while the first holds that lock gets a `ConflictResponse`.
27//!
28//! An earlier version of this doc claimed the executor ran
29//! `fuser /var/lib/dpkg/lock` before each mutating command. It never did —
30//! there is no such probe anywhere in the daemon. The gate above covers
31//! contention *between SysKnife actions*, which is what SysKnife can actually
32//! control; it does not detect a dpkg lock held by something outside SysKnife
33//! (an operator's own `apt` session, or unattended-upgrades). In that case apt
34//! itself reports the lock and the action fails with apt's own message.
35//!
36//! ## Environment variables
37//!
38//! `DEBIAN_FRONTEND=noninteractive` suppresses all debconf prompts.
39//! `NEEDRESTART_MODE=a` tells the `needrestart` post-install hook to
40//! automatically restart services instead of prompting the operator.
41//! Both are required for daemon-driven apt invocations; omitting them
42//! causes apt to hang waiting for terminal input that never arrives. The
43//! `apt` verb of `packaging/sysknife-action-steps` provides both.
44
45use super::{command_mechanism, ActionSpec};
46use sysknife_types::RiskLevel;
47
48// ---------------------------------------------------------------------------
49// Constants
50// ---------------------------------------------------------------------------
51
52/// The root-owned helper every `apt-get` and `apt-mark` action goes through.
53///
54/// `tests/sudoers_grants_match_argv.rs` checks that the packaged sudoers file
55/// grants exactly this verb, and `tests/release/action-steps.test.sh` reads the
56/// subcommands below out of this file and checks the helper accepts each one and
57/// nothing else.
58const ACTION_STEPS: &str = "/usr/lib/sysknife/action-steps";
59
60// ---------------------------------------------------------------------------
61// specs() — for action_consistency tests
62// ---------------------------------------------------------------------------
63
64/// Return one representative `ActionSpec` per action name so the consistency
65/// test can verify policy + preview + executor coverage.
66pub fn specs() -> Vec<ActionSpec> {
67    vec![
68        apt_update(),
69        apt_upgrade(),
70        apt_install("curl"),
71        apt_remove("curl"),
72        apt_purge("curl"),
73        apt_autoremove(),
74        apt_hold("curl"),
75        apt_unhold("curl"),
76        apt_search("curl"),
77        apt_list_installed(),
78        apt_show("curl"),
79        apt_list_upgradable(),
80        apt_history_list(),
81        configure_unattended_upgrades(true),
82    ]
83}
84
85/// Installed path of the privileged unattended-upgrades helper script.
86/// See `packaging/sysknife-unattended-upgrades-edit` and the matching NOPASSWD
87/// grant in `packaging/sysknife-sudoers`.
88const UNATTENDED_UPGRADES_HELPER: &str = "/usr/lib/sysknife/unattended-upgrades-edit";
89
90/// Enable or disable Ubuntu automatic (unattended) security updates.
91///
92/// Risk: High. Delegates to the root-owned helper, which writes the fixed
93/// `/etc/apt/apt.conf.d/20auto-upgrades` content (no free-form input) and, when
94/// enabling, ensures the `unattended-upgrades` package is installed. Changing
95/// the automatic-update posture is a security-relevant, system-wide operation.
96pub fn configure_unattended_upgrades(enabled: bool) -> ActionSpec {
97    let flag = if enabled { "--enable" } else { "--disable" };
98    ActionSpec {
99        action_name: "ConfigureUnattendedUpgrades",
100        mechanism: command_mechanism("sudo", [UNATTENDED_UPGRADES_HELPER, flag]),
101        risk_level: RiskLevel::High,
102        reboot_required: false,
103        rollback_available: false,
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Action constructors
109// ---------------------------------------------------------------------------
110
111/// Refresh the apt package index (`action-steps apt update`, which runs `apt-get update`).
112///
113/// Risk: Low. No packages are changed, no lock contention for writes.
114/// Requires network access to reach configured APT sources.
115pub fn apt_update() -> ActionSpec {
116    ActionSpec {
117        action_name: "AptUpdate",
118        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "update"]),
119        risk_level: RiskLevel::Low,
120        reboot_required: false,
121        rollback_available: false,
122    }
123}
124
125/// Upgrade all installed packages (`action-steps apt dist-upgrade`, which runs
126/// `apt-get dist-upgrade -y`).
127///
128/// Risk: High. `dist-upgrade` may remove packages to resolve dependency
129/// conflicts (unlike `upgrade`). Can trigger `needrestart` service restarts.
130pub fn apt_upgrade() -> ActionSpec {
131    ActionSpec {
132        action_name: "AptUpgrade",
133        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "dist-upgrade"]),
134        risk_level: RiskLevel::High,
135        reboot_required: false,
136        rollback_available: false,
137    }
138}
139
140/// Install a single package (`action-steps apt install <package>`, which runs
141/// `apt-get -o APT::Cmd::Pattern-Only=1 install -y -- <package>`).
142///
143/// The package name is validated by `validated_install_package`, which refuses a
144/// trailing `-` (apt-get reads `pkg-` on `install` as "remove pkg").
145///
146/// Risk: Medium. Installs a package and its dependencies; reversible with
147/// `apt-get remove` but not transactionally atomic.
148pub fn apt_install(package: &str) -> ActionSpec {
149    ActionSpec {
150        action_name: "AptInstall",
151        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "install", package]),
152        risk_level: RiskLevel::Medium,
153        reboot_required: false,
154        rollback_available: false,
155    }
156}
157
158/// Remove a package but keep its configuration files
159/// (`action-steps apt remove <package>`, which runs `apt-get -o APT::Cmd::Pattern-Only=1 remove -y -- <package>`).
160///
161/// The package name is validated by `validated_unmodified_package`, which refuses
162/// a trailing `+` (apt-get reads `pkg+` on `remove` as "install pkg") and a
163/// trailing `-`.
164///
165/// Risk: Medium. Configuration files remain in `/etc`; reversible by
166/// reinstalling the package. Use `apt_purge` to also remove config files.
167pub fn apt_remove(package: &str) -> ActionSpec {
168    ActionSpec {
169        action_name: "AptRemove",
170        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "remove", package]),
171        risk_level: RiskLevel::Medium,
172        reboot_required: false,
173        rollback_available: false,
174    }
175}
176
177/// Remove a package AND its configuration files (`action-steps apt purge
178/// <package>`, which runs `apt-get -o APT::Cmd::Pattern-Only=1 purge -y -- <package>`). Validated like
179/// [`apt_remove`].
180///
181/// Risk: Medium. Configuration files are deleted; harder to reverse than
182/// `remove`. Choose `purge` when a clean reinstall is the goal.
183pub fn apt_purge(package: &str) -> ActionSpec {
184    ActionSpec {
185        action_name: "AptPurge",
186        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "purge", package]),
187        risk_level: RiskLevel::Medium,
188        reboot_required: false,
189        rollback_available: false,
190    }
191}
192
193/// Remove automatically-installed packages no longer needed
194/// (`action-steps apt autoremove`, which runs `apt-get autoremove -y`).
195///
196/// Risk: Medium. The set of packages actually removed is chosen by the
197/// dependency resolver at execution time, not named by the caller, so the
198/// approved preview (`apt-get autoremove -y`) cannot show what will go. In
199/// practice that set has been known to include old kernels and driver or
200/// bootloader metapackages that were marked auto-installed. It stays below
201/// `AptUpgrade`'s High only because it never *installs* or upgrades anything.
202pub fn apt_autoremove() -> ActionSpec {
203    ActionSpec {
204        action_name: "AptAutoremove",
205        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "autoremove"]),
206        risk_level: RiskLevel::Medium,
207        reboot_required: false,
208        rollback_available: false,
209    }
210}
211
212/// Pin a package at its current version (`action-steps apt hold <package>`,
213/// which runs `apt-mark -o APT::Cmd::Pattern-Only=1 hold -- <package>`).
214///
215/// The helper builds the argv because `apt-mark` takes apt's own options after
216/// the subcommand (`-o Dir::Bin::dpkg=<path>` runs `<path>` as root) and, for an
217/// operand that is not an exact package name, falls back to a regular
218/// expression and holds every package it matches. The package name is validated
219/// by `validated_unmodified_package`: `apt-mark` strips a trailing `+`, so the
220/// package held would not be the one previewed.
221///
222/// Risk: Medium. Prevents upgrades for the named package until unheld.
223/// Useful to freeze a known-good version when upstream upgrades are risky.
224pub fn apt_hold(package: &str) -> ActionSpec {
225    ActionSpec {
226        action_name: "AptHold",
227        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "hold", package]),
228        risk_level: RiskLevel::Medium,
229        reboot_required: false,
230        rollback_available: false,
231    }
232}
233
234/// Remove the hold on a package, allowing upgrades again
235/// (`action-steps apt unhold <package>`; see [`apt_hold`]).
236///
237/// Risk: Medium. After unholding, the next `apt upgrade` can update the package.
238pub fn apt_unhold(package: &str) -> ActionSpec {
239    ActionSpec {
240        action_name: "AptUnhold",
241        mechanism: command_mechanism("sudo", [ACTION_STEPS, "apt", "unhold", package]),
242        risk_level: RiskLevel::Medium,
243        reboot_required: false,
244        rollback_available: false,
245    }
246}
247
248/// Search apt repositories for packages matching a term (`apt-cache search <term>`).
249///
250/// Risk: Low. Read-only query; no system changes.
251pub fn apt_search(term: &str) -> ActionSpec {
252    ActionSpec {
253        action_name: "AptSearch",
254        mechanism: command_mechanism("apt-cache", ["search", term]),
255        risk_level: RiskLevel::Low,
256        reboot_required: false,
257        rollback_available: false,
258    }
259}
260
261/// List all installed packages (`dpkg -l`).
262///
263/// Risk: Low. Read-only query; no system changes.
264pub fn apt_list_installed() -> ActionSpec {
265    ActionSpec {
266        action_name: "AptListInstalled",
267        mechanism: command_mechanism("dpkg", ["-l"]),
268        risk_level: RiskLevel::Low,
269        reboot_required: false,
270        rollback_available: false,
271    }
272}
273
274/// Show detailed information about a package (`apt-cache show <package>`).
275///
276/// Risk: Low. Read-only query showing version, dependencies, description.
277pub fn apt_show(package: &str) -> ActionSpec {
278    ActionSpec {
279        action_name: "AptShow",
280        mechanism: command_mechanism("apt-cache", ["show", package]),
281        risk_level: RiskLevel::Low,
282        reboot_required: false,
283        rollback_available: false,
284    }
285}
286
287/// List packages that have upgrades available (`apt list --upgradable`).
288///
289/// Risk: Low. Read-only query; no packages are installed or changed.
290/// Stderr suppressed via `2>/dev/null` to hide the "WARNING: apt does not
291/// have a stable CLI interface" notice that apt emits on non-interactive
292/// invocations. The script is tee'd through `bash -c` because `apt list`
293/// writes its "Listing..." header to stderr.
294pub fn apt_list_upgradable() -> ActionSpec {
295    ActionSpec {
296        action_name: "AptListUpgradable",
297        mechanism: command_mechanism("bash", ["-c", "apt list --upgradable 2>/dev/null"]),
298        risk_level: RiskLevel::Low,
299        reboot_required: false,
300        rollback_available: false,
301    }
302}
303
304/// Show recent apt transaction history from `/var/log/apt/history.log`.
305///
306/// Risk: Low. Read-only file inspection; no packages are changed.
307/// Retrieves the 80 most recent log lines covering the last few Start-Date
308/// entries so the user can audit what was installed, removed, or upgraded.
309pub fn apt_history_list() -> ActionSpec {
310    ActionSpec {
311        action_name: "AptHistoryList",
312        mechanism: command_mechanism(
313            "bash",
314            [
315                "-c",
316                "grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80",
317            ],
318        ),
319        risk_level: RiskLevel::Low,
320        reboot_required: false,
321        rollback_available: false,
322    }
323}
324
325/// The set of packages `apt-get -s autoremove` would remove, parsed from its
326/// simulate (`-s`) output.
327///
328/// apt prints one `Remv <package> [<version>]` line per package it would remove
329/// (the package token may carry an `:arch` suffix, which we keep verbatim). This
330/// is used to bind `AptAutoremove`'s approval to the exact set the operator saw
331/// at preview time — `apt-get autoremove -y` otherwise resolves the deletion set
332/// at run time, so the executed effect is unbound by the approval (#151).
333pub fn parse_autoremove_removals(simulate_stdout: &str) -> std::collections::BTreeSet<String> {
334    simulate_stdout
335        .lines()
336        .filter_map(|line| {
337            let rest = line.trim_start().strip_prefix("Remv ")?;
338            rest.split_whitespace().next().map(str::to_string)
339        })
340        .collect()
341}
342
343// ---------------------------------------------------------------------------
344// Tests
345// ---------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::actions::ActionMechanism;
351
352    fn removals(s: &str) -> Vec<String> {
353        parse_autoremove_removals(s).into_iter().collect()
354    }
355
356    #[test]
357    fn parse_autoremove_extracts_the_remv_package_set() {
358        let out = "Reading package lists... Done\n\
359                   Building dependency tree... Done\n\
360                   The following packages will be REMOVED:\n\
361                   \x20 linux-image-5.4.0-100 linux-modules-5.4.0-100\n\
362                   0 upgraded, 0 newly installed, 2 to remove and 0 not upgraded.\n\
363                   Remv linux-image-5.4.0-100 [5.4.0-100.113]\n\
364                   Remv linux-modules-5.4.0-100 [5.4.0-100.113]\n";
365        assert_eq!(
366            removals(out),
367            vec![
368                "linux-image-5.4.0-100".to_string(),
369                "linux-modules-5.4.0-100".to_string()
370            ]
371        );
372    }
373
374    #[test]
375    fn parse_autoremove_keeps_arch_suffix_and_ignores_non_remv_lines() {
376        let out = "Inst libkeep [1.0] (2.0 amd64)\n\
377                   Remv libfoo:amd64 [1.2-3]\n\
378                   Conf libbar [4.5]\n";
379        assert_eq!(removals(out), vec!["libfoo:amd64".to_string()]);
380    }
381
382    #[test]
383    fn parse_autoremove_on_empty_or_nothing_to_remove_is_empty() {
384        assert!(parse_autoremove_removals("").is_empty());
385        assert!(parse_autoremove_removals(
386            "Reading package lists... Done\n0 to remove and 0 not upgraded.\n"
387        )
388        .is_empty());
389    }
390
391    #[test]
392    fn parse_autoremove_deduplicates_and_sorts() {
393        // A BTreeSet gives a stable, deduplicated order regardless of apt's.
394        let out = "Remv b [1]\nRemv a [1]\nRemv b [1]\n";
395        assert_eq!(removals(out), vec!["a".to_string(), "b".to_string()]);
396    }
397
398    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<&str>) {
399        match &spec.mechanism {
400            ActionMechanism::Command { program, args } => {
401                (*program, args.iter().map(String::as_str).collect())
402            }
403            _ => panic!("expected Command mechanism"),
404        }
405    }
406
407    // ── apt_update ───────────────────────────────────────────────────────────
408
409    #[test]
410    fn apt_update_action_name() {
411        assert_eq!(apt_update().action_name, "AptUpdate");
412    }
413
414    #[test]
415    fn apt_update_argv_correct() {
416        let spec = apt_update();
417        let (prog, args) = extract_args(&spec);
418        assert_eq!(prog, "sudo");
419        assert_eq!(args, vec![ACTION_STEPS, "apt", "update"]);
420    }
421
422    #[test]
423    fn apt_update_risk_is_low() {
424        assert_eq!(apt_update().risk_level, RiskLevel::Low);
425    }
426
427    #[test]
428    fn apt_update_no_reboot_no_rollback() {
429        let spec = apt_update();
430        assert!(!spec.reboot_required);
431        assert!(!spec.rollback_available);
432    }
433
434    // ── apt_upgrade ──────────────────────────────────────────────────────────
435
436    #[test]
437    fn apt_upgrade_action_name() {
438        assert_eq!(apt_upgrade().action_name, "AptUpgrade");
439    }
440
441    #[test]
442    fn apt_upgrade_argv_correct() {
443        let spec = apt_upgrade();
444        let (prog, args) = extract_args(&spec);
445        assert_eq!(prog, "sudo");
446        assert_eq!(args, vec![ACTION_STEPS, "apt", "dist-upgrade"]);
447    }
448
449    #[test]
450    fn apt_upgrade_risk_is_high() {
451        assert_eq!(apt_upgrade().risk_level, RiskLevel::High);
452    }
453
454    // ── apt_install ──────────────────────────────────────────────────────────
455
456    #[test]
457    fn apt_install_action_name() {
458        assert_eq!(apt_install("vim").action_name, "AptInstall");
459    }
460
461    #[test]
462    fn apt_install_includes_package_name() {
463        let spec = apt_install("vim");
464        let (prog, args) = extract_args(&spec);
465        assert_eq!(prog, "sudo");
466        assert_eq!(args, vec![ACTION_STEPS, "apt", "install", "vim"]);
467    }
468
469    #[test]
470    fn apt_install_risk_is_medium() {
471        assert_eq!(apt_install("vim").risk_level, RiskLevel::Medium);
472    }
473
474    // ── apt_remove ───────────────────────────────────────────────────────────
475
476    #[test]
477    fn apt_remove_action_name() {
478        assert_eq!(apt_remove("vim").action_name, "AptRemove");
479    }
480
481    #[test]
482    fn apt_remove_argv_contains_remove_not_purge() {
483        let spec = apt_remove("vim");
484        let (prog, args) = extract_args(&spec);
485        assert_eq!(prog, "sudo");
486        // Must NOT be purge — that's a different action.
487        assert_eq!(args, vec![ACTION_STEPS, "apt", "remove", "vim"]);
488    }
489
490    #[test]
491    fn apt_remove_risk_is_medium() {
492        assert_eq!(apt_remove("vim").risk_level, RiskLevel::Medium);
493    }
494
495    // ── apt_purge ────────────────────────────────────────────────────────────
496
497    #[test]
498    fn apt_purge_action_name() {
499        assert_eq!(apt_purge("vim").action_name, "AptPurge");
500    }
501
502    #[test]
503    fn apt_purge_argv_uses_purge_subcommand() {
504        let spec = apt_purge("vim");
505        let (prog, args) = extract_args(&spec);
506        assert_eq!(prog, "sudo");
507        assert_eq!(args, vec![ACTION_STEPS, "apt", "purge", "vim"]);
508    }
509
510    #[test]
511    fn apt_purge_risk_is_medium() {
512        assert_eq!(apt_purge("vim").risk_level, RiskLevel::Medium);
513    }
514
515    // ── apt_autoremove ───────────────────────────────────────────────────────
516
517    #[test]
518    fn apt_autoremove_action_name() {
519        assert_eq!(apt_autoremove().action_name, "AptAutoremove");
520    }
521
522    #[test]
523    fn apt_autoremove_argv_correct() {
524        let spec = apt_autoremove();
525        let (prog, args) = extract_args(&spec);
526        assert_eq!(prog, "sudo");
527        assert_eq!(args, vec![ACTION_STEPS, "apt", "autoremove"]);
528    }
529
530    #[test]
531    fn apt_autoremove_risk_is_medium() {
532        assert_eq!(apt_autoremove().risk_level, RiskLevel::Medium);
533    }
534
535    // ── apt_hold ─────────────────────────────────────────────────────────────
536
537    #[test]
538    fn apt_hold_action_name() {
539        assert_eq!(apt_hold("nginx").action_name, "AptHold");
540    }
541
542    #[test]
543    fn apt_hold_uses_apt_mark_hold() {
544        let spec = apt_hold("nginx");
545        let (prog, args) = extract_args(&spec);
546        assert_eq!(prog, "sudo");
547        assert_eq!(args, vec![ACTION_STEPS, "apt", "hold", "nginx"]);
548    }
549
550    #[test]
551    fn apt_hold_risk_is_medium() {
552        assert_eq!(apt_hold("nginx").risk_level, RiskLevel::Medium);
553    }
554
555    // ── apt_unhold ───────────────────────────────────────────────────────────
556
557    #[test]
558    fn apt_unhold_action_name() {
559        assert_eq!(apt_unhold("nginx").action_name, "AptUnhold");
560    }
561
562    #[test]
563    fn apt_unhold_uses_apt_mark_unhold() {
564        let spec = apt_unhold("nginx");
565        let (_, args) = extract_args(&spec);
566        assert_eq!(args, vec![ACTION_STEPS, "apt", "unhold", "nginx"]);
567    }
568
569    #[test]
570    fn apt_unhold_risk_is_medium() {
571        assert_eq!(apt_unhold("nginx").risk_level, RiskLevel::Medium);
572    }
573
574    // ── apt_search ───────────────────────────────────────────────────────────
575
576    #[test]
577    fn apt_search_action_name() {
578        assert_eq!(apt_search("docker").action_name, "AptSearch");
579    }
580
581    #[test]
582    fn apt_search_uses_apt_cache_search() {
583        let spec = apt_search("docker");
584        let (prog, args) = extract_args(&spec);
585        assert_eq!(prog, "apt-cache");
586        assert!(args.contains(&"search"));
587        assert!(args.contains(&"docker"));
588    }
589
590    #[test]
591    fn apt_search_risk_is_low() {
592        assert_eq!(apt_search("docker").risk_level, RiskLevel::Low);
593    }
594
595    #[test]
596    fn apt_search_no_sudo() {
597        // Read-only search must not require sudo.
598        let spec = apt_search("docker");
599        let (prog, _) = extract_args(&spec);
600        assert_ne!(prog, "sudo");
601    }
602
603    // ── apt_list_installed ───────────────────────────────────────────────────
604
605    #[test]
606    fn apt_list_installed_action_name() {
607        assert_eq!(apt_list_installed().action_name, "AptListInstalled");
608    }
609
610    #[test]
611    fn apt_list_installed_uses_dpkg() {
612        let spec = apt_list_installed();
613        let (prog, args) = extract_args(&spec);
614        assert_eq!(prog, "dpkg");
615        assert!(args.contains(&"-l"));
616    }
617
618    #[test]
619    fn apt_list_installed_risk_is_low() {
620        assert_eq!(apt_list_installed().risk_level, RiskLevel::Low);
621    }
622
623    // ── apt_show ─────────────────────────────────────────────────────────────
624
625    #[test]
626    fn apt_show_action_name() {
627        assert_eq!(apt_show("openssh-server").action_name, "AptShow");
628    }
629
630    #[test]
631    fn apt_show_uses_apt_cache_show() {
632        let spec = apt_show("openssh-server");
633        let (prog, args) = extract_args(&spec);
634        assert_eq!(prog, "apt-cache");
635        assert!(args.contains(&"show"));
636        assert!(args.contains(&"openssh-server"));
637    }
638
639    #[test]
640    fn apt_show_risk_is_low() {
641        assert_eq!(apt_show("openssh-server").risk_level, RiskLevel::Low);
642    }
643
644    // ── apt_list_upgradable ──────────────────────────────────────────────────
645
646    #[test]
647    fn apt_list_upgradable_action_name() {
648        assert_eq!(apt_list_upgradable().action_name, "AptListUpgradable");
649    }
650
651    #[test]
652    fn apt_list_upgradable_uses_bash_and_apt_list() {
653        let spec = apt_list_upgradable();
654        match &spec.mechanism {
655            ActionMechanism::Command { program, args } => {
656                assert_eq!(*program, "bash");
657                let joined = args.join(" ");
658                assert!(joined.contains("apt list --upgradable"), "argv: {joined}");
659            }
660            _ => panic!("expected Command mechanism"),
661        }
662    }
663
664    #[test]
665    fn apt_list_upgradable_risk_is_low() {
666        assert_eq!(apt_list_upgradable().risk_level, RiskLevel::Low);
667    }
668
669    // ── apt_history_list ─────────────────────────────────────────────────────
670
671    #[test]
672    fn apt_history_list_action_name() {
673        assert_eq!(apt_history_list().action_name, "AptHistoryList");
674    }
675
676    #[test]
677    fn apt_history_list_uses_bash_and_grep() {
678        let spec = apt_history_list();
679        match &spec.mechanism {
680            ActionMechanism::Command { program, args } => {
681                assert_eq!(*program, "bash");
682                let joined = args.join(" ");
683                assert!(
684                    joined.contains("/var/log/apt/history.log"),
685                    "argv: {joined}"
686                );
687            }
688            _ => panic!("expected Command mechanism"),
689        }
690    }
691
692    #[test]
693    fn apt_history_list_risk_is_low() {
694        assert_eq!(apt_history_list().risk_level, RiskLevel::Low);
695    }
696
697    // ── specs() completeness ─────────────────────────────────────────────────
698
699    #[test]
700    fn specs_covers_all_action_names() {
701        let expected_names = [
702            "AptUpdate",
703            "AptUpgrade",
704            "AptInstall",
705            "AptRemove",
706            "AptPurge",
707            "AptAutoremove",
708            "AptHold",
709            "AptUnhold",
710            "AptSearch",
711            "AptListInstalled",
712            "AptShow",
713            "AptListUpgradable",
714            "AptHistoryList",
715        ];
716        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
717        for name in &expected_names {
718            assert!(
719                spec_names.contains(name),
720                "specs() is missing action {name}"
721            );
722        }
723    }
724
725    // ── helper routing ───────────────────────────────────────────────────────
726
727    /// The eight actions that run apt-get or apt-mark as root, built with an
728    /// operand that is a legal package name, so every argv below is one the
729    /// daemon can really send.
730    fn apt_get_actions() -> Vec<ActionSpec> {
731        vec![
732            apt_update(),
733            apt_upgrade(),
734            apt_install("vim"),
735            apt_remove("vim"),
736            apt_purge("vim"),
737            apt_autoremove(),
738            apt_hold("vim"),
739            apt_unhold("vim"),
740        ]
741    }
742
743    /// `env DEBIAN_FRONTEND=… NEEDRESTART_MODE=a /usr/bin/apt-get *` was a sudoers
744    /// grant, and a trailing `*` takes apt's own options: `apt-get -o
745    /// APT::Update::Pre-Invoke::=<cmd> update` runs <cmd> as root. The actions go
746    /// through `action-steps apt`, which sets the environment and builds the argv,
747    /// so nothing the daemon sends names apt-get, env or an option.
748    #[test]
749    fn apt_get_actions_go_through_the_helper_and_carry_no_option() {
750        for spec in apt_get_actions() {
751            let (prog, args) = extract_args(&spec);
752            assert_eq!(prog, "sudo", "{}", spec.action_name);
753            assert_eq!(
754                args[..2],
755                [ACTION_STEPS, "apt"],
756                "{} must go through the helper verb: {args:?}",
757                spec.action_name
758            );
759            for arg in &args {
760                assert!(
761                    !arg.starts_with('-'),
762                    "{} sends an option {arg:?}; the helper adds the flags itself",
763                    spec.action_name
764                );
765                assert!(
766                    !arg.contains("apt-get")
767                        && !arg.contains("apt-mark")
768                        && *arg != "env"
769                        && !arg.contains('='),
770                    "{} still builds a direct apt-get or apt-mark argv: {args:?}",
771                    spec.action_name
772                );
773            }
774        }
775    }
776
777    /// The dispatcher serialises actions on the lock named by the argv, so the
778    /// helper verb has to keep naming a tool `exclusive_resource` recognises.
779    #[test]
780    fn apt_get_actions_still_contend_for_the_dpkg_lock() {
781        for spec in apt_get_actions() {
782            assert_eq!(
783                crate::actions::exclusive_resource(&spec),
784                Some(crate::actions::ExclusiveResource::Dpkg),
785                "{} lost the dpkg lock when it moved to the helper",
786                spec.action_name
787            );
788        }
789    }
790
791    /// `apt install` of a package named like another tool (`snap`, `flatpak`)
792    /// must still resolve to dpkg: the verb comes first in the argv.
793    #[test]
794    fn a_package_named_like_another_tool_does_not_change_the_lock() {
795        for operand in ["snap", "flatpak", "rpm-ostree"] {
796            assert_eq!(
797                crate::actions::exclusive_resource(&apt_install(operand)),
798                Some(crate::actions::ExclusiveResource::Dpkg),
799                "apt install {operand} must hold the dpkg lock"
800            );
801        }
802    }
803}