Skip to main content

sysknife_daemon/
executor.rs

1use crate::actions::{
2    apparmor, apt, apt_preferences, auditd, certbot, cloudinit, containers, deployment, distrobox,
3    fail2ban, filesystem, flatpak, grub, identity, journald, layering, livepatch, logging, lvm,
4    mounts, multipass, netplan, network, package_repos, pam, ppa, processes, reboot,
5    release_upgrade, resolvectl, services, snap, ssh, sudoers, sysctl, system_info, toolbox,
6    ubuntu_pro, ufw, users,
7    validate::{
8        shell_equivalent_sudo_command, validated_activatable_unit, validated_apparmor_profile,
9        validated_apt_package, validated_apt_pin_expr, validated_apt_pin_name,
10        validated_apt_show_package, validated_audit_path, validated_audit_perms,
11        validated_cpu_quota, validated_domain, validated_email, validated_fstype, validated_group,
12        validated_group_not_critical, validated_home_dir, validated_hostname,
13        validated_install_package, validated_journal_grep, validated_journal_priority,
14        validated_journal_time, validated_locale, validated_log_path, validated_log_rotation,
15        validated_lvm_name, validated_lvm_size, validated_memory_limit, validated_mount_device,
16        validated_mount_options, validated_mount_point, validated_port_or_service,
17        validated_ppa_name, validated_pro_service, validated_rebase_ref,
18        validated_resource_limit_unit, validated_safe_arg, validated_stoppable_unit,
19        validated_sudo_commands, validated_sudoers_name, validated_swap_path, validated_sysctl_key,
20        validated_sysctl_value, validated_syslog_host, validated_tasks_max, validated_timezone,
21        validated_unit_name, validated_unmodified_package, validated_username,
22        validated_username_not_critical,
23    },
24    ActionMechanism, ActionSpec,
25};
26use async_trait::async_trait;
27use serde_json::Value;
28use std::io;
29use std::net::IpAddr;
30use std::process::Stdio;
31use std::str::FromStr;
32use std::time::Duration;
33use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
34use tokio::sync::mpsc::UnboundedSender;
35
36// ---------------------------------------------------------------------------
37// Parameter bounds
38// ---------------------------------------------------------------------------
39//
40// These mirror the ceilings of the tools being driven (`chage`, `faillock`,
41// `pam_pwquality`, `fail2ban`, `journalctl`, `mkswap`). Naming them keeps the
42// rejection reason legible and keeps the same tool's ceiling from being spelled
43// two different ways in two arms of the same match.
44
45/// Longest scheduled-job command line.
46const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
47/// Longest schedule expression (`OnCalendar=` / cron form).
48const MAX_SCHEDULE_EXPR_LEN: usize = 128;
49/// Widest `journalctl --lines` request. Values are clamped, not rejected: an
50/// over-large request is a preference, not an error.
51const MAX_JOURNAL_LINES: u64 = 10_000;
52/// Largest swap file, in MiB (1 TiB).
53const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
54/// `chage`'s own ceiling for password-age days.
55const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
56/// Widest `pam_pwquality` minimum length.
57const MAX_PASSWORD_MINLEN: u64 = 128;
58/// Longest `faillock` unlock/interval window, in seconds (7 days).
59const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
60/// Longest fail2ban bantime/findtime, in seconds (30 days).
61const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
62
63#[derive(Debug, thiserror::Error)]
64pub enum ExecutorError {
65    #[error("unknown action: {0}")]
66    UnknownAction(String),
67
68    #[error("missing required param: {0}")]
69    MissingParam(&'static str),
70
71    #[error("invalid param type for: {0}")]
72    InvalidParam(&'static str),
73
74    /// Richer variant that carries the offending value for actionable diagnostics.
75    ///
76    /// Used when an action constructor returns a typed `InvalidIpAddress` error —
77    /// the value is forwarded to user-facing output rather than being silently
78    /// discarded as in the generic `InvalidParam` path.
79    #[error("invalid IP address for param '{param}': '{value}'")]
80    InvalidIpAddress { param: &'static str, value: String },
81
82    #[error("io error: {0}")]
83    Io(#[from] io::Error),
84
85    /// The action outran its deadline and could not be confirmed stopped.
86    ///
87    /// Separate from a plain timeout because the safe response differs. A
88    /// timeout that stopped the work leaves the host where it was, so an
89    /// automatic rollback is reasonable. This variant means privileged work may
90    /// still be running, and rolling back over it would put two package
91    /// transactions on the host at once. The caller must not treat it as a
92    /// quiet failure.
93    #[error(
94        "{program} exceeded the {timeout_secs}s action timeout and could not be \
95         confirmed stopped: process group {pgid} still has members. Automatic \
96         rollback was skipped because the original action may still be running; \
97         inspect the host before retrying"
98    )]
99    ActionNotStopped {
100        program: String,
101        pgid: i32,
102        timeout_secs: u64,
103    },
104}
105
106/// Output of a single executed action.
107///
108/// `exit_code` is the discriminant between success and failure.  Prefer
109/// [`is_success`](Self::is_success) / [`is_nonzero`](Self::is_nonzero) at
110/// call sites — `if output.exit_code == 0` is harder to read and easier to
111/// invert by accident than `if output.is_success()`.  The raw `exit_code`
112/// stays public because the dispatcher echoes it back to callers and the
113/// rollback path includes the precise code in diagnostic messages.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ExecutionOutput {
116    pub stdout: String,
117    pub stderr: String,
118    pub exit_code: i32,
119}
120
121impl ExecutionOutput {
122    /// `true` when the action exited cleanly (`exit_code == 0`).
123    pub fn is_success(&self) -> bool {
124        self.exit_code == 0
125    }
126
127    /// `true` when the action failed (`exit_code != 0`).
128    pub fn is_nonzero(&self) -> bool {
129        self.exit_code != 0
130    }
131}
132
133/// Abstraction over action execution, making the execute + rollback path
134/// testable without spawning real OS commands.
135///
136/// The production implementation (`RealActionExecutor`) delegates to
137/// `tokio::process::Command`. Tests can inject a mock that controls exit
138/// codes and output per program.
139#[async_trait]
140pub trait ActionExecutor: Send + Sync {
141    /// Execute an [`ActionSpec`] and return its output.
142    async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
143
144    /// Execute an action and publish stdout lines while it runs.
145    ///
146    /// Test doubles normally implement only [`execute`](Self::execute); this
147    /// default forwards their captured output after completion. Production
148    /// overrides the method so command output remains live.
149    async fn execute_with_progress(
150        &self,
151        spec: &ActionSpec,
152        progress: UnboundedSender<String>,
153    ) -> Result<ExecutionOutput, ExecutorError> {
154        let output = self.execute(spec).await?;
155        for line in output.stdout.lines().filter(|line| !line.is_empty()) {
156            let _ = progress.send(line.to_string());
157        }
158        Ok(output)
159    }
160}
161
162/// Production executor that delegates to real OS processes and filesystem ops.
163pub struct RealActionExecutor;
164
165#[async_trait]
166impl ActionExecutor for RealActionExecutor {
167    async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
168        execute_spec(spec).await
169    }
170
171    async fn execute_with_progress(
172        &self,
173        spec: &ActionSpec,
174        progress: UnboundedSender<String>,
175    ) -> Result<ExecutionOutput, ExecutorError> {
176        match &spec.mechanism {
177            ActionMechanism::Command { program, args } => {
178                execute_command_with_progress(program, args, progress).await
179            }
180            _ => execute_spec(spec).await,
181        }
182    }
183}
184
185/// Wall-clock ceiling on a single privileged action.
186///
187/// Nothing else bounds these processes: without a deadline a child that never
188/// exits — `apt-get` waiting on a dead mirror, a helper blocked on a prompt
189/// that will never arrive — wedges its connection forever, and the exclusion
190/// lock it holds is never released, so every other action contending for that
191/// resource is refused from then on.
192///
193/// The ceiling is deliberately generous rather than tight. A release upgrade
194/// or an OSTree rebase legitimately runs for tens of minutes, and killing a
195/// half-finished package transaction is worse than waiting. This is a
196/// backstop against a hang, not a performance budget. Override with
197/// `SYSKNIFE_ACTION_TIMEOUT_SECS` when an action on a slow link needs longer.
198const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
199
200fn action_timeout() -> Duration {
201    match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
202        Ok(raw) => match raw.trim().parse::<u64>() {
203            Ok(secs) if secs > 0 => Duration::from_secs(secs),
204            _ => {
205                eprintln!(
206                    "[sysknife-daemon] WARNING: ignoring invalid \
207                     SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
208                );
209                DEFAULT_ACTION_TIMEOUT
210            }
211        },
212        Err(_) => DEFAULT_ACTION_TIMEOUT,
213    }
214}
215
216/// What a `killpg` probe says about a process group.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum GroupState {
219    /// `ESRCH`: no process holds this group id. Gone.
220    Gone,
221    /// The group has members this process could signal.
222    AliveReachable,
223    /// `EPERM`: the group has members, but they run as a user this process
224    /// cannot signal. On an unprivileged daemon that is the normal state of a
225    /// `sudo` child still running as root, so it counts as *alive*, not gone.
226    /// Reading it as gone was the bug that let the rollback fire over live work.
227    AliveUnreachable,
228    /// Any other errno. Treated as alive, because the safe default when the
229    /// kernel will not answer is "still running", not "stopped".
230    Unknown(i32),
231}
232
233impl GroupState {
234    fn is_gone(self) -> bool {
235        matches!(self, GroupState::Gone)
236    }
237}
238
239/// Probe a process group without delivering a signal (`signal 0`).
240fn probe_group(pgid: i32) -> GroupState {
241    // SAFETY: `killpg` is async-signal-safe and takes no pointers. `pgid` is a
242    // real group id from `child.id()` for a child spawned with
243    // `process_group(0)`, so it is >= 1 and names that child's own group, never
244    // 0 (which would address the daemon's own group).
245    let rc = unsafe { libc::killpg(pgid, 0) };
246    if rc == 0 {
247        return GroupState::AliveReachable;
248    }
249    match io::Error::last_os_error().raw_os_error() {
250        Some(libc::ESRCH) => GroupState::Gone,
251        Some(libc::EPERM) => GroupState::AliveUnreachable,
252        other => GroupState::Unknown(other.unwrap_or(0)),
253    }
254}
255
256/// Send a signal to a process group. `ESRCH` is success (already gone).
257fn signal_group(pgid: i32, signal: i32) -> Result<(), io::Error> {
258    // SAFETY: see `probe_group`; same `pgid` contract.
259    let rc = unsafe { libc::killpg(pgid, signal) };
260    if rc == 0 {
261        return Ok(());
262    }
263    let err = io::Error::last_os_error();
264    if err.raw_os_error() == Some(libc::ESRCH) {
265        return Ok(());
266    }
267    Err(err)
268}
269
270/// How long a group gets to exit on SIGTERM before SIGKILL.
271///
272/// Short on purpose. The deadline has already passed, so this is the courtesy
273/// window for a package manager to unwind, not a second timeout.
274const GROUP_TERM_GRACE: Duration = Duration::from_secs(5);
275
276/// Whether this action's real work is an `rpm-ostree` transaction. Such work
277/// runs inside `rpm-ostreed`'s own cgroup (rpm-ostree is a thin D-Bus client),
278/// not this process group, so a group signal cannot reach it — it must be
279/// cancelled with `rpm-ostree cancel` instead (#142). `program` is usually
280/// `sudo`, so the real command is found in the arguments.
281fn is_rpm_ostree_action(program: &str, args: &[String]) -> bool {
282    let is_ostree = |s: &str| s == "rpm-ostree" || s.ends_with("/rpm-ostree");
283    is_ostree(program) || args.iter().any(|a| is_ostree(a))
284}
285
286/// The argv for a privileged, non-interactive signal to a whole process group:
287/// `sudo -n /usr/lib/sysknife/action-steps signal <SIGNAL> -<pgid>`. Run as
288/// root, it reaches the root children a `sudo` action forked that the
289/// unprivileged daemon cannot signal itself. `-n` never prompts (the sysknife
290/// sudoers entry is NOPASSWD). The helper reads `-<pgid>` as a group and
291/// refuses -1, 0 and 1 for every caller, which a `kill -s *` grant could not.
292fn root_group_kill_argv(signal: &str, pgid: i32) -> Vec<String> {
293    vec![
294        "-n".to_string(),
295        "/usr/lib/sysknife/action-steps".to_string(),
296        "signal".to_string(),
297        signal.to_string(),
298        format!("-{pgid}"),
299    ]
300}
301
302/// Counts `run_sudo` invocations so a test can prove the escalation path did
303/// (not) fire without needing a real sudo. Test-only.
304#[cfg(test)]
305static SUDO_INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
306
307/// Run a bounded, output-discarding `sudo` command for the privileged reaper.
308/// Best-effort for the *verdict* (the caller re-probes the group to decide the
309/// outcome), but the exit status is logged so a later "could not be confirmed
310/// stopped" line can be told apart from a misconfigured sudoers grant or a
311/// missing `sudo` — the reaper failing and the process genuinely wedging look
312/// identical from the group probe alone.
313async fn run_sudo(args: Vec<String>) {
314    #[cfg(test)]
315    SUDO_INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
316    let fut = tokio::process::Command::new("sudo")
317        .args(&args)
318        .stdin(Stdio::null())
319        .stdout(Stdio::null())
320        .stderr(Stdio::null())
321        .status();
322    let joined = args.join(" ");
323    match tokio::time::timeout(REAP_WAIT_CAP, fut).await {
324        Ok(Ok(status)) if status.success() => {}
325        Ok(Ok(status)) => {
326            eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` exited {status}")
327        }
328        Ok(Err(e)) => {
329            eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` could not run: {e}")
330        }
331        Err(_) => eprintln!(
332            "[sysknife-daemon] privileged reap `sudo {joined}` timed out after {}s",
333            REAP_WAIT_CAP.as_secs()
334        ),
335    }
336}
337
338/// The first privileged reap command (the args passed to `sudo`) for an action
339/// whose group survived the daemon's own signals: `rpm-ostree cancel` for an
340/// rpm-ostree transaction (its work is in another cgroup), otherwise a root TERM
341/// to the action's own process group. Split out so the dispatch and the exact
342/// argv are unit-testable without running `sudo`.
343fn reap_command(pgid: i32, program: &str, args: &[String]) -> Vec<String> {
344    if is_rpm_ostree_action(program, args) {
345        vec![
346            "-n".to_string(),
347            "/usr/bin/rpm-ostree".to_string(),
348            "cancel".to_string(),
349        ]
350    } else {
351        root_group_kill_argv("TERM", pgid)
352    }
353}
354
355/// Last-resort privileged reap for a group the daemon's own signals could not
356/// stop (its members run as root). Cancels an `rpm-ostree` transaction, or root
357/// TERM→KILLs the process group. This is the *termination* half of #140's
358/// detect-and-veto, so `ActionNotStopped` becomes a genuine last resort rather
359/// than the expected path for every sudo action.
360async fn privileged_reap(pgid: i32, program: &str, args: &[String]) {
361    run_sudo(reap_command(pgid, program, args)).await;
362    // rpm-ostree cancel is a single, blocking call; only the group-kill path
363    // escalates TERM → KILL. The client process blocks until the daemon-side
364    // transaction resolves (completes, errors, or cancels), so once `cancel`
365    // returns the transaction is stopped and the client group exits — which is
366    // why the caller's group probe (of the client) correctly reflects the
367    // transaction, even though the work itself ran in rpm-ostreed's cgroup.
368    if is_rpm_ostree_action(program, args) {
369        return;
370    }
371    tokio::time::sleep(Duration::from_millis(300)).await;
372    if !probe_group(pgid).is_gone() {
373        run_sudo(root_group_kill_argv("KILL", pgid)).await;
374    }
375}
376
377/// Upper bound on any single `child.wait()` in the timeout path.
378///
379/// The direct child of a `sudo` action runs as root and this daemon cannot
380/// signal it, so `wait()` on that child can block until the privileged work
381/// finishes on its own — hours. The action timeout exists to stop a hang, so it
382/// must not itself become one. Every wait here is bounded by this; an unreaped
383/// child becomes the subreaper's problem, not a wedged connection.
384const REAP_WAIT_CAP: Duration = Duration::from_secs(2);
385
386/// Stop an action that outran the deadline, and report honestly whether it
387/// actually stopped.
388///
389/// Signals the child's whole **process group**, not just its pid. The daemon
390/// runs unprivileged and elevates through `sudo`, which forks: signalling the
391/// pid alone leaves the privileged work running (issue #140). SIGTERM first so a
392/// package manager can unwind, then SIGKILL.
393///
394/// An unprivileged daemon cannot signal a process running as root, which every
395/// `sudo` action's real work does. When the group survives the daemon's own
396/// signals it therefore escalates to a privileged reaper ([`privileged_reap`]):
397/// a root kill of the group via the sudoers-authorised `action-steps signal`, or an
398/// `rpm-ostree cancel` for a transaction that lives in another cgroup (#142).
399/// Only when even that cannot confirm the group gone does it return
400/// [`ExecutorError::ActionNotStopped`] — now a genuine last resort — and the
401/// dispatcher refuses the automatic rollback rather than starting a second
402/// privileged transaction over the first.
403async fn kill_and_reap(
404    child: &mut tokio::process::Child,
405    program: &str,
406    args: &[String],
407    cause: StopCause,
408) -> ExecutorError {
409    let secs = action_timeout().as_secs();
410    let why = cause.describe(secs);
411    let Some(pgid) = child.id().map(|pid| pid as i32) else {
412        // Already reaped: the child exited between the deadline and here, so the
413        // work is done and there is nothing to signal.
414        eprintln!("[sysknife-daemon] {program} {why}; already exited");
415        return cause.into_error(program, secs);
416    };
417
418    // A pid-targeted floor. `start_kill` (SIGKILL to the direct child) always
419    // lands when the child is ours to signal, which covers every non-`sudo`
420    // action; the group signal below covers the grandchildren `sudo` forks.
421    let _ = child.start_kill();
422    if let Err(err) = signal_group(pgid, libc::SIGTERM) {
423        eprintln!("[sysknife-daemon] SIGTERM to process group {pgid} failed: {err}");
424    }
425
426    // Reap the direct child first, bounded: an unreaped child is a zombie that
427    // keeps the group alive and would make the probe below lie. Bounded because
428    // a root `sudo` child can outlast any signal this daemon can send.
429    let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
430
431    let grace = tokio::time::Instant::now() + GROUP_TERM_GRACE;
432    while tokio::time::Instant::now() < grace && !probe_group(pgid).is_gone() {
433        tokio::time::sleep(Duration::from_millis(50)).await;
434    }
435    if !probe_group(pgid).is_gone() {
436        if let Err(err) = signal_group(pgid, libc::SIGKILL) {
437            eprintln!("[sysknife-daemon] SIGKILL to process group {pgid} failed: {err}");
438        }
439        tokio::time::sleep(Duration::from_millis(200)).await;
440        let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
441    }
442
443    // The group survived the daemon's own signals: its members run as root (a
444    // `sudo` action's privileged child), which an unprivileged daemon cannot
445    // signal. Escalate to a privileged reaper — a root kill of the group, or an
446    // `rpm-ostree cancel` for a transaction that lives in another cgroup (#142).
447    //
448    // Escalate only for `AliveUnreachable` (EPERM): the members run as root, the
449    // sudo action's privileged child the daemon cannot signal. A same-uid process
450    // wedged in D-state (`AliveReachable`) or an `Unknown` errno would not be
451    // helped by a root signal, so those fall straight through to
452    // `ActionNotStopped` rather than a pointless sudo round-trip.
453    //
454    // Signalling the group by its numeric pgid is safe against pid reuse: POSIX
455    // keeps a process-group id allocated for the group's whole lifetime (while it
456    // has any member), so a probe reporting the group alive guarantees the pgid
457    // still names THIS action's group, never a recycled unrelated process.
458    if probe_group(pgid) == GroupState::AliveUnreachable {
459        eprintln!(
460            "[sysknife-daemon] {program} process group {pgid} survived as root; \
461             escalating to a privileged reap"
462        );
463        privileged_reap(pgid, program, args).await;
464        tokio::time::sleep(Duration::from_millis(200)).await;
465        let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
466    }
467
468    match probe_group(pgid) {
469        GroupState::Gone => {
470            eprintln!("[sysknife-daemon] {program} {why}; process group {pgid} stopped");
471            cause.into_error(program, secs)
472        }
473        state => {
474            eprintln!(
475                "[sysknife-daemon] {program} {why} and process group {pgid} \
476                 could not be confirmed stopped ({state:?})"
477            );
478            ExecutorError::ActionNotStopped {
479                program: program.to_string(),
480                pgid,
481                timeout_secs: secs,
482            }
483        }
484    }
485}
486
487/// Why the executor is tearing down a still-running privileged child.
488///
489/// Both causes must stop the process group — a root child left running after the
490/// executor has reported failure defeats the rollback interlock and the
491/// one-privileged-action-at-a-time guarantee — but they are not the same event,
492/// and reporting a read failure as a timeout would send an operator hunting a
493/// deadline that never fired.
494enum StopCause {
495    /// The action's own deadline elapsed.
496    Timeout,
497    /// The daemon lost the ability to read the child (a non-UTF-8 line, a broken
498    /// pipe, a `wait` that failed). Carries the error to surface once the group
499    /// is confirmed stopped.
500    Unreadable(io::Error),
501}
502
503impl StopCause {
504    /// The operator-log fragment, e.g. `exceeded the 900s action timeout`.
505    fn describe(&self, secs: u64) -> String {
506        match self {
507            StopCause::Timeout => format!("exceeded the {secs}s action timeout"),
508            StopCause::Unreadable(err) => format!("could not be read ({err})"),
509        }
510    }
511
512    /// The error to return once the group is confirmed gone. A group that
513    /// survives reports [`ExecutorError::ActionNotStopped`] instead, whatever the
514    /// cause — the caller needs to know work is still running far more than it
515    /// needs to know why the executor gave up on it.
516    fn into_error(self, program: &str, secs: u64) -> ExecutorError {
517        match self {
518            StopCause::Timeout => ExecutorError::Io(io::Error::new(
519                io::ErrorKind::TimedOut,
520                format!("{program} exceeded the {secs}s action timeout and was stopped"),
521            )),
522            StopCause::Unreadable(err) => ExecutorError::Io(err),
523        }
524    }
525}
526
527async fn execute_command_with_progress(
528    program: &'static str,
529    args: &[String],
530    progress: UnboundedSender<String>,
531) -> Result<ExecutionOutput, ExecutorError> {
532    let mut child = tokio::process::Command::new(program)
533        .args(args)
534        // Own process group, so the deadline can signal the whole tree rather
535        // than one pid. Without it the kill lands on `sudo`, which has already
536        // forked the privileged work, and the action outlives the timeout that
537        // claims to have stopped it. See issue #140.
538        .process_group(0)
539        // Null stdin, matching `execute_spec`. A background process group with a
540        // controlling terminal would otherwise get SIGTTIN on a stdin read and
541        // stop, burning the whole grace window before SIGKILL. No privileged
542        // action should read the daemon's stdin regardless.
543        .stdin(Stdio::null())
544        .stdout(Stdio::piped())
545        .stderr(Stdio::piped())
546        .spawn()
547        .map_err(ExecutorError::Io)?;
548
549    let stdout = child.stdout.take().expect("stdout was piped");
550    let stderr = child.stderr.take().expect("stderr was piped");
551    let stderr_task = tokio::spawn(async move {
552        let mut buf = Vec::new();
553        BufReader::new(stderr)
554            .read_to_end(&mut buf)
555            .await
556            .map(|_| buf)
557    });
558
559    let deadline = tokio::time::Instant::now() + action_timeout();
560
561    let mut lines = BufReader::new(stdout).lines();
562    let mut stdout_buf = String::new();
563    loop {
564        // The deadline covers the whole action, not each read: a process that
565        // emits one progress line an hour must still be cut off.
566        let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
567            Ok(Ok(line)) => line,
568            // Losing the read is not a reason to walk away from a running root
569            // child. `lines()` fails with `InvalidData` on non-UTF-8 output, so
570            // this is reachable from any action whose stdout carries a byte
571            // sequence outside UTF-8 — one oddly-encoded filename is enough.
572            Ok(Err(err)) => {
573                return Err(
574                    kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await,
575                )
576            }
577            Err(_) => {
578                return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
579            }
580        };
581        let Some(line) = next else { break };
582        if !line.is_empty() {
583            let _ = progress.send(line.clone());
584        }
585        stdout_buf.push_str(&line);
586        stdout_buf.push('\n');
587    }
588
589    let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
590        Ok(Ok(status)) => status,
591        // A failed `wait` leaves the child's fate unknown, which is the one state
592        // the dispatcher must never treat as "stopped".
593        Ok(Err(err)) => {
594            return Err(kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await)
595        }
596        Err(_) => return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await),
597    };
598    let stderr_bytes = stderr_task
599        .await
600        .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
601        .map_err(ExecutorError::Io)?;
602
603    Ok(ExecutionOutput {
604        stdout: stdout_buf,
605        stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
606        exit_code: exit_status.code().unwrap_or(-1),
607    })
608}
609
610/// Parameter names accepted by each action, including deliberate aliases.
611/// Keep this next to the builder so optional/defaulted parameters cannot mask
612/// misspelled keys. Dispatcher-only actions use the same boundary check.
613fn allowed_action_params(action_name: &str) -> Option<&'static [&'static str]> {
614    Some(match action_name {
615        "GetSystemState"
616        | "CollectDiagnostics"
617        | "GetDeploymentHistory"
618        | "ListDeployments"
619        | "UpdateSystem"
620        | "CleanupDeployments"
621        | "RebootSystem"
622        | "RollbackDeployment"
623        | "GetKernelArguments"
624        | "GetLayeredPackages"
625        | "ResetLayeredPackageOverride"
626        | "GetPendingUpdates"
627        | "ListPackageRepositories"
628        | "ListServices"
629        | "ListTimers"
630        | "ReloadDaemon"
631        | "GetDateTime"
632        | "GetDiskUsage"
633        | "ListProcesses"
634        | "GetLvmReport"
635        | "GetMounts"
636        | "GetSudoGrants"
637        | "RemoveRemoteSyslog"
638        | "GetMemoryInfo"
639        | "GetHostState"
640        | "GetFirewallState"
641        | "GetNetworkStatus"
642        | "GetListeningPorts"
643        | "GetNftablesRuleset"
644        | "GetFirewallBackendState"
645        | "ListUsers"
646        | "ListGroups"
647        | "AptUpdate"
648        | "AptUpgrade"
649        | "AptAutoremove"
650        | "AptListInstalled"
651        | "AptListUpgradable"
652        | "AptHistoryList"
653        | "SnapList"
654        | "GrubGetKargs"
655        | "CheckPendingReboot"
656        | "UfwEnable"
657        | "UfwDisable"
658        | "UfwReset"
659        | "DistroboxList"
660        | "NetplanGetConfig"
661        | "NetplanApply"
662        | "NetplanGenerate"
663        | "ProStatus"
664        | "ProDetach"
665        | "LivepatchStatus"
666        | "MultipassList"
667        | "UbuntuReleaseUpgrade"
668        | "ResolvectlStatus"
669        | "AppArmorStatus"
670        | "CloudInitStatus"
671        | "GetAuditRules"
672        | "GetCertificates"
673        | "RenewCertificates" => &[],
674        "PinDeployment" | "UnpinDeployment" => &["index"],
675        "RebaseSystem" => &["target_ref"],
676        "SetKernelArguments" => &["add", "remove"],
677        "ListFlatpakRemotes"
678        | "ListInstalledFlatpaks"
679        | "ListContainers"
680        | "ListToolboxes"
681        | "DeleteUser"
682        | "LockUserAccount"
683        | "UnlockUserAccount"
684        | "GetAuthorizedKeys"
685        | "UbuntuListFlatpaks" => &["user", "username"],
686        "InstallFlatpak" | "UbuntuInstallFlatpak" => &["app_id", "remote", "user", "username"],
687        "RemoveFlatpak"
688        | "GetFlatpakAppInfo"
689        | "UpdateFlatpak"
690        | "UbuntuRemoveFlatpak"
691        | "UbuntuUpdateFlatpak" => &["app_id", "user", "username"],
692        "SearchFlatpakApps" | "AptSearch" => &["term"],
693        "AddFlatpakRemote" => &["remote", "url", "user", "username"],
694        "RemoveFlatpakRemote" => &["remote", "user", "username"],
695        "CreateContainer" => &["image", "name", "user", "username"],
696        "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
697        | "RemoveToolbox" => &["name", "user", "username"],
698        "InstallPackages" | "RemovePackages" => &["packages"],
699        "AddLayeredPackage"
700        | "RemoveLayeredPackage"
701        | "RemoveBasePackage"
702        | "GetAptPins"
703        | "AptInstall"
704        | "AptRemove"
705        | "AptPurge"
706        | "AptHold"
707        | "AptUnhold"
708        | "AptShow" => &["package"],
709        "ReplaceLayeredPackage" => &["new", "old"],
710        "AddPackageRepository" => &["repo_id", "repo_url"],
711        "RemovePackageRepository" | "EnablePackageRepository" | "DisablePackageRepository" => {
712            &["repo_id"]
713        }
714        "StartService"
715        | "StopService"
716        | "RestartService"
717        | "MaskService"
718        | "UnmaskService"
719        | "GetServiceLogs"
720        | "GetServiceStatus"
721        | "ReloadService"
722        | "GetServiceResourceLimits" => &["unit"],
723        "SetServiceEnabled" => &["enabled", "unit"],
724        "CreateScheduledJob" => &["command", "name", "schedule"],
725        "SetServiceResourceLimits" => &[
726            "cpu_quota",
727            "memory_high",
728            "memory_max",
729            "tasks_max",
730            "unit",
731        ],
732        "CreateToolbox" => &["image", "name", "release", "user", "username"],
733        "SetHostname" => &["hostname"],
734        "SetTimezone" => &["timezone"],
735        "SetLocale" => &["locale"],
736        "SetNtp" | "ConfigureUnattendedUpgrades" => &["enabled"],
737        "SignalProcess" => &["pid", "signal"],
738        "GetJournalLog" => &[
739            "boot", "grep", "kernel", "lines", "priority", "since", "unit", "until",
740        ],
741        "VacuumJournal" => &["retain_days", "size_mb"],
742        "ExtendLogicalVolume" => &["lv", "size", "vg"],
743        "CreateLogicalVolume" => &["name", "size", "vg"],
744        "CreateLvSnapshot" => &["origin", "size", "snapshot", "vg"],
745        "GetSysctl" | "RemoveAuditRule" => &["key"],
746        "SetSysctl" | "NetplanSet" => &["key", "value"],
747        "AddMount" => &["device", "fstype", "mountpoint", "options"],
748        "RemoveMount" => &["mountpoint"],
749        "AddSwap" => &["file", "size_mb"],
750        "RemoveSwap" => &["file"],
751        "GrantSudoAccess" => &["commands", "name", "nopasswd", "runas", "user"],
752        "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" | "AddPpa" | "RemovePpa"
753        | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo" | "SnapRevert"
754        | "SnapClassicInstall" | "DistroboxRemove" => &["name"],
755        "SetAptPin" => &["name", "package", "pin", "priority"],
756        "GetLogrotateStatus" => &["config"],
757        "ConfigureLogRotation" => &["compress", "frequency", "name", "path", "rotate"],
758        "ConfigureRemoteSyslog" => &["host", "port", "protocol"],
759        "GetPasswordAging" => &["user"],
760        "SetPasswordAging" => &["max_days", "min_days", "user", "warn_days"],
761        "SetPasswordPolicy" => &["dcredit", "lcredit", "minlen", "ocredit", "ucredit"],
762        "SetAccountLockout" => &["deny", "fail_interval", "unlock_time"],
763        "ConfigureWifi" => &["password", "ssid"],
764        "SetDnsServers" | "ResolvectlSetDns" => &["interface", "servers"],
765        "ConfigureFirewall" => &["enabled", "service", "zone"],
766        "CreateUser" => &["home", "shell", "user", "username"],
767        "AddUserToGroup" | "RemoveUserFromGroup" => &["group", "user", "username"],
768        "CreateGroup" => &["group", "system"],
769        "DeleteGroup" => &["group"],
770        "AddAuthorizedKey" | "RemoveAuthorizedKey" => &["public_key", "user", "username"],
771        "SetSshdOption" => &["option", "value"],
772        "SnapInstall" => &["auto_update", "channel", "name"],
773        "GrubSetKargs" => &["append", "delete"],
774        "UfwAllow" | "UfwDeny" => &["port_or_service"],
775        "UfwStatus" => &["numbered"],
776        "DistroboxCreate" => &["image", "name"],
777        "UfwDeleteRule" => &["rule_number"],
778        "UfwLimit" => &["target"],
779        "ProAttach" => &["token"],
780        "EnableProService" | "DisableProService" => &["service"],
781        "AppArmorEnforce" | "AppArmorComplain" => &["profile_path"],
782        "Fail2banStatus" => &["jail"],
783        "Fail2banBanIp" | "Fail2banUnbanIp" => &["ip", "jail"],
784        "ConfigureFail2banJail" => &["bantime", "enabled", "findtime", "maxretry", "name"],
785        "AddAuditRule" => &["key", "path", "perms"],
786        "ObtainCertificate" => &["challenge", "domain", "domains", "email"],
787        "ListJobHistory" => &[
788            "limit",
789            "status_filter",
790            "action_filter",
791            "since_hours",
792            "since",
793        ],
794        _ => return None,
795    })
796}
797
798pub(crate) fn validate_action_params(
799    action_name: &str,
800    params: &Value,
801) -> Result<(), ExecutorError> {
802    let allowed = allowed_action_params(action_name)
803        .ok_or_else(|| ExecutorError::UnknownAction(action_name.to_string()))?;
804    let object = params
805        .as_object()
806        .ok_or(ExecutorError::InvalidParam("params"))?;
807    if object.keys().any(|key| !allowed.contains(&key.as_str())) {
808        return Err(ExecutorError::InvalidParam("unknown parameter"));
809    }
810    Ok(())
811}
812
813/// Map an action name and JSON params to an [`ActionSpec`].
814///
815/// Returns [`ExecutorError::UnknownAction`] for unrecognised names and
816/// [`ExecutorError::MissingParam`] when a required param is absent, or
817/// [`ExecutorError::InvalidParam`] for non-object params or unrecognised keys.
818pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
819    validate_action_params(action_name, params)?;
820    match action_name {
821        // ── Deployment: no params ─────────────────────────────────────────
822        "GetSystemState" => Ok(deployment::get_system_state()),
823        "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
824        "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
825        "ListDeployments" => Ok(deployment::list_deployments()),
826        "UpdateSystem" => Ok(deployment::update_system()),
827        "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
828        "RebootSystem" => Ok(deployment::reboot_system()),
829        "RollbackDeployment" => Ok(deployment::rollback_deployment()),
830        "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
831
832        // ── Deployment: parameterized ─────────────────────────────────────
833        "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
834        "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
835        "RebaseSystem" => {
836            let target_ref = require_str(params, "target_ref")?;
837            let target_ref = validated_rebase_ref(target_ref, "target_ref")?;
838            Ok(deployment::rebase_system(&target_ref))
839        }
840        "SetKernelArguments" => {
841            let add = str_array_or_empty(params, "add")?;
842            let remove = str_array_or_empty(params, "remove")?;
843            // Reject dangerous kernel arguments that could bypass security
844            // mechanisms or give unauthenticated root access on next boot.
845            for arg in add.iter() {
846                validated_safe_kernel_arg(arg, "add")?;
847            }
848            // Removal is screened too: stripping a protective arg reaches the
849            // same end state as adding its weakening counterpart.
850            for arg in remove.iter() {
851                validated_safe_kernel_arg_removal(arg, "remove")?;
852            }
853            let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
854            let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
855            Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
856        }
857
858        // ── Flatpak ───────────────────────────────────────────────────────
859        // All user-scoped Flatpak operations require a `username` param so the
860        // daemon can switch to that user's environment via `runuser -l`. This
861        // ensures operations target the user's Flatpak installation
862        // (~/.local/share/flatpak/) rather than the system store.
863        "ListFlatpakRemotes" => {
864            let username = validated_username(resolve_username(params)?, "username")?;
865            Ok(flatpak::list_flatpak_remotes(&username))
866        }
867        "InstallFlatpak" => {
868            let username = validated_username(resolve_username(params)?, "username")?;
869            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
870            // `remote` defaults to "flathub" — the universal Flatpak remote.
871            // Models frequently omit it; accepting the default avoids a
872            // MissingParam failure for the most common install case.
873            let remote = params
874                .get("remote")
875                .and_then(|v| v.as_str())
876                .unwrap_or("flathub");
877            let remote = validated_safe_arg(remote, "remote")?;
878            Ok(flatpak::install_flatpak(&username, &app_id, &remote))
879        }
880        "RemoveFlatpak" => {
881            let username = validated_username(resolve_username(params)?, "username")?;
882            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
883            Ok(flatpak::remove_flatpak(&username, &app_id))
884        }
885        "SearchFlatpakApps" => {
886            let term = validated_safe_arg(require_str(params, "term")?, "term")?;
887            Ok(flatpak::search_flatpak_apps(&term))
888        }
889        "AddFlatpakRemote" => {
890            let username = validated_username(resolve_username(params)?, "username")?;
891            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
892            let url = validated_safe_arg(require_str(params, "url")?, "url")?;
893            Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
894        }
895        "RemoveFlatpakRemote" => {
896            let username = validated_username(resolve_username(params)?, "username")?;
897            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
898            Ok(flatpak::remove_flatpak_remote(&username, &remote))
899        }
900        "GetFlatpakAppInfo" => {
901            let username = validated_username(resolve_username(params)?, "username")?;
902            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
903            Ok(flatpak::get_flatpak_app_info(&username, &app_id))
904        }
905        "ListInstalledFlatpaks" => {
906            let username = validated_username(resolve_username(params)?, "username")?;
907            Ok(flatpak::list_installed_flatpaks(&username))
908        }
909        "UpdateFlatpak" => {
910            let username = validated_username(resolve_username(params)?, "username")?;
911            // app_id is optional — omitting it updates all installed Flatpaks.
912            // Empty string is treated as absent (no app specified → update all).
913            let app_id = params
914                .get("app_id")
915                .and_then(|v| v.as_str())
916                .filter(|id| !id.is_empty())
917                .map(|id| validated_safe_arg(id, "app_id"))
918                .transpose()?;
919            Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
920        }
921
922        // ── Containers ────────────────────────────────────────────────────
923        // All container operations require a `username` param so the daemon can
924        // switch to that user's rootless Podman environment via `runuser -l`.
925        // Podman storage is per-user; running as the `sysknife` system user
926        // would see an empty, unrelated container store.
927        "ListContainers" => {
928            let username = validated_username(resolve_username(params)?, "username")?;
929            Ok(containers::list_containers(&username))
930        }
931        "CreateContainer" => {
932            let username = validated_username(resolve_username(params)?, "username")?;
933            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
934            let image = validated_safe_arg(require_str(params, "image")?, "image")?;
935            Ok(containers::create_container(&username, &name, &image))
936        }
937        "StartContainer" => {
938            let username = validated_username(resolve_username(params)?, "username")?;
939            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
940            Ok(containers::start_container(&username, &name))
941        }
942        "StopContainer" => {
943            let username = validated_username(resolve_username(params)?, "username")?;
944            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
945            Ok(containers::stop_container(&username, &name))
946        }
947        "RemoveContainer" => {
948            let username = validated_username(resolve_username(params)?, "username")?;
949            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
950            Ok(containers::remove_container(&username, &name))
951        }
952        "GetContainerInfo" => {
953            let username = validated_username(resolve_username(params)?, "username")?;
954            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
955            Ok(containers::get_container_info(&username, &name))
956        }
957
958        // ── Layering ──────────────────────────────────────────────────────
959        "GetLayeredPackages" => Ok(layering::get_layered_packages()),
960        "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
961        "InstallPackages" => {
962            let pkgs = str_array_or_empty(params, "packages")?;
963            let validated: Vec<String> = pkgs
964                .iter()
965                .map(|p| validated_install_package(p, "packages"))
966                .collect::<Result<_, _>>()?;
967            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
968            Ok(layering::install_packages(&refs))
969        }
970        "RemovePackages" => {
971            let pkgs = str_array_or_empty(params, "packages")?;
972            let validated: Vec<String> = pkgs
973                .iter()
974                .map(|p| validated_install_package(p, "packages"))
975                .collect::<Result<_, _>>()?;
976            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
977            Ok(layering::remove_packages(&refs))
978        }
979        "AddLayeredPackage" => {
980            let package = validated_install_package(require_str(params, "package")?, "package")?;
981            Ok(layering::add_layered_package(&package))
982        }
983        "RemoveLayeredPackage" => {
984            let package = validated_install_package(require_str(params, "package")?, "package")?;
985            Ok(layering::remove_layered_package(&package))
986        }
987        "ReplaceLayeredPackage" => {
988            let old = validated_install_package(require_str(params, "old")?, "old")?;
989            let new = validated_install_package(require_str(params, "new")?, "new")?;
990            Ok(layering::replace_layered_package(&old, &new))
991        }
992        "RemoveBasePackage" => {
993            let package = validated_install_package(require_str(params, "package")?, "package")?;
994            Ok(layering::remove_base_package(&package))
995        }
996        "GetPendingUpdates" => Ok(layering::get_pending_updates()),
997
998        // ── Package repositories ──────────────────────────────────────────
999        "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
1000        "AddPackageRepository" => Ok(package_repos::add_package_repository(
1001            validated_repo_id(params)?,
1002            validated_no_newline(params, "repo_url")?,
1003        )),
1004        "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
1005            validated_repo_id(params)?,
1006        )),
1007        "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
1008            validated_repo_id(params)?,
1009        )),
1010        "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
1011            validated_repo_id(params)?,
1012        )),
1013
1014        // ── Services ─────────────────────────────────────────────────────
1015        "ListServices" => Ok(services::list_services()),
1016        "StartService" => {
1017            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1018            Ok(services::start_service(&unit))
1019        }
1020        "StopService" => {
1021            let unit = validated_stoppable_unit(require_str(params, "unit")?, "unit")?;
1022            Ok(services::stop_service(&unit))
1023        }
1024        "RestartService" => {
1025            // A restart is a stop and a start, so it clears both screens.
1026            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1027            let unit = validated_stoppable_unit(&unit, "unit")?;
1028            Ok(services::restart_service(&unit))
1029        }
1030        "SetServiceEnabled" => {
1031            let enabled = require_bool(params, "enabled")?;
1032            // Enabling brings the unit up at boot, so it must clear the root-shell
1033            // denylist; disabling one is a mitigation and stays allowed.
1034            let unit = if enabled {
1035                validated_activatable_unit(require_str(params, "unit")?, "unit")?
1036            } else {
1037                validated_unit_name(require_str(params, "unit")?, "unit")?
1038            };
1039            Ok(services::set_service_enabled(&unit, enabled))
1040        }
1041        "MaskService" => {
1042            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1043            Ok(services::mask_service(&unit))
1044        }
1045        "UnmaskService" => {
1046            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1047            Ok(services::unmask_service(&unit))
1048        }
1049        "GetServiceLogs" => {
1050            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1051            Ok(services::get_service_logs(&unit))
1052        }
1053        "GetServiceStatus" => {
1054            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1055            Ok(services::get_service_status(&unit))
1056        }
1057        "ReloadService" => {
1058            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1059            Ok(services::reload_service(&unit))
1060        }
1061        "ListTimers" => Ok(services::list_timers()),
1062        "ReloadDaemon" => Ok(services::reload_daemon()),
1063        "CreateScheduledJob" => {
1064            // Job name: safe logical name. The service module owns the
1065            // exact sysknife-<name> unit-path rendering used by preview.
1066            let name = require_str(params, "name")?;
1067            if services::scheduled_job_unit_paths(name).is_none() {
1068                return Err(ExecutorError::InvalidParam("name"));
1069            }
1070            // Command: reject control characters (newlines would inject extra
1071            // unit directives). systemd argv-splits ExecStart with no shell.
1072            let command = require_str(params, "command")?;
1073            if command.is_empty()
1074                || command.len() > MAX_SCHEDULED_COMMAND_LEN
1075                || command.chars().any(|c| c.is_control())
1076            {
1077                return Err(ExecutorError::InvalidParam("command"));
1078            }
1079            // Schedule: OnCalendar charset; the helper validates it semantically
1080            // with `systemd-analyze calendar`.
1081            let schedule = require_str(params, "schedule")?;
1082            if schedule.is_empty()
1083                || schedule.len() > MAX_SCHEDULE_EXPR_LEN
1084                || !schedule.chars().all(|c| {
1085                    c.is_ascii_alphanumeric()
1086                        || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
1087                })
1088            {
1089                return Err(ExecutorError::InvalidParam("schedule"));
1090            }
1091            Ok(services::create_scheduled_job(name, command, schedule))
1092        }
1093        "GetServiceResourceLimits" => {
1094            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1095            Ok(services::get_service_resource_limits(&unit))
1096        }
1097        "SetServiceResourceLimits" => {
1098            // Not `validated_unit_name`: setting a property is neither an
1099            // activation nor a read, and this action is Medium-risk, so a
1100            // Dev-tier caller reaches it. See RESOURCE_LIMIT_PROTECTED_UNITS.
1101            let unit = validated_resource_limit_unit(require_str(params, "unit")?, "unit")?;
1102            // Build validated PROPERTY=VALUE assignments from whichever limits
1103            // were supplied; at least one is required.
1104            let mut assignments = Vec::new();
1105            if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
1106                assignments.push(format!("MemoryMax={v}"));
1107            }
1108            if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
1109                assignments.push(format!("MemoryHigh={v}"));
1110            }
1111            if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
1112                assignments.push(format!("CPUQuota={v}"));
1113            }
1114            if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
1115                assignments.push(format!("TasksMax={v}"));
1116            }
1117            if assignments.is_empty() {
1118                return Err(ExecutorError::MissingParam("memory_max"));
1119            }
1120            Ok(services::set_service_resource_limits(&unit, &assignments))
1121        }
1122
1123        // ── Toolbox ───────────────────────────────────────────────────────
1124        // Toolbox operations require a `username` param — toolbox containers are
1125        // per-user (rootless Podman) and must be managed in the user's context.
1126        "ListToolboxes" => {
1127            let username = validated_username(resolve_username(params)?, "username")?;
1128            Ok(toolbox::list_toolboxes(&username))
1129        }
1130        "CreateToolbox" => {
1131            let username = validated_username(resolve_username(params)?, "username")?;
1132            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1133            let image = params
1134                .get("image")
1135                .and_then(|v| v.as_str())
1136                .map(|img| validated_safe_arg(img, "image"))
1137                .transpose()?;
1138            let release = params
1139                .get("release")
1140                .and_then(|v| v.as_str())
1141                .map(|r| validated_safe_arg(r, "release"))
1142                .transpose()?;
1143            Ok(toolbox::create_toolbox(
1144                &username,
1145                &name,
1146                release.as_deref(),
1147                image.as_deref(),
1148            ))
1149        }
1150        "RemoveToolbox" => {
1151            let username = validated_username(resolve_username(params)?, "username")?;
1152            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1153            Ok(toolbox::remove_toolbox(&username, &name))
1154        }
1155
1156        // ── Identity ─────────────────────────────────────────────────────
1157        "GetDateTime" => Ok(identity::get_datetime()),
1158        "SetHostname" => {
1159            let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
1160            Ok(identity::set_hostname(&hostname))
1161        }
1162        "SetTimezone" => {
1163            let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
1164            Ok(identity::set_timezone(&timezone))
1165        }
1166        "SetLocale" => {
1167            let locale = validated_locale(require_str(params, "locale")?, "locale")?;
1168            Ok(identity::set_locale(&locale))
1169        }
1170        "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
1171
1172        // ── Filesystem ────────────────────────────────────────────────────
1173        "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
1174
1175        // ── Processes ────────────────────────────────────────────────────
1176        "ListProcesses" => Ok(processes::list_processes_spec()),
1177        "SignalProcess" => {
1178            // pid may arrive as a JSON number or a numeric string.
1179            let pid = params
1180                .get("pid")
1181                .and_then(|v| {
1182                    v.as_u64()
1183                        .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
1184                })
1185                .ok_or(ExecutorError::MissingParam("pid"))?;
1186            // Reject pid 0 (whole process group) and 1 (init/systemd); anything
1187            // outside the u32 pid space is invalid.
1188            if pid < 2 || pid > u32::MAX as u64 {
1189                return Err(ExecutorError::InvalidParam("pid"));
1190            }
1191            let signal = validated_kill_signal(
1192                params
1193                    .get("signal")
1194                    .and_then(|v| v.as_str())
1195                    .unwrap_or("TERM"),
1196            )?;
1197            Ok(processes::signal_process(pid as u32, signal))
1198        }
1199
1200        // ── Journald ──────────────────────────────────────────────────────
1201        "GetJournalLog" => {
1202            let unit = optional_validated(params, "unit", validated_unit_name)?;
1203            let priority = optional_validated(params, "priority", validated_journal_priority)?;
1204            let since = optional_validated(params, "since", validated_journal_time)?;
1205            let until = optional_validated(params, "until", validated_journal_time)?;
1206            let grep = optional_validated(params, "grep", validated_journal_grep)?;
1207            // `lines` defaults to 100 and is clamped so an enormous value cannot
1208            // make the daemon buffer an unbounded journal dump.
1209            let lines = params
1210                .get("lines")
1211                .and_then(|v| v.as_u64())
1212                .unwrap_or(100)
1213                .clamp(1, MAX_JOURNAL_LINES) as u32;
1214            let boot = params
1215                .get("boot")
1216                .and_then(|v| v.as_bool())
1217                .unwrap_or(false);
1218            let kernel = params
1219                .get("kernel")
1220                .and_then(|v| v.as_bool())
1221                .unwrap_or(false);
1222            Ok(journald::get_journal_log(&journald::JournalQuery {
1223                lines,
1224                unit: unit.as_deref(),
1225                priority: priority.as_deref(),
1226                boot,
1227                kernel,
1228                since: since.as_deref(),
1229                until: until.as_deref(),
1230                grep: grep.as_deref(),
1231            }))
1232        }
1233        "VacuumJournal" => {
1234            // Exactly one of size_mb / retain_days selects the vacuum mode.
1235            let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1236            let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1237            match (size_mb, retain_days) {
1238                (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1239                    Ok(journald::vacuum_journal_by_size(mb as u32))
1240                }
1241                (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1242                    Ok(journald::vacuum_journal_by_time(days as u32))
1243                }
1244                (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1245                // both supplied, or an out-of-range value
1246                _ => Err(ExecutorError::InvalidParam("size_mb")),
1247            }
1248        }
1249
1250        // ── Storage / LVM ───────────────────────────────────────────────────
1251        "GetLvmReport" => Ok(lvm::get_lvm_report()),
1252        "ExtendLogicalVolume" => {
1253            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1254            let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1255            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1256            Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1257        }
1258        "CreateLogicalVolume" => {
1259            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1260            let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1261            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1262            Ok(lvm::create_logical_volume(&vg, &name, &size))
1263        }
1264        "CreateLvSnapshot" => {
1265            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1266            let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1267            let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1268            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1269            Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1270        }
1271
1272        // ── Kernel / sysctl ─────────────────────────────────────────────────
1273        "GetSysctl" => {
1274            // `key` is optional — absent means dump the whole table (sysctl -a).
1275            let key = optional_validated(params, "key", validated_sysctl_key)?;
1276            Ok(sysctl::get_sysctl(key.as_deref()))
1277        }
1278        "SetSysctl" => {
1279            let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1280            let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1281            Ok(sysctl::set_sysctl(&key, &value))
1282        }
1283
1284        // ── Filesystem mounts / swap ────────────────────────────────────────
1285        "GetMounts" => Ok(mounts::get_mounts()),
1286        "AddMount" => {
1287            let device = validated_mount_device(require_str(params, "device")?, "device")?;
1288            let mountpoint =
1289                validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1290            let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1291            let options = optional_validated(params, "options", validated_mount_options)?;
1292            Ok(mounts::add_mount(
1293                &device,
1294                &mountpoint,
1295                &fstype,
1296                options.as_deref(),
1297            ))
1298        }
1299        "RemoveMount" => {
1300            let mountpoint =
1301                validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1302            Ok(mounts::remove_mount(&mountpoint))
1303        }
1304        "AddSwap" => {
1305            let file = validated_swap_path(require_str(params, "file")?, "file")?;
1306            let size_mb = require_u32(params, "size_mb")?;
1307            // 1 MiB .. 1 TiB — reject 0 (empty) and absurdly large requests.
1308            if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1309                return Err(ExecutorError::InvalidParam("size_mb"));
1310            }
1311            Ok(mounts::add_swap(&file, size_mb))
1312        }
1313        "RemoveSwap" => {
1314            let file = validated_swap_path(require_str(params, "file")?, "file")?;
1315            Ok(mounts::remove_swap(&file))
1316        }
1317
1318        // ── Scoped sudoers.d ────────────────────────────────────────────────
1319        "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1320        "GrantSudoAccess" => {
1321            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1322            let user = validated_username(require_str(params, "user")?, "user")?;
1323            let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1324            // runas defaults to root; if given it must be "ALL" or a username.
1325            let runas = match params
1326                .get("runas")
1327                .and_then(|v| v.as_str())
1328                .filter(|s| !s.is_empty())
1329            {
1330                Some("ALL") => Some("ALL".to_string()),
1331                Some(u) => Some(validated_username(u, "runas")?),
1332                None => None,
1333            };
1334            let nopasswd = params
1335                .get("nopasswd")
1336                .and_then(|v| v.as_bool())
1337                .unwrap_or(false);
1338            // `commands = "ALL"` together with `nopasswd` mints a standing,
1339            // passwordless, unrestricted root credential (`user ALL=(ALL)
1340            // NOPASSWD: ALL`). Unlike every other action here, the effect
1341            // outlives the job: it is a permanent privilege grant, not a
1342            // one-time change, and it is indistinguishable from the daemon's
1343            // own authority thereafter. Require the caller to give up one of
1344            // the two dimensions — scope the commands, or keep the password
1345            // prompt. `visudo` would happily accept the combination, so this
1346            // is the only place it can be refused.
1347            //
1348            // `"ALL"` was the only spelling this refused, and
1349            // `validated_sudo_commands` accepts `/bin/bash`, which is the same
1350            // capability written differently: `user ALL=(root) NOPASSWD:
1351            // /bin/bash` is a standing passwordless root shell and `visudo -cf`
1352            // takes it without complaint. A shell-equivalent list is held to
1353            // the same line as `"ALL"` now. See SHELL_EQUIVALENT_COMMANDS for
1354            // what that list covers and what it deliberately leaves to the
1355            // preview.
1356            let shell_equivalent = shell_equivalent_sudo_command(&commands);
1357            if (commands == "ALL" || shell_equivalent.is_some()) && nopasswd {
1358                return Err(ExecutorError::InvalidParam("commands"));
1359            }
1360            Ok(sudoers::grant_sudo_access(
1361                &name,
1362                &user,
1363                &commands,
1364                runas.as_deref(),
1365                nopasswd,
1366            ))
1367        }
1368        "RevokeSudoAccess" => {
1369            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1370            Ok(sudoers::revoke_sudo_access(&name))
1371        }
1372
1373        // ── apt pinning (preferences.d) ─────────────────────────────────────
1374        "GetAptPins" => {
1375            let package = optional_validated(params, "package", validated_apt_package)?;
1376            Ok(apt_preferences::get_apt_pins(package.as_deref()))
1377        }
1378        "SetAptPin" => {
1379            let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1380            let package = validated_apt_package(require_str(params, "package")?, "package")?;
1381            let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1382            let priority = params
1383                .get("priority")
1384                .and_then(|v| v.as_i64())
1385                .ok_or(ExecutorError::MissingParam("priority"))?;
1386            if !(-1..=1000).contains(&priority) {
1387                return Err(ExecutorError::InvalidParam("priority"));
1388            }
1389            Ok(apt_preferences::set_apt_pin(
1390                &name, &package, &pin, priority,
1391            ))
1392        }
1393        "RemoveAptPin" => {
1394            let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1395            Ok(apt_preferences::remove_apt_pin(&name))
1396        }
1397
1398        // ── Log management (logrotate + rsyslog) ────────────────────────────
1399        "GetLogrotateStatus" => {
1400            let config = optional_validated(params, "config", validated_log_path)?;
1401            Ok(logging::get_logrotate_status(config.as_deref()))
1402        }
1403        "ConfigureLogRotation" => {
1404            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1405            let frequency = require_str(params, "frequency")?;
1406            if !matches!(frequency, "daily" | "weekly" | "monthly") {
1407                return Err(ExecutorError::InvalidParam("frequency"));
1408            }
1409            let rotate = require_u32(params, "rotate")?;
1410            if rotate > 1000 {
1411                return Err(ExecutorError::InvalidParam("rotate"));
1412            }
1413            let path = validated_log_rotation(require_str(params, "path")?, rotate)?;
1414            let compress = params
1415                .get("compress")
1416                .and_then(|v| v.as_bool())
1417                .unwrap_or(false);
1418            Ok(logging::configure_log_rotation(
1419                &name, &path, frequency, rotate, compress,
1420            ))
1421        }
1422        "RemoveLogRotation" => {
1423            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1424            Ok(logging::remove_log_rotation(&name))
1425        }
1426        "ConfigureRemoteSyslog" => {
1427            let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1428            let port = require_u32(params, "port")?;
1429            if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1430                return Err(ExecutorError::InvalidParam("port"));
1431            }
1432            let protocol = require_str(params, "protocol")?;
1433            if !matches!(protocol, "tcp" | "udp") {
1434                return Err(ExecutorError::InvalidParam("protocol"));
1435            }
1436            Ok(logging::configure_remote_syslog(
1437                &host,
1438                port as u16,
1439                protocol,
1440            ))
1441        }
1442        "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1443
1444        // ── PAM password policy ───────────────────────────────────────────
1445        "GetPasswordAging" => {
1446            let user = validated_username(require_str(params, "user")?, "user")?;
1447            Ok(pam::get_password_aging(&user))
1448        }
1449        "SetPasswordAging" => {
1450            let user = validated_username(require_str(params, "user")?, "user")?;
1451            // chage days: 0..=99999 (chage's own ceiling). At least one required.
1452            let mut flags = Vec::new();
1453            for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1454                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1455                    if n > MAX_PASSWORD_AGE_DAYS {
1456                        return Err(ExecutorError::InvalidParam(key));
1457                    }
1458                    flags.push(opt.to_string());
1459                    flags.push(n.to_string());
1460                }
1461            }
1462            if flags.is_empty() {
1463                return Err(ExecutorError::MissingParam("max_days"));
1464            }
1465            Ok(pam::set_password_aging(&user, &flags))
1466        }
1467        "SetPasswordPolicy" => {
1468            // minlen 1..=128; *credit knobs -64..=64 (negative = require that
1469            // many chars of the class). At least one required.
1470            let mut extra = Vec::new();
1471            if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1472                if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1473                    return Err(ExecutorError::InvalidParam("minlen"));
1474                }
1475                extra.push("--minlen".to_string());
1476                extra.push(n.to_string());
1477            }
1478            for (key, flag) in [
1479                ("dcredit", "--dcredit"),
1480                ("ucredit", "--ucredit"),
1481                ("lcredit", "--lcredit"),
1482                ("ocredit", "--ocredit"),
1483            ] {
1484                if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1485                    if !(-64..=64).contains(&n) {
1486                        return Err(ExecutorError::InvalidParam(key));
1487                    }
1488                    extra.push(flag.to_string());
1489                    extra.push(n.to_string());
1490                }
1491            }
1492            if extra.is_empty() {
1493                return Err(ExecutorError::MissingParam("minlen"));
1494            }
1495            Ok(pam::set_password_policy(&extra))
1496        }
1497        "SetAccountLockout" => {
1498            // deny 1..=1000; unlock_time/fail_interval seconds 0..=604800 (7d).
1499            let mut extra = Vec::new();
1500            if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1501                if !(1..=1000).contains(&n) {
1502                    return Err(ExecutorError::InvalidParam("deny"));
1503                }
1504                extra.push("--deny".to_string());
1505                extra.push(n.to_string());
1506            }
1507            for (key, flag) in [
1508                ("unlock_time", "--unlock-time"),
1509                ("fail_interval", "--fail-interval"),
1510            ] {
1511                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1512                    if n > MAX_LOCKOUT_WINDOW_SECS {
1513                        return Err(ExecutorError::InvalidParam(key));
1514                    }
1515                    extra.push(flag.to_string());
1516                    extra.push(n.to_string());
1517                }
1518            }
1519            if extra.is_empty() {
1520                return Err(ExecutorError::MissingParam("deny"));
1521            }
1522            Ok(pam::set_account_lockout(&extra))
1523        }
1524
1525        // ── System info ──────────────────────────────────────────────────
1526        "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1527        "GetHostState" => Ok(system_info::get_host_state()),
1528
1529        // ── Network ───────────────────────────────────────────────────────
1530        "GetFirewallState" => Ok(network::get_firewall_state()),
1531        "GetNetworkStatus" => Ok(network::get_network_status()),
1532        "GetListeningPorts" => Ok(network::get_listening_ports()),
1533        "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1534        "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1535        "ConfigureWifi" => {
1536            let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1537            // password is optional — open networks connect without one.
1538            let password = params
1539                .get("password")
1540                .and_then(|v| v.as_str())
1541                .filter(|p| !p.is_empty())
1542                .map(|p| validated_safe_arg(p, "password"))
1543                .transpose()?;
1544            Ok(network::configure_wifi(&ssid, password.as_deref()))
1545        }
1546        "SetDnsServers" => {
1547            let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1548            let servers = str_array_or_empty(params, "servers")?;
1549            let validated: Vec<String> = servers
1550                .iter()
1551                .map(|s| validated_safe_arg(s, "servers"))
1552                .collect::<Result<_, _>>()?;
1553            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1554            Ok(network::set_dns_servers(&interface, &refs))
1555        }
1556        "ConfigureFirewall" => {
1557            let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1558            let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1559            Ok(network::configure_firewall(
1560                &zone,
1561                &service,
1562                require_bool(params, "enabled")?,
1563            ))
1564        }
1565
1566        // ── Users ─────────────────────────────────────────────────────────
1567        "ListUsers" => Ok(users::list_users()),
1568        "ListGroups" => Ok(users::list_groups()),
1569        "CreateUser" => {
1570            let username = validated_username(resolve_username(params)?, "username")?;
1571            let shell = params
1572                .get("shell")
1573                .and_then(|v| v.as_str())
1574                .map(|s| validated_safe_arg(s, "shell"))
1575                .transpose()?;
1576            let home = params
1577                .get("home")
1578                .and_then(|v| v.as_str())
1579                .map(|h| validated_home_dir(h, "home"))
1580                .transpose()?;
1581            Ok(users::create_user(
1582                &username,
1583                shell.as_deref(),
1584                home.as_deref(),
1585            ))
1586        }
1587        "DeleteUser" => {
1588            let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1589            Ok(users::delete_user(&username))
1590        }
1591        "AddUserToGroup" => {
1592            let username = validated_username(resolve_username(params)?, "username")?;
1593            let group = validated_group(require_str(params, "group")?, "group")?;
1594            Ok(users::add_user_to_group(&username, &group))
1595        }
1596        "RemoveUserFromGroup" => {
1597            let username = validated_username(resolve_username(params)?, "username")?;
1598            let group = validated_group(require_str(params, "group")?, "group")?;
1599            Ok(users::remove_user_from_group(&username, &group))
1600        }
1601        "CreateGroup" => {
1602            let group = validated_group(require_str(params, "group")?, "group")?;
1603            let system = params
1604                .get("system")
1605                .and_then(|v| v.as_bool())
1606                .unwrap_or(false);
1607            Ok(users::create_group(&group, system))
1608        }
1609        "DeleteGroup" => {
1610            let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1611            Ok(users::delete_group(&group))
1612        }
1613        "LockUserAccount" => {
1614            let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1615            Ok(users::lock_user_account(&username))
1616        }
1617        "UnlockUserAccount" => {
1618            let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1619            Ok(users::unlock_user_account(&username))
1620        }
1621
1622        // ── SSH ──────────────────────────────────────────────────────────
1623        "GetAuthorizedKeys" => {
1624            let username = validated_username(resolve_username(params)?, "username")?;
1625            Ok(ssh::get_authorized_keys(&username))
1626        }
1627        "AddAuthorizedKey" => {
1628            let username = validated_username(resolve_username(params)?, "username")?;
1629            let public_key = validated_public_key(require_str(params, "public_key")?)?;
1630            Ok(ssh::add_authorized_key(&username, &public_key))
1631        }
1632        "RemoveAuthorizedKey" => {
1633            let username = validated_username(resolve_username(params)?, "username")?;
1634            let public_key = validated_public_key(require_str(params, "public_key")?)?;
1635            Ok(ssh::remove_authorized_key(&username, &public_key))
1636        }
1637        "SetSshdOption" => {
1638            // Allowlist the option name and its permitted values (the helper
1639            // re-validates as defense-in-depth). This is deliberately not an
1640            // arbitrary sshd_config editor.
1641            let option = require_str(params, "option")?;
1642            let value = require_str(params, "value")?;
1643            let allowed_values: &[&str] = match option {
1644                "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1645                "PasswordAuthentication"
1646                | "PubkeyAuthentication"
1647                | "X11Forwarding"
1648                | "PermitEmptyPasswords" => &["yes", "no"],
1649                _ => return Err(ExecutorError::InvalidParam("option")),
1650            };
1651            if !allowed_values.contains(&value) {
1652                return Err(ExecutorError::InvalidParam("value"));
1653            }
1654            Ok(ssh::set_sshd_option(option, value))
1655        }
1656
1657        // ── apt ──────────────────────────────────────────────────────────
1658        "AptUpdate" => Ok(apt::apt_update()),
1659        "AptUpgrade" => Ok(apt::apt_upgrade()),
1660        "AptInstall" => {
1661            let package = validated_install_package(require_str(params, "package")?, "package")?;
1662            Ok(apt::apt_install(&package))
1663        }
1664        "AptRemove" => {
1665            let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1666            Ok(apt::apt_remove(&package))
1667        }
1668        "AptPurge" => {
1669            let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1670            Ok(apt::apt_purge(&package))
1671        }
1672        "AptAutoremove" => Ok(apt::apt_autoremove()),
1673        "AptHold" => {
1674            let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1675            Ok(apt::apt_hold(&package))
1676        }
1677        "AptUnhold" => {
1678            let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1679            Ok(apt::apt_unhold(&package))
1680        }
1681        "AptSearch" => {
1682            let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1683            Ok(apt::apt_search(&term))
1684        }
1685        "AptListInstalled" => Ok(apt::apt_list_installed()),
1686        "AptShow" => {
1687            let package = validated_apt_show_package(require_str(params, "package")?, "package")?;
1688            Ok(apt::apt_show(&package))
1689        }
1690        "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1691        "AptHistoryList" => Ok(apt::apt_history_list()),
1692        "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1693            params, "enabled",
1694        )?)),
1695
1696        // ── ppa ──────────────────────────────────────────────────────────
1697        "AddPpa" => {
1698            let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1699            Ok(ppa::add_ppa(&name))
1700        }
1701        "RemovePpa" => {
1702            let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1703            Ok(ppa::remove_ppa(&name))
1704        }
1705
1706        // ── snap ─────────────────────────────────────────────────────────
1707        "SnapInstall" => {
1708            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1709            let channel = params
1710                .get("channel")
1711                .and_then(|v| v.as_str())
1712                .filter(|s| !s.is_empty())
1713                .map(|s| validated_safe_arg(s, "channel"))
1714                .transpose()?;
1715            let auto_update = params
1716                .get("auto_update")
1717                .and_then(|v| v.as_bool())
1718                .unwrap_or(false);
1719            snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1720                snap::SnapInstallError::InvalidArg { param, .. } => {
1721                    ExecutorError::InvalidParam(param)
1722                }
1723            })
1724        }
1725        "SnapRemove" => {
1726            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1727            Ok(snap::snap_remove(&name))
1728        }
1729        "SnapRefresh" => {
1730            let name = params
1731                .get("name")
1732                .and_then(|v| v.as_str())
1733                .filter(|s| !s.is_empty())
1734                .map(|s| validated_safe_arg(s, "name"))
1735                .transpose()?;
1736            Ok(snap::snap_refresh(name.as_deref()))
1737        }
1738        "SnapHold" => {
1739            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1740            Ok(snap::snap_hold(&name))
1741        }
1742        "SnapUnhold" => {
1743            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1744            Ok(snap::snap_unhold(&name))
1745        }
1746        "SnapList" => Ok(snap::snap_list()),
1747        "SnapInfo" => {
1748            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1749            Ok(snap::snap_info(&name))
1750        }
1751        "SnapRevert" => {
1752            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1753            Ok(snap::snap_revert(&name))
1754        }
1755        "SnapClassicInstall" => {
1756            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1757            Ok(snap::snap_classic_install(&name))
1758        }
1759
1760        // ── grub ─────────────────────────────────────────────────────────
1761        "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1762        "GrubSetKargs" => {
1763            let append: Vec<String> = params
1764                .get("append")
1765                .and_then(|v| v.as_array())
1766                .map(|arr| {
1767                    arr.iter()
1768                        .filter_map(|v| v.as_str())
1769                        .map(String::from)
1770                        .collect()
1771                })
1772                .unwrap_or_default();
1773            let delete: Vec<String> = params
1774                .get("delete")
1775                .and_then(|v| v.as_array())
1776                .map(|arr| {
1777                    arr.iter()
1778                        .filter_map(|v| v.as_str())
1779                        .map(String::from)
1780                        .collect()
1781                })
1782                .unwrap_or_default();
1783            // Validate each arg in both lists. `append` gets the charset check
1784            // (rejects `=`, `,`, shell metacharacters — the latter also blocks
1785            // CSV injection into the helper's comma-separated list) *and* the
1786            // kernel-arg denylist, so a bare `single`/`s`/`1` cannot boot the
1787            // host into a single-user root shell — parity with
1788            // SetKernelArguments. `delete` gets the charset check plus the
1789            // removal screen: dropping `module.sig_enforce=1` or `lockdown=`
1790            // weakens the next boot exactly as adding its counterpart would.
1791            for a in &append {
1792                validated_safe_arg(a, "append")?;
1793                validated_safe_kernel_arg(a, "append")?;
1794            }
1795            for d in &delete {
1796                validated_safe_arg(d, "delete")?;
1797                validated_safe_kernel_arg_removal(d, "delete")?;
1798            }
1799            // The constructor itself enforces "at least one of append/delete
1800            // non-empty" — this is the single source of truth for the invariant.
1801            let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1802            let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1803            grub::grub_set_kargs(&append_refs, &delete_refs)
1804                .map_err(|_| ExecutorError::MissingParam("append or delete"))
1805        }
1806
1807        // ── reboot ────────────────────────────────────────────────────────
1808        "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1809
1810        // ── ufw ──────────────────────────────────────────────────────────
1811        "UfwEnable" => Ok(ufw::ufw_enable()),
1812        "UfwDisable" => Ok(ufw::ufw_disable()),
1813        "UfwAllow" => {
1814            let port_or_service = validated_port_or_service(
1815                require_str(params, "port_or_service")?,
1816                "port_or_service",
1817            )?;
1818            Ok(ufw::ufw_allow(&port_or_service))
1819        }
1820        "UfwDeny" => {
1821            let port_or_service = validated_port_or_service(
1822                require_str(params, "port_or_service")?,
1823                "port_or_service",
1824            )?;
1825            Ok(ufw::ufw_deny(&port_or_service))
1826        }
1827        "UfwReset" => Ok(ufw::ufw_reset()),
1828        "UfwStatus" => {
1829            let numbered = match params.get("numbered") {
1830                None => false,
1831                Some(value) => value
1832                    .as_bool()
1833                    .ok_or(ExecutorError::InvalidParam("numbered"))?,
1834            };
1835            Ok(ufw::ufw_status_with_numbered(numbered))
1836        }
1837
1838        // ── distrobox ────────────────────────────────────────────────────
1839        "DistroboxList" => Ok(distrobox::distrobox_list()),
1840        "DistroboxCreate" => {
1841            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1842            let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1843            Ok(distrobox::distrobox_create(&name, &image))
1844        }
1845        "DistroboxRemove" => {
1846            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1847            Ok(distrobox::distrobox_remove(&name))
1848        }
1849
1850        // ── netplan ──────────────────────────────────────────────────────
1851        "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1852        "NetplanApply" => Ok(netplan::netplan_apply()),
1853        "NetplanSet" => {
1854            let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1855            let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1856            Ok(netplan::netplan_set(&key, &value))
1857        }
1858        "NetplanGenerate" => Ok(netplan::netplan_generate()),
1859
1860        // ── ufw Tier 3 ────────────────────────────────────────────────────
1861        "UfwDeleteRule" => {
1862            let rule_number = require_positive_u32(params, "rule_number")?;
1863            ufw::ufw_delete_rule(rule_number)
1864                .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1865        }
1866        "UfwLimit" => {
1867            let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1868            Ok(ufw::ufw_limit(&target))
1869        }
1870
1871        // ── Ubuntu Pro ────────────────────────────────────────────────────
1872        "ProStatus" => Ok(ubuntu_pro::pro_status()),
1873        "ProAttach" => {
1874            // token is a credential: read it from params but do NOT log it.
1875            let token = require_str(params, "token")?;
1876            // Minimal structural validation: non-empty, no shell metacharacters.
1877            let token = validated_safe_arg(token, "token")?;
1878            Ok(ubuntu_pro::pro_attach(&token))
1879        }
1880        "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1881        "EnableProService" => {
1882            let service = validated_pro_service(require_str(params, "service")?, "service")?;
1883            Ok(ubuntu_pro::enable_pro_service(&service))
1884        }
1885        "DisableProService" => {
1886            let service = validated_pro_service(require_str(params, "service")?, "service")?;
1887            Ok(ubuntu_pro::disable_pro_service(&service))
1888        }
1889
1890        // ── Livepatch ─────────────────────────────────────────────────────
1891        "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1892
1893        // ── Multipass ─────────────────────────────────────────────────────
1894        "MultipassList" => Ok(multipass::multipass_list()),
1895
1896        // ── Release upgrade ───────────────────────────────────────────────
1897        "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1898
1899        // ── resolvectl (cross-distro / systemd-resolved) ──────────────────
1900        "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1901        "ResolvectlSetDns" => {
1902            let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1903            let raw_servers: Vec<String> = params
1904                .get("servers")
1905                .and_then(|v| v.as_array())
1906                .map(|arr| {
1907                    arr.iter()
1908                        .filter_map(|v| v.as_str())
1909                        .map(String::from)
1910                        .collect()
1911                })
1912                .unwrap_or_default();
1913            if raw_servers.is_empty() {
1914                return Err(ExecutorError::MissingParam("servers"));
1915            }
1916            // Parse every server string as a typed IpAddr before passing to the
1917            // constructor. This rejects leading-dash strings (flag injection) and
1918            // malformed addresses that would silently misconfigure systemd-resolved.
1919            let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1920            for s in &raw_servers {
1921                let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1922                    param: "servers",
1923                    value: s.clone(),
1924                })?;
1925                parsed_servers.push(addr);
1926            }
1927            Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1928        }
1929
1930        // ── apparmor ──────────────────────────────────────────────────────
1931        "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1932        "AppArmorEnforce" => {
1933            let profile_path =
1934                validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1935            Ok(apparmor::apparmor_enforce(&profile_path))
1936        }
1937        "AppArmorComplain" => {
1938            let profile_path =
1939                validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1940            Ok(apparmor::apparmor_complain(&profile_path))
1941        }
1942
1943        // ── cloud-init ────────────────────────────────────────────────────
1944        "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1945
1946        // ── Ubuntu Flatpak ─────────────────────────────────────────────────
1947        "UbuntuInstallFlatpak" => {
1948            let username = validated_username(resolve_username(params)?, "username")?;
1949            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1950            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1951            Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1952        }
1953        "UbuntuRemoveFlatpak" => {
1954            let username = validated_username(resolve_username(params)?, "username")?;
1955            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1956            Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1957        }
1958        "UbuntuUpdateFlatpak" => {
1959            let username = validated_username(resolve_username(params)?, "username")?;
1960            let app_id = params
1961                .get("app_id")
1962                .and_then(|v| v.as_str())
1963                .filter(|s| !s.is_empty())
1964                .map(|s| validated_safe_arg(s, "app_id"))
1965                .transpose()?;
1966            Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1967        }
1968        "UbuntuListFlatpaks" => {
1969            let username = validated_username(resolve_username(params)?, "username")?;
1970            Ok(flatpak::ubuntu_list_flatpaks(&username))
1971        }
1972
1973        // ── fail2ban ──────────────────────────────────────────────────────
1974        "Fail2banStatus" => {
1975            let jail = params
1976                .get("jail")
1977                .and_then(|v| v.as_str())
1978                .filter(|s| !s.is_empty())
1979                .map(|s| validated_safe_arg(s, "jail"))
1980                .transpose()?;
1981            fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1982                fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1983                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1984            })
1985        }
1986        "Fail2banBanIp" => {
1987            let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1988            let ip = require_str(params, "ip")?;
1989            fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1990                fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1991                    param: "ip",
1992                    value: v,
1993                },
1994                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1995            })
1996        }
1997        "Fail2banUnbanIp" => {
1998            let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1999            let ip = require_str(params, "ip")?;
2000            fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
2001                fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
2002                    param: "ip",
2003                    value: v,
2004                },
2005                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
2006            })
2007        }
2008        "ConfigureFail2banJail" => {
2009            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
2010            let mut extra = Vec::new();
2011            if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
2012                extra.push("--enabled".to_string());
2013                extra.push(if n { "true" } else { "false" }.to_string());
2014            }
2015            if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
2016                if !(1..=100).contains(&n) {
2017                    return Err(ExecutorError::InvalidParam("maxretry"));
2018                }
2019                extra.push("--maxretry".to_string());
2020                extra.push(n.to_string());
2021            }
2022            for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
2023                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
2024                    if n > MAX_FAIL2BAN_WINDOW_SECS {
2025                        return Err(ExecutorError::InvalidParam(key));
2026                    }
2027                    extra.push(flag.to_string());
2028                    extra.push(n.to_string());
2029                }
2030            }
2031            if extra.is_empty() {
2032                return Err(ExecutorError::MissingParam("maxretry"));
2033            }
2034            fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
2035                fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
2036                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
2037            })
2038        }
2039
2040        // ── auditd file-watch rules ───────────────────────────────────────
2041        "GetAuditRules" => Ok(auditd::get_audit_rules()),
2042        "AddAuditRule" => {
2043            let path = validated_audit_path(require_str(params, "path")?, "path")?;
2044            let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
2045            let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2046            Ok(auditd::add_audit_rule(&path, &perms, &key))
2047        }
2048        "RemoveAuditRule" => {
2049            let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2050            Ok(auditd::remove_audit_rule(&key))
2051        }
2052
2053        // ── certbot / ACME ────────────────────────────────────────────────
2054        "GetCertificates" => Ok(certbot::get_certificates()),
2055        "ObtainCertificate" => {
2056            // Accept a "domains" array or a single "domain" string; ≥1 required.
2057            let mut domains = Vec::new();
2058            if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
2059                for d in arr {
2060                    let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
2061                    domains.push(validated_domain(s, "domains")?);
2062                }
2063            } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
2064                domains.push(validated_domain(d, "domain")?);
2065            }
2066            if domains.is_empty() {
2067                return Err(ExecutorError::MissingParam("domains"));
2068            }
2069            let email = validated_email(require_str(params, "email")?, "email")?;
2070            let challenge = params
2071                .get("challenge")
2072                .and_then(|v| v.as_str())
2073                .unwrap_or("standalone");
2074            if !matches!(challenge, "standalone" | "nginx" | "apache") {
2075                return Err(ExecutorError::InvalidParam("challenge"));
2076            }
2077            Ok(certbot::obtain_certificate(&domains, &email, challenge))
2078        }
2079        "RenewCertificates" => Ok(certbot::renew_certificates()),
2080
2081        _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
2082    }
2083}
2084
2085/// Execute an [`ActionSpec`] and return the output.
2086///
2087/// For `Command` mechanisms, the process is spawned and its stdout/stderr
2088/// are captured. For file mechanisms, the operation is performed directly
2089/// on the filesystem and an empty stdout is returned.
2090pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
2091    match &spec.mechanism {
2092        ActionMechanism::Command { program, args } => {
2093            // Spawned rather than `.output()`ed so the child can be killed on
2094            // timeout; `.output()` gives no handle to kill, so a hung process
2095            // would hold the task forever.
2096            let mut child = tokio::process::Command::new(program)
2097                .args(args)
2098                .stdin(Stdio::null())
2099                // Own process group: the deadline below must be able to stop the
2100                // whole tree, not just the pid we spawned. See issue #140.
2101                .process_group(0)
2102                .stdout(Stdio::piped())
2103                .stderr(Stdio::piped())
2104                .spawn()?;
2105
2106            // `wait_with_output` consumes the child, which would leave nothing
2107            // to kill when the deadline fires. Drain the pipes in their own
2108            // tasks instead — draining also stops a chatty process from
2109            // blocking on a full pipe buffer while we wait.
2110            let stdout_h = child.stdout.take().expect("stdout was piped");
2111            let stderr_h = child.stderr.take().expect("stderr was piped");
2112            let stdout_task = tokio::spawn(async move {
2113                let mut buf = Vec::new();
2114                BufReader::new(stdout_h)
2115                    .read_to_end(&mut buf)
2116                    .await
2117                    .map(|_| buf)
2118            });
2119            let stderr_task = tokio::spawn(async move {
2120                let mut buf = Vec::new();
2121                BufReader::new(stderr_h)
2122                    .read_to_end(&mut buf)
2123                    .await
2124                    .map(|_| buf)
2125            });
2126
2127            let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
2128                Ok(Ok(status)) => status,
2129                // Same rule as the streaming path: a `wait` that fails leaves a
2130                // privileged child in an unknown state, so stop the group before
2131                // reporting the failure upward.
2132                Ok(Err(err)) => {
2133                    return Err(kill_and_reap(
2134                        &mut child,
2135                        program,
2136                        args,
2137                        StopCause::Unreadable(err),
2138                    )
2139                    .await)
2140                }
2141                Err(_) => {
2142                    return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
2143                }
2144            };
2145            let join =
2146                |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
2147            let stdout = stdout_task.await.map_err(join)??;
2148            let stderr = stderr_task.await.map_err(join)??;
2149
2150            Ok(ExecutionOutput {
2151                stdout: String::from_utf8_lossy(&stdout).into_owned(),
2152                stderr: String::from_utf8_lossy(&stderr).into_owned(),
2153                exit_code: status.code().unwrap_or(-1),
2154            })
2155        }
2156        ActionMechanism::FileScan { path } => {
2157            let mut entries = tokio::fs::read_dir(path).await?;
2158            let mut names = Vec::new();
2159            while let Some(entry) = entries.next_entry().await? {
2160                names.push(entry.file_name().to_string_lossy().into_owned());
2161            }
2162            names.sort();
2163            Ok(ExecutionOutput {
2164                stdout: names.join("\n"),
2165                stderr: String::new(),
2166                exit_code: 0,
2167            })
2168        }
2169        ActionMechanism::FileWrite { path, content } => {
2170            if let Some(parent) = std::path::Path::new(path).parent() {
2171                tokio::fs::create_dir_all(parent).await?;
2172            }
2173            tokio::fs::write(path, content).await?;
2174            Ok(ExecutionOutput {
2175                stdout: String::new(),
2176                stderr: String::new(),
2177                exit_code: 0,
2178            })
2179        }
2180        ActionMechanism::FilePatch {
2181            path,
2182            search,
2183            replace,
2184        } => {
2185            let content = tokio::fs::read_to_string(path).await?;
2186            let patched = content.replacen(search.as_str(), replace.as_str(), 1);
2187            if patched == content && !search.is_empty() {
2188                return Ok(ExecutionOutput {
2189                    stdout: String::new(),
2190                    stderr: format!("search string not found in file: {}", path),
2191                    exit_code: 1,
2192                });
2193            }
2194            tokio::fs::write(path, patched).await?;
2195            Ok(ExecutionOutput {
2196                stdout: String::new(),
2197                stderr: String::new(),
2198                exit_code: 0,
2199            })
2200        }
2201        ActionMechanism::FileDelete { path } => {
2202            tokio::fs::remove_file(path).await?;
2203            Ok(ExecutionOutput {
2204                stdout: String::new(),
2205                stderr: String::new(),
2206                exit_code: 0,
2207            })
2208        }
2209    }
2210}
2211
2212fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
2213    match params.get(key) {
2214        None => Err(ExecutorError::MissingParam(key)),
2215        Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
2216    }
2217}
2218
2219/// Extract an optional string param and validate it. An absent key or an empty
2220/// string yields `None` (the filter is simply omitted); a present non-empty
2221/// value is passed through `validator`, propagating any validation error.
2222fn optional_validated<F>(
2223    params: &Value,
2224    key: &'static str,
2225    validator: F,
2226) -> Result<Option<String>, ExecutorError>
2227where
2228    F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2229{
2230    match params
2231        .get(key)
2232        .and_then(|v| v.as_str())
2233        .filter(|s| !s.is_empty())
2234    {
2235        Some(s) => Ok(Some(validator(s, key)?)),
2236        None => Ok(None),
2237    }
2238}
2239
2240/// Extract the username from params, accepting either `"username"` or `"user"`
2241/// as the key.  The `"username"` key takes precedence.
2242///
2243/// Tolerates the `"user"` alias because LLMs trained on general Linux tooling
2244/// frequently produce `"user"` — accepting both here eliminates an entire class
2245/// of Describe/Execute failures without requiring the model to be perfect.
2246///
2247/// Returns [`ExecutorError::MissingParam`] if neither key is present,
2248/// [`ExecutorError::InvalidParam`] if the value is not a string.
2249fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2250    params
2251        .get("username")
2252        .or_else(|| params.get("user"))
2253        .ok_or(ExecutorError::MissingParam("username"))
2254        .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2255}
2256
2257/// Validate a repo_id: must be non-empty and contain only ASCII letters,
2258/// digits, hyphens, and underscores. Rejects `/`, `.`, and whitespace to
2259/// prevent path traversal (e.g. `../cron.d/evil`) and shell injection.
2260fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2261    let id = require_str(params, "repo_id")?;
2262    let valid = !id.is_empty()
2263        && id
2264            .chars()
2265            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2266    if valid {
2267        Ok(id)
2268    } else {
2269        Err(ExecutorError::InvalidParam("repo_id"))
2270    }
2271}
2272
2273/// Validate that a string contains no newlines. Used for repo_url to prevent
2274/// INI-section injection into `.repo` file content.
2275fn validated_no_newline<'a>(
2276    params: &'a Value,
2277    key: &'static str,
2278) -> Result<&'a str, ExecutorError> {
2279    let val = require_str(params, key)?;
2280    if val.contains('\n') || val.contains('\r') {
2281        Err(ExecutorError::InvalidParam(key))
2282    } else {
2283        Ok(val)
2284    }
2285}
2286
2287/// Validate an SSH public key: must start with a known key-type prefix,
2288/// contain only printable ASCII, no newlines, no single quotes (to prevent
2289/// shell injection in `sh -c` scripts), and be at most 8192 characters.
2290fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2291    const MAX_LEN: usize = 8192;
2292    const ALLOWED_PREFIXES: &[&str] = &[
2293        "ssh-rsa",
2294        "ssh-ed25519",
2295        "ssh-ed25519-sk",
2296        "ecdsa-sha2-nistp256",
2297        "ecdsa-sha2-nistp384",
2298        "ecdsa-sha2-nistp521",
2299        "sk-ssh-ed25519",
2300        "sk-ecdsa-sha2-nistp256",
2301    ];
2302
2303    if s.is_empty() || s.len() > MAX_LEN {
2304        return Err(ExecutorError::InvalidParam("public_key"));
2305    }
2306    if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2307        return Err(ExecutorError::InvalidParam("public_key"));
2308    }
2309    // No newlines, no shell metacharacters, only printable ASCII.
2310    //
2311    // Blocked characters and why:
2312    //   '\''  — breaks single-quoted shell strings in add_authorized_key
2313    //   '|'   — shell pipe (the ssh key ops no longer build a sed address,
2314    //           but '|' never appears in a valid key, so keep rejecting it)
2315    //   ';'   — shell command separator
2316    //   '`'   — shell command substitution
2317    //   '$'   — shell variable expansion
2318    //   '\\'  — shell escape; could be used to smuggle other metacharacters
2319    //   '&'   — shell background / AND operator
2320    //
2321    // None of these characters appear in valid SSH public key data (type prefix,
2322    // base64 body, or ASCII comment) so this list is safe to block unconditionally.
2323    if s.chars().any(|c| {
2324        matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2325            || !c.is_ascii()
2326            || c.is_ascii_control()
2327    }) {
2328        return Err(ExecutorError::InvalidParam("public_key"));
2329    }
2330    Ok(s.to_string())
2331}
2332
2333fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2334    match params.get(key) {
2335        None => Err(ExecutorError::MissingParam(key)),
2336        Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2337    }
2338}
2339
2340fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2341    match params.get(key) {
2342        None => Err(ExecutorError::MissingParam(key)),
2343        Some(v) => {
2344            let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2345            u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2346        }
2347    }
2348}
2349
2350/// Like [`require_u32`] but additionally rejects zero.
2351///
2352/// Used for rule numbers and similar 1-based indices where 0 is never valid.
2353fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2354    let n = require_u32(params, key)?;
2355    if n == 0 {
2356        return Err(ExecutorError::InvalidParam(key));
2357    }
2358    Ok(n)
2359}
2360
2361/// Returns a vec of owned strings from a JSON array, or an empty vec if the
2362/// key is absent or null. Returns [`ExecutorError::InvalidParam`] if the key
2363/// is present but not an array of strings.
2364fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2365    match params.get(key) {
2366        None | Some(Value::Null) => Ok(vec![]),
2367        Some(Value::Array(arr)) => arr
2368            .iter()
2369            .map(|v| {
2370                v.as_str()
2371                    .map(String::from)
2372                    .ok_or(ExecutorError::InvalidParam(key))
2373            })
2374            .collect(),
2375        _ => Err(ExecutorError::InvalidParam(key)),
2376    }
2377}
2378
2379/// The kernel arguments `SetKernelArguments` and `GrubSetKargs` may add, in the
2380/// normal form of [`normalized_kernel_arg`]. Anything not named is refused.
2381/// Documentation/admin-guide/kernel-parameters.txt and kernel-command-line(7)
2382/// describe each key.
2383///
2384/// An allowlist, because four review rounds of the denylists it replaced each
2385/// found another spelling past them: a hyphen in the key (`spectre-v2=off`), a
2386/// `kstrtobool` prefix (`mitigations=of`), a mitigation switch nobody had listed
2387/// (`nopku`, `spectre_v2_user=off`). One list, three copies: this file,
2388/// `packaging/sysknife-grub-kargs-edit` and `packaging/sysknife-action-steps`.
2389/// `tests/release/grub-kargs-edit.test.sh` and
2390/// `tests/release/action-steps.test.sh` compare them element by element.
2391///
2392/// Ordinary switches with no value; removable.
2393const KARG_FLAGS: &[&str] = &[
2394    "quiet",
2395    "splash",
2396    "rhgb",
2397    "nomodeset",
2398    "nowatchdog",
2399    "threadirqs",
2400];
2401
2402/// Hardening switches with no value: they may be added and never removed.
2403const KARG_HARDENING_FLAGS: &[&str] = &["nosmt", "slab_nomerge"];
2404
2405/// Ordinary keys that take any value of the one-token character set; removable.
2406const KARG_VALUE_KEYS: &[&str] = &[
2407    "console",
2408    "video",
2409    "loglevel",
2410    "hugepages",
2411    "hugepagesz",
2412    "default_hugepagesz",
2413    "nvme_core.default_ps_max_latency_us",
2414    "usbcore.autosuspend",
2415    "nvidia_drm.modeset",
2416    "nvidia_drm.fbdev",
2417    "i915.enable_psr",
2418    "amdgpu.dc",
2419    "acpi_backlight",
2420    "mem_sleep_default",
2421    "pcie_aspm",
2422    "transparent_hugepage",
2423    "zswap.enabled",
2424    "systemd.show_status",
2425    "plymouth.enable",
2426];
2427
2428/// Ordinary keys whose value is a comma-separated list, each element from the
2429/// key's own set; removable. `rd.driver.blacklist=` keeps a driver from loading,
2430/// so it takes GPU drivers only: `tpm_crb` or `dm_crypt` there would switch off a
2431/// protection. `resume=` is not on the allowlist at all: at a device the caller
2432/// controls it boots a crafted hibernation image.
2433const KARG_LIST_VALUES: &[(&str, &[&str])] = &[(
2434    "rd.driver.blacklist",
2435    &[
2436        "nouveau",
2437        "nvidia",
2438        "nvidia_drm",
2439        "nvidia_modeset",
2440        "radeon",
2441        "amdgpu",
2442        "i915",
2443    ],
2444)];
2445
2446/// Hardening keys with the only values that may be added. A hardening key is
2447/// never removed, with or without a value: libostree's
2448/// `ostree_kernel_args_delete` deletes a key's only value when asked for the bare
2449/// key, so `--delete=lockdown` would drop `lockdown=confidentiality`.
2450const KARG_HARDENING_VALUES: &[(&str, &[&str])] = &[
2451    ("lockdown", &["integrity", "confidentiality"]),
2452    ("module.sig_enforce", &["1"]),
2453    ("intel_iommu", &["on"]),
2454    ("amd_iommu", &["force_isolation"]),
2455    ("init_on_alloc", &["1"]),
2456    ("init_on_free", &["1"]),
2457    ("page_alloc.shuffle", &["1"]),
2458    ("randomize_kstack_offset", &["on"]),
2459    ("mitigations", &["auto", "auto,nosmt"]),
2460    ("pti", &["on"]),
2461    ("vsyscall", &["none"]),
2462    ("debugfs", &["off"]),
2463];
2464
2465/// Exact downgrades, in normal form. Removing one hardens the host, so it is
2466/// allowed although its key is not an ordinary one; none of them can be added.
2467const KARG_WEAKENING: &[&str] = &[
2468    "selinux=0",
2469    "enforcing=0",
2470    "apparmor=0",
2471    "lockdown=none",
2472    "debugfs=on",
2473    "iommu=off",
2474    "intel_iommu=off",
2475    "nokaslr",
2476    "nopti",
2477    "nosmap",
2478    "nosmep",
2479    "nospectre_v1",
2480    "nospectre_v2",
2481    "nospec_store_bypass_disable",
2482    "nospectre_bhb",
2483    "single",
2484    "emergency",
2485    "rescue",
2486    "rd.break",
2487    "rd.shell",
2488    "systemd.debug_shell",
2489    "init=/bin/sh",
2490    "init=/bin/bash",
2491    "mitigations=off",
2492    "pti=off",
2493    "audit=0",
2494    "module.sig_enforce=0",
2495    "init_on_alloc=0",
2496    "init_on_free=0",
2497    "page_alloc.shuffle=0",
2498    "randomize_kstack_offset=off",
2499    "spectre_v2=off",
2500    "spec_store_bypass_disable=off",
2501    "mds=off",
2502    "tsx_async_abort=off",
2503    "mmio_stale_data=off",
2504    "retbleed=off",
2505    "srbds=off",
2506    "l1tf=off",
2507    "spectre_bhi=off",
2508    "gather_data_sampling=off",
2509    "reg_file_data_sampling=off",
2510];
2511
2512/// The form both kernel-argument screens match on: lowercase, with `-` read as
2513/// `_` in the key. The kernel's `parameq()` (kernel/params.c) and systemd's
2514/// `proc_cmdline_key_streq()` treat the two as one character in a parameter
2515/// name, so `spectre-v2=off` is `spectre_v2=off`. The value keeps its spelling.
2516/// Every list entry above is written in this form.
2517fn normalized_kernel_arg(arg: &str) -> String {
2518    let lower = arg.to_lowercase();
2519    match lower.split_once('=') {
2520        Some((key, value)) => format!("{}={value}", key.replace('-', "_")),
2521        None => lower.replace('-', "_"),
2522    }
2523}
2524
2525/// Screen a kernel argument that is about to be **added** to the boot line
2526/// (`SetKernelArguments`'s `add`, `GrubSetKargs`'s `append`): one boot-line
2527/// token, and on the allowlist. A flag is accepted bare, an ordinary key with
2528/// any non-empty value, a list key with every element in its set, a hardening
2529/// key with one of its listed values. `param`
2530/// names the request field in the error.
2531fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2532    validated_kernel_arg_token(arg, param)?;
2533    let normal = normalized_kernel_arg(arg);
2534    let allowed = match normal.split_once('=') {
2535        None => {
2536            KARG_FLAGS.contains(&normal.as_str()) || KARG_HARDENING_FLAGS.contains(&normal.as_str())
2537        }
2538        Some((key, value)) => {
2539            (!value.is_empty() && KARG_VALUE_KEYS.contains(&key))
2540                || KARG_LIST_VALUES
2541                    .iter()
2542                    .any(|(k, allowed)| *k == key && value.split(',').all(|v| allowed.contains(&v)))
2543                || KARG_HARDENING_VALUES
2544                    .iter()
2545                    .any(|(k, values)| *k == key && values.contains(&value))
2546        }
2547    };
2548    if allowed {
2549        Ok(())
2550    } else {
2551        Err(ExecutorError::InvalidParam(param))
2552    }
2553}
2554
2555/// Require `arg` to be exactly one token of the kernel command line.
2556///
2557/// Both kargs screens judge one key and one value. The boot line is split on
2558/// whitespace, so a value carrying a second token behind a space, tab or newline
2559/// (or a quote that groups one) would land that token on the boot line
2560/// unscreened: `console=tty0 init=/bin/sh` is a listed key with a value. The
2561/// character set is `validated_safe_arg`'s plus `=` and `,`, which real
2562/// arguments need (`console=ttyS0,115200`), and the leading `-` stays refused.
2563fn validated_kernel_arg_token(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2564    if arg.is_empty() || arg.len() > crate::actions::validate::SAFE_ARG_MAX_BYTES {
2565        return Err(ExecutorError::InvalidParam(param));
2566    }
2567    if arg.starts_with('-') {
2568        return Err(ExecutorError::InvalidParam(param));
2569    }
2570    if !arg.chars().all(|c| {
2571        c.is_ascii_alphanumeric()
2572            || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-' | '=' | ',')
2573    }) {
2574        return Err(ExecutorError::InvalidParam(param));
2575    }
2576    Ok(())
2577}
2578
2579/// Screen a kernel argument that is about to be **removed** from the boot line.
2580///
2581/// Removing a protective argument reaches the same next boot as adding its
2582/// weakening counterpart, so removal is allowed only for an ordinary key of the
2583/// allowlist (bare or with a value), or for an exact downgrade in
2584/// [`KARG_WEAKENING`], whose removal hardens the host. A hardening key and any
2585/// key nobody listed are refused, bare or with a value.
2586fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2587    validated_kernel_arg_token(arg, param)?;
2588    let normal = normalized_kernel_arg(arg);
2589    let key = normal.split('=').next().unwrap_or(&normal);
2590    if KARG_WEAKENING.contains(&normal.as_str())
2591        || KARG_FLAGS.contains(&key)
2592        || KARG_VALUE_KEYS.contains(&key)
2593        || KARG_LIST_VALUES.iter().any(|(k, _)| *k == key)
2594    {
2595        Ok(())
2596    } else {
2597        Err(ExecutorError::InvalidParam(param))
2598    }
2599}
2600
2601/// Validate a `kill` signal against a strict allowlist, returning the canonical
2602/// signal name for `kill -s <name>`.
2603///
2604/// Only stop/reload signals are permitted (`TERM`, `KILL`, `HUP`, `INT`); this
2605/// blocks exotic or numeric signals and, combined with the caller's `pid >= 2`
2606/// check, keeps `SignalProcess` from becoming an arbitrary-signal primitive.
2607/// Accepts case-insensitive input with an optional `SIG` prefix.
2608fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2609    let normalized = s.trim().to_ascii_uppercase();
2610    match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2611        "TERM" => Ok("TERM"),
2612        "KILL" => Ok("KILL"),
2613        "HUP" => Ok("HUP"),
2614        "INT" => Ok("INT"),
2615        _ => Err(ExecutorError::InvalidParam("signal")),
2616    }
2617}
2618
2619/// Return the rollback [`ActionSpec`] for `action_name`, or `None` if no
2620/// automatic rollback is defined.
2621///
2622/// Only the rpm-ostree deployment and layering actions support rollback —
2623/// they all revert via `rpm-ostree rollback`. All other actions either have
2624/// no sensible rollback or are low-risk enough that a rollback would be
2625/// net-harmful.
2626///
2627/// `RollbackDeployment` itself is excluded to prevent infinite recursion.
2628pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2629    match action_name {
2630        "UpdateSystem"
2631        | "InstallPackages"
2632        | "RemovePackages"
2633        | "RebaseSystem"
2634        | "SetKernelArguments"
2635        | "AddLayeredPackage"
2636        | "RemoveLayeredPackage"
2637        | "ReplaceLayeredPackage"
2638        | "ResetLayeredPackageOverride"
2639        | "RemoveBasePackage" => Some(ActionSpec {
2640            action_name: "RollbackDeployment",
2641            mechanism: ActionMechanism::Command {
2642                program: "rpm-ostree",
2643                args: vec!["rollback".to_string()],
2644            },
2645            risk_level: sysknife_types::RiskLevel::High,
2646            reboot_required: true,
2647            rollback_available: false,
2648        }),
2649        _ => None,
2650    }
2651}
2652
2653#[cfg(test)]
2654mod tests {
2655    use super::*;
2656    use serde_json::json;
2657    use sysknife_types::RiskLevel;
2658    use tempfile::tempdir;
2659
2660    // ── build_action_spec ─────────────────────────────────────────────────
2661
2662    #[test]
2663    fn build_spec_rejects_unknown_keys_across_catalogue() {
2664        for name in sysknife_types::KNOWN_ACTION_NAMES {
2665            if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2666                continue;
2667            }
2668            assert!(
2669                matches!(
2670                    build_action_spec(name, &json!({"__unknown_param": 1})),
2671                    Err(ExecutorError::InvalidParam(_))
2672                ),
2673                "{name} must reject unknown keys before reading required params"
2674            );
2675        }
2676    }
2677
2678    #[test]
2679    fn build_spec_rejects_non_object_params_across_catalogue() {
2680        for name in sysknife_types::KNOWN_ACTION_NAMES {
2681            if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2682                continue;
2683            }
2684            for params in [json!(null), json!([]), json!(42), json!(true), json!("{}")] {
2685                assert!(
2686                    matches!(
2687                        build_action_spec(name, &params),
2688                        Err(ExecutorError::InvalidParam(_))
2689                    ),
2690                    "{name} must reject non-object params: {params}"
2691                );
2692            }
2693        }
2694    }
2695
2696    #[test]
2697    fn build_spec_rejects_cross_distro_kernel_argument_key() {
2698        assert!(matches!(
2699            build_action_spec("SetKernelArguments", &json!({"append": ["quiet"]})),
2700            Err(ExecutorError::InvalidParam(_))
2701        ));
2702        assert!(matches!(
2703            build_action_spec("UfwEnable", &json!({"x": 1})),
2704            Err(ExecutorError::InvalidParam(_))
2705        ));
2706    }
2707
2708    #[test]
2709    fn build_spec_accepts_catalogue_params_and_username_aliases() {
2710        for name in sysknife_types::KNOWN_ACTION_NAMES {
2711            if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2712                continue;
2713            }
2714            // Complete valid examples exercise every parameter, including optional
2715            // keys; future required parameters make this catalogue test fail.
2716            let params = match *name {
2717                "PinDeployment" | "UnpinDeployment" => json!({"index": 0}),
2718                "RebaseSystem" => json!({"target_ref": "fedora-stable"}),
2719                "SetKernelArguments" => json!({"add": ["quiet"], "remove": ["splash"]}),
2720                "ListFlatpakRemotes"
2721                | "ListInstalledFlatpaks"
2722                | "ListContainers"
2723                | "ListToolboxes"
2724                | "DeleteUser"
2725                | "LockUserAccount"
2726                | "UnlockUserAccount"
2727                | "GetAuthorizedKeys"
2728                | "UbuntuListFlatpaks" => json!({"username": "alice"}),
2729                "InstallFlatpak" | "UbuntuInstallFlatpak" => {
2730                    json!({"username": "alice", "app_id": "org.mozilla.firefox", "remote": "flathub"})
2731                }
2732                "RemoveFlatpak"
2733                | "GetFlatpakAppInfo"
2734                | "UbuntuRemoveFlatpak"
2735                | "UpdateFlatpak"
2736                | "UbuntuUpdateFlatpak" => {
2737                    json!({"username": "alice", "app_id": "org.mozilla.firefox"})
2738                }
2739                "SearchFlatpakApps" | "AptSearch" => json!({"term": "vim"}),
2740                "AddFlatpakRemote" => {
2741                    json!({"username": "alice", "remote": "flathub", "url": "https://flathub.org/repo/flathub.flatpakrepo"})
2742                }
2743                "RemoveFlatpakRemote" => json!({"username": "alice", "remote": "flathub"}),
2744                "CreateContainer" => {
2745                    json!({"username": "alice", "name": "test", "image": "alpine"})
2746                }
2747                "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
2748                | "RemoveToolbox" => json!({"username": "alice", "name": "test"}),
2749                "InstallPackages" | "RemovePackages" => json!({"packages": ["vim"]}),
2750                "AddLayeredPackage"
2751                | "RemoveLayeredPackage"
2752                | "RemoveBasePackage"
2753                | "AptInstall"
2754                | "AptRemove"
2755                | "AptPurge"
2756                | "AptHold"
2757                | "AptUnhold"
2758                | "AptShow"
2759                | "GetAptPins" => json!({"package": "vim"}),
2760                "ReplaceLayeredPackage" => json!({"old": "vim", "new": "nano"}),
2761                "AddPackageRepository" => {
2762                    json!({"repo_id": "test", "repo_url": "https://example.com/repo"})
2763                }
2764                "RemovePackageRepository"
2765                | "EnablePackageRepository"
2766                | "DisablePackageRepository" => json!({"repo_id": "test"}),
2767                "StartService"
2768                | "StopService"
2769                | "RestartService"
2770                | "MaskService"
2771                | "UnmaskService"
2772                | "GetServiceLogs"
2773                | "GetServiceStatus"
2774                | "ReloadService"
2775                | "GetServiceResourceLimits" => json!({"unit": "nginx.service"}),
2776                "SetServiceEnabled" => json!({"unit": "nginx.service", "enabled": true}),
2777                "CreateScheduledJob" => {
2778                    json!({"name": "test", "command": "/usr/bin/true", "schedule": "daily"})
2779                }
2780                "SetServiceResourceLimits" => {
2781                    json!({"unit": "nginx.service", "memory_max": "1G", "memory_high": "512M", "cpu_quota": "50%", "tasks_max": "100"})
2782                }
2783                "CreateToolbox" => {
2784                    json!({"username": "alice", "name": "test", "image": "fedora-toolbox", "release": "41"})
2785                }
2786                "SetHostname" => json!({"hostname": "testhost"}),
2787                "SetTimezone" => json!({"timezone": "Europe/London"}),
2788                "SetLocale" => json!({"locale": "en_US.UTF-8"}),
2789                "SetNtp" | "ConfigureUnattendedUpgrades" => json!({"enabled": true}),
2790                "SignalProcess" => json!({"pid": 42, "signal": "TERM"}),
2791                "GetJournalLog" => {
2792                    json!({"unit": "nginx.service", "priority": "warning", "since": "yesterday", "until": "now", "grep": "error", "lines": 10, "boot": true, "kernel": false})
2793                }
2794                "VacuumJournal" => json!({"size_mb": 10}),
2795                "ExtendLogicalVolume" => json!({"vg": "vg0", "lv": "data", "size": "+1G"}),
2796                "CreateLogicalVolume" => json!({"vg": "vg0", "name": "data", "size": "1G"}),
2797                "CreateLvSnapshot" => {
2798                    json!({"vg": "vg0", "origin": "data", "snapshot": "backup", "size": "1G"})
2799                }
2800                "GetSysctl" => json!({"key": "net.ipv4.ip_forward"}),
2801                "SetSysctl" => json!({"key": "net.ipv4.ip_forward", "value": "1"}),
2802                "AddMount" => {
2803                    json!({"device": "/dev/sdb1", "mountpoint": "/mnt/data", "fstype": "ext4", "options": "defaults"})
2804                }
2805                "RemoveMount" => json!({"mountpoint": "/mnt/data"}),
2806                "AddSwap" => json!({"file": "/swapfile", "size_mb": 1024}),
2807                "RemoveSwap" => json!({"file": "/swapfile"}),
2808                "GrantSudoAccess" => {
2809                    json!({"name": "test", "user": "alice", "commands": "/usr/bin/uptime", "runas": "root", "nopasswd": true})
2810                }
2811                "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" => {
2812                    json!({"name": "test"})
2813                }
2814                "SetAptPin" => {
2815                    json!({"name": "test", "package": "vim", "pin": "version 1.*", "priority": 500})
2816                }
2817                "GetLogrotateStatus" => json!({"config": "/var/log/app.log"}),
2818                "ConfigureLogRotation" => {
2819                    json!({"name": "test", "path": "/var/log/app.log", "frequency": "daily", "rotate": 7, "compress": true})
2820                }
2821                "ConfigureRemoteSyslog" => {
2822                    json!({"host": "logs.example.com", "port": 514, "protocol": "tcp"})
2823                }
2824                "GetPasswordAging" => json!({"user": "alice"}),
2825                "SetPasswordAging" => {
2826                    json!({"user": "alice", "max_days": 90, "min_days": 1, "warn_days": 7})
2827                }
2828                "SetPasswordPolicy" => {
2829                    json!({"minlen": 12, "dcredit": -1, "ucredit": -1, "lcredit": -1, "ocredit": -1})
2830                }
2831                "SetAccountLockout" => json!({"deny": 5, "unlock_time": 600, "fail_interval": 900}),
2832                "ConfigureWifi" => json!({"ssid": "testwifi", "password": "examplepassword"}),
2833                "SetDnsServers" | "ResolvectlSetDns" => {
2834                    json!({"interface": "eth0", "servers": ["1.1.1.1"]})
2835                }
2836                "ConfigureFirewall" => json!({"zone": "public", "service": "ssh", "enabled": true}),
2837                "CreateUser" => {
2838                    json!({"username": "alice", "shell": "/bin/bash", "home": "/home/alice"})
2839                }
2840                "AddUserToGroup" | "RemoveUserFromGroup" => {
2841                    json!({"username": "alice", "group": "developers"})
2842                }
2843                "CreateGroup" => json!({"group": "developers", "system": true}),
2844                "DeleteGroup" => json!({"group": "developers"}),
2845                "AddAuthorizedKey" | "RemoveAuthorizedKey" => {
2846                    json!({"username": "alice", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey alice"})
2847                }
2848                "SetSshdOption" => json!({"option": "PasswordAuthentication", "value": "no"}),
2849                "AddPpa" | "RemovePpa" => json!({"name": "deadsnakes/ppa"}),
2850                "SnapInstall" => json!({"name": "test", "channel": "stable", "auto_update": true}),
2851                "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo"
2852                | "SnapRevert" | "SnapClassicInstall" | "DistroboxRemove" => {
2853                    json!({"name": "test"})
2854                }
2855                "GrubSetKargs" => json!({"append": ["quiet"], "delete": ["splash"]}),
2856                "UfwAllow" | "UfwDeny" => json!({"port_or_service": "22/tcp"}),
2857                "UfwStatus" => json!({"numbered": true}),
2858                "DistroboxCreate" => json!({"name": "test", "image": "ubuntu"}),
2859                "NetplanSet" => json!({"key": "ethernets.eth0.dhcp4", "value": "true"}),
2860                "UfwDeleteRule" => json!({"rule_number": 1}),
2861                "UfwLimit" => json!({"target": "ssh"}),
2862                "ProAttach" => json!({"token": "exampletoken"}),
2863                "EnableProService" | "DisableProService" => json!({"service": "esm-apps"}),
2864                "AppArmorEnforce" | "AppArmorComplain" => {
2865                    json!({"profile_path": "/etc/apparmor.d/usr.bin.test"})
2866                }
2867                "Fail2banStatus" => json!({"jail": "sshd"}),
2868                "Fail2banBanIp" | "Fail2banUnbanIp" => json!({"jail": "sshd", "ip": "192.0.2.1"}),
2869                "ConfigureFail2banJail" => {
2870                    json!({"name": "sshd", "enabled": true, "maxretry": 5, "bantime": 600, "findtime": 900})
2871                }
2872                "AddAuditRule" => json!({"path": "/etc/passwd", "perms": "wa", "key": "identity"}),
2873                "RemoveAuditRule" => json!({"key": "identity"}),
2874                "ObtainCertificate" => {
2875                    json!({"domains": ["example.com"], "domain": "example.com", "email": "admin@example.com", "challenge": "standalone"})
2876                }
2877                _ => json!({}),
2878            };
2879            build_action_spec(name, &params)
2880                .unwrap_or_else(|err| panic!("valid parameters rejected for {name}: {err}"));
2881            if params.get("username").is_some() {
2882                let mut aliased = params;
2883                let username = aliased.as_object_mut().unwrap().remove("username").unwrap();
2884                aliased["user"] = username;
2885                build_action_spec(name, &aliased)
2886                    .unwrap_or_else(|err| panic!("user alias rejected for {name}: {err}"));
2887            }
2888        }
2889        for params in [json!({"retain_days": 7}), json!({"size_mb": 10})] {
2890            build_action_spec("VacuumJournal", &params).unwrap();
2891        }
2892        build_action_spec(
2893            "ObtainCertificate",
2894            &json!({"domain": "example.com", "email": "admin@example.com"}),
2895        )
2896        .unwrap();
2897        build_action_spec("SignalProcess", &json!({"pid": "42"})).unwrap();
2898        build_action_spec("SetKernelArguments", &json!({"add": null, "remove": null})).unwrap();
2899    }
2900
2901    #[test]
2902    fn build_spec_no_params_for_get_system_state() {
2903        let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2904        assert_eq!(spec.action_name, "GetSystemState");
2905        assert_eq!(spec.risk_level, RiskLevel::Low);
2906    }
2907
2908    #[test]
2909    fn build_spec_get_datetime_is_low_risk() {
2910        let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2911        assert_eq!(spec.action_name, "GetDateTime");
2912        assert_eq!(spec.risk_level, RiskLevel::Low);
2913        assert!(!spec.reboot_required);
2914    }
2915
2916    #[test]
2917    fn build_spec_unknown_action_returns_error() {
2918        let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2919        assert!(
2920            matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2921            "expected UnknownAction, got: {err}"
2922        );
2923    }
2924
2925    #[test]
2926    fn build_spec_missing_param_for_install_flatpak() {
2927        // username is the first required param; its absence is reported first.
2928        let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2929        assert!(
2930            matches!(err, ExecutorError::MissingParam("username")),
2931            "expected MissingParam(username), got: {err}"
2932        );
2933    }
2934
2935    /// LLMs trained on standard Linux tooling frequently produce `"user"` instead
2936    /// of `"username"`.  `resolve_username` accepts both keys so these actions
2937    /// never fail with a spurious MissingParam.
2938    #[test]
2939    fn build_spec_flatpak_accepts_user_alias() {
2940        let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2941        assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2942    }
2943
2944    /// `resolve_username` prefers `"username"` when both keys are present.
2945    #[test]
2946    fn build_spec_resolve_username_prefers_explicit_username() {
2947        let spec = build_action_spec(
2948            "ListInstalledFlatpaks",
2949            &json!({ "username": "alice", "user": "bob" }),
2950        )
2951        .unwrap();
2952        // Verify it didn't error — the "alice" value passes validation.
2953        assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2954    }
2955
2956    /// `remote` defaults to "flathub" when absent — eliminates the most common
2957    /// model omission without changing behaviour when the param is explicit.
2958    #[test]
2959    fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2960        let spec = build_action_spec(
2961            "InstallFlatpak",
2962            &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2963        )
2964        .unwrap();
2965        assert_eq!(
2966            spec.mechanism,
2967            ActionMechanism::Command {
2968                program: "sudo",
2969                args: vec![
2970                    "/usr/lib/sysknife/action-steps".to_string(),
2971                    "flatpak".to_string(),
2972                    "alice".to_string(),
2973                    "install".to_string(),
2974                    "--user".to_string(),
2975                    "-y".to_string(),
2976                    "flathub".to_string(),
2977                    "org.mozilla.firefox".to_string(),
2978                ],
2979            }
2980        );
2981    }
2982
2983    #[test]
2984    fn build_spec_install_flatpak_injects_app_and_remote() {
2985        let spec = build_action_spec(
2986            "InstallFlatpak",
2987            &json!({
2988                "username": "alice",
2989                "app_id": "org.mozilla.firefox",
2990                "remote": "flathub"
2991            }),
2992        )
2993        .unwrap();
2994        assert_eq!(spec.action_name, "InstallFlatpak");
2995        assert_eq!(
2996            spec.mechanism,
2997            ActionMechanism::Command {
2998                program: "sudo",
2999                args: vec![
3000                    "/usr/lib/sysknife/action-steps".to_string(),
3001                    "flatpak".to_string(),
3002                    "alice".to_string(),
3003                    "install".to_string(),
3004                    "--user".to_string(),
3005                    "-y".to_string(),
3006                    "flathub".to_string(),
3007                    "org.mozilla.firefox".to_string(),
3008                ],
3009            }
3010        );
3011    }
3012
3013    #[test]
3014    fn build_spec_pin_deployment_injects_index() {
3015        let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
3016        assert_eq!(
3017            spec.mechanism,
3018            ActionMechanism::Command {
3019                program: "sudo",
3020                args: vec![
3021                    "ostree".to_string(),
3022                    "admin".to_string(),
3023                    "pin".to_string(),
3024                    "1".to_string()
3025                ],
3026            }
3027        );
3028    }
3029
3030    #[test]
3031    fn build_spec_unpin_deployment_includes_unpin_flag() {
3032        let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
3033        assert_eq!(
3034            spec.mechanism,
3035            ActionMechanism::Command {
3036                program: "sudo",
3037                args: vec![
3038                    "ostree".to_string(),
3039                    "admin".to_string(),
3040                    "pin".to_string(),
3041                    "--unpin".to_string(),
3042                    "2".to_string(),
3043                ],
3044            }
3045        );
3046    }
3047
3048    #[test]
3049    fn require_u32_rejects_overflow() {
3050        let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
3051        assert!(
3052            matches!(err, ExecutorError::InvalidParam("index")),
3053            "expected InvalidParam(index), got: {err}"
3054        );
3055    }
3056
3057    /// The root-owned helper `packaging/sysknife-action-steps` validates every
3058    /// rpm-ostree operand again, because it is reachable through sudo without the
3059    /// daemon. Both copies read `tests/data/rpm-ostree-parity.txt`; the helper's
3060    /// side is `class RpmOstreeVerb` in `tests/release/action-steps.test.sh`.
3061    #[test]
3062    fn rpm_ostree_parity_corpus_agrees_with_the_daemon_validators() {
3063        let corpus = include_str!("../tests/data/rpm-ostree-parity.txt");
3064        let mut seen: std::collections::BTreeSet<(String, String)> = Default::default();
3065        let mut checked = 0;
3066        for (index, line) in corpus.lines().enumerate() {
3067            let number = index + 1;
3068            if line.is_empty() || line.starts_with('#') {
3069                continue;
3070            }
3071            let mut parts = line.splitn(3, ' ');
3072            let (Some(kind), Some(verdict), Some(operand)) =
3073                (parts.next(), parts.next(), parts.next())
3074            else {
3075                panic!("corpus line {number} is not `<kind> <ok|refuse> <operand>`: {line:?}");
3076            };
3077            let accepted = match kind {
3078                "package" => validated_install_package(operand, "package").is_ok(),
3079                "ref" => validated_rebase_ref(operand, "target_ref").is_ok(),
3080                "append" => validated_safe_kernel_arg(operand, "add").is_ok(),
3081                "delete" => validated_safe_kernel_arg_removal(operand, "remove").is_ok(),
3082                other => panic!("corpus line {number}: unknown kind {other:?}"),
3083            };
3084            match verdict {
3085                "ok" => assert!(
3086                    accepted,
3087                    "corpus line {number}: `{kind} {operand}` must be accepted"
3088                ),
3089                "refuse" => assert!(
3090                    !accepted,
3091                    "corpus line {number}: `{kind} {operand}` must be refused"
3092                ),
3093                other => panic!("corpus line {number}: unknown verdict {other:?}"),
3094            }
3095            seen.insert((kind.to_string(), verdict.to_string()));
3096            checked += 1;
3097        }
3098        assert!(
3099            checked >= 60,
3100            "only {checked} corpus lines were checked; the corpus was truncated or not parsed"
3101        );
3102        for kind in ["package", "ref", "append", "delete"] {
3103            for verdict in ["ok", "refuse"] {
3104                assert!(
3105                    seen.contains(&(kind.to_string(), verdict.to_string())),
3106                    "the corpus has no `{kind} {verdict}` line, so that half is unchecked"
3107                );
3108            }
3109        }
3110    }
3111
3112    #[test]
3113    fn rebase_system_refuses_a_local_source() {
3114        for target_ref in ["/tmp/repo", "ostree-unverified-image:oci:/tmp/img"] {
3115            let err = build_action_spec("RebaseSystem", &json!({ "target_ref": target_ref }))
3116                .unwrap_err();
3117            assert!(
3118                matches!(err, ExecutorError::InvalidParam("target_ref")),
3119                "{target_ref:?}: expected InvalidParam(target_ref), got: {err}"
3120            );
3121        }
3122    }
3123
3124    #[test]
3125    fn build_spec_rebase_system_injects_target_ref() {
3126        let spec = build_action_spec(
3127            "RebaseSystem",
3128            &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
3129        )
3130        .unwrap();
3131        assert_eq!(
3132            spec.mechanism,
3133            ActionMechanism::Command {
3134                program: "sudo",
3135                args: vec![
3136                    "/usr/lib/sysknife/action-steps".to_string(),
3137                    "rpm-ostree".to_string(),
3138                    "rebase".to_string(),
3139                    "fedora/41/x86_64/silverblue".to_string(),
3140                ],
3141            }
3142        );
3143    }
3144
3145    #[test]
3146    fn build_spec_set_kernel_arguments_appends_and_deletes() {
3147        let spec = build_action_spec(
3148            "SetKernelArguments",
3149            &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
3150        )
3151        .unwrap();
3152        assert_eq!(
3153            spec.mechanism,
3154            ActionMechanism::Command {
3155                program: "sudo",
3156                args: vec![
3157                    "/usr/lib/sysknife/action-steps".to_string(),
3158                    "rpm-ostree".to_string(),
3159                    "kargs".to_string(),
3160                    "append".to_string(),
3161                    "nomodeset".to_string(),
3162                    "delete".to_string(),
3163                    "quiet".to_string(),
3164                ],
3165            }
3166        );
3167    }
3168
3169    #[test]
3170    fn build_spec_set_kernel_arguments_with_empty_arrays() {
3171        let spec =
3172            build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
3173        assert_eq!(
3174            spec.mechanism,
3175            ActionMechanism::Command {
3176                program: "sudo",
3177                args: vec![
3178                    "/usr/lib/sysknife/action-steps".to_string(),
3179                    "rpm-ostree".to_string(),
3180                    "kargs".to_string(),
3181                ],
3182            }
3183        );
3184    }
3185
3186    #[test]
3187    fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
3188        let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
3189        assert_eq!(
3190            spec.mechanism,
3191            ActionMechanism::Command {
3192                program: "sudo",
3193                args: vec![
3194                    "/usr/lib/sysknife/action-steps".to_string(),
3195                    "rpm-ostree".to_string(),
3196                    "kargs".to_string(),
3197                ],
3198            }
3199        );
3200    }
3201
3202    // ── Critical-account denylist (DeleteUser/DeleteGroup/LockUserAccount) ──
3203
3204    #[test]
3205    fn delete_user_rejects_critical_account() {
3206        let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
3207        assert!(
3208            matches!(err, ExecutorError::InvalidParam("username")),
3209            "expected InvalidParam(username), got: {err}"
3210        );
3211    }
3212
3213    #[test]
3214    fn delete_group_rejects_critical_group() {
3215        let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
3216        assert!(
3217            matches!(err, ExecutorError::InvalidParam("group")),
3218            "expected InvalidParam(group), got: {err}"
3219        );
3220    }
3221
3222    #[test]
3223    fn lock_user_account_rejects_critical_account() {
3224        let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
3225        assert!(
3226            matches!(err, ExecutorError::InvalidParam("username")),
3227            "expected InvalidParam(username), got: {err}"
3228        );
3229    }
3230
3231    /// The helper behind UnlockUserAccount refuses critical accounts, as the
3232    /// lock path always has: unlocking a root account an administrator locked
3233    /// on purpose is the same decision made in reverse.
3234    #[test]
3235    fn unlock_user_account_rejects_critical_account() {
3236        let err =
3237            build_action_spec("UnlockUserAccount", &json!({ "username": "root" })).unwrap_err();
3238        assert!(
3239            matches!(err, ExecutorError::InvalidParam("username")),
3240            "expected InvalidParam(username), got: {err}"
3241        );
3242        assert!(build_action_spec("UnlockUserAccount", &json!({ "username": "alice" })).is_ok());
3243    }
3244
3245    #[test]
3246    fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
3247        assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
3248        assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
3249        assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
3250    }
3251
3252    // ── execute_spec ──────────────────────────────────────────────────────
3253
3254    #[test]
3255    fn build_spec_add_package_repository_rejects_path_traversal() {
3256        let err = build_action_spec(
3257            "AddPackageRepository",
3258            &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
3259        )
3260        .unwrap_err();
3261        assert!(
3262            matches!(err, ExecutorError::InvalidParam("repo_id")),
3263            "expected InvalidParam(repo_id), got: {err}"
3264        );
3265    }
3266
3267    #[test]
3268    fn build_spec_add_package_repository_rejects_newline_in_url() {
3269        let err = build_action_spec(
3270            "AddPackageRepository",
3271            &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
3272        )
3273        .unwrap_err();
3274        assert!(
3275            matches!(err, ExecutorError::InvalidParam("repo_url")),
3276            "expected InvalidParam(repo_url), got: {err}"
3277        );
3278    }
3279
3280    #[test]
3281    fn build_spec_add_package_repository_accepts_valid_repo_id() {
3282        let spec = build_action_spec(
3283            "AddPackageRepository",
3284            &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
3285        )
3286        .unwrap();
3287        assert_eq!(spec.action_name, "AddPackageRepository");
3288    }
3289
3290    #[test]
3291    fn build_spec_remove_package_repository_rejects_path_traversal() {
3292        let err = build_action_spec(
3293            "RemovePackageRepository",
3294            &json!({ "repo_id": "../../etc/passwd" }),
3295        )
3296        .unwrap_err();
3297        assert!(
3298            matches!(err, ExecutorError::InvalidParam("repo_id")),
3299            "expected InvalidParam(repo_id), got: {err}"
3300        );
3301    }
3302
3303    #[tokio::test]
3304    async fn execute_spec_command_captures_stdout() {
3305        let spec = ActionSpec {
3306            action_name: "GetSystemState",
3307            mechanism: ActionMechanism::Command {
3308                program: "echo",
3309                args: vec!["hello".to_string()],
3310            },
3311            risk_level: RiskLevel::Low,
3312            reboot_required: false,
3313            rollback_available: false,
3314        };
3315        let out = execute_spec(&spec).await.unwrap();
3316        assert_eq!(out.stdout.trim(), "hello");
3317        assert_eq!(out.exit_code, 0);
3318    }
3319
3320    /// Regression guard for the live subprocess stdout relay. This coverage
3321    /// previously lived in the dispatcher (`stream_command_sends_job_progress_
3322    /// lines_during_execution`) and was lost when the streaming code moved here;
3323    /// The `ActionNotStopped` message is what an operator reads in the job
3324    /// summary, and the dispatcher keys the rollback veto off this variant, so
3325    /// its fields and wording are load-bearing. No integration test constructs
3326    /// it (the real path needs a root child), so pin it directly.
3327    #[test]
3328    fn action_not_stopped_names_the_group_and_the_timeout() {
3329        let err = ExecutorError::ActionNotStopped {
3330            program: "rpm-ostree".to_string(),
3331            pgid: 4242,
3332            timeout_secs: 7200,
3333        };
3334        let msg = err.to_string();
3335        assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
3336        assert!(msg.contains("7200"), "must name the timeout: {msg}");
3337        assert!(
3338            msg.contains("could not be confirmed stopped"),
3339            "must say what happened: {msg}"
3340        );
3341        assert!(
3342            msg.contains("rollback was skipped"),
3343            "must say the rollback was skipped: {msg}"
3344        );
3345    }
3346
3347    /// A same-uid group is genuinely killable, so `kill_and_reap` must return the
3348    /// plain stopped-timeout `Io`, not `ActionNotStopped`. This pins the
3349    /// discrimination the variant exists for, and that the function returns
3350    /// promptly rather than hanging on the reap.
3351    #[tokio::test]
3352    async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
3353        let mut child = tokio::process::Command::new("sh")
3354            .args(["-c", "sleep 3600 & wait"])
3355            .process_group(0)
3356            .stdin(Stdio::null())
3357            .stdout(Stdio::null())
3358            .stderr(Stdio::null())
3359            .spawn()
3360            .expect("spawn");
3361        SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
3362        let started = tokio::time::Instant::now();
3363        let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
3364        let elapsed = started.elapsed();
3365
3366        assert!(
3367            matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
3368            "a killable group must report a plain stopped-timeout, got: {err:?}"
3369        );
3370        assert!(
3371            elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
3372            "must return promptly, took {elapsed:?}"
3373        );
3374        // A same-uid group is killed by the daemon's own signals, so the
3375        // privileged escalation must NOT fire (a mutation that always escalates
3376        // would shell out to sudo and trip this).
3377        assert_eq!(
3378            SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
3379            0,
3380            "a killable group must not escalate to the privileged reaper"
3381        );
3382    }
3383
3384    /// Losing the ability to READ a privileged child must stop it, exactly as a
3385    /// timeout does. `lines()` fails with `InvalidData` on non-UTF-8 stdout —
3386    /// reachable from any action whose output carries a byte sequence that is not
3387    /// valid UTF-8 (a filename in a foreign encoding is enough). Returning that
3388    /// error without reaping leaves a root process group running while the
3389    /// executor reports the action failed, so the dispatcher's rollback starts a
3390    /// second privileged transaction on top of the first.
3391    #[tokio::test]
3392    async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
3393        let dir = tempfile::tempdir().expect("tempdir");
3394        let pgid_file = dir.path().join("pgid");
3395        // `process_group(0)` makes the shell its own group leader, so `$$` IS the
3396        // pgid. It prints one good line, then a lone \377 (never valid UTF-8),
3397        // then outlives the read failure unless the executor stops it.
3398        let script = format!(
3399            "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
3400            pgid_file.display()
3401        );
3402        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3403        let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
3404            .await
3405            .expect_err("non-UTF-8 stdout must fail the action");
3406        assert!(
3407            matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
3408            "the read failure itself must be surfaced, got: {err:?}"
3409        );
3410
3411        let pgid: i32 = std::fs::read_to_string(&pgid_file)
3412            .expect("the shell must have recorded its pgid")
3413            .trim()
3414            .parse()
3415            .expect("pgid");
3416        assert!(
3417            probe_group(pgid).is_gone(),
3418            "process group {pgid} survived the read failure; \
3419             the privileged child was orphaned"
3420        );
3421    }
3422
3423    #[test]
3424    fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
3425        // rpm-ostree -> cancel (its work is in another cgroup, a signal can't reach it).
3426        assert_eq!(
3427            reap_command(
3428                99,
3429                "sudo",
3430                &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
3431            ),
3432            vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
3433        );
3434        // Everything else -> a root TERM to its own process group.
3435        assert_eq!(
3436            reap_command(
3437                99,
3438                "sudo",
3439                &[
3440                    "env".to_string(),
3441                    "apt-get".to_string(),
3442                    "install".to_string()
3443                ]
3444            ),
3445            root_group_kill_argv("TERM", 99)
3446        );
3447    }
3448
3449    /// The operand-taking rpm-ostree actions moved behind `action-steps
3450    /// rpm-ostree`, and the transaction still runs in rpm-ostreed's cgroup, so a
3451    /// timed-out one must still be cancelled rather than group-killed.
3452    /// tests/sudoers_grants_match_argv.rs checks a grant admits the cancel argv.
3453    #[test]
3454    fn reap_command_still_cancels_an_rpm_ostree_action_behind_the_helper() {
3455        for name in [
3456            "AddLayeredPackage",
3457            "InstallPackages",
3458            "RemovePackages",
3459            "RemoveLayeredPackage",
3460            "ReplaceLayeredPackage",
3461            "RemoveBasePackage",
3462            "RebaseSystem",
3463            "SetKernelArguments",
3464        ] {
3465            let params = match name {
3466                "InstallPackages" | "RemovePackages" => json!({"packages": ["vim"]}),
3467                "ReplaceLayeredPackage" => json!({"old": "vim", "new": "nano"}),
3468                "RebaseSystem" => json!({"target_ref": "fedora:fedora/41/x86_64/silverblue"}),
3469                "SetKernelArguments" => json!({"add": ["quiet"]}),
3470                _ => json!({"package": "vim"}),
3471            };
3472            let spec = build_action_spec(name, &params).unwrap();
3473            let ActionMechanism::Command { program, args } = &spec.mechanism else {
3474                panic!("{name} is not a command");
3475            };
3476            assert_eq!(
3477                args[0], "/usr/lib/sysknife/action-steps",
3478                "{name}: {args:?}"
3479            );
3480            assert_eq!(
3481                reap_command(99, program, args),
3482                vec!["-n", "/usr/bin/rpm-ostree", "cancel"],
3483                "{name} would be group-killed while its transaction runs on"
3484            );
3485        }
3486    }
3487
3488    #[test]
3489    fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
3490        // sudo -n action-steps signal KILL -<pgid>: non-interactive
3491        // (NOPASSWD). tests/sudoers_grants_match_argv.rs holds REAPER_ARGV,
3492        // the same argv, and fails if no grant admits it.
3493        assert_eq!(
3494            root_group_kill_argv("KILL", 4242),
3495            vec![
3496                "-n",
3497                "/usr/lib/sysknife/action-steps",
3498                "signal",
3499                "KILL",
3500                "-4242"
3501            ]
3502        );
3503        assert_eq!(
3504            root_group_kill_argv("TERM", 7)[3..].to_vec(),
3505            vec!["TERM", "-7"]
3506        );
3507    }
3508
3509    #[test]
3510    fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
3511        let sudo = |a: &[&str]| {
3512            is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
3513        };
3514        // rpm-ostree runs behind sudo, absolute or bare, and directly.
3515        assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
3516        assert!(sudo(&[
3517            "rpm-ostree",
3518            "rebase",
3519            "fedora:fedora/40/x86_64/silverblue"
3520        ]));
3521        assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
3522        // Package managers whose work IS in the process group must NOT be
3523        // treated as rpm-ostree (they get the group kill, not `cancel`).
3524        assert!(!sudo(&[
3525            "env",
3526            "DEBIAN_FRONTEND=noninteractive",
3527            "apt-get",
3528            "install",
3529            "vim"
3530        ]));
3531        assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
3532        assert!(!is_rpm_ostree_action("sudo", &[]));
3533        // Absolute path as the top-level program (no sudo wrapper).
3534        assert!(is_rpm_ostree_action(
3535            "/usr/bin/rpm-ostree",
3536            &["deploy".to_string()]
3537        ));
3538        // A path segment must match, not a substring (guards a future refactor
3539        // to `.contains()`).
3540        assert!(!is_rpm_ostree_action(
3541            "sudo",
3542            &["explain-rpm-ostree-usage".to_string()]
3543        ));
3544    }
3545
3546    /// restored at its owning layer. Proves `RealActionExecutor` forwards each
3547    /// stdout line over the mpsc channel as it arrives and reports the exit code.
3548    #[tokio::test]
3549    async fn real_executor_streams_each_stdout_line() {
3550        let spec = ActionSpec {
3551            action_name: "GetSystemState",
3552            mechanism: ActionMechanism::Command {
3553                program: "printf",
3554                args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
3555            },
3556            risk_level: RiskLevel::Low,
3557            reboot_required: false,
3558            rollback_available: false,
3559        };
3560        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3561        let out = RealActionExecutor
3562            .execute_with_progress(&spec, tx)
3563            .await
3564            .unwrap();
3565
3566        let mut lines = Vec::new();
3567        while let Ok(line) = rx.try_recv() {
3568            lines.push(line);
3569        }
3570        assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
3571        assert_eq!(out.exit_code, 0);
3572        assert!(out.stdout.contains("line-two"));
3573    }
3574
3575    #[tokio::test]
3576    async fn execute_spec_file_write_creates_file() {
3577        let dir = tempdir().unwrap();
3578        let path = dir.path().join("test.conf").to_string_lossy().into_owned();
3579        let spec = ActionSpec {
3580            action_name: "AddPackageRepository",
3581            mechanism: ActionMechanism::FileWrite {
3582                path: path.clone(),
3583                content: "[repo]\nbaseurl=https://example.test\n".to_string(),
3584            },
3585            risk_level: RiskLevel::Medium,
3586            reboot_required: false,
3587            rollback_available: true,
3588        };
3589        let out = execute_spec(&spec).await.unwrap();
3590        assert_eq!(out.exit_code, 0);
3591        assert_eq!(
3592            std::fs::read_to_string(&path).unwrap(),
3593            "[repo]\nbaseurl=https://example.test\n"
3594        );
3595    }
3596
3597    #[tokio::test]
3598    async fn execute_spec_file_patch_replaces_first_occurrence() {
3599        let dir = tempdir().unwrap();
3600        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3601        std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
3602        let spec = ActionSpec {
3603            action_name: "EnablePackageRepository",
3604            mechanism: ActionMechanism::FilePatch {
3605                path: path.clone(),
3606                search: "enabled=0".to_string(),
3607                replace: "enabled=1".to_string(),
3608            },
3609            risk_level: RiskLevel::Medium,
3610            reboot_required: false,
3611            rollback_available: true,
3612        };
3613        execute_spec(&spec).await.unwrap();
3614        assert_eq!(
3615            std::fs::read_to_string(&path).unwrap(),
3616            "[myrepo]\nenabled=1\n"
3617        );
3618    }
3619
3620    #[tokio::test]
3621    async fn execute_spec_file_patch_returns_error_when_search_not_found() {
3622        let dir = tempdir().unwrap();
3623        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3624        std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
3625        let spec = ActionSpec {
3626            action_name: "EnablePackageRepository",
3627            mechanism: ActionMechanism::FilePatch {
3628                path: path.clone(),
3629                search: "enabled=0".to_string(),
3630                replace: "enabled=1".to_string(),
3631            },
3632            risk_level: RiskLevel::Medium,
3633            reboot_required: false,
3634            rollback_available: true,
3635        };
3636        let out = execute_spec(&spec).await.unwrap();
3637        assert_eq!(out.exit_code, 1, "should fail when search string is absent");
3638        assert!(
3639            out.stderr.contains("search string not found in file"),
3640            "stderr should explain the failure: {}",
3641            out.stderr
3642        );
3643        // File should remain unchanged.
3644        assert_eq!(
3645            std::fs::read_to_string(&path).unwrap(),
3646            "[myrepo]\nenabled=1\n"
3647        );
3648    }
3649
3650    #[tokio::test]
3651    async fn execute_spec_file_patch_allows_empty_search_string() {
3652        // An empty search string triggers replacen's prepend behavior and should
3653        // not be rejected — the caller explicitly asked for a no-op search.
3654        let dir = tempdir().unwrap();
3655        let path = dir.path().join("file.txt").to_string_lossy().into_owned();
3656        std::fs::write(&path, "hello").unwrap();
3657        let spec = ActionSpec {
3658            action_name: "Test",
3659            mechanism: ActionMechanism::FilePatch {
3660                path: path.clone(),
3661                search: String::new(),
3662                replace: "prefix-".to_string(),
3663            },
3664            risk_level: RiskLevel::Low,
3665            reboot_required: false,
3666            rollback_available: false,
3667        };
3668        let out = execute_spec(&spec).await.unwrap();
3669        assert_eq!(out.exit_code, 0);
3670        assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3671    }
3672
3673    #[tokio::test]
3674    async fn execute_spec_file_delete_removes_file() {
3675        let dir = tempdir().unwrap();
3676        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3677        std::fs::write(&path, "[myrepo]\n").unwrap();
3678        let spec = ActionSpec {
3679            action_name: "RemovePackageRepository",
3680            mechanism: ActionMechanism::FileDelete { path: path.clone() },
3681            risk_level: RiskLevel::Medium,
3682            reboot_required: false,
3683            rollback_available: true,
3684        };
3685        execute_spec(&spec).await.unwrap();
3686        assert!(!std::path::Path::new(&path).exists());
3687    }
3688
3689    #[tokio::test]
3690    async fn execute_spec_file_scan_lists_directory_entries() {
3691        let dir = tempdir().unwrap();
3692        std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3693        std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3694        let spec = ActionSpec {
3695            action_name: "ListPackageRepositories",
3696            mechanism: ActionMechanism::FileScan {
3697                path: dir.path().to_string_lossy().into_owned(),
3698            },
3699            risk_level: RiskLevel::Low,
3700            reboot_required: false,
3701            rollback_available: false,
3702        };
3703        let out = execute_spec(&spec).await.unwrap();
3704        assert!(
3705            out.stdout.contains("a.repo"),
3706            "expected a.repo in: {}",
3707            out.stdout
3708        );
3709        assert!(
3710            out.stdout.contains("b.repo"),
3711            "expected b.repo in: {}",
3712            out.stdout
3713        );
3714        assert_eq!(out.exit_code, 0);
3715    }
3716
3717    // ── rollback_spec_for ─────────────────────────────────────────────────────
3718
3719    #[test]
3720    fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3721        let spec = rollback_spec_for("UpdateSystem").unwrap();
3722        assert_eq!(spec.action_name, "RollbackDeployment");
3723        assert!(
3724            matches!(
3725                &spec.mechanism,
3726                ActionMechanism::Command { program: "rpm-ostree", args }
3727                if args == &["rollback".to_string()]
3728            ),
3729            "expected rpm-ostree rollback, got: {:?}",
3730            spec.mechanism
3731        );
3732        assert!(!spec.rollback_available, "rollback spec must not recurse");
3733    }
3734
3735    #[test]
3736    fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3737        assert!(rollback_spec_for("InstallPackages").is_some());
3738    }
3739
3740    #[test]
3741    fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3742        assert!(rollback_spec_for("RemovePackages").is_some());
3743    }
3744
3745    #[test]
3746    fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3747        assert!(rollback_spec_for("RebaseSystem").is_some());
3748    }
3749
3750    #[test]
3751    fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3752        assert!(rollback_spec_for("SetKernelArguments").is_some());
3753    }
3754
3755    #[test]
3756    fn rollback_spec_for_read_only_action_returns_none() {
3757        assert!(rollback_spec_for("GetSystemState").is_none());
3758        assert!(rollback_spec_for("ListUsers").is_none());
3759        assert!(rollback_spec_for("GetFirewallState").is_none());
3760    }
3761
3762    #[test]
3763    fn rollback_spec_for_non_rollbackable_actions_return_none() {
3764        assert!(rollback_spec_for("AddUserToGroup").is_none());
3765        assert!(rollback_spec_for("DeleteUser").is_none());
3766        assert!(rollback_spec_for("CleanupDeployments").is_none());
3767        // No infinite recursion — RollbackDeployment has no rollback of its own
3768        assert!(rollback_spec_for("RollbackDeployment").is_none());
3769    }
3770
3771    // ── validated_public_key ──────────────────────────────────────────────
3772
3773    #[test]
3774    fn public_key_accepts_valid_ed25519() {
3775        let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3776        assert!(validated_public_key(key).is_ok());
3777    }
3778
3779    #[test]
3780    fn public_key_accepts_valid_rsa() {
3781        let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3782        assert!(validated_public_key(key).is_ok());
3783    }
3784
3785    #[test]
3786    fn public_key_rejects_empty() {
3787        assert!(matches!(
3788            validated_public_key(""),
3789            Err(ExecutorError::InvalidParam("public_key"))
3790        ));
3791    }
3792
3793    #[test]
3794    fn public_key_rejects_unknown_prefix() {
3795        assert!(matches!(
3796            validated_public_key("sk-rsa AAAA... user@host"),
3797            Err(ExecutorError::InvalidParam("public_key"))
3798        ));
3799        assert!(matches!(
3800            validated_public_key("AAAAB3Nz... user@host"),
3801            Err(ExecutorError::InvalidParam("public_key"))
3802        ));
3803    }
3804
3805    #[test]
3806    fn public_key_rejects_single_quote() {
3807        let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3808        assert!(matches!(
3809            validated_public_key(key),
3810            Err(ExecutorError::InvalidParam("public_key"))
3811        ));
3812    }
3813
3814    /// The install/remove package actions must refuse a filesystem path, because
3815    /// `apt-get install /tmp/x.deb` / `rpm-ostree install /tmp/x.rpm` install a
3816    /// local file and run its scripts as root. `validate.rs` proves the validator
3817    /// refuses paths; only this proves each action arm actually calls it. Table
3818    /// driven so an arm added later that forgets the validator fails here, which
3819    /// is exactly the regression that shipped `InstallPackages`/`RemovePackages`
3820    /// on the generic safe-arg by mistake.
3821    #[test]
3822    fn package_actions_reject_a_local_file_target() {
3823        let reject: &[(&str, serde_json::Value)] = &[
3824            ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3825            ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3826            ("AptPurge", json!({"package": "./evil.deb"})),
3827            ("AptHold", json!({"package": "/tmp/evil.deb"})),
3828            ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3829            ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3830            ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3831            ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3832            (
3833                "ReplaceLayeredPackage",
3834                json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3835            ),
3836            (
3837                "ReplaceLayeredPackage",
3838                json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3839            ),
3840            ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3841            ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3842        ];
3843        for (action, params) in reject {
3844            assert!(
3845                matches!(
3846                    build_action_spec(action, params),
3847                    Err(ExecutorError::InvalidParam(_))
3848                ),
3849                "{action} must refuse a local-file package target: {params}"
3850            );
3851        }
3852        // Positive twin: real package specs must still build, or the test above
3853        // would pass for a validator that rejects everything.
3854        let accept: &[(&str, serde_json::Value)] = &[
3855            ("AptInstall", json!({"package": "nginx"})),
3856            ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3857            (
3858                "InstallPackages",
3859                json!({"packages": ["nginx", "python3-pip"]}),
3860            ),
3861        ];
3862        for (action, params) in accept {
3863            assert!(
3864                build_action_spec(action, params).is_ok(),
3865                "{action} must accept a real package spec: {params}"
3866            );
3867        }
3868    }
3869
3870    /// apt-get reads the last character of an operand: `pkg-` on `install` removes
3871    /// the package, `pkg+` on `remove` or `purge` installs it, and `pkg^` installs a
3872    /// task. The validators refuse all three; only this proves every apt arm
3873    /// actually calls the right one, so an arm that goes back to the install
3874    /// validator fails here.
3875    #[test]
3876    fn apt_package_actions_refuse_a_trailing_modifier() {
3877        let reject: &[(&str, &str)] = &[
3878            ("AptInstall", "linux-image-generic-"),
3879            ("AptInstall", "nginx-"),
3880            ("AptRemove", "nginx-"),
3881            ("AptRemove", "nginx+"),
3882            ("AptRemove", "g++"),
3883            ("AptPurge", "nginx-"),
3884            ("AptPurge", "linux-image-generic+"),
3885            ("AptHold", "nginx-"),
3886            ("AptHold", "nginx+"),
3887            ("AptUnhold", "nginx-"),
3888            ("AptUnhold", "nginx+"),
3889            ("AptShow", "nginx-"),
3890            // A trailing `^` installs every package of a task.
3891            ("AptInstall", "ubuntu-desktop^"),
3892            ("AptRemove", "ubuntu-desktop^"),
3893            ("AptPurge", "ubuntu-desktop^"),
3894            ("AptHold", "ubuntu-desktop^"),
3895            ("AptUnhold", "ubuntu-desktop^"),
3896            ("AptShow", "ubuntu-desktop^"),
3897        ];
3898        for (action, package) in reject {
3899            let result = build_action_spec(action, &json!({ "package": package }));
3900            assert!(
3901                matches!(result, Err(ExecutorError::InvalidParam("package"))),
3902                "{action} {package:?} must be refused, got {result:?}"
3903            );
3904        }
3905        // Positive twins, so the table above cannot pass for a validator that
3906        // refuses everything. `g++` and `libstdc++6` must stay installable.
3907        let accept: &[(&str, &str)] = &[
3908            ("AptInstall", "g++"),
3909            ("AptInstall", "libstdc++6"),
3910            ("AptInstall", "nginx+"),
3911            ("AptRemove", "libstdc++6"),
3912            ("AptRemove", "g++:amd64"),
3913            ("AptPurge", "g++:amd64"),
3914            ("AptHold", "libstdc++6"),
3915            ("AptUnhold", "libstdc++6"),
3916            ("AptShow", "g++"),
3917            ("AptShow", "nginx/noble"),
3918        ];
3919        for (action, package) in accept {
3920            assert!(
3921                build_action_spec(action, &json!({ "package": package })).is_ok(),
3922                "{action} {package:?} must be accepted"
3923            );
3924        }
3925    }
3926
3927    /// The activation verbs must refuse a root-shell unit through the reachable
3928    /// action, and `SetServiceEnabled` must gate only on `enabled: true`. Mutation
3929    /// testing showed reverting these call sites to the loose validator passed the
3930    /// whole suite, so the denylist was pinned only at the validator, not here.
3931    #[test]
3932    fn activating_actions_refuse_a_root_shell_unit() {
3933        for action in ["StartService", "RestartService", "UnmaskService"] {
3934            for unit in [
3935                "debug-shell.service",
3936                "emergency.target",
3937                "rescue.service",
3938                "runlevel1.target",
3939            ] {
3940                assert!(
3941                    matches!(
3942                        build_action_spec(action, &json!({"unit": unit})),
3943                        Err(ExecutorError::InvalidParam("unit"))
3944                    ),
3945                    "{action} must not activate {unit}"
3946                );
3947            }
3948            assert!(
3949                build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3950                "{action} must still activate an ordinary unit"
3951            );
3952        }
3953        assert!(
3954            build_action_spec(
3955                "SetServiceEnabled",
3956                &json!({"unit": "debug-shell.service", "enabled": true})
3957            )
3958            .is_err(),
3959            "enabling a root-shell unit brings it up at boot and must be refused"
3960        );
3961        assert!(
3962            build_action_spec(
3963                "SetServiceEnabled",
3964                &json!({"unit": "debug-shell.service", "enabled": false})
3965            )
3966            .is_ok(),
3967            "disabling a root-shell unit is a mitigation and must stay reachable"
3968        );
3969        // The safe verbs must not have been tightened into the denylist, or an
3970        // operator loses the ability to stop or mask a running root shell.
3971        for action in ["StopService", "MaskService"] {
3972            assert!(
3973                build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3974                "{action} on debug-shell is a mitigation and must stay allowed"
3975            );
3976        }
3977    }
3978
3979    #[test]
3980    fn authorized_keys_actions_reject_a_traversal_username() {
3981        // `actions/ssh.rs` builds `/home/{username}/.ssh/authorized_keys` by
3982        // interpolation, so the only thing keeping the path inside the user's
3983        // home is `validated_username` being called in these three match arms.
3984        // `validate.rs` tests the validator in isolation, and `ssh_key_ops.rs`
3985        // calls the action functions directly — neither proves the guard is
3986        // actually wired to the reachable actions.
3987        for action in [
3988            "GetAuthorizedKeys",
3989            "AddAuthorizedKey",
3990            "RemoveAuthorizedKey",
3991        ] {
3992            for bad in ["../../etc", "..", "root/../../etc"] {
3993                let mut params = json!({"username": bad});
3994                if action != "GetAuthorizedKeys" {
3995                    params["public_key"] =
3996                        json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h");
3997                }
3998                let err = build_action_spec(action, &params).unwrap_err();
3999                assert!(
4000                    matches!(err, ExecutorError::InvalidParam("username")),
4001                    "{action} must reject username {bad:?}, got {err:?}"
4002                );
4003            }
4004        }
4005    }
4006
4007    #[test]
4008    fn ip_taking_actions_reject_a_malformed_address() {
4009        // `ExecutorError::InvalidIpAddress` was constructed by three actions
4010        // and reached by no test.
4011        let cases: [(&str, serde_json::Value); 3] = [
4012            (
4013                "ResolvectlSetDns",
4014                json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
4015            ),
4016            (
4017                "Fail2banBanIp",
4018                json!({ "jail": "sshd", "ip": "999.1.1.1" }),
4019            ),
4020            (
4021                "Fail2banUnbanIp",
4022                json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
4023            ),
4024        ];
4025        for (action, params) in cases {
4026            let err = build_action_spec(action, &params).unwrap_err();
4027            assert!(
4028                matches!(err, ExecutorError::InvalidIpAddress { .. }),
4029                "{action} must reject a malformed IP, got {err:?}"
4030            );
4031        }
4032    }
4033
4034    #[test]
4035    fn public_key_rejects_pipe_metacharacter() {
4036        // '|' is a shell pipe and never appears in a valid key. (It was also
4037        // the sed address delimiter before the key ops were made regex-free;
4038        // the rejection stands on its own merits either way.)
4039        let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
4040        assert!(matches!(
4041            validated_public_key(key),
4042            Err(ExecutorError::InvalidParam("public_key"))
4043        ));
4044    }
4045
4046    #[test]
4047    fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
4048        // Deliberate and load-bearing: `.` is legal inside a key comment
4049        // (`alice@example.com`), so the validator cannot blocklist regex
4050        // metacharacters without rejecting valid keys. Any consumer that
4051        // interprets a key as a pattern is therefore the defect — see
4052        // `actions::ssh::REMOVE_KEY_SCRIPT`, which uses `grep -Fxv` for
4053        // exactly this reason.
4054        let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
4055        assert!(validated_public_key(key).is_ok());
4056        // The wildcard-shaped payload passes validation too. It is safe only
4057        // because no consumer treats it as a pattern.
4058        assert!(validated_public_key("ssh-ed25519 .*").is_ok());
4059    }
4060
4061    #[test]
4062    fn public_key_rejects_shell_metacharacters() {
4063        for (metachar, desc) in [
4064            (';', "semicolon"),
4065            ('`', "backtick"),
4066            ('$', "dollar"),
4067            ('\\', "backslash"),
4068            ('&', "ampersand"),
4069        ] {
4070            let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
4071            assert!(
4072                matches!(
4073                    validated_public_key(&key),
4074                    Err(ExecutorError::InvalidParam("public_key"))
4075                ),
4076                "{desc} should be rejected"
4077            );
4078        }
4079    }
4080
4081    #[test]
4082    fn public_key_rejects_newline() {
4083        let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
4084        assert!(matches!(
4085            validated_public_key(key),
4086            Err(ExecutorError::InvalidParam("public_key"))
4087        ));
4088        let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
4089        assert!(matches!(
4090            validated_public_key(key_cr),
4091            Err(ExecutorError::InvalidParam("public_key"))
4092        ));
4093    }
4094
4095    #[test]
4096    fn public_key_rejects_too_long() {
4097        // Build a key that exceeds MAX_LEN (8192 bytes)
4098        let long_payload = "A".repeat(8192);
4099        let key = format!("ssh-ed25519 {long_payload} user@host");
4100        assert!(matches!(
4101            validated_public_key(&key),
4102            Err(ExecutorError::InvalidParam("public_key"))
4103        ));
4104    }
4105
4106    // ── str_array_or_empty ────────────────────────────────────────────────
4107
4108    #[test]
4109    fn str_array_or_empty_rejects_non_string_element() {
4110        let params = json!({ "packages": ["vim", 42, "curl"] });
4111        assert!(matches!(
4112            str_array_or_empty(&params, "packages"),
4113            Err(ExecutorError::InvalidParam("packages"))
4114        ));
4115    }
4116
4117    #[test]
4118    fn str_array_or_empty_accepts_string_array() {
4119        let params = json!({ "packages": ["vim", "curl"] });
4120        assert_eq!(
4121            str_array_or_empty(&params, "packages").unwrap(),
4122            vec!["vim".to_string(), "curl".to_string()]
4123        );
4124    }
4125
4126    #[test]
4127    fn str_array_or_empty_returns_empty_when_key_absent() {
4128        let params = json!({});
4129        assert_eq!(
4130            str_array_or_empty(&params, "packages").unwrap(),
4131            Vec::<String>::new()
4132        );
4133    }
4134
4135    // ── validated_safe_kernel_arg ─────────────────────────────────────────
4136
4137    #[test]
4138    fn kernel_arg_allows_safe_args() {
4139        assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
4140        assert!(validated_safe_kernel_arg("splash", "add").is_ok());
4141        assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
4142        assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
4143    }
4144
4145    #[test]
4146    fn kernel_arg_blocks_init_override() {
4147        assert!(matches!(
4148            validated_safe_kernel_arg("init=/bin/sh", "add"),
4149            Err(ExecutorError::InvalidParam("add"))
4150        ));
4151        assert!(matches!(
4152            validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
4153            Err(ExecutorError::InvalidParam("add"))
4154        ));
4155    }
4156
4157    #[test]
4158    fn kernel_arg_blocks_selinux_disable() {
4159        assert!(matches!(
4160            validated_safe_kernel_arg("selinux=0", "add"),
4161            Err(ExecutorError::InvalidParam("add"))
4162        ));
4163        assert!(matches!(
4164            validated_safe_kernel_arg("enforcing=0", "add"),
4165            Err(ExecutorError::InvalidParam("add"))
4166        ));
4167    }
4168
4169    #[test]
4170    fn kernel_arg_blocks_security_override() {
4171        assert!(matches!(
4172            validated_safe_kernel_arg("security=none", "add"),
4173            Err(ExecutorError::InvalidParam("add"))
4174        ));
4175    }
4176
4177    #[test]
4178    fn kernel_arg_blocks_module_blacklist() {
4179        assert!(matches!(
4180            validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
4181            Err(ExecutorError::InvalidParam("add"))
4182        ));
4183    }
4184
4185    // ── validated_safe_kernel_arg_removal ─────────────────────────────────
4186    //
4187    // "Removing an argument is always safe" was the stated premise for exempting
4188    // `remove`/`delete` from any policy check. It is false: several arguments
4189    // protect the host by being PRESENT, so deleting one lands the same end state
4190    // as adding its weakening counterpart, which the add-path already refuses.
4191
4192    #[test]
4193    fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
4194        // KSPP-recommended boot parameters plus the LSM selectors. Each is a
4195        // protection the host loses the moment the token leaves the boot line.
4196        for arg in [
4197            "lockdown=confidentiality",
4198            "module.sig_enforce=1",
4199            "selinux=1",
4200            "enforcing=1",
4201            "apparmor=1",
4202            "security=selinux",
4203            "pti=on",
4204            "mitigations=auto,nosmt",
4205            "init_on_alloc=1",
4206            "init_on_free=1",
4207            "slab_nomerge",
4208            "page_alloc.shuffle=1",
4209            "randomize_kstack_offset=on",
4210            "slub_debug=FZP",
4211            "vsyscall=none",
4212            "debugfs=off",
4213            "iommu=force",
4214            "intel_iommu=on",
4215            "amd_iommu=force_isolation",
4216        ] {
4217            assert!(
4218                matches!(
4219                    validated_safe_kernel_arg_removal(arg, "remove"),
4220                    Err(ExecutorError::InvalidParam("remove"))
4221                ),
4222                "removing {arg} is a boot-security downgrade and must be refused"
4223            );
4224        }
4225    }
4226
4227    /// The screens accept a fixed list of keys. Four review rounds of the
4228    /// denylist this replaced each found another spelling past it (a hyphen in
4229    /// the key, a `kstrtobool` prefix such as `of`, a mitigation switch nobody
4230    /// had listed), so anything not named is refused, with an error that names
4231    /// the parameter.
4232    #[test]
4233    fn kernel_arg_add_accepts_only_the_allowlist() {
4234        for arg in [
4235            "quiet",
4236            "splash",
4237            "rhgb",
4238            "nomodeset",
4239            "nowatchdog",
4240            "threadirqs",
4241            "nosmt",
4242            "slab_nomerge",
4243            "console=ttyS0,115200",
4244            "rd.driver.blacklist=nouveau",
4245            "transparent_hugepage=madvise",
4246            "nvidia-drm.modeset=1",
4247            "lockdown=integrity",
4248            "lockdown=confidentiality",
4249            "module.sig_enforce=1",
4250            "intel_iommu=on",
4251            "amd_iommu=force_isolation",
4252            "init_on_alloc=1",
4253            "init_on_free=1",
4254            "page_alloc.shuffle=1",
4255            "randomize_kstack_offset=on",
4256            "mitigations=auto",
4257            "mitigations=auto,nosmt",
4258            "pti=on",
4259            "vsyscall=none",
4260            "debugfs=off",
4261            "QUIET",
4262        ] {
4263            assert!(
4264                validated_safe_kernel_arg(arg, "add").is_ok(),
4265                "{arg} is on the allowlist and must be accepted"
4266            );
4267        }
4268        for arg in [
4269            // The review rounds' bypass spellings.
4270            "spectre-v2=off",
4271            "audit=off",
4272            "systemd.wants=debug-shell.service",
4273            "lsm=landlock",
4274            "mitigations=of",
4275            "nopku",
4276            "spectre_v2_user=off",
4277            "kpti=0",
4278            "its=off",
4279            "tsa=off",
4280            "vmscape=off",
4281            "noibrs",
4282            "noibpb",
4283            "audit=f",
4284            // Unknown keys, a flag given a value, an empty value, a value off the list.
4285            "foo=bar",
4286            "foo",
4287            "root=/dev/sdb1",
4288            "quiet=1",
4289            "console=",
4290            "lockdown=none",
4291            "mitigations=off",
4292            "mitigations=auto,nosmt,foo",
4293            "module.sig_enforce=0",
4294            "debugfs=on",
4295        ] {
4296            assert!(
4297                matches!(
4298                    validated_safe_kernel_arg(arg, "add"),
4299                    Err(ExecutorError::InvalidParam("add"))
4300                ),
4301                "{arg} is not on the allowlist and must be refused, naming `add`"
4302            );
4303        }
4304    }
4305
4306    /// `resume=` at a device the caller controls boots a crafted hibernation
4307    /// image, so it is not on the list (this test's `resume=/dev/mapper/luks-swap`
4308    /// used to be accepted as an ordinary token). `rd.driver.blacklist=` takes
4309    /// only GPU drivers, each comma-separated value from its own set, so
4310    /// `tpm_crb` or `dm_crypt` cannot be kept from loading.
4311    #[test]
4312    fn kernel_arg_add_refuses_resume_and_limits_the_driver_blacklist() {
4313        for arg in [
4314            "resume=/dev/sdb1",
4315            "resume=/dev/mapper/luks-swap",
4316            "resume_offset=1",
4317            "rd.driver.blacklist=tpm_crb",
4318            "rd.driver.blacklist=nouveau,dm_crypt",
4319            "rd.driver.blacklist=",
4320            "rd.driver.blacklist=nouveau,",
4321        ] {
4322            assert!(
4323                matches!(
4324                    validated_safe_kernel_arg(arg, "add"),
4325                    Err(ExecutorError::InvalidParam("add"))
4326                ),
4327                "{arg} must be refused"
4328            );
4329        }
4330        for arg in [
4331            "rd.driver.blacklist=nouveau",
4332            "rd.driver.blacklist=nouveau,nvidia,nvidia_drm,nvidia_modeset",
4333            "rd.driver.blacklist=radeon,amdgpu,i915",
4334        ] {
4335            assert!(
4336                validated_safe_kernel_arg(arg, "add").is_ok(),
4337                "{arg} must be accepted"
4338            );
4339        }
4340        for arg in ["resume", "resume=/dev/mapper/luks-swap", "resume_offset"] {
4341            assert!(
4342                validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4343                "{arg} is not on the allowlist, so removing it is refused"
4344            );
4345        }
4346        assert!(validated_safe_kernel_arg_removal("rd.driver.blacklist=nouveau", "remove").is_ok());
4347    }
4348
4349    /// Removal is allowed for the ordinary keys of the allowlist and for the
4350    /// exact downgrades in KARG_WEAKENING, and refused for a hardening key
4351    /// (bare or with a value) and for any key nobody listed.
4352    #[test]
4353    fn kernel_arg_remove_accepts_only_ordinary_keys_and_known_downgrades() {
4354        for arg in [
4355            "quiet",
4356            "rhgb",
4357            "quiet=1",
4358            "console=ttyS0",
4359            "rd.driver.blacklist=nouveau",
4360            "mitigations=off",
4361            "init=/bin/sh",
4362            "single",
4363            "nokaslr",
4364            "spectre_v2=off",
4365        ] {
4366            assert!(
4367                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4368                "{arg} must stay removable"
4369            );
4370        }
4371        for arg in [
4372            "lockdown",
4373            "module.sig_enforce",
4374            "lockdown=integrity",
4375            "nosmt",
4376            "slab_nomerge",
4377            "vsyscall=none",
4378            "mitigations=auto",
4379            "foo=bar",
4380            "foo",
4381            "mitigations=of",
4382        ] {
4383            assert!(
4384                matches!(
4385                    validated_safe_kernel_arg_removal(arg, "remove"),
4386                    Err(ExecutorError::InvalidParam("remove"))
4387                ),
4388                "removing {arg} must be refused, naming `remove`"
4389            );
4390        }
4391    }
4392
4393    /// The kernel's `parameq()` (kernel/params.c) and systemd's
4394    /// `proc_cmdline_key_streq()` treat `-` and `_` as equal in a parameter
4395    /// name, so a hyphen spelling reaches the same switch an underscore-only
4396    /// list refuses. The value is not normalised: unit names keep their hyphens.
4397    #[test]
4398    fn kernel_arg_screens_treat_dash_and_underscore_alike_in_the_key() {
4399        for arg in [
4400            "spectre-v2=off",
4401            "module-blacklist=apparmor",
4402            "Spec-Store-Bypass-Disable=off",
4403            "systemd.debug-shell=1",
4404            "rd.systemd.debug-shell",
4405            "nospectre-v2",
4406            "nospectre-bhb",
4407            "tsx-async-abort=off",
4408        ] {
4409            assert!(
4410                validated_safe_kernel_arg(arg, "add").is_err(),
4411                "{arg} is an underscore switch spelled with hyphens and must be refused"
4412            );
4413        }
4414        for arg in [
4415            "module.sig-enforce=1",
4416            "init-on-alloc=1",
4417            "randomize-kstack-offset=on",
4418            "page-alloc.shuffle=1",
4419        ] {
4420            assert!(
4421                validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4422                "{arg} is a protection spelled with hyphens and must not be removable"
4423            );
4424        }
4425        for arg in ["init-on-alloc=0", "module.sig-enforce=0"] {
4426            assert!(
4427                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4428                "{arg} is a weakening spelled with hyphens and must stay removable"
4429            );
4430        }
4431        assert!(validated_safe_kernel_arg("systemd.unit=debug-shell.service", "add").is_err());
4432        // A listed key spelled with hyphens is the same key.
4433        assert!(validated_safe_kernel_arg("nvidia-drm.modeset=1", "add").is_ok());
4434    }
4435
4436    /// Where the kernel reads a switch as a boolean or as `off`, every spelling
4437    /// of "off" turns it off, not only the one a list happened to name.
4438    #[test]
4439    fn kernel_arg_blocks_every_spelling_of_off_for_a_protection_switch() {
4440        for arg in [
4441            "audit=off",
4442            "audit=0",
4443            "audit=n",
4444            "audit=no",
4445            "audit=false",
4446            "AUDIT=OFF",
4447            "mitigations=false",
4448            "pti=no",
4449            "spectre_v2=n",
4450            "mds=off",
4451            "tsx_async_abort=off",
4452            "mmio_stale_data=off",
4453            "retbleed=off",
4454            "srbds=off",
4455            "l1tf=off",
4456            "spectre_bhi=off",
4457            "gather_data_sampling=off",
4458            "reg_file_data_sampling=off",
4459            "module.sig_enforce=no",
4460            "init_on_free=false",
4461            "nospec_store_bypass_disable",
4462            "nospectre_bhb",
4463        ] {
4464            assert!(
4465                matches!(
4466                    validated_safe_kernel_arg(arg, "add"),
4467                    Err(ExecutorError::InvalidParam("add"))
4468                ),
4469                "{arg} switches a protection off and must be refused"
4470            );
4471        }
4472        for arg in [
4473            "audit=1",
4474            "mds=full",
4475            "retbleed=auto",
4476            "l1tf=flush",
4477            "spectre_bhi=on",
4478        ] {
4479            assert!(
4480                validated_safe_kernel_arg(arg, "add").is_err(),
4481                "{arg} is not on the allowlist"
4482            );
4483        }
4484        for arg in ["mitigations=auto", "pti=on"] {
4485            assert!(
4486                validated_safe_kernel_arg(arg, "add").is_ok(),
4487                "{arg} must stay valid"
4488            );
4489        }
4490    }
4491
4492    /// The mitigation switches the add screen now refuses turning off are
4493    /// protections on the remove side, and their off spellings stay removable.
4494    #[test]
4495    fn kernel_arg_removal_protects_the_mitigation_switches() {
4496        for arg in [
4497            "mds=full",
4498            "tsx_async_abort=full",
4499            "mmio_stale_data=full",
4500            "retbleed=auto",
4501            "srbds=on",
4502            "l1tf=flush",
4503            "spectre_bhi=on",
4504            "gather_data_sampling=force",
4505            "reg_file_data_sampling=on",
4506            "spectre_v2=on",
4507            "spec_store_bypass_disable=on",
4508            "mds",
4509        ] {
4510            assert!(
4511                validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4512                "removing {arg} weakens the next boot and must be refused"
4513            );
4514        }
4515        for arg in ["mds=off", "retbleed=off", "spectre_v2=off"] {
4516            assert!(
4517                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4518                "{arg} is a weakening, so removing it must stay allowed"
4519            );
4520        }
4521        // Only the exact spellings in KARG_WEAKENING are known downgrades.
4522        for arg in ["audit=false", "pti=no"] {
4523            assert!(
4524                validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4525                "{arg} is not in KARG_WEAKENING"
4526            );
4527        }
4528    }
4529
4530    /// libostree deletes a key's only value when asked for the bare key
4531    /// (`ostree_kernel_args_delete`, "for input arg with a single key ... the
4532    /// key/value pair will be deleted if there is only one value"), so
4533    /// `--delete=lockdown` removes `lockdown=confidentiality`. The prefix screen
4534    /// `lockdown=` never matched the bare `lockdown`.
4535    #[test]
4536    fn kernel_arg_removal_refuses_a_protective_bare_key() {
4537        for arg in [
4538            "lockdown",
4539            "module.sig_enforce",
4540            "selinux",
4541            "enforcing",
4542            "apparmor",
4543            "security",
4544            "iommu",
4545            "mitigations",
4546            "init_on_alloc",
4547            "lsm",
4548            "audit",
4549            "LOCKDOWN",
4550            "kaslr=1",
4551        ] {
4552            assert!(
4553                matches!(
4554                    validated_safe_kernel_arg_removal(arg, "remove"),
4555                    Err(ExecutorError::InvalidParam("remove"))
4556                ),
4557                "removing the bare key {arg} removes its protective value and must be refused"
4558            );
4559        }
4560        // The full weakening token stays removable, and so does an ordinary key.
4561        for arg in ["mitigations=off", "selinux=0", "audit=0", "quiet", "rhgb"] {
4562            assert!(
4563                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4564                "{arg} must stay removable"
4565            );
4566        }
4567    }
4568
4569    /// `lsm=` picks the LSM stack as `security=` does, and `audit=1` is what
4570    /// turns kernel auditing on, so dropping either weakens the next boot.
4571    #[test]
4572    fn kernel_arg_removal_refuses_the_lsm_and_audit_selectors() {
4573        for arg in ["lsm=landlock,lockdown,yama,apparmor", "audit=1", "Audit=1"] {
4574            assert!(
4575                validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4576                "removing {arg} weakens the boot and must be refused"
4577            );
4578        }
4579    }
4580
4581    /// `systemd.wants=` pulls a unit into the boot transaction as surely as
4582    /// `systemd.unit=` makes it the target: `systemd.wants=debug-shell.service`
4583    /// is the same root shell on tty9.
4584    #[test]
4585    fn kernel_arg_blocks_root_shell_units_pulled_in_by_wants() {
4586        for arg in [
4587            "systemd.wants=debug-shell.service",
4588            "systemd.wants=emergency.service",
4589            "rd.systemd.wants=rescue.target",
4590            "rd.systemd.wants=debug-shell.service",
4591            "SYSTEMD.WANTS=Debug-Shell.service",
4592        ] {
4593            assert!(
4594                validated_safe_kernel_arg(arg, "add").is_err(),
4595                "{arg} boots into a root shell and must be refused"
4596            );
4597        }
4598        // The allowlist names no `systemd.wants=`, whatever the unit.
4599        assert!(validated_safe_kernel_arg("systemd.wants=sshd.service", "add").is_err());
4600    }
4601
4602    /// Weakenings the add screen missed: twins of arguments it already refused
4603    /// (`lsm=` for `security=`, `modprobe.blacklist=` for `module_blacklist=`), the
4604    /// initramfs shell, masking a unit at boot, and the per-mitigation switches.
4605    #[test]
4606    fn kernel_arg_blocks_the_remaining_weakenings() {
4607        for arg in [
4608            "lsm=landlock",
4609            "rd.shell",
4610            "rd.shell=1",
4611            "systemd.mask=auditd.service",
4612            "rd.systemd.mask=systemd-journald.service",
4613            "modprobe.blacklist=apparmor",
4614            "audit=0",
4615            "nopti",
4616            "nospectre_v1",
4617            "nospectre_v2",
4618            "spectre_v2=off",
4619            "spec_store_bypass_disable=off",
4620            "nokaslr",
4621            "NOPTI",
4622        ] {
4623            assert!(
4624                matches!(
4625                    validated_safe_kernel_arg(arg, "add"),
4626                    Err(ExecutorError::InvalidParam("add"))
4627                ),
4628                "{arg} weakens the next boot and must be refused"
4629            );
4630        }
4631        for arg in ["spectre_v2=on", "audit=1"] {
4632            assert!(
4633                validated_safe_kernel_arg(arg, "add").is_err(),
4634                "{arg} is not on the allowlist"
4635            );
4636        }
4637        assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
4638    }
4639
4640    /// The screen must not turn into "no removals at all" — an argument that is
4641    /// itself a weakening (the add-path refuses it) has to stay removable, or
4642    /// SysKnife could never undo `mitigations=off` on a host that already has it.
4643    #[test]
4644    fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
4645        for arg in [
4646            "mitigations=off",
4647            "selinux=0",
4648            "enforcing=0",
4649            "apparmor=0",
4650            "pti=off",
4651            "lockdown=none",
4652            "debugfs=on",
4653            "init=/bin/sh",
4654            "single",
4655        ] {
4656            assert!(
4657                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4658                "{arg} weakens the host, so removing it must stay allowed"
4659            );
4660        }
4661    }
4662
4663    #[test]
4664    fn kernel_arg_removal_allows_ordinary_args() {
4665        for arg in [
4666            "quiet",
4667            "splash",
4668            "nomodeset",
4669            "console=ttyS0,115200",
4670            "rd.driver.blacklist=nouveau",
4671        ] {
4672            assert!(
4673                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4674                "{arg} carries no boot-security meaning and must stay removable"
4675            );
4676        }
4677    }
4678
4679    // A kernel argument is one token of the boot line. The denylists above read
4680    // the start of the value, so a value that carries a second token behind a
4681    // space, a tab or a newline would reach the boot line with that token
4682    // unscreened. NUL and other control bytes have no place in a boot argument.
4683    const NOT_ONE_KERNEL_TOKEN: &[&str] = &[
4684        "quiet init=/bin/sh",
4685        "x selinux=0",
4686        "quiet\tmitigations=off",
4687        "quiet\ninit=/bin/sh",
4688        "quiet\rsingle",
4689        "\u{0}nomodeset",
4690        "nomodeset\u{0}",
4691        "quiet\u{7f}",
4692        "quiet\u{a0}single",
4693        "console=\"ttyS0 init=/bin/sh\"",
4694        "quiet'",
4695        "quiet\\",
4696        "-b",
4697        "-quiet",
4698        "",
4699    ];
4700
4701    #[test]
4702    fn set_kernel_arguments_refuses_a_value_that_is_not_one_token() {
4703        for arg in NOT_ONE_KERNEL_TOKEN {
4704            for key in ["add", "remove"] {
4705                assert!(
4706                    build_action_spec("SetKernelArguments", &json!({ key: [arg] })).is_err(),
4707                    "SetKernelArguments must refuse {key} {arg:?}"
4708                );
4709            }
4710        }
4711        let too_long = "a".repeat(crate::actions::validate::SAFE_ARG_MAX_BYTES + 1);
4712        for key in ["add", "remove"] {
4713            assert!(
4714                build_action_spec("SetKernelArguments", &json!({ key: [too_long] })).is_err(),
4715                "SetKernelArguments must refuse an over-long {key} value"
4716            );
4717        }
4718    }
4719
4720    #[test]
4721    fn set_kernel_arguments_keeps_ordinary_tokens() {
4722        for arg in [
4723            "quiet",
4724            "nomodeset",
4725            "console=ttyS0,115200",
4726            "rd.driver.blacklist=nouveau",
4727            "video=HDMI-A-1:1920x1080@60",
4728            "nvidia-drm.modeset=1",
4729        ] {
4730            for key in ["add", "remove"] {
4731                assert!(
4732                    build_action_spec("SetKernelArguments", &json!({ key: [arg] })).is_ok(),
4733                    "SetKernelArguments must keep accepting {key} {arg:?}"
4734                );
4735            }
4736        }
4737        // `root=` picks the root filesystem the next boot mounts; it is not on
4738        // the allowlist, so neither list takes it.
4739        for key in ["add", "remove"] {
4740            assert!(
4741                build_action_spec(
4742                    "SetKernelArguments",
4743                    &json!({ key: ["root=UUID=0b1c-77aa"] })
4744                )
4745                .is_err(),
4746                "SetKernelArguments must refuse {key} root="
4747            );
4748        }
4749    }
4750
4751    #[test]
4752    fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
4753        assert!(matches!(
4754            validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
4755            Err(ExecutorError::InvalidParam("add"))
4756        ));
4757        assert!(matches!(
4758            validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
4759            Err(ExecutorError::InvalidParam("add"))
4760        ));
4761        assert!(matches!(
4762            validated_safe_kernel_arg("systemd.unit=single.target", "add"),
4763            Err(ExecutorError::InvalidParam("add"))
4764        ));
4765    }
4766
4767    /// Boot-mode and run tokens that systemd(1) and kernel-command-line(7)
4768    /// honour. `emergency`, `rescue`, `-b` and runlevels 2-5 pick the boot target
4769    /// as `single` and `1` do; `systemd.run=` runs a command as a root service
4770    /// after boot; `rdinit=` and `rd.systemd.unit=` are the initramfs twins of
4771    /// `init=` and `systemd.unit=`; `rd.break` and `break` stop in an initramfs
4772    /// root shell. tests/release/grub-kargs-edit.test.sh holds the helper to the
4773    /// same list.
4774    #[test]
4775    fn kernel_arg_blocks_boot_mode_and_run_tokens() {
4776        for arg in [
4777            "emergency",
4778            "rescue",
4779            "-b",
4780            "rd.emergency",
4781            "rd.rescue",
4782            "2",
4783            "3",
4784            "4",
4785            "5",
4786            "systemd.run=/tmp/x",
4787            "systemd.run_success_action=reboot",
4788            "systemd.run_failure_action=none",
4789            "rdinit=/bin/sh",
4790            "rd.break",
4791            "rd.break=pre-mount",
4792            "break",
4793            "break=premount",
4794            "rd.systemd.unit=emergency.target",
4795            "rd.systemd.unit=rescue.target",
4796            "rd.systemd.unit=debug-shell.service",
4797            "EMERGENCY",
4798            "Systemd.Run=/tmp/x",
4799        ] {
4800            assert!(
4801                matches!(
4802                    validated_safe_kernel_arg(arg, "add"),
4803                    Err(ExecutorError::InvalidParam("add"))
4804                ),
4805                "{arg} boots into a root shell or runs a root command and must be refused"
4806            );
4807        }
4808        // The allowlist names no systemd unit selector, whatever the unit.
4809        assert!(validated_safe_kernel_arg("rd.systemd.unit=graphical.target", "add").is_err());
4810        for arg in ["quiet", "splash", "loglevel=3"] {
4811            assert!(
4812                validated_safe_kernel_arg(arg, "add").is_ok(),
4813                "{arg} carries no boot-security meaning and must stay appendable"
4814            );
4815        }
4816    }
4817
4818    #[test]
4819    fn kernel_arg_blocks_apparmor_disable() {
4820        assert!(matches!(
4821            validated_safe_kernel_arg("apparmor=0", "add"),
4822            Err(ExecutorError::InvalidParam("add"))
4823        ));
4824    }
4825
4826    #[test]
4827    fn kernel_arg_blocks_mitigations_off() {
4828        assert!(matches!(
4829            validated_safe_kernel_arg("mitigations=off", "add"),
4830            Err(ExecutorError::InvalidParam("add"))
4831        ));
4832    }
4833
4834    #[test]
4835    fn kernel_arg_blocks_lockdown_override() {
4836        assert!(matches!(
4837            validated_safe_kernel_arg("lockdown=none", "add"),
4838            Err(ExecutorError::InvalidParam("add"))
4839        ));
4840    }
4841
4842    #[test]
4843    fn kernel_arg_blocks_pti_off() {
4844        assert!(matches!(
4845            validated_safe_kernel_arg("pti=off", "add"),
4846            Err(ExecutorError::InvalidParam("add"))
4847        ));
4848    }
4849
4850    #[test]
4851    fn kernel_arg_blocks_smap_smep_disable() {
4852        assert!(matches!(
4853            validated_safe_kernel_arg("nosmap", "add"),
4854            Err(ExecutorError::InvalidParam("add"))
4855        ));
4856        assert!(matches!(
4857            validated_safe_kernel_arg("nosmep", "add"),
4858            Err(ExecutorError::InvalidParam("add"))
4859        ));
4860    }
4861
4862    #[test]
4863    fn kernel_arg_blocks_single_user_shortcuts() {
4864        // Runlevel shortcuts that drop to a root shell.
4865        assert!(matches!(
4866            validated_safe_kernel_arg("single", "add"),
4867            Err(ExecutorError::InvalidParam("add"))
4868        ));
4869        assert!(matches!(
4870            validated_safe_kernel_arg("s", "add"),
4871            Err(ExecutorError::InvalidParam("add"))
4872        ));
4873        assert!(matches!(
4874            validated_safe_kernel_arg("1", "add"),
4875            Err(ExecutorError::InvalidParam("add"))
4876        ));
4877    }
4878
4879    #[test]
4880    fn signal_process_guardrails() {
4881        // Reject pid 0 (whole process group) and 1 (init/systemd).
4882        for bad_pid in [0, 1] {
4883            let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
4884            assert!(
4885                matches!(err, ExecutorError::InvalidParam("pid")),
4886                "pid {bad_pid} must be rejected, got {err:?}"
4887            );
4888        }
4889        // Reject a signal outside the allowlist.
4890        let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
4891            .unwrap_err();
4892        assert!(
4893            matches!(err, ExecutorError::InvalidParam("signal")),
4894            "got {err:?}"
4895        );
4896        // Missing pid is a MissingParam.
4897        assert!(matches!(
4898            build_action_spec("SignalProcess", &json!({})).unwrap_err(),
4899            ExecutorError::MissingParam("pid")
4900        ));
4901        // A valid pid + signal builds, and accepts a numeric string + SIG prefix.
4902        let spec = build_action_spec(
4903            "SignalProcess",
4904            &json!({ "pid": "4242", "signal": "sigkill" }),
4905        )
4906        .unwrap();
4907        assert_eq!(spec.action_name, "SignalProcess");
4908        assert_eq!(
4909            spec.mechanism,
4910            ActionMechanism::Command {
4911                program: "sudo",
4912                args: vec![
4913                    "/usr/lib/sysknife/action-steps".to_string(),
4914                    "signal".to_string(),
4915                    "KILL".to_string(),
4916                    "4242".to_string()
4917                ],
4918            }
4919        );
4920        // Default signal is TERM when omitted.
4921        let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
4922        if let ActionMechanism::Command { args, .. } = spec.mechanism {
4923            assert_eq!(
4924                args,
4925                vec!["/usr/lib/sysknife/action-steps", "signal", "TERM", "4242"]
4926            );
4927        } else {
4928            panic!("expected Command mechanism");
4929        }
4930    }
4931
4932    #[test]
4933    fn create_scheduled_job_validates_name_command_schedule() {
4934        // A newline in the command would inject extra unit directives.
4935        assert!(matches!(
4936            build_action_spec(
4937                "CreateScheduledJob",
4938                &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
4939            )
4940            .unwrap_err(),
4941            ExecutorError::InvalidParam("command")
4942        ));
4943        // A path-like / dotted name is rejected (must be a safe unit stem).
4944        assert!(matches!(
4945            build_action_spec(
4946                "CreateScheduledJob",
4947                &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
4948            )
4949            .unwrap_err(),
4950            ExecutorError::InvalidParam("name")
4951        ));
4952        // A schedule with shell metacharacters is rejected by the charset gate.
4953        assert!(matches!(
4954            build_action_spec(
4955                "CreateScheduledJob",
4956                &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
4957            )
4958            .unwrap_err(),
4959            ExecutorError::InvalidParam("schedule")
4960        ));
4961        // A valid job routes through the scoped helper with the right argv.
4962        let spec = build_action_spec(
4963            "CreateScheduledJob",
4964            &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
4965        )
4966        .unwrap();
4967        assert_eq!(
4968            spec.mechanism,
4969            ActionMechanism::Command {
4970                program: "sudo",
4971                args: vec![
4972                    "/usr/lib/sysknife/scheduled-job-edit".to_string(),
4973                    "--name".to_string(),
4974                    "nightly-backup".to_string(),
4975                    "--command".to_string(),
4976                    "/usr/bin/backup --full".to_string(),
4977                    "--schedule".to_string(),
4978                    "*-*-* 02:00:00".to_string(),
4979                ],
4980            }
4981        );
4982    }
4983
4984    #[test]
4985    fn set_sshd_option_allowlist_guardrails() {
4986        // Non-allowlisted option rejected.
4987        assert!(matches!(
4988            build_action_spec(
4989                "SetSshdOption",
4990                &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
4991            )
4992            .unwrap_err(),
4993            ExecutorError::InvalidParam("option")
4994        ));
4995        // Allowlisted option, disallowed value.
4996        assert!(matches!(
4997            build_action_spec(
4998                "SetSshdOption",
4999                &json!({ "option": "PermitRootLogin", "value": "maybe" })
5000            )
5001            .unwrap_err(),
5002            ExecutorError::InvalidParam("value")
5003        ));
5004        // Valid combo routes through the scoped helper.
5005        let spec = build_action_spec(
5006            "SetSshdOption",
5007            &json!({ "option": "PasswordAuthentication", "value": "no" }),
5008        )
5009        .unwrap();
5010        assert_eq!(
5011            spec.mechanism,
5012            ActionMechanism::Command {
5013                program: "sudo",
5014                args: vec![
5015                    "/usr/lib/sysknife/sshd-option-edit".to_string(),
5016                    "--option".to_string(),
5017                    "PasswordAuthentication".to_string(),
5018                    "--value".to_string(),
5019                    "no".to_string(),
5020                ],
5021            }
5022        );
5023    }
5024
5025    #[test]
5026    fn configure_unattended_upgrades_toggles_helper_flag() {
5027        for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
5028            let spec = build_action_spec(
5029                "ConfigureUnattendedUpgrades",
5030                &json!({ "enabled": enabled }),
5031            )
5032            .unwrap();
5033            assert_eq!(
5034                spec.mechanism,
5035                ActionMechanism::Command {
5036                    program: "sudo",
5037                    args: vec![
5038                        "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
5039                        flag.to_string(),
5040                    ],
5041                }
5042            );
5043        }
5044        assert!(matches!(
5045            build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
5046            ExecutorError::MissingParam("enabled")
5047        ));
5048    }
5049
5050    #[test]
5051    fn grub_set_kargs_append_blocks_single_user_shortcut() {
5052        // Regression: GrubSetKargs previously used only the charset validator,
5053        // which accepts the bare `single`/`s`/`1` runlevel shortcuts. The
5054        // kernel-arg allowlist must apply to `append` too — booting into a
5055        // single-user root shell is exactly the SetKernelArguments threat.
5056        // These tokens are valueless, so they pass the charset check and the
5057        // allowlist is the only thing stopping them — the call is load-bearing
5058        // here, not redundant with `validated_safe_arg`.
5059        for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
5060            let err = build_action_spec(
5061                "GrubSetKargs",
5062                &json!({ "append": [dangerous], "delete": [] }),
5063            )
5064            .unwrap_err();
5065            assert!(
5066                matches!(err, ExecutorError::InvalidParam("append")),
5067                "GrubSetKargs must reject append=[{dangerous:?}] via the allowlist, got {err:?}"
5068            );
5069        }
5070        // A benign flag still builds successfully.
5071        assert!(build_action_spec(
5072            "GrubSetKargs",
5073            &json!({ "append": ["quiet"], "delete": [] })
5074        )
5075        .is_ok());
5076    }
5077
5078    #[test]
5079    fn kernel_arg_build_spec_rejects_dangerous_arg() {
5080        // End-to-end: build_action_spec must propagate the allowlist error.
5081        // This action does NOT apply the `validated_safe_arg` charset check,
5082        // so `init=/bin/bash` reaches `validated_safe_kernel_arg`, whose
5083        // `key=value` half is live here even though GrubSetKargs never reaches it.
5084        let err = build_action_spec(
5085            "SetKernelArguments",
5086            &json!({ "add": ["init=/bin/bash"], "remove": [] }),
5087        )
5088        .unwrap_err();
5089        assert!(
5090            matches!(err, ExecutorError::InvalidParam("add")),
5091            "expected InvalidParam(add), got {err:?}"
5092        );
5093    }
5094
5095    #[test]
5096    fn grub_set_kargs_cannot_delete_a_protective_karg() {
5097        // A review flagged `delete: ["apparmor=1"]` as a way to strip AppArmor
5098        // because the kernel-arg denylist is not applied to `delete`. It is
5099        // not reachable: `delete` goes through `validated_safe_arg`, whose
5100        // charset excludes `=`, so any `key=value` token is refused before the
5101        // denylist would matter. Pinned here so a future widening of that
5102        // charset cannot silently open the hole.
5103        for karg in [
5104            "apparmor=1",
5105            "lockdown=confidentiality",
5106            "mitigations=auto",
5107            "pti=on",
5108            "selinux=1",
5109        ] {
5110            let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
5111                .unwrap_err();
5112            assert!(
5113                matches!(err, ExecutorError::InvalidParam("delete")),
5114                "deleting {karg:?} must be refused, got {err:?}"
5115            );
5116        }
5117    }
5118
5119    /// Every action that claims `rollback_available: true` MUST have a
5120    /// corresponding entry in `rollback_spec_for()`; every action that claims
5121    /// `false` MUST NOT. This prevents the spec and the executor from
5122    /// drifting apart.
5123    #[test]
5124    fn rollback_available_matches_rollback_spec_for_all_actions() {
5125        // Iterate the FULL catalogue (`crate::actions::all_specs`), not a
5126        // hand-picked subset of families — a manually-chained list here once
5127        // silently omitted `ppa`, `netplan`, `grub`, and `ubuntu_pro`, which is
5128        // exactly how `AddPpa`/`RemovePpa`/`NetplanSet`/`GrubSetKargs`/
5129        // `ProAttach`/`ProDetach` were able to claim `rollback_available: true`
5130        // with no matching `rollback_spec_for` entry for years without this
5131        // test catching it.
5132        for spec in crate::actions::all_specs() {
5133            let has_rollback = rollback_spec_for(spec.action_name).is_some();
5134            assert_eq!(
5135                spec.rollback_available,
5136                has_rollback,
5137                "action {:?}: rollback_available={} but rollback_spec_for returns {}",
5138                spec.action_name,
5139                spec.rollback_available,
5140                if has_rollback { "Some" } else { "None" },
5141            );
5142        }
5143    }
5144
5145    // `home` reaches `useradd --create-home --home-dir <home>` as root, so it is
5146    // a path the caller chooses and root acts on. It was validated by
5147    // `validated_safe_arg`, which enforces a charset and rejects a leading dash
5148    // and accepts both `..` and a relative path. `validated_absolute_path`, in
5149    // the same file, rejects both, and every other root-acting path parameter
5150    // already uses it. The weaker of two adjacent validators was guarding the
5151    // more dangerous parameter.
5152    #[test]
5153    fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
5154        for home in [
5155            "../../etc/skel",
5156            "/home/../etc",
5157            "home/alice",
5158            "/home/alice/../../../root",
5159        ] {
5160            let got =
5161                build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
5162            assert!(
5163                got.is_err(),
5164                "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
5165            );
5166        }
5167        // An ordinary home directory must still work, or the guard has eaten
5168        // the feature rather than secured it.
5169        build_action_spec(
5170            "CreateUser",
5171            &json!({ "username": "alice", "home": "/home/alice" }),
5172        )
5173        .expect("a plain absolute home must still be accepted");
5174    }
5175
5176    // ── Risk level reclassification (NIST 800-53 / CIS Controls v8.1) ────────
5177    // These five actions were incorrectly classified Medium; they must be High.
5178    // T1136.001 (CreateUser), T1562.004 (ConfigureFirewall), T1562.001 (MaskService),
5179    // supply-chain vector (AddPackageRepository), T1557 path (SetDnsServers).
5180
5181    #[test]
5182    fn create_user_is_high_risk() {
5183        let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
5184        assert_eq!(
5185            spec.risk_level,
5186            RiskLevel::High,
5187            "CreateUser must be High (T1136.001 Persistence)"
5188        );
5189    }
5190
5191    #[test]
5192    fn configure_firewall_is_high_risk() {
5193        let spec = build_action_spec(
5194            "ConfigureFirewall",
5195            &json!({ "zone": "public", "service": "ssh", "enabled": true }),
5196        )
5197        .unwrap();
5198        assert_eq!(
5199            spec.risk_level,
5200            RiskLevel::High,
5201            "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
5202        );
5203    }
5204
5205    #[test]
5206    fn mask_service_is_high_risk() {
5207        let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
5208        assert_eq!(
5209            spec.risk_level,
5210            RiskLevel::High,
5211            "MaskService must be High (T1562.001 Impair Defenses)"
5212        );
5213    }
5214
5215    #[test]
5216    fn add_package_repository_is_high_risk() {
5217        let spec = build_action_spec(
5218            "AddPackageRepository",
5219            &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
5220        )
5221        .unwrap();
5222        assert_eq!(
5223            spec.risk_level,
5224            RiskLevel::High,
5225            "AddPackageRepository must be High (supply-chain vector)"
5226        );
5227    }
5228
5229    #[test]
5230    fn set_dns_servers_is_high_risk() {
5231        let spec = build_action_spec(
5232            "SetDnsServers",
5233            &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
5234        )
5235        .unwrap();
5236        assert_eq!(
5237            spec.risk_level,
5238            RiskLevel::High,
5239            "SetDnsServers must be High (DNS hijacking / T1557)"
5240        );
5241    }
5242}