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        validated_activatable_unit, validated_apparmor_profile, validated_apt_package,
9        validated_apt_pin_expr, validated_apt_pin_name, validated_audit_path,
10        validated_audit_perms, validated_cpu_quota, validated_domain, validated_email,
11        validated_fstype, validated_group, validated_group_not_critical, validated_home_dir,
12        validated_hostname, validated_install_package, validated_journal_grep,
13        validated_journal_priority, validated_journal_time, validated_locale, validated_log_path,
14        validated_lvm_name, validated_lvm_size, validated_memory_limit, validated_mount_device,
15        validated_mount_options, validated_mount_point, validated_port_or_service,
16        validated_ppa_name, validated_pro_service, validated_safe_arg, validated_sudo_commands,
17        validated_sudoers_name, validated_swap_path, validated_sysctl_key, validated_sysctl_value,
18        validated_syslog_host, validated_tasks_max, validated_timezone, validated_unit_name,
19        validated_username, validated_username_not_critical,
20    },
21    ActionMechanism, ActionSpec,
22};
23use async_trait::async_trait;
24use serde_json::Value;
25use std::io;
26use std::net::IpAddr;
27use std::process::Stdio;
28use std::str::FromStr;
29use std::time::Duration;
30use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
31use tokio::sync::mpsc::UnboundedSender;
32
33// ---------------------------------------------------------------------------
34// Parameter bounds
35// ---------------------------------------------------------------------------
36//
37// These mirror the ceilings of the tools being driven (`chage`, `faillock`,
38// `pam_pwquality`, `fail2ban`, `journalctl`, `mkswap`). Naming them keeps the
39// rejection reason legible and keeps the same tool's ceiling from being spelled
40// two different ways in two arms of the same match.
41
42/// Longest scheduled-job command line.
43const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
44/// Longest schedule expression (`OnCalendar=` / cron form).
45const MAX_SCHEDULE_EXPR_LEN: usize = 128;
46/// Widest `journalctl --lines` request. Values are clamped, not rejected: an
47/// over-large request is a preference, not an error.
48const MAX_JOURNAL_LINES: u64 = 10_000;
49/// Largest swap file, in MiB (1 TiB).
50const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
51/// `chage`'s own ceiling for password-age days.
52const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
53/// Widest `pam_pwquality` minimum length.
54const MAX_PASSWORD_MINLEN: u64 = 128;
55/// Longest `faillock` unlock/interval window, in seconds (7 days).
56const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
57/// Longest fail2ban bantime/findtime, in seconds (30 days).
58const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
59
60#[derive(Debug, thiserror::Error)]
61pub enum ExecutorError {
62    #[error("unknown action: {0}")]
63    UnknownAction(String),
64
65    #[error("missing required param: {0}")]
66    MissingParam(&'static str),
67
68    #[error("invalid param type for: {0}")]
69    InvalidParam(&'static str),
70
71    /// Richer variant that carries the offending value for actionable diagnostics.
72    ///
73    /// Used when an action constructor returns a typed `InvalidIpAddress` error —
74    /// the value is forwarded to user-facing output rather than being silently
75    /// discarded as in the generic `InvalidParam` path.
76    #[error("invalid IP address for param '{param}': '{value}'")]
77    InvalidIpAddress { param: &'static str, value: String },
78
79    #[error("io error: {0}")]
80    Io(#[from] io::Error),
81
82    /// The action outran its deadline and could not be confirmed stopped.
83    ///
84    /// Separate from a plain timeout because the safe response differs. A
85    /// timeout that stopped the work leaves the host where it was, so an
86    /// automatic rollback is reasonable. This variant means privileged work may
87    /// still be running, and rolling back over it would put two package
88    /// transactions on the host at once. The caller must not treat it as a
89    /// quiet failure.
90    #[error(
91        "{program} exceeded the {timeout_secs}s action timeout and could not be \
92         confirmed stopped: process group {pgid} still has members. Automatic \
93         rollback was skipped because the original action may still be running; \
94         inspect the host before retrying"
95    )]
96    ActionNotStopped {
97        program: String,
98        pgid: i32,
99        timeout_secs: u64,
100    },
101}
102
103/// Output of a single executed action.
104///
105/// `exit_code` is the discriminant between success and failure.  Prefer
106/// [`is_success`](Self::is_success) / [`is_nonzero`](Self::is_nonzero) at
107/// call sites — `if output.exit_code == 0` is harder to read and easier to
108/// invert by accident than `if output.is_success()`.  The raw `exit_code`
109/// stays public because the dispatcher echoes it back to callers and the
110/// rollback path includes the precise code in diagnostic messages.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ExecutionOutput {
113    pub stdout: String,
114    pub stderr: String,
115    pub exit_code: i32,
116}
117
118impl ExecutionOutput {
119    /// `true` when the action exited cleanly (`exit_code == 0`).
120    pub fn is_success(&self) -> bool {
121        self.exit_code == 0
122    }
123
124    /// `true` when the action failed (`exit_code != 0`).
125    pub fn is_nonzero(&self) -> bool {
126        self.exit_code != 0
127    }
128}
129
130/// Abstraction over action execution, making the execute + rollback path
131/// testable without spawning real OS commands.
132///
133/// The production implementation (`RealActionExecutor`) delegates to
134/// `tokio::process::Command`. Tests can inject a mock that controls exit
135/// codes and output per program.
136#[async_trait]
137pub trait ActionExecutor: Send + Sync {
138    /// Execute an [`ActionSpec`] and return its output.
139    async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
140
141    /// Execute an action and publish stdout lines while it runs.
142    ///
143    /// Test doubles normally implement only [`execute`](Self::execute); this
144    /// default forwards their captured output after completion. Production
145    /// overrides the method so command output remains live.
146    async fn execute_with_progress(
147        &self,
148        spec: &ActionSpec,
149        progress: UnboundedSender<String>,
150    ) -> Result<ExecutionOutput, ExecutorError> {
151        let output = self.execute(spec).await?;
152        for line in output.stdout.lines().filter(|line| !line.is_empty()) {
153            let _ = progress.send(line.to_string());
154        }
155        Ok(output)
156    }
157}
158
159/// Production executor that delegates to real OS processes and filesystem ops.
160pub struct RealActionExecutor;
161
162#[async_trait]
163impl ActionExecutor for RealActionExecutor {
164    async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
165        execute_spec(spec).await
166    }
167
168    async fn execute_with_progress(
169        &self,
170        spec: &ActionSpec,
171        progress: UnboundedSender<String>,
172    ) -> Result<ExecutionOutput, ExecutorError> {
173        match &spec.mechanism {
174            ActionMechanism::Command { program, args } => {
175                execute_command_with_progress(program, args, progress).await
176            }
177            _ => execute_spec(spec).await,
178        }
179    }
180}
181
182/// Wall-clock ceiling on a single privileged action.
183///
184/// Nothing else bounds these processes: without a deadline a child that never
185/// exits — `apt-get` waiting on a dead mirror, a helper blocked on a prompt
186/// that will never arrive — wedges its connection forever, and the exclusion
187/// lock it holds is never released, so every other action contending for that
188/// resource is refused from then on.
189///
190/// The ceiling is deliberately generous rather than tight. A release upgrade
191/// or an OSTree rebase legitimately runs for tens of minutes, and killing a
192/// half-finished package transaction is worse than waiting. This is a
193/// backstop against a hang, not a performance budget. Override with
194/// `SYSKNIFE_ACTION_TIMEOUT_SECS` when an action on a slow link needs longer.
195const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
196
197fn action_timeout() -> Duration {
198    match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
199        Ok(raw) => match raw.trim().parse::<u64>() {
200            Ok(secs) if secs > 0 => Duration::from_secs(secs),
201            _ => {
202                eprintln!(
203                    "[sysknife-daemon] WARNING: ignoring invalid \
204                     SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
205                );
206                DEFAULT_ACTION_TIMEOUT
207            }
208        },
209        Err(_) => DEFAULT_ACTION_TIMEOUT,
210    }
211}
212
213/// What a `killpg` probe says about a process group.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215enum GroupState {
216    /// `ESRCH`: no process holds this group id. Gone.
217    Gone,
218    /// The group has members this process could signal.
219    AliveReachable,
220    /// `EPERM`: the group has members, but they run as a user this process
221    /// cannot signal. On an unprivileged daemon that is the normal state of a
222    /// `sudo` child still running as root, so it counts as *alive*, not gone.
223    /// Reading it as gone was the bug that let the rollback fire over live work.
224    AliveUnreachable,
225    /// Any other errno. Treated as alive, because the safe default when the
226    /// kernel will not answer is "still running", not "stopped".
227    Unknown(i32),
228}
229
230impl GroupState {
231    fn is_gone(self) -> bool {
232        matches!(self, GroupState::Gone)
233    }
234}
235
236/// Probe a process group without delivering a signal (`signal 0`).
237fn probe_group(pgid: i32) -> GroupState {
238    // SAFETY: `killpg` is async-signal-safe and takes no pointers. `pgid` is a
239    // real group id from `child.id()` for a child spawned with
240    // `process_group(0)`, so it is >= 1 and names that child's own group, never
241    // 0 (which would address the daemon's own group).
242    let rc = unsafe { libc::killpg(pgid, 0) };
243    if rc == 0 {
244        return GroupState::AliveReachable;
245    }
246    match io::Error::last_os_error().raw_os_error() {
247        Some(libc::ESRCH) => GroupState::Gone,
248        Some(libc::EPERM) => GroupState::AliveUnreachable,
249        other => GroupState::Unknown(other.unwrap_or(0)),
250    }
251}
252
253/// Send a signal to a process group. `ESRCH` is success (already gone).
254fn signal_group(pgid: i32, signal: i32) -> Result<(), io::Error> {
255    // SAFETY: see `probe_group`; same `pgid` contract.
256    let rc = unsafe { libc::killpg(pgid, signal) };
257    if rc == 0 {
258        return Ok(());
259    }
260    let err = io::Error::last_os_error();
261    if err.raw_os_error() == Some(libc::ESRCH) {
262        return Ok(());
263    }
264    Err(err)
265}
266
267/// How long a group gets to exit on SIGTERM before SIGKILL.
268///
269/// Short on purpose. The deadline has already passed, so this is the courtesy
270/// window for a package manager to unwind, not a second timeout.
271const GROUP_TERM_GRACE: Duration = Duration::from_secs(5);
272
273/// Whether this action's real work is an `rpm-ostree` transaction. Such work
274/// runs inside `rpm-ostreed`'s own cgroup (rpm-ostree is a thin D-Bus client),
275/// not this process group, so a group signal cannot reach it — it must be
276/// cancelled with `rpm-ostree cancel` instead (#142). `program` is usually
277/// `sudo`, so the real command is found in the arguments.
278fn is_rpm_ostree_action(program: &str, args: &[String]) -> bool {
279    let is_ostree = |s: &str| s == "rpm-ostree" || s.ends_with("/rpm-ostree");
280    is_ostree(program) || args.iter().any(|a| is_ostree(a))
281}
282
283/// The argv for a privileged, non-interactive signal to a whole process group:
284/// `sudo -n /usr/bin/kill -s <SIGNAL> -- -<pgid>`. Run as root, it reaches the
285/// root children a `sudo` action forked that the unprivileged daemon cannot
286/// signal itself. `-n` never prompts (the sysknife sudoers entry is NOPASSWD),
287/// `--` ends options so the negative pgid is unambiguously a group, not a flag.
288fn root_group_kill_argv(signal: &str, pgid: i32) -> Vec<String> {
289    vec![
290        "-n".to_string(),
291        "/usr/bin/kill".to_string(),
292        "-s".to_string(),
293        signal.to_string(),
294        "--".to_string(),
295        format!("-{pgid}"),
296    ]
297}
298
299/// Counts `run_sudo` invocations so a test can prove the escalation path did
300/// (not) fire without needing a real sudo. Test-only.
301#[cfg(test)]
302static SUDO_INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
303
304/// Run a bounded, output-discarding `sudo` command for the privileged reaper.
305/// Best-effort for the *verdict* (the caller re-probes the group to decide the
306/// outcome), but the exit status is logged so a later "could not be confirmed
307/// stopped" line can be told apart from a misconfigured sudoers grant or a
308/// missing `sudo` — the reaper failing and the process genuinely wedging look
309/// identical from the group probe alone.
310async fn run_sudo(args: Vec<String>) {
311    #[cfg(test)]
312    SUDO_INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
313    let fut = tokio::process::Command::new("sudo")
314        .args(&args)
315        .stdin(Stdio::null())
316        .stdout(Stdio::null())
317        .stderr(Stdio::null())
318        .status();
319    let joined = args.join(" ");
320    match tokio::time::timeout(REAP_WAIT_CAP, fut).await {
321        Ok(Ok(status)) if status.success() => {}
322        Ok(Ok(status)) => {
323            eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` exited {status}")
324        }
325        Ok(Err(e)) => {
326            eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` could not run: {e}")
327        }
328        Err(_) => eprintln!(
329            "[sysknife-daemon] privileged reap `sudo {joined}` timed out after {}s",
330            REAP_WAIT_CAP.as_secs()
331        ),
332    }
333}
334
335/// The first privileged reap command (the args passed to `sudo`) for an action
336/// whose group survived the daemon's own signals: `rpm-ostree cancel` for an
337/// rpm-ostree transaction (its work is in another cgroup), otherwise a root TERM
338/// to the action's own process group. Split out so the dispatch and the exact
339/// argv are unit-testable without running `sudo`.
340fn reap_command(pgid: i32, program: &str, args: &[String]) -> Vec<String> {
341    if is_rpm_ostree_action(program, args) {
342        vec![
343            "-n".to_string(),
344            "/usr/bin/rpm-ostree".to_string(),
345            "cancel".to_string(),
346        ]
347    } else {
348        root_group_kill_argv("TERM", pgid)
349    }
350}
351
352/// Last-resort privileged reap for a group the daemon's own signals could not
353/// stop (its members run as root). Cancels an `rpm-ostree` transaction, or root
354/// TERM→KILLs the process group. This is the *termination* half of #140's
355/// detect-and-veto, so `ActionNotStopped` becomes a genuine last resort rather
356/// than the expected path for every sudo action.
357async fn privileged_reap(pgid: i32, program: &str, args: &[String]) {
358    run_sudo(reap_command(pgid, program, args)).await;
359    // rpm-ostree cancel is a single, blocking call; only the group-kill path
360    // escalates TERM → KILL. The client process blocks until the daemon-side
361    // transaction resolves (completes, errors, or cancels), so once `cancel`
362    // returns the transaction is stopped and the client group exits — which is
363    // why the caller's group probe (of the client) correctly reflects the
364    // transaction, even though the work itself ran in rpm-ostreed's cgroup.
365    if is_rpm_ostree_action(program, args) {
366        return;
367    }
368    tokio::time::sleep(Duration::from_millis(300)).await;
369    if !probe_group(pgid).is_gone() {
370        run_sudo(root_group_kill_argv("KILL", pgid)).await;
371    }
372}
373
374/// Upper bound on any single `child.wait()` in the timeout path.
375///
376/// The direct child of a `sudo` action runs as root and this daemon cannot
377/// signal it, so `wait()` on that child can block until the privileged work
378/// finishes on its own — hours. The action timeout exists to stop a hang, so it
379/// must not itself become one. Every wait here is bounded by this; an unreaped
380/// child becomes the subreaper's problem, not a wedged connection.
381const REAP_WAIT_CAP: Duration = Duration::from_secs(2);
382
383/// Stop an action that outran the deadline, and report honestly whether it
384/// actually stopped.
385///
386/// Signals the child's whole **process group**, not just its pid. The daemon
387/// runs unprivileged and elevates through `sudo`, which forks: signalling the
388/// pid alone leaves the privileged work running (issue #140). SIGTERM first so a
389/// package manager can unwind, then SIGKILL.
390///
391/// An unprivileged daemon cannot signal a process running as root, which every
392/// `sudo` action's real work does. When the group survives the daemon's own
393/// signals it therefore escalates to a privileged reaper ([`privileged_reap`]):
394/// a root kill of the group via the sudoers-authorised `/usr/bin/kill`, or an
395/// `rpm-ostree cancel` for a transaction that lives in another cgroup (#142).
396/// Only when even that cannot confirm the group gone does it return
397/// [`ExecutorError::ActionNotStopped`] — now a genuine last resort — and the
398/// dispatcher refuses the automatic rollback rather than starting a second
399/// privileged transaction over the first.
400async fn kill_and_reap(
401    child: &mut tokio::process::Child,
402    program: &str,
403    args: &[String],
404    cause: StopCause,
405) -> ExecutorError {
406    let secs = action_timeout().as_secs();
407    let why = cause.describe(secs);
408    let Some(pgid) = child.id().map(|pid| pid as i32) else {
409        // Already reaped: the child exited between the deadline and here, so the
410        // work is done and there is nothing to signal.
411        eprintln!("[sysknife-daemon] {program} {why}; already exited");
412        return cause.into_error(program, secs);
413    };
414
415    // A pid-targeted floor. `start_kill` (SIGKILL to the direct child) always
416    // lands when the child is ours to signal, which covers every non-`sudo`
417    // action; the group signal below covers the grandchildren `sudo` forks.
418    let _ = child.start_kill();
419    if let Err(err) = signal_group(pgid, libc::SIGTERM) {
420        eprintln!("[sysknife-daemon] SIGTERM to process group {pgid} failed: {err}");
421    }
422
423    // Reap the direct child first, bounded: an unreaped child is a zombie that
424    // keeps the group alive and would make the probe below lie. Bounded because
425    // a root `sudo` child can outlast any signal this daemon can send.
426    let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
427
428    let grace = tokio::time::Instant::now() + GROUP_TERM_GRACE;
429    while tokio::time::Instant::now() < grace && !probe_group(pgid).is_gone() {
430        tokio::time::sleep(Duration::from_millis(50)).await;
431    }
432    if !probe_group(pgid).is_gone() {
433        if let Err(err) = signal_group(pgid, libc::SIGKILL) {
434            eprintln!("[sysknife-daemon] SIGKILL to process group {pgid} failed: {err}");
435        }
436        tokio::time::sleep(Duration::from_millis(200)).await;
437        let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
438    }
439
440    // The group survived the daemon's own signals: its members run as root (a
441    // `sudo` action's privileged child), which an unprivileged daemon cannot
442    // signal. Escalate to a privileged reaper — a root kill of the group, or an
443    // `rpm-ostree cancel` for a transaction that lives in another cgroup (#142).
444    //
445    // Escalate only for `AliveUnreachable` (EPERM): the members run as root, the
446    // sudo action's privileged child the daemon cannot signal. A same-uid process
447    // wedged in D-state (`AliveReachable`) or an `Unknown` errno would not be
448    // helped by a root signal, so those fall straight through to
449    // `ActionNotStopped` rather than a pointless sudo round-trip.
450    //
451    // Signalling the group by its numeric pgid is safe against pid reuse: POSIX
452    // keeps a process-group id allocated for the group's whole lifetime (while it
453    // has any member), so a probe reporting the group alive guarantees the pgid
454    // still names THIS action's group, never a recycled unrelated process.
455    if probe_group(pgid) == GroupState::AliveUnreachable {
456        eprintln!(
457            "[sysknife-daemon] {program} process group {pgid} survived as root; \
458             escalating to a privileged reap"
459        );
460        privileged_reap(pgid, program, args).await;
461        tokio::time::sleep(Duration::from_millis(200)).await;
462        let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
463    }
464
465    match probe_group(pgid) {
466        GroupState::Gone => {
467            eprintln!("[sysknife-daemon] {program} {why}; process group {pgid} stopped");
468            cause.into_error(program, secs)
469        }
470        state => {
471            eprintln!(
472                "[sysknife-daemon] {program} {why} and process group {pgid} \
473                 could not be confirmed stopped ({state:?})"
474            );
475            ExecutorError::ActionNotStopped {
476                program: program.to_string(),
477                pgid,
478                timeout_secs: secs,
479            }
480        }
481    }
482}
483
484/// Why the executor is tearing down a still-running privileged child.
485///
486/// Both causes must stop the process group — a root child left running after the
487/// executor has reported failure defeats the rollback interlock and the
488/// one-privileged-action-at-a-time guarantee — but they are not the same event,
489/// and reporting a read failure as a timeout would send an operator hunting a
490/// deadline that never fired.
491enum StopCause {
492    /// The action's own deadline elapsed.
493    Timeout,
494    /// The daemon lost the ability to read the child (a non-UTF-8 line, a broken
495    /// pipe, a `wait` that failed). Carries the error to surface once the group
496    /// is confirmed stopped.
497    Unreadable(io::Error),
498}
499
500impl StopCause {
501    /// The operator-log fragment, e.g. `exceeded the 900s action timeout`.
502    fn describe(&self, secs: u64) -> String {
503        match self {
504            StopCause::Timeout => format!("exceeded the {secs}s action timeout"),
505            StopCause::Unreadable(err) => format!("could not be read ({err})"),
506        }
507    }
508
509    /// The error to return once the group is confirmed gone. A group that
510    /// survives reports [`ExecutorError::ActionNotStopped`] instead, whatever the
511    /// cause — the caller needs to know work is still running far more than it
512    /// needs to know why the executor gave up on it.
513    fn into_error(self, program: &str, secs: u64) -> ExecutorError {
514        match self {
515            StopCause::Timeout => ExecutorError::Io(io::Error::new(
516                io::ErrorKind::TimedOut,
517                format!("{program} exceeded the {secs}s action timeout and was stopped"),
518            )),
519            StopCause::Unreadable(err) => ExecutorError::Io(err),
520        }
521    }
522}
523
524async fn execute_command_with_progress(
525    program: &'static str,
526    args: &[String],
527    progress: UnboundedSender<String>,
528) -> Result<ExecutionOutput, ExecutorError> {
529    let mut child = tokio::process::Command::new(program)
530        .args(args)
531        // Own process group, so the deadline can signal the whole tree rather
532        // than one pid. Without it the kill lands on `sudo`, which has already
533        // forked the privileged work, and the action outlives the timeout that
534        // claims to have stopped it. See issue #140.
535        .process_group(0)
536        // Null stdin, matching `execute_spec`. A background process group with a
537        // controlling terminal would otherwise get SIGTTIN on a stdin read and
538        // stop, burning the whole grace window before SIGKILL. No privileged
539        // action should read the daemon's stdin regardless.
540        .stdin(Stdio::null())
541        .stdout(Stdio::piped())
542        .stderr(Stdio::piped())
543        .spawn()
544        .map_err(ExecutorError::Io)?;
545
546    let stdout = child.stdout.take().expect("stdout was piped");
547    let stderr = child.stderr.take().expect("stderr was piped");
548    let stderr_task = tokio::spawn(async move {
549        let mut buf = Vec::new();
550        BufReader::new(stderr)
551            .read_to_end(&mut buf)
552            .await
553            .map(|_| buf)
554    });
555
556    let deadline = tokio::time::Instant::now() + action_timeout();
557
558    let mut lines = BufReader::new(stdout).lines();
559    let mut stdout_buf = String::new();
560    loop {
561        // The deadline covers the whole action, not each read: a process that
562        // emits one progress line an hour must still be cut off.
563        let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
564            Ok(Ok(line)) => line,
565            // Losing the read is not a reason to walk away from a running root
566            // child. `lines()` fails with `InvalidData` on non-UTF-8 output, so
567            // this is reachable from any action whose stdout carries a byte
568            // sequence outside UTF-8 — one oddly-encoded filename is enough.
569            Ok(Err(err)) => {
570                return Err(
571                    kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await,
572                )
573            }
574            Err(_) => {
575                return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
576            }
577        };
578        let Some(line) = next else { break };
579        if !line.is_empty() {
580            let _ = progress.send(line.clone());
581        }
582        stdout_buf.push_str(&line);
583        stdout_buf.push('\n');
584    }
585
586    let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
587        Ok(Ok(status)) => status,
588        // A failed `wait` leaves the child's fate unknown, which is the one state
589        // the dispatcher must never treat as "stopped".
590        Ok(Err(err)) => {
591            return Err(kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await)
592        }
593        Err(_) => return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await),
594    };
595    let stderr_bytes = stderr_task
596        .await
597        .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
598        .map_err(ExecutorError::Io)?;
599
600    Ok(ExecutionOutput {
601        stdout: stdout_buf,
602        stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
603        exit_code: exit_status.code().unwrap_or(-1),
604    })
605}
606
607/// Map an action name and JSON params to an [`ActionSpec`].
608///
609/// Returns [`ExecutorError::UnknownAction`] for unrecognised names and
610/// [`ExecutorError::MissingParam`] when a required param is absent.
611pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
612    match action_name {
613        // ── Deployment: no params ─────────────────────────────────────────
614        "GetSystemState" => Ok(deployment::get_system_state()),
615        "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
616        "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
617        "ListDeployments" => Ok(deployment::list_deployments()),
618        "UpdateSystem" => Ok(deployment::update_system()),
619        "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
620        "RebootSystem" => Ok(deployment::reboot_system()),
621        "RollbackDeployment" => Ok(deployment::rollback_deployment()),
622        "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
623
624        // ── Deployment: parameterized ─────────────────────────────────────
625        "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
626        "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
627        "RebaseSystem" => {
628            let target_ref = require_str(params, "target_ref")?;
629            let target_ref = validated_safe_arg(target_ref, "target_ref")?;
630            Ok(deployment::rebase_system(&target_ref))
631        }
632        "SetKernelArguments" => {
633            let add = str_array_or_empty(params, "add")?;
634            let remove = str_array_or_empty(params, "remove")?;
635            // Reject dangerous kernel arguments that could bypass security
636            // mechanisms or give unauthenticated root access on next boot.
637            for arg in add.iter() {
638                validated_safe_kernel_arg(arg, "add")?;
639            }
640            // Removal is screened too: stripping a protective arg reaches the
641            // same end state as adding its weakening counterpart.
642            for arg in remove.iter() {
643                validated_safe_kernel_arg_removal(arg, "remove")?;
644            }
645            let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
646            let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
647            Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
648        }
649
650        // ── Flatpak ───────────────────────────────────────────────────────
651        // All user-scoped Flatpak operations require a `username` param so the
652        // daemon can switch to that user's environment via `runuser -l`. This
653        // ensures operations target the user's Flatpak installation
654        // (~/.local/share/flatpak/) rather than the system store.
655        "ListFlatpakRemotes" => {
656            let username = validated_username(resolve_username(params)?, "username")?;
657            Ok(flatpak::list_flatpak_remotes(&username))
658        }
659        "InstallFlatpak" => {
660            let username = validated_username(resolve_username(params)?, "username")?;
661            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
662            // `remote` defaults to "flathub" — the universal Flatpak remote.
663            // Models frequently omit it; accepting the default avoids a
664            // MissingParam failure for the most common install case.
665            let remote = params
666                .get("remote")
667                .and_then(|v| v.as_str())
668                .unwrap_or("flathub");
669            let remote = validated_safe_arg(remote, "remote")?;
670            Ok(flatpak::install_flatpak(&username, &app_id, &remote))
671        }
672        "RemoveFlatpak" => {
673            let username = validated_username(resolve_username(params)?, "username")?;
674            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
675            Ok(flatpak::remove_flatpak(&username, &app_id))
676        }
677        "SearchFlatpakApps" => {
678            let term = validated_safe_arg(require_str(params, "term")?, "term")?;
679            Ok(flatpak::search_flatpak_apps(&term))
680        }
681        "AddFlatpakRemote" => {
682            let username = validated_username(resolve_username(params)?, "username")?;
683            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
684            let url = validated_safe_arg(require_str(params, "url")?, "url")?;
685            Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
686        }
687        "RemoveFlatpakRemote" => {
688            let username = validated_username(resolve_username(params)?, "username")?;
689            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
690            Ok(flatpak::remove_flatpak_remote(&username, &remote))
691        }
692        "GetFlatpakAppInfo" => {
693            let username = validated_username(resolve_username(params)?, "username")?;
694            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
695            Ok(flatpak::get_flatpak_app_info(&username, &app_id))
696        }
697        "ListInstalledFlatpaks" => {
698            let username = validated_username(resolve_username(params)?, "username")?;
699            Ok(flatpak::list_installed_flatpaks(&username))
700        }
701        "UpdateFlatpak" => {
702            let username = validated_username(resolve_username(params)?, "username")?;
703            // app_id is optional — omitting it updates all installed Flatpaks.
704            // Empty string is treated as absent (no app specified → update all).
705            let app_id = params
706                .get("app_id")
707                .and_then(|v| v.as_str())
708                .filter(|id| !id.is_empty())
709                .map(|id| validated_safe_arg(id, "app_id"))
710                .transpose()?;
711            Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
712        }
713
714        // ── Containers ────────────────────────────────────────────────────
715        // All container operations require a `username` param so the daemon can
716        // switch to that user's rootless Podman environment via `runuser -l`.
717        // Podman storage is per-user; running as the `sysknife` system user
718        // would see an empty, unrelated container store.
719        "ListContainers" => {
720            let username = validated_username(resolve_username(params)?, "username")?;
721            Ok(containers::list_containers(&username))
722        }
723        "CreateContainer" => {
724            let username = validated_username(resolve_username(params)?, "username")?;
725            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
726            let image = validated_safe_arg(require_str(params, "image")?, "image")?;
727            Ok(containers::create_container(&username, &name, &image))
728        }
729        "StartContainer" => {
730            let username = validated_username(resolve_username(params)?, "username")?;
731            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
732            Ok(containers::start_container(&username, &name))
733        }
734        "StopContainer" => {
735            let username = validated_username(resolve_username(params)?, "username")?;
736            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
737            Ok(containers::stop_container(&username, &name))
738        }
739        "RemoveContainer" => {
740            let username = validated_username(resolve_username(params)?, "username")?;
741            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
742            Ok(containers::remove_container(&username, &name))
743        }
744        "GetContainerInfo" => {
745            let username = validated_username(resolve_username(params)?, "username")?;
746            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
747            Ok(containers::get_container_info(&username, &name))
748        }
749
750        // ── Layering ──────────────────────────────────────────────────────
751        "GetLayeredPackages" => Ok(layering::get_layered_packages()),
752        "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
753        "InstallPackages" => {
754            let pkgs = str_array_or_empty(params, "packages")?;
755            let validated: Vec<String> = pkgs
756                .iter()
757                .map(|p| validated_install_package(p, "packages"))
758                .collect::<Result<_, _>>()?;
759            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
760            Ok(layering::install_packages(&refs))
761        }
762        "RemovePackages" => {
763            let pkgs = str_array_or_empty(params, "packages")?;
764            let validated: Vec<String> = pkgs
765                .iter()
766                .map(|p| validated_install_package(p, "packages"))
767                .collect::<Result<_, _>>()?;
768            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
769            Ok(layering::remove_packages(&refs))
770        }
771        "AddLayeredPackage" => {
772            let package = validated_install_package(require_str(params, "package")?, "package")?;
773            Ok(layering::add_layered_package(&package))
774        }
775        "RemoveLayeredPackage" => {
776            let package = validated_install_package(require_str(params, "package")?, "package")?;
777            Ok(layering::remove_layered_package(&package))
778        }
779        "ReplaceLayeredPackage" => {
780            let old = validated_install_package(require_str(params, "old")?, "old")?;
781            let new = validated_install_package(require_str(params, "new")?, "new")?;
782            Ok(layering::replace_layered_package(&old, &new))
783        }
784        "RemoveBasePackage" => {
785            let package = validated_install_package(require_str(params, "package")?, "package")?;
786            Ok(layering::remove_base_package(&package))
787        }
788        "GetPendingUpdates" => Ok(layering::get_pending_updates()),
789
790        // ── Package repositories ──────────────────────────────────────────
791        "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
792        "AddPackageRepository" => Ok(package_repos::add_package_repository(
793            validated_repo_id(params)?,
794            validated_no_newline(params, "repo_url")?,
795        )),
796        "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
797            validated_repo_id(params)?,
798        )),
799        "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
800            validated_repo_id(params)?,
801        )),
802        "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
803            validated_repo_id(params)?,
804        )),
805
806        // ── Services ─────────────────────────────────────────────────────
807        "ListServices" => Ok(services::list_services()),
808        "StartService" => {
809            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
810            Ok(services::start_service(&unit))
811        }
812        "StopService" => {
813            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
814            Ok(services::stop_service(&unit))
815        }
816        "RestartService" => {
817            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
818            Ok(services::restart_service(&unit))
819        }
820        "SetServiceEnabled" => {
821            let enabled = require_bool(params, "enabled")?;
822            // Enabling brings the unit up at boot, so it must clear the root-shell
823            // denylist; disabling one is a mitigation and stays allowed.
824            let unit = if enabled {
825                validated_activatable_unit(require_str(params, "unit")?, "unit")?
826            } else {
827                validated_unit_name(require_str(params, "unit")?, "unit")?
828            };
829            Ok(services::set_service_enabled(&unit, enabled))
830        }
831        "MaskService" => {
832            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
833            Ok(services::mask_service(&unit))
834        }
835        "UnmaskService" => {
836            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
837            Ok(services::unmask_service(&unit))
838        }
839        "GetServiceLogs" => {
840            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
841            Ok(services::get_service_logs(&unit))
842        }
843        "GetServiceStatus" => {
844            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
845            Ok(services::get_service_status(&unit))
846        }
847        "ReloadService" => {
848            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
849            Ok(services::reload_service(&unit))
850        }
851        "ListTimers" => Ok(services::list_timers()),
852        "ReloadDaemon" => Ok(services::reload_daemon()),
853        "CreateScheduledJob" => {
854            // Job name: safe logical name. The service module owns the
855            // exact sysknife-<name> unit-path rendering used by preview.
856            let name = require_str(params, "name")?;
857            if services::scheduled_job_unit_paths(name).is_none() {
858                return Err(ExecutorError::InvalidParam("name"));
859            }
860            // Command: reject control characters (newlines would inject extra
861            // unit directives). systemd argv-splits ExecStart with no shell.
862            let command = require_str(params, "command")?;
863            if command.is_empty()
864                || command.len() > MAX_SCHEDULED_COMMAND_LEN
865                || command.chars().any(|c| c.is_control())
866            {
867                return Err(ExecutorError::InvalidParam("command"));
868            }
869            // Schedule: OnCalendar charset; the helper validates it semantically
870            // with `systemd-analyze calendar`.
871            let schedule = require_str(params, "schedule")?;
872            if schedule.is_empty()
873                || schedule.len() > MAX_SCHEDULE_EXPR_LEN
874                || !schedule.chars().all(|c| {
875                    c.is_ascii_alphanumeric()
876                        || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
877                })
878            {
879                return Err(ExecutorError::InvalidParam("schedule"));
880            }
881            Ok(services::create_scheduled_job(name, command, schedule))
882        }
883        "GetServiceResourceLimits" => {
884            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
885            Ok(services::get_service_resource_limits(&unit))
886        }
887        "SetServiceResourceLimits" => {
888            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
889            // Build validated PROPERTY=VALUE assignments from whichever limits
890            // were supplied; at least one is required.
891            let mut assignments = Vec::new();
892            if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
893                assignments.push(format!("MemoryMax={v}"));
894            }
895            if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
896                assignments.push(format!("MemoryHigh={v}"));
897            }
898            if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
899                assignments.push(format!("CPUQuota={v}"));
900            }
901            if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
902                assignments.push(format!("TasksMax={v}"));
903            }
904            if assignments.is_empty() {
905                return Err(ExecutorError::MissingParam("memory_max"));
906            }
907            Ok(services::set_service_resource_limits(&unit, &assignments))
908        }
909
910        // ── Toolbox ───────────────────────────────────────────────────────
911        // Toolbox operations require a `username` param — toolbox containers are
912        // per-user (rootless Podman) and must be managed in the user's context.
913        "ListToolboxes" => {
914            let username = validated_username(resolve_username(params)?, "username")?;
915            Ok(toolbox::list_toolboxes(&username))
916        }
917        "CreateToolbox" => {
918            let username = validated_username(resolve_username(params)?, "username")?;
919            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
920            let image = params
921                .get("image")
922                .and_then(|v| v.as_str())
923                .map(|img| validated_safe_arg(img, "image"))
924                .transpose()?;
925            let release = params
926                .get("release")
927                .and_then(|v| v.as_str())
928                .map(|r| validated_safe_arg(r, "release"))
929                .transpose()?;
930            Ok(toolbox::create_toolbox(
931                &username,
932                &name,
933                release.as_deref(),
934                image.as_deref(),
935            ))
936        }
937        "RemoveToolbox" => {
938            let username = validated_username(resolve_username(params)?, "username")?;
939            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
940            Ok(toolbox::remove_toolbox(&username, &name))
941        }
942
943        // ── Identity ─────────────────────────────────────────────────────
944        "GetDateTime" => Ok(identity::get_datetime()),
945        "SetHostname" => {
946            let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
947            Ok(identity::set_hostname(&hostname))
948        }
949        "SetTimezone" => {
950            let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
951            Ok(identity::set_timezone(&timezone))
952        }
953        "SetLocale" => {
954            let locale = validated_locale(require_str(params, "locale")?, "locale")?;
955            Ok(identity::set_locale(&locale))
956        }
957        "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
958
959        // ── Filesystem ────────────────────────────────────────────────────
960        "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
961
962        // ── Processes ────────────────────────────────────────────────────
963        "ListProcesses" => Ok(processes::list_processes_spec()),
964        "SignalProcess" => {
965            // pid may arrive as a JSON number or a numeric string.
966            let pid = params
967                .get("pid")
968                .and_then(|v| {
969                    v.as_u64()
970                        .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
971                })
972                .ok_or(ExecutorError::MissingParam("pid"))?;
973            // Reject pid 0 (whole process group) and 1 (init/systemd); anything
974            // outside the u32 pid space is invalid.
975            if pid < 2 || pid > u32::MAX as u64 {
976                return Err(ExecutorError::InvalidParam("pid"));
977            }
978            let signal = validated_kill_signal(
979                params
980                    .get("signal")
981                    .and_then(|v| v.as_str())
982                    .unwrap_or("TERM"),
983            )?;
984            Ok(processes::signal_process(pid as u32, signal))
985        }
986
987        // ── Journald ──────────────────────────────────────────────────────
988        "GetJournalLog" => {
989            let unit = optional_validated(params, "unit", validated_unit_name)?;
990            let priority = optional_validated(params, "priority", validated_journal_priority)?;
991            let since = optional_validated(params, "since", validated_journal_time)?;
992            let until = optional_validated(params, "until", validated_journal_time)?;
993            let grep = optional_validated(params, "grep", validated_journal_grep)?;
994            // `lines` defaults to 100 and is clamped so an enormous value cannot
995            // make the daemon buffer an unbounded journal dump.
996            let lines = params
997                .get("lines")
998                .and_then(|v| v.as_u64())
999                .unwrap_or(100)
1000                .clamp(1, MAX_JOURNAL_LINES) as u32;
1001            let boot = params
1002                .get("boot")
1003                .and_then(|v| v.as_bool())
1004                .unwrap_or(false);
1005            let kernel = params
1006                .get("kernel")
1007                .and_then(|v| v.as_bool())
1008                .unwrap_or(false);
1009            Ok(journald::get_journal_log(&journald::JournalQuery {
1010                lines,
1011                unit: unit.as_deref(),
1012                priority: priority.as_deref(),
1013                boot,
1014                kernel,
1015                since: since.as_deref(),
1016                until: until.as_deref(),
1017                grep: grep.as_deref(),
1018            }))
1019        }
1020        "VacuumJournal" => {
1021            // Exactly one of size_mb / retain_days selects the vacuum mode.
1022            let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1023            let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1024            match (size_mb, retain_days) {
1025                (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1026                    Ok(journald::vacuum_journal_by_size(mb as u32))
1027                }
1028                (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1029                    Ok(journald::vacuum_journal_by_time(days as u32))
1030                }
1031                (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1032                // both supplied, or an out-of-range value
1033                _ => Err(ExecutorError::InvalidParam("size_mb")),
1034            }
1035        }
1036
1037        // ── Storage / LVM ───────────────────────────────────────────────────
1038        "GetLvmReport" => Ok(lvm::get_lvm_report()),
1039        "ExtendLogicalVolume" => {
1040            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1041            let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1042            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1043            Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1044        }
1045        "CreateLogicalVolume" => {
1046            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1047            let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1048            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1049            Ok(lvm::create_logical_volume(&vg, &name, &size))
1050        }
1051        "CreateLvSnapshot" => {
1052            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1053            let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1054            let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1055            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1056            Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1057        }
1058
1059        // ── Kernel / sysctl ─────────────────────────────────────────────────
1060        "GetSysctl" => {
1061            // `key` is optional — absent means dump the whole table (sysctl -a).
1062            let key = optional_validated(params, "key", validated_sysctl_key)?;
1063            Ok(sysctl::get_sysctl(key.as_deref()))
1064        }
1065        "SetSysctl" => {
1066            let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1067            let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1068            Ok(sysctl::set_sysctl(&key, &value))
1069        }
1070
1071        // ── Filesystem mounts / swap ────────────────────────────────────────
1072        "GetMounts" => Ok(mounts::get_mounts()),
1073        "AddMount" => {
1074            let device = validated_mount_device(require_str(params, "device")?, "device")?;
1075            let mountpoint =
1076                validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1077            let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1078            let options = optional_validated(params, "options", validated_mount_options)?;
1079            Ok(mounts::add_mount(
1080                &device,
1081                &mountpoint,
1082                &fstype,
1083                options.as_deref(),
1084            ))
1085        }
1086        "RemoveMount" => {
1087            let mountpoint =
1088                validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1089            Ok(mounts::remove_mount(&mountpoint))
1090        }
1091        "AddSwap" => {
1092            let file = validated_swap_path(require_str(params, "file")?, "file")?;
1093            let size_mb = require_u32(params, "size_mb")?;
1094            // 1 MiB .. 1 TiB — reject 0 (empty) and absurdly large requests.
1095            if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1096                return Err(ExecutorError::InvalidParam("size_mb"));
1097            }
1098            Ok(mounts::add_swap(&file, size_mb))
1099        }
1100        "RemoveSwap" => {
1101            let file = validated_swap_path(require_str(params, "file")?, "file")?;
1102            Ok(mounts::remove_swap(&file))
1103        }
1104
1105        // ── Scoped sudoers.d ────────────────────────────────────────────────
1106        "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1107        "GrantSudoAccess" => {
1108            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1109            let user = validated_username(require_str(params, "user")?, "user")?;
1110            let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1111            // runas defaults to root; if given it must be "ALL" or a username.
1112            let runas = match params
1113                .get("runas")
1114                .and_then(|v| v.as_str())
1115                .filter(|s| !s.is_empty())
1116            {
1117                Some("ALL") => Some("ALL".to_string()),
1118                Some(u) => Some(validated_username(u, "runas")?),
1119                None => None,
1120            };
1121            let nopasswd = params
1122                .get("nopasswd")
1123                .and_then(|v| v.as_bool())
1124                .unwrap_or(false);
1125            // `commands = "ALL"` together with `nopasswd` mints a standing,
1126            // passwordless, unrestricted root credential (`user ALL=(ALL)
1127            // NOPASSWD: ALL`). Unlike every other action here, the effect
1128            // outlives the job: it is a permanent privilege grant, not a
1129            // one-time change, and it is indistinguishable from the daemon's
1130            // own authority thereafter. Require the caller to give up one of
1131            // the two dimensions — scope the commands, or keep the password
1132            // prompt. `visudo` would happily accept the combination, so this
1133            // is the only place it can be refused.
1134            if commands == "ALL" && nopasswd {
1135                return Err(ExecutorError::InvalidParam("commands"));
1136            }
1137            Ok(sudoers::grant_sudo_access(
1138                &name,
1139                &user,
1140                &commands,
1141                runas.as_deref(),
1142                nopasswd,
1143            ))
1144        }
1145        "RevokeSudoAccess" => {
1146            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1147            Ok(sudoers::revoke_sudo_access(&name))
1148        }
1149
1150        // ── apt pinning (preferences.d) ─────────────────────────────────────
1151        "GetAptPins" => {
1152            let package = optional_validated(params, "package", validated_apt_package)?;
1153            Ok(apt_preferences::get_apt_pins(package.as_deref()))
1154        }
1155        "SetAptPin" => {
1156            let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1157            let package = validated_apt_package(require_str(params, "package")?, "package")?;
1158            let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1159            let priority = params
1160                .get("priority")
1161                .and_then(|v| v.as_i64())
1162                .ok_or(ExecutorError::MissingParam("priority"))?;
1163            if !(-1..=1000).contains(&priority) {
1164                return Err(ExecutorError::InvalidParam("priority"));
1165            }
1166            Ok(apt_preferences::set_apt_pin(
1167                &name, &package, &pin, priority,
1168            ))
1169        }
1170        "RemoveAptPin" => {
1171            let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1172            Ok(apt_preferences::remove_apt_pin(&name))
1173        }
1174
1175        // ── Log management (logrotate + rsyslog) ────────────────────────────
1176        "GetLogrotateStatus" => {
1177            let config = optional_validated(params, "config", validated_log_path)?;
1178            Ok(logging::get_logrotate_status(config.as_deref()))
1179        }
1180        "ConfigureLogRotation" => {
1181            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1182            let path = validated_log_path(require_str(params, "path")?, "path")?;
1183            let frequency = require_str(params, "frequency")?;
1184            if !matches!(frequency, "daily" | "weekly" | "monthly") {
1185                return Err(ExecutorError::InvalidParam("frequency"));
1186            }
1187            let rotate = require_u32(params, "rotate")?;
1188            if rotate > 1000 {
1189                return Err(ExecutorError::InvalidParam("rotate"));
1190            }
1191            let compress = params
1192                .get("compress")
1193                .and_then(|v| v.as_bool())
1194                .unwrap_or(false);
1195            Ok(logging::configure_log_rotation(
1196                &name, &path, frequency, rotate, compress,
1197            ))
1198        }
1199        "RemoveLogRotation" => {
1200            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1201            Ok(logging::remove_log_rotation(&name))
1202        }
1203        "ConfigureRemoteSyslog" => {
1204            let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1205            let port = require_u32(params, "port")?;
1206            if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1207                return Err(ExecutorError::InvalidParam("port"));
1208            }
1209            let protocol = require_str(params, "protocol")?;
1210            if !matches!(protocol, "tcp" | "udp") {
1211                return Err(ExecutorError::InvalidParam("protocol"));
1212            }
1213            Ok(logging::configure_remote_syslog(
1214                &host,
1215                port as u16,
1216                protocol,
1217            ))
1218        }
1219        "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1220
1221        // ── PAM password policy ───────────────────────────────────────────
1222        "GetPasswordAging" => {
1223            let user = validated_username(require_str(params, "user")?, "user")?;
1224            Ok(pam::get_password_aging(&user))
1225        }
1226        "SetPasswordAging" => {
1227            let user = validated_username(require_str(params, "user")?, "user")?;
1228            // chage days: 0..=99999 (chage's own ceiling). At least one required.
1229            let mut flags = Vec::new();
1230            for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1231                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1232                    if n > MAX_PASSWORD_AGE_DAYS {
1233                        return Err(ExecutorError::InvalidParam(key));
1234                    }
1235                    flags.push(opt.to_string());
1236                    flags.push(n.to_string());
1237                }
1238            }
1239            if flags.is_empty() {
1240                return Err(ExecutorError::MissingParam("max_days"));
1241            }
1242            Ok(pam::set_password_aging(&user, &flags))
1243        }
1244        "SetPasswordPolicy" => {
1245            // minlen 1..=128; *credit knobs -64..=64 (negative = require that
1246            // many chars of the class). At least one required.
1247            let mut extra = Vec::new();
1248            if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1249                if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1250                    return Err(ExecutorError::InvalidParam("minlen"));
1251                }
1252                extra.push("--minlen".to_string());
1253                extra.push(n.to_string());
1254            }
1255            for (key, flag) in [
1256                ("dcredit", "--dcredit"),
1257                ("ucredit", "--ucredit"),
1258                ("lcredit", "--lcredit"),
1259                ("ocredit", "--ocredit"),
1260            ] {
1261                if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1262                    if !(-64..=64).contains(&n) {
1263                        return Err(ExecutorError::InvalidParam(key));
1264                    }
1265                    extra.push(flag.to_string());
1266                    extra.push(n.to_string());
1267                }
1268            }
1269            if extra.is_empty() {
1270                return Err(ExecutorError::MissingParam("minlen"));
1271            }
1272            Ok(pam::set_password_policy(&extra))
1273        }
1274        "SetAccountLockout" => {
1275            // deny 1..=1000; unlock_time/fail_interval seconds 0..=604800 (7d).
1276            let mut extra = Vec::new();
1277            if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1278                if !(1..=1000).contains(&n) {
1279                    return Err(ExecutorError::InvalidParam("deny"));
1280                }
1281                extra.push("--deny".to_string());
1282                extra.push(n.to_string());
1283            }
1284            for (key, flag) in [
1285                ("unlock_time", "--unlock-time"),
1286                ("fail_interval", "--fail-interval"),
1287            ] {
1288                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1289                    if n > MAX_LOCKOUT_WINDOW_SECS {
1290                        return Err(ExecutorError::InvalidParam(key));
1291                    }
1292                    extra.push(flag.to_string());
1293                    extra.push(n.to_string());
1294                }
1295            }
1296            if extra.is_empty() {
1297                return Err(ExecutorError::MissingParam("deny"));
1298            }
1299            Ok(pam::set_account_lockout(&extra))
1300        }
1301
1302        // ── System info ──────────────────────────────────────────────────
1303        "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1304        "GetHostState" => Ok(system_info::get_host_state()),
1305
1306        // ── Network ───────────────────────────────────────────────────────
1307        "GetFirewallState" => Ok(network::get_firewall_state()),
1308        "GetNetworkStatus" => Ok(network::get_network_status()),
1309        "GetListeningPorts" => Ok(network::get_listening_ports()),
1310        "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1311        "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1312        "ConfigureWifi" => {
1313            let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1314            // password is optional — open networks connect without one.
1315            let password = params
1316                .get("password")
1317                .and_then(|v| v.as_str())
1318                .filter(|p| !p.is_empty())
1319                .map(|p| validated_safe_arg(p, "password"))
1320                .transpose()?;
1321            Ok(network::configure_wifi(&ssid, password.as_deref()))
1322        }
1323        "SetDnsServers" => {
1324            let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1325            let servers = str_array_or_empty(params, "servers")?;
1326            let validated: Vec<String> = servers
1327                .iter()
1328                .map(|s| validated_safe_arg(s, "servers"))
1329                .collect::<Result<_, _>>()?;
1330            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1331            Ok(network::set_dns_servers(&interface, &refs))
1332        }
1333        "ConfigureFirewall" => {
1334            let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1335            let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1336            Ok(network::configure_firewall(
1337                &zone,
1338                &service,
1339                require_bool(params, "enabled")?,
1340            ))
1341        }
1342
1343        // ── Users ─────────────────────────────────────────────────────────
1344        "ListUsers" => Ok(users::list_users()),
1345        "ListGroups" => Ok(users::list_groups()),
1346        "CreateUser" => {
1347            let username = validated_username(resolve_username(params)?, "username")?;
1348            let shell = params
1349                .get("shell")
1350                .and_then(|v| v.as_str())
1351                .map(|s| validated_safe_arg(s, "shell"))
1352                .transpose()?;
1353            let home = params
1354                .get("home")
1355                .and_then(|v| v.as_str())
1356                .map(|h| validated_home_dir(h, "home"))
1357                .transpose()?;
1358            Ok(users::create_user(
1359                &username,
1360                shell.as_deref(),
1361                home.as_deref(),
1362            ))
1363        }
1364        "DeleteUser" => {
1365            let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1366            Ok(users::delete_user(&username))
1367        }
1368        "AddUserToGroup" => {
1369            let username = validated_username(resolve_username(params)?, "username")?;
1370            let group = validated_group(require_str(params, "group")?, "group")?;
1371            Ok(users::add_user_to_group(&username, &group))
1372        }
1373        "RemoveUserFromGroup" => {
1374            let username = validated_username(resolve_username(params)?, "username")?;
1375            let group = validated_group(require_str(params, "group")?, "group")?;
1376            Ok(users::remove_user_from_group(&username, &group))
1377        }
1378        "CreateGroup" => {
1379            let group = validated_group(require_str(params, "group")?, "group")?;
1380            let system = params
1381                .get("system")
1382                .and_then(|v| v.as_bool())
1383                .unwrap_or(false);
1384            Ok(users::create_group(&group, system))
1385        }
1386        "DeleteGroup" => {
1387            let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1388            Ok(users::delete_group(&group))
1389        }
1390        "LockUserAccount" => {
1391            let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1392            Ok(users::lock_user_account(&username))
1393        }
1394        "UnlockUserAccount" => {
1395            let username = validated_username(resolve_username(params)?, "username")?;
1396            Ok(users::unlock_user_account(&username))
1397        }
1398
1399        // ── SSH ──────────────────────────────────────────────────────────
1400        "GetAuthorizedKeys" => {
1401            let username = validated_username(resolve_username(params)?, "username")?;
1402            Ok(ssh::get_authorized_keys(&username))
1403        }
1404        "AddAuthorizedKey" => {
1405            let username = validated_username(resolve_username(params)?, "username")?;
1406            let public_key = validated_public_key(require_str(params, "public_key")?)?;
1407            Ok(ssh::add_authorized_key(&username, &public_key))
1408        }
1409        "RemoveAuthorizedKey" => {
1410            let username = validated_username(resolve_username(params)?, "username")?;
1411            let public_key = validated_public_key(require_str(params, "public_key")?)?;
1412            Ok(ssh::remove_authorized_key(&username, &public_key))
1413        }
1414        "SetSshdOption" => {
1415            // Allowlist the option name and its permitted values (the helper
1416            // re-validates as defense-in-depth). This is deliberately not an
1417            // arbitrary sshd_config editor.
1418            let option = require_str(params, "option")?;
1419            let value = require_str(params, "value")?;
1420            let allowed_values: &[&str] = match option {
1421                "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1422                "PasswordAuthentication"
1423                | "PubkeyAuthentication"
1424                | "X11Forwarding"
1425                | "PermitEmptyPasswords" => &["yes", "no"],
1426                _ => return Err(ExecutorError::InvalidParam("option")),
1427            };
1428            if !allowed_values.contains(&value) {
1429                return Err(ExecutorError::InvalidParam("value"));
1430            }
1431            Ok(ssh::set_sshd_option(option, value))
1432        }
1433
1434        // ── apt ──────────────────────────────────────────────────────────
1435        "AptUpdate" => Ok(apt::apt_update()),
1436        "AptUpgrade" => Ok(apt::apt_upgrade()),
1437        "AptInstall" => {
1438            let package = validated_install_package(require_str(params, "package")?, "package")?;
1439            Ok(apt::apt_install(&package))
1440        }
1441        "AptRemove" => {
1442            let package = validated_install_package(require_str(params, "package")?, "package")?;
1443            Ok(apt::apt_remove(&package))
1444        }
1445        "AptPurge" => {
1446            let package = validated_install_package(require_str(params, "package")?, "package")?;
1447            Ok(apt::apt_purge(&package))
1448        }
1449        "AptAutoremove" => Ok(apt::apt_autoremove()),
1450        "AptHold" => {
1451            let package = validated_install_package(require_str(params, "package")?, "package")?;
1452            Ok(apt::apt_hold(&package))
1453        }
1454        "AptUnhold" => {
1455            let package = validated_install_package(require_str(params, "package")?, "package")?;
1456            Ok(apt::apt_unhold(&package))
1457        }
1458        "AptSearch" => {
1459            let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1460            Ok(apt::apt_search(&term))
1461        }
1462        "AptListInstalled" => Ok(apt::apt_list_installed()),
1463        "AptShow" => {
1464            let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1465            Ok(apt::apt_show(&package))
1466        }
1467        "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1468        "AptHistoryList" => Ok(apt::apt_history_list()),
1469        "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1470            params, "enabled",
1471        )?)),
1472
1473        // ── ppa ──────────────────────────────────────────────────────────
1474        "AddPpa" => {
1475            let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1476            Ok(ppa::add_ppa(&name))
1477        }
1478        "RemovePpa" => {
1479            let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1480            Ok(ppa::remove_ppa(&name))
1481        }
1482
1483        // ── snap ─────────────────────────────────────────────────────────
1484        "SnapInstall" => {
1485            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1486            let channel = params
1487                .get("channel")
1488                .and_then(|v| v.as_str())
1489                .filter(|s| !s.is_empty())
1490                .map(|s| validated_safe_arg(s, "channel"))
1491                .transpose()?;
1492            let auto_update = params
1493                .get("auto_update")
1494                .and_then(|v| v.as_bool())
1495                .unwrap_or(false);
1496            snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1497                snap::SnapInstallError::InvalidArg { param, .. } => {
1498                    ExecutorError::InvalidParam(param)
1499                }
1500            })
1501        }
1502        "SnapRemove" => {
1503            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1504            Ok(snap::snap_remove(&name))
1505        }
1506        "SnapRefresh" => {
1507            let name = params
1508                .get("name")
1509                .and_then(|v| v.as_str())
1510                .filter(|s| !s.is_empty())
1511                .map(|s| validated_safe_arg(s, "name"))
1512                .transpose()?;
1513            Ok(snap::snap_refresh(name.as_deref()))
1514        }
1515        "SnapHold" => {
1516            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1517            Ok(snap::snap_hold(&name))
1518        }
1519        "SnapUnhold" => {
1520            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1521            Ok(snap::snap_unhold(&name))
1522        }
1523        "SnapList" => Ok(snap::snap_list()),
1524        "SnapInfo" => {
1525            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1526            Ok(snap::snap_info(&name))
1527        }
1528        "SnapRevert" => {
1529            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1530            Ok(snap::snap_revert(&name))
1531        }
1532        "SnapClassicInstall" => {
1533            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1534            Ok(snap::snap_classic_install(&name))
1535        }
1536
1537        // ── grub ─────────────────────────────────────────────────────────
1538        "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1539        "GrubSetKargs" => {
1540            let append: Vec<String> = params
1541                .get("append")
1542                .and_then(|v| v.as_array())
1543                .map(|arr| {
1544                    arr.iter()
1545                        .filter_map(|v| v.as_str())
1546                        .map(String::from)
1547                        .collect()
1548                })
1549                .unwrap_or_default();
1550            let delete: Vec<String> = params
1551                .get("delete")
1552                .and_then(|v| v.as_array())
1553                .map(|arr| {
1554                    arr.iter()
1555                        .filter_map(|v| v.as_str())
1556                        .map(String::from)
1557                        .collect()
1558                })
1559                .unwrap_or_default();
1560            // Validate each arg in both lists. `append` gets the charset check
1561            // (rejects `=`, `,`, shell metacharacters — the latter also blocks
1562            // CSV injection into the helper's comma-separated list) *and* the
1563            // kernel-arg denylist, so a bare `single`/`s`/`1` cannot boot the
1564            // host into a single-user root shell — parity with
1565            // SetKernelArguments. `delete` gets the charset check plus the
1566            // removal screen: dropping `module.sig_enforce=1` or `lockdown=`
1567            // weakens the next boot exactly as adding its counterpart would.
1568            for a in &append {
1569                validated_safe_arg(a, "append")?;
1570                validated_safe_kernel_arg(a, "append")?;
1571            }
1572            for d in &delete {
1573                validated_safe_arg(d, "delete")?;
1574                validated_safe_kernel_arg_removal(d, "delete")?;
1575            }
1576            // The constructor itself enforces "at least one of append/delete
1577            // non-empty" — this is the single source of truth for the invariant.
1578            let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1579            let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1580            grub::grub_set_kargs(&append_refs, &delete_refs)
1581                .map_err(|_| ExecutorError::MissingParam("append or delete"))
1582        }
1583
1584        // ── reboot ────────────────────────────────────────────────────────
1585        "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1586
1587        // ── ufw ──────────────────────────────────────────────────────────
1588        "UfwEnable" => Ok(ufw::ufw_enable()),
1589        "UfwDisable" => Ok(ufw::ufw_disable()),
1590        "UfwAllow" => {
1591            let port_or_service = validated_port_or_service(
1592                require_str(params, "port_or_service")?,
1593                "port_or_service",
1594            )?;
1595            Ok(ufw::ufw_allow(&port_or_service))
1596        }
1597        "UfwDeny" => {
1598            let port_or_service = validated_port_or_service(
1599                require_str(params, "port_or_service")?,
1600                "port_or_service",
1601            )?;
1602            Ok(ufw::ufw_deny(&port_or_service))
1603        }
1604        "UfwReset" => Ok(ufw::ufw_reset()),
1605        "UfwStatus" => {
1606            let numbered = match params.get("numbered") {
1607                None => false,
1608                Some(value) => value
1609                    .as_bool()
1610                    .ok_or(ExecutorError::InvalidParam("numbered"))?,
1611            };
1612            Ok(ufw::ufw_status_with_numbered(numbered))
1613        }
1614
1615        // ── distrobox ────────────────────────────────────────────────────
1616        "DistroboxList" => Ok(distrobox::distrobox_list()),
1617        "DistroboxCreate" => {
1618            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1619            let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1620            Ok(distrobox::distrobox_create(&name, &image))
1621        }
1622        "DistroboxRemove" => {
1623            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1624            Ok(distrobox::distrobox_remove(&name))
1625        }
1626
1627        // ── netplan ──────────────────────────────────────────────────────
1628        "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1629        "NetplanApply" => Ok(netplan::netplan_apply()),
1630        "NetplanSet" => {
1631            let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1632            let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1633            Ok(netplan::netplan_set(&key, &value))
1634        }
1635        "NetplanGenerate" => Ok(netplan::netplan_generate()),
1636
1637        // ── ufw Tier 3 ────────────────────────────────────────────────────
1638        "UfwDeleteRule" => {
1639            let rule_number = require_positive_u32(params, "rule_number")?;
1640            ufw::ufw_delete_rule(rule_number)
1641                .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1642        }
1643        "UfwLimit" => {
1644            let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1645            Ok(ufw::ufw_limit(&target))
1646        }
1647
1648        // ── Ubuntu Pro ────────────────────────────────────────────────────
1649        "ProStatus" => Ok(ubuntu_pro::pro_status()),
1650        "ProAttach" => {
1651            // token is a credential: read it from params but do NOT log it.
1652            let token = require_str(params, "token")?;
1653            // Minimal structural validation: non-empty, no shell metacharacters.
1654            let token = validated_safe_arg(token, "token")?;
1655            Ok(ubuntu_pro::pro_attach(&token))
1656        }
1657        "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1658        "EnableProService" => {
1659            let service = validated_pro_service(require_str(params, "service")?, "service")?;
1660            Ok(ubuntu_pro::enable_pro_service(&service))
1661        }
1662        "DisableProService" => {
1663            let service = validated_pro_service(require_str(params, "service")?, "service")?;
1664            Ok(ubuntu_pro::disable_pro_service(&service))
1665        }
1666
1667        // ── Livepatch ─────────────────────────────────────────────────────
1668        "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1669
1670        // ── Multipass ─────────────────────────────────────────────────────
1671        "MultipassList" => Ok(multipass::multipass_list()),
1672
1673        // ── Release upgrade ───────────────────────────────────────────────
1674        "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1675
1676        // ── resolvectl (cross-distro / systemd-resolved) ──────────────────
1677        "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1678        "ResolvectlSetDns" => {
1679            let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1680            let raw_servers: Vec<String> = params
1681                .get("servers")
1682                .and_then(|v| v.as_array())
1683                .map(|arr| {
1684                    arr.iter()
1685                        .filter_map(|v| v.as_str())
1686                        .map(String::from)
1687                        .collect()
1688                })
1689                .unwrap_or_default();
1690            if raw_servers.is_empty() {
1691                return Err(ExecutorError::MissingParam("servers"));
1692            }
1693            // Parse every server string as a typed IpAddr before passing to the
1694            // constructor. This rejects leading-dash strings (flag injection) and
1695            // malformed addresses that would silently misconfigure systemd-resolved.
1696            let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1697            for s in &raw_servers {
1698                let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1699                    param: "servers",
1700                    value: s.clone(),
1701                })?;
1702                parsed_servers.push(addr);
1703            }
1704            Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1705        }
1706
1707        // ── apparmor ──────────────────────────────────────────────────────
1708        "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1709        "AppArmorEnforce" => {
1710            let profile_path =
1711                validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1712            Ok(apparmor::apparmor_enforce(&profile_path))
1713        }
1714        "AppArmorComplain" => {
1715            let profile_path =
1716                validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1717            Ok(apparmor::apparmor_complain(&profile_path))
1718        }
1719
1720        // ── cloud-init ────────────────────────────────────────────────────
1721        "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1722
1723        // ── Ubuntu Flatpak ─────────────────────────────────────────────────
1724        "UbuntuInstallFlatpak" => {
1725            let username = validated_username(resolve_username(params)?, "username")?;
1726            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1727            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1728            Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1729        }
1730        "UbuntuRemoveFlatpak" => {
1731            let username = validated_username(resolve_username(params)?, "username")?;
1732            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1733            Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1734        }
1735        "UbuntuUpdateFlatpak" => {
1736            let username = validated_username(resolve_username(params)?, "username")?;
1737            let app_id = params
1738                .get("app_id")
1739                .and_then(|v| v.as_str())
1740                .filter(|s| !s.is_empty())
1741                .map(|s| validated_safe_arg(s, "app_id"))
1742                .transpose()?;
1743            Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1744        }
1745        "UbuntuListFlatpaks" => {
1746            let username = validated_username(resolve_username(params)?, "username")?;
1747            Ok(flatpak::ubuntu_list_flatpaks(&username))
1748        }
1749
1750        // ── fail2ban ──────────────────────────────────────────────────────
1751        "Fail2banStatus" => {
1752            let jail = params
1753                .get("jail")
1754                .and_then(|v| v.as_str())
1755                .filter(|s| !s.is_empty())
1756                .map(|s| validated_safe_arg(s, "jail"))
1757                .transpose()?;
1758            fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1759                fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1760                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1761            })
1762        }
1763        "Fail2banBanIp" => {
1764            let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1765            let ip = require_str(params, "ip")?;
1766            fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1767                fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1768                    param: "ip",
1769                    value: v,
1770                },
1771                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1772            })
1773        }
1774        "Fail2banUnbanIp" => {
1775            let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1776            let ip = require_str(params, "ip")?;
1777            fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1778                fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1779                    param: "ip",
1780                    value: v,
1781                },
1782                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1783            })
1784        }
1785        "ConfigureFail2banJail" => {
1786            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1787            let mut extra = Vec::new();
1788            if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1789                extra.push("--enabled".to_string());
1790                extra.push(if n { "true" } else { "false" }.to_string());
1791            }
1792            if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1793                if !(1..=100).contains(&n) {
1794                    return Err(ExecutorError::InvalidParam("maxretry"));
1795                }
1796                extra.push("--maxretry".to_string());
1797                extra.push(n.to_string());
1798            }
1799            for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1800                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1801                    if n > MAX_FAIL2BAN_WINDOW_SECS {
1802                        return Err(ExecutorError::InvalidParam(key));
1803                    }
1804                    extra.push(flag.to_string());
1805                    extra.push(n.to_string());
1806                }
1807            }
1808            if extra.is_empty() {
1809                return Err(ExecutorError::MissingParam("maxretry"));
1810            }
1811            fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
1812                fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
1813                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
1814            })
1815        }
1816
1817        // ── auditd file-watch rules ───────────────────────────────────────
1818        "GetAuditRules" => Ok(auditd::get_audit_rules()),
1819        "AddAuditRule" => {
1820            let path = validated_audit_path(require_str(params, "path")?, "path")?;
1821            let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1822            let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1823            Ok(auditd::add_audit_rule(&path, &perms, &key))
1824        }
1825        "RemoveAuditRule" => {
1826            let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1827            Ok(auditd::remove_audit_rule(&key))
1828        }
1829
1830        // ── certbot / ACME ────────────────────────────────────────────────
1831        "GetCertificates" => Ok(certbot::get_certificates()),
1832        "ObtainCertificate" => {
1833            // Accept a "domains" array or a single "domain" string; ≥1 required.
1834            let mut domains = Vec::new();
1835            if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1836                for d in arr {
1837                    let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1838                    domains.push(validated_domain(s, "domains")?);
1839                }
1840            } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1841                domains.push(validated_domain(d, "domain")?);
1842            }
1843            if domains.is_empty() {
1844                return Err(ExecutorError::MissingParam("domains"));
1845            }
1846            let email = validated_email(require_str(params, "email")?, "email")?;
1847            let challenge = params
1848                .get("challenge")
1849                .and_then(|v| v.as_str())
1850                .unwrap_or("standalone");
1851            if !matches!(challenge, "standalone" | "nginx" | "apache") {
1852                return Err(ExecutorError::InvalidParam("challenge"));
1853            }
1854            Ok(certbot::obtain_certificate(&domains, &email, challenge))
1855        }
1856        "RenewCertificates" => Ok(certbot::renew_certificates()),
1857
1858        _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1859    }
1860}
1861
1862/// Execute an [`ActionSpec`] and return the output.
1863///
1864/// For `Command` mechanisms, the process is spawned and its stdout/stderr
1865/// are captured. For file mechanisms, the operation is performed directly
1866/// on the filesystem and an empty stdout is returned.
1867pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1868    match &spec.mechanism {
1869        ActionMechanism::Command { program, args } => {
1870            // Spawned rather than `.output()`ed so the child can be killed on
1871            // timeout; `.output()` gives no handle to kill, so a hung process
1872            // would hold the task forever.
1873            let mut child = tokio::process::Command::new(program)
1874                .args(args)
1875                .stdin(Stdio::null())
1876                // Own process group: the deadline below must be able to stop the
1877                // whole tree, not just the pid we spawned. See issue #140.
1878                .process_group(0)
1879                .stdout(Stdio::piped())
1880                .stderr(Stdio::piped())
1881                .spawn()?;
1882
1883            // `wait_with_output` consumes the child, which would leave nothing
1884            // to kill when the deadline fires. Drain the pipes in their own
1885            // tasks instead — draining also stops a chatty process from
1886            // blocking on a full pipe buffer while we wait.
1887            let stdout_h = child.stdout.take().expect("stdout was piped");
1888            let stderr_h = child.stderr.take().expect("stderr was piped");
1889            let stdout_task = tokio::spawn(async move {
1890                let mut buf = Vec::new();
1891                BufReader::new(stdout_h)
1892                    .read_to_end(&mut buf)
1893                    .await
1894                    .map(|_| buf)
1895            });
1896            let stderr_task = tokio::spawn(async move {
1897                let mut buf = Vec::new();
1898                BufReader::new(stderr_h)
1899                    .read_to_end(&mut buf)
1900                    .await
1901                    .map(|_| buf)
1902            });
1903
1904            let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
1905                Ok(Ok(status)) => status,
1906                // Same rule as the streaming path: a `wait` that fails leaves a
1907                // privileged child in an unknown state, so stop the group before
1908                // reporting the failure upward.
1909                Ok(Err(err)) => {
1910                    return Err(kill_and_reap(
1911                        &mut child,
1912                        program,
1913                        args,
1914                        StopCause::Unreadable(err),
1915                    )
1916                    .await)
1917                }
1918                Err(_) => {
1919                    return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
1920                }
1921            };
1922            let join =
1923                |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
1924            let stdout = stdout_task.await.map_err(join)??;
1925            let stderr = stderr_task.await.map_err(join)??;
1926
1927            Ok(ExecutionOutput {
1928                stdout: String::from_utf8_lossy(&stdout).into_owned(),
1929                stderr: String::from_utf8_lossy(&stderr).into_owned(),
1930                exit_code: status.code().unwrap_or(-1),
1931            })
1932        }
1933        ActionMechanism::FileScan { path } => {
1934            let mut entries = tokio::fs::read_dir(path).await?;
1935            let mut names = Vec::new();
1936            while let Some(entry) = entries.next_entry().await? {
1937                names.push(entry.file_name().to_string_lossy().into_owned());
1938            }
1939            names.sort();
1940            Ok(ExecutionOutput {
1941                stdout: names.join("\n"),
1942                stderr: String::new(),
1943                exit_code: 0,
1944            })
1945        }
1946        ActionMechanism::FileWrite { path, content } => {
1947            if let Some(parent) = std::path::Path::new(path).parent() {
1948                tokio::fs::create_dir_all(parent).await?;
1949            }
1950            tokio::fs::write(path, content).await?;
1951            Ok(ExecutionOutput {
1952                stdout: String::new(),
1953                stderr: String::new(),
1954                exit_code: 0,
1955            })
1956        }
1957        ActionMechanism::FilePatch {
1958            path,
1959            search,
1960            replace,
1961        } => {
1962            let content = tokio::fs::read_to_string(path).await?;
1963            let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1964            if patched == content && !search.is_empty() {
1965                return Ok(ExecutionOutput {
1966                    stdout: String::new(),
1967                    stderr: format!("search string not found in file: {}", path),
1968                    exit_code: 1,
1969                });
1970            }
1971            tokio::fs::write(path, patched).await?;
1972            Ok(ExecutionOutput {
1973                stdout: String::new(),
1974                stderr: String::new(),
1975                exit_code: 0,
1976            })
1977        }
1978        ActionMechanism::FileDelete { path } => {
1979            tokio::fs::remove_file(path).await?;
1980            Ok(ExecutionOutput {
1981                stdout: String::new(),
1982                stderr: String::new(),
1983                exit_code: 0,
1984            })
1985        }
1986    }
1987}
1988
1989fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
1990    match params.get(key) {
1991        None => Err(ExecutorError::MissingParam(key)),
1992        Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
1993    }
1994}
1995
1996/// Extract an optional string param and validate it. An absent key or an empty
1997/// string yields `None` (the filter is simply omitted); a present non-empty
1998/// value is passed through `validator`, propagating any validation error.
1999fn optional_validated<F>(
2000    params: &Value,
2001    key: &'static str,
2002    validator: F,
2003) -> Result<Option<String>, ExecutorError>
2004where
2005    F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2006{
2007    match params
2008        .get(key)
2009        .and_then(|v| v.as_str())
2010        .filter(|s| !s.is_empty())
2011    {
2012        Some(s) => Ok(Some(validator(s, key)?)),
2013        None => Ok(None),
2014    }
2015}
2016
2017/// Extract the username from params, accepting either `"username"` or `"user"`
2018/// as the key.  The `"username"` key takes precedence.
2019///
2020/// Tolerates the `"user"` alias because LLMs trained on general Linux tooling
2021/// frequently produce `"user"` — accepting both here eliminates an entire class
2022/// of Describe/Execute failures without requiring the model to be perfect.
2023///
2024/// Returns [`ExecutorError::MissingParam`] if neither key is present,
2025/// [`ExecutorError::InvalidParam`] if the value is not a string.
2026fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2027    params
2028        .get("username")
2029        .or_else(|| params.get("user"))
2030        .ok_or(ExecutorError::MissingParam("username"))
2031        .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2032}
2033
2034/// Validate a repo_id: must be non-empty and contain only ASCII letters,
2035/// digits, hyphens, and underscores. Rejects `/`, `.`, and whitespace to
2036/// prevent path traversal (e.g. `../cron.d/evil`) and shell injection.
2037fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2038    let id = require_str(params, "repo_id")?;
2039    let valid = !id.is_empty()
2040        && id
2041            .chars()
2042            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2043    if valid {
2044        Ok(id)
2045    } else {
2046        Err(ExecutorError::InvalidParam("repo_id"))
2047    }
2048}
2049
2050/// Validate that a string contains no newlines. Used for repo_url to prevent
2051/// INI-section injection into `.repo` file content.
2052fn validated_no_newline<'a>(
2053    params: &'a Value,
2054    key: &'static str,
2055) -> Result<&'a str, ExecutorError> {
2056    let val = require_str(params, key)?;
2057    if val.contains('\n') || val.contains('\r') {
2058        Err(ExecutorError::InvalidParam(key))
2059    } else {
2060        Ok(val)
2061    }
2062}
2063
2064/// Validate an SSH public key: must start with a known key-type prefix,
2065/// contain only printable ASCII, no newlines, no single quotes (to prevent
2066/// shell injection in `sh -c` scripts), and be at most 8192 characters.
2067fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2068    const MAX_LEN: usize = 8192;
2069    const ALLOWED_PREFIXES: &[&str] = &[
2070        "ssh-rsa",
2071        "ssh-ed25519",
2072        "ssh-ed25519-sk",
2073        "ecdsa-sha2-nistp256",
2074        "ecdsa-sha2-nistp384",
2075        "ecdsa-sha2-nistp521",
2076        "sk-ssh-ed25519",
2077        "sk-ecdsa-sha2-nistp256",
2078    ];
2079
2080    if s.is_empty() || s.len() > MAX_LEN {
2081        return Err(ExecutorError::InvalidParam("public_key"));
2082    }
2083    if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2084        return Err(ExecutorError::InvalidParam("public_key"));
2085    }
2086    // No newlines, no shell metacharacters, only printable ASCII.
2087    //
2088    // Blocked characters and why:
2089    //   '\''  — breaks single-quoted shell strings in add_authorized_key
2090    //   '|'   — shell pipe (the ssh key ops no longer build a sed address,
2091    //           but '|' never appears in a valid key, so keep rejecting it)
2092    //   ';'   — shell command separator
2093    //   '`'   — shell command substitution
2094    //   '$'   — shell variable expansion
2095    //   '\\'  — shell escape; could be used to smuggle other metacharacters
2096    //   '&'   — shell background / AND operator
2097    //
2098    // None of these characters appear in valid SSH public key data (type prefix,
2099    // base64 body, or ASCII comment) so this list is safe to block unconditionally.
2100    if s.chars().any(|c| {
2101        matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2102            || !c.is_ascii()
2103            || c.is_ascii_control()
2104    }) {
2105        return Err(ExecutorError::InvalidParam("public_key"));
2106    }
2107    Ok(s.to_string())
2108}
2109
2110fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2111    match params.get(key) {
2112        None => Err(ExecutorError::MissingParam(key)),
2113        Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2114    }
2115}
2116
2117fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2118    match params.get(key) {
2119        None => Err(ExecutorError::MissingParam(key)),
2120        Some(v) => {
2121            let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2122            u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2123        }
2124    }
2125}
2126
2127/// Like [`require_u32`] but additionally rejects zero.
2128///
2129/// Used for rule numbers and similar 1-based indices where 0 is never valid.
2130fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2131    let n = require_u32(params, key)?;
2132    if n == 0 {
2133        return Err(ExecutorError::InvalidParam(key));
2134    }
2135    Ok(n)
2136}
2137
2138/// Returns a vec of owned strings from a JSON array, or an empty vec if the
2139/// key is absent or null. Returns [`ExecutorError::InvalidParam`] if the key
2140/// is present but not an array of strings.
2141fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2142    match params.get(key) {
2143        None | Some(Value::Null) => Ok(vec![]),
2144        Some(Value::Array(arr)) => arr
2145            .iter()
2146            .map(|v| {
2147                v.as_str()
2148                    .map(String::from)
2149                    .ok_or(ExecutorError::InvalidParam(key))
2150            })
2151            .collect(),
2152        _ => Err(ExecutorError::InvalidParam(key)),
2153    }
2154}
2155
2156/// Reject kernel command-line arguments that could bypass security mechanisms
2157/// or drop to an unauthenticated root shell on next boot. Applies only to
2158/// arguments being *added* (`SetKernelArguments`'s `add`, `GrubSetKargs`'s
2159/// `append`) — removing an existing argument is always safe.
2160///
2161/// `param` names the request field being validated so the error points at the
2162/// caller's actual parameter (`"add"` for `SetKernelArguments`, `"append"` for
2163/// `GrubSetKargs`).
2164///
2165/// This is a *denylist* layered on top of the caller's charset validation —
2166/// both callers run their charset check first. In particular `GrubSetKargs`
2167/// runs [`validated_safe_arg`] (which already rejects `=` and `,`), so on that
2168/// path the load-bearing checks here are the bare runlevel shortcuts
2169/// (`single`/`s`/`1`).
2170///
2171/// Blocked (case-insensitive):
2172///
2173/// - `init=`           — replaces init, can give a root shell
2174/// - `selinux=0`       — disables SELinux
2175/// - `enforcing=0`     — sets SELinux to permissive
2176/// - `security=`       — overrides LSM module selection
2177/// - `apparmor=0`      — disables the AppArmor LSM (Ubuntu's default MAC)
2178/// - `systemd.unit=emergency` / `systemd.unit=rescue` / `systemd.unit=single`
2179///   — unprotected root shell
2180/// - `single` / `1` / `s` — single-user mode (root without password)
2181/// - `module_blacklist=` — can disable security-critical kernel modules
2182/// - `mitigations=off` — disables CPU speculative-execution vulnerability
2183///   mitigations (Spectre/Meltdown/MDS/etc.)
2184/// - `lockdown=`       — weakens or disables the kernel lockdown LSM
2185/// - `pti=off`         — disables Page Table Isolation (Meltdown mitigation)
2186/// - `nosmap` / `nosmep` — disable SMAP/SMEP, CPU features that block a large
2187///   class of kernel-exploitation techniques
2188/// - `systemd.debug_shell` — the same root shell as `systemd.unit=debug-shell`,
2189///   asked for by a different name
2190fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2191    const BLOCKED_PREFIXES: &[&str] = &[
2192        "init=",
2193        "selinux=0",
2194        "enforcing=0",
2195        "security=",
2196        "module_blacklist=",
2197        "apparmor=0",
2198        "mitigations=off",
2199        "lockdown=",
2200        "pti=off",
2201        // `systemd.unit=` is not the only way to ask for `debug-shell.service`.
2202        // systemd-debug-generator also honours `systemd.debug_shell`, which
2203        // pulls the unit into the boot transaction and spawns a root shell on
2204        // tty9 before anyone logs in — the identical end state the
2205        // `BLOCKED_UNIT_PREFIXES` screen below exists to prevent. Underscore is
2206        // the real spelling per systemd-debug-generator(8); `rd.` is the
2207        // initrd-honoured variant, and the hyphen forms are listed so a
2208        // near-miss cannot creep back.
2209        "systemd.debug_shell",
2210        "systemd.debug-shell",
2211        "rd.systemd.debug_shell",
2212        "rd.systemd.debug-shell",
2213    ];
2214    const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2215    // Shared with `validated_activatable_unit`, deliberately: these two screens
2216    // encode the same idea from opposite directions — one refuses to START a
2217    // root-shell unit, the other refuses to BOOT into it — and keeping two lists
2218    // let them drift until `debug-shell` and `runlevel1` were blocked on one path
2219    // and reachable on the other.
2220    use crate::actions::validate::ROOT_SHELL_UNITS as BLOCKED_UNIT_PREFIXES;
2221
2222    let lower = arg.to_lowercase();
2223    // Strip optional value (e.g. "quiet=1" → "quiet") for exact matches.
2224    let base = lower.split('=').next().unwrap_or(&lower);
2225
2226    if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2227        return Err(ExecutorError::InvalidParam(param));
2228    }
2229    if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2230        return Err(ExecutorError::InvalidParam(param));
2231    }
2232    // Block systemd.unit= pointing to emergency/rescue/single targets.
2233    if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2234        if BLOCKED_UNIT_PREFIXES
2235            .iter()
2236            .any(|u| unit_val.starts_with(u))
2237        {
2238            return Err(ExecutorError::InvalidParam(param));
2239        }
2240    }
2241    // Guard against the base arg matching dangerous exact values even with =.
2242    if BLOCKED_EXACT.contains(&base) {
2243        return Err(ExecutorError::InvalidParam(param));
2244    }
2245    Ok(())
2246}
2247
2248/// Validate a kernel argument that is about to be **removed** from the boot line.
2249///
2250/// [`validated_safe_kernel_arg`] screens additions. Removal was left unscreened
2251/// on the premise that "removing a dangerous arg is always safe", which inverts
2252/// the risk: the dangerous move is removing a *protective* arg. Deleting
2253/// `module.sig_enforce=1` or `lockdown=confidentiality` reaches the same end
2254/// state as adding its weakening counterpart, which the add-path refuses — so
2255/// leaving `remove` open handed back every guarantee the add-path buys.
2256///
2257/// The prefixes below are the KSPP-recommended boot parameters plus the LSM
2258/// selectors; each is a protection the host loses the moment the token leaves
2259/// the boot line. `WEAKENING` is the escape hatch that keeps the screen from
2260/// collapsing into "no removals at all": an argument that is itself a downgrade
2261/// must stay removable, or SysKnife could never harden a host that already boots
2262/// with `mitigations=off`.
2263fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2264    /// Removing one of these hardens the host, so removal stays allowed even
2265    /// though the token matches a protective prefix below.
2266    const WEAKENING: &[&str] = &[
2267        "selinux=0",
2268        "enforcing=0",
2269        "apparmor=0",
2270        "mitigations=off",
2271        "pti=off",
2272        "lockdown=none",
2273        "debugfs=on",
2274        "iommu=off",
2275        "intel_iommu=off",
2276        "module.sig_enforce=0",
2277        "init_on_alloc=0",
2278        "init_on_free=0",
2279        "randomize_kstack_offset=off",
2280        "page_alloc.shuffle=0",
2281    ];
2282    const PROTECTIVE_PREFIXES: &[&str] = &[
2283        "lockdown=",
2284        "module.sig_enforce=",
2285        "security=",
2286        "selinux=",
2287        "enforcing=",
2288        "apparmor=",
2289        "pti=",
2290        "mitigations=",
2291        "init_on_alloc=",
2292        "init_on_free=",
2293        "page_alloc.shuffle=",
2294        "randomize_kstack_offset=",
2295        "slub_debug=",
2296        "page_poison=",
2297        "vsyscall=",
2298        "debugfs=",
2299        "iommu=",
2300        "intel_iommu=",
2301        "amd_iommu=",
2302    ];
2303    const PROTECTIVE_EXACT: &[&str] = &["slab_nomerge", "nosmt", "kaslr"];
2304
2305    let lower = arg.to_lowercase();
2306    if WEAKENING.contains(&lower.as_str()) {
2307        return Ok(());
2308    }
2309    // Protective wins over the add-path inversion below. `security=` is refused
2310    // by BOTH screens: adding it picks an LSM, removing it drops the one in use.
2311    if PROTECTIVE_PREFIXES.iter().any(|p| lower.starts_with(p))
2312        || PROTECTIVE_EXACT.contains(&lower.as_str())
2313    {
2314        return Err(ExecutorError::InvalidParam(param));
2315    }
2316    // Everything else — ordinary boot arguments, and the add-path's own denylist
2317    // (`init=`, a bare `single`, an emergency `systemd.unit=`, all weakenings the
2318    // host is better off without) — stays removable.
2319    Ok(())
2320}
2321
2322/// Validate a `kill` signal against a strict allowlist, returning the canonical
2323/// signal name for `kill -s <name>`.
2324///
2325/// Only stop/reload signals are permitted (`TERM`, `KILL`, `HUP`, `INT`); this
2326/// blocks exotic or numeric signals and, combined with the caller's `pid >= 2`
2327/// check, keeps `SignalProcess` from becoming an arbitrary-signal primitive.
2328/// Accepts case-insensitive input with an optional `SIG` prefix.
2329fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2330    let normalized = s.trim().to_ascii_uppercase();
2331    match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2332        "TERM" => Ok("TERM"),
2333        "KILL" => Ok("KILL"),
2334        "HUP" => Ok("HUP"),
2335        "INT" => Ok("INT"),
2336        _ => Err(ExecutorError::InvalidParam("signal")),
2337    }
2338}
2339
2340/// Return the rollback [`ActionSpec`] for `action_name`, or `None` if no
2341/// automatic rollback is defined.
2342///
2343/// Only the rpm-ostree deployment and layering actions support rollback —
2344/// they all revert via `rpm-ostree rollback`. All other actions either have
2345/// no sensible rollback or are low-risk enough that a rollback would be
2346/// net-harmful.
2347///
2348/// `RollbackDeployment` itself is excluded to prevent infinite recursion.
2349pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2350    match action_name {
2351        "UpdateSystem"
2352        | "InstallPackages"
2353        | "RemovePackages"
2354        | "RebaseSystem"
2355        | "SetKernelArguments"
2356        | "AddLayeredPackage"
2357        | "RemoveLayeredPackage"
2358        | "ReplaceLayeredPackage"
2359        | "ResetLayeredPackageOverride"
2360        | "RemoveBasePackage" => Some(ActionSpec {
2361            action_name: "RollbackDeployment",
2362            mechanism: ActionMechanism::Command {
2363                program: "rpm-ostree",
2364                args: vec!["rollback".to_string()],
2365            },
2366            risk_level: sysknife_types::RiskLevel::High,
2367            reboot_required: true,
2368            rollback_available: false,
2369        }),
2370        _ => None,
2371    }
2372}
2373
2374#[cfg(test)]
2375mod tests {
2376    use super::*;
2377    use serde_json::json;
2378    use sysknife_types::RiskLevel;
2379    use tempfile::tempdir;
2380
2381    // ── build_action_spec ─────────────────────────────────────────────────
2382
2383    #[test]
2384    fn build_spec_no_params_for_get_system_state() {
2385        let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2386        assert_eq!(spec.action_name, "GetSystemState");
2387        assert_eq!(spec.risk_level, RiskLevel::Low);
2388    }
2389
2390    #[test]
2391    fn build_spec_get_datetime_is_low_risk() {
2392        let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2393        assert_eq!(spec.action_name, "GetDateTime");
2394        assert_eq!(spec.risk_level, RiskLevel::Low);
2395        assert!(!spec.reboot_required);
2396    }
2397
2398    #[test]
2399    fn build_spec_unknown_action_returns_error() {
2400        let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2401        assert!(
2402            matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2403            "expected UnknownAction, got: {err}"
2404        );
2405    }
2406
2407    #[test]
2408    fn build_spec_missing_param_for_install_flatpak() {
2409        // username is the first required param; its absence is reported first.
2410        let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2411        assert!(
2412            matches!(err, ExecutorError::MissingParam("username")),
2413            "expected MissingParam(username), got: {err}"
2414        );
2415    }
2416
2417    /// LLMs trained on standard Linux tooling frequently produce `"user"` instead
2418    /// of `"username"`.  `resolve_username` accepts both keys so these actions
2419    /// never fail with a spurious MissingParam.
2420    #[test]
2421    fn build_spec_flatpak_accepts_user_alias() {
2422        let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2423        assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2424    }
2425
2426    /// `resolve_username` prefers `"username"` when both keys are present.
2427    #[test]
2428    fn build_spec_resolve_username_prefers_explicit_username() {
2429        let spec = build_action_spec(
2430            "ListInstalledFlatpaks",
2431            &json!({ "username": "alice", "user": "bob" }),
2432        )
2433        .unwrap();
2434        // Verify it didn't error — the "alice" value passes validation.
2435        assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2436    }
2437
2438    /// `remote` defaults to "flathub" when absent — eliminates the most common
2439    /// model omission without changing behaviour when the param is explicit.
2440    #[test]
2441    fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2442        let spec = build_action_spec(
2443            "InstallFlatpak",
2444            &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2445        )
2446        .unwrap();
2447        assert_eq!(
2448            spec.mechanism,
2449            ActionMechanism::Command {
2450                program: "sudo",
2451                args: vec![
2452                    "/usr/lib/sysknife/action-steps".to_string(),
2453                    "flatpak".to_string(),
2454                    "alice".to_string(),
2455                    "install".to_string(),
2456                    "--user".to_string(),
2457                    "-y".to_string(),
2458                    "flathub".to_string(),
2459                    "org.mozilla.firefox".to_string(),
2460                ],
2461            }
2462        );
2463    }
2464
2465    #[test]
2466    fn build_spec_install_flatpak_injects_app_and_remote() {
2467        let spec = build_action_spec(
2468            "InstallFlatpak",
2469            &json!({
2470                "username": "alice",
2471                "app_id": "org.mozilla.firefox",
2472                "remote": "flathub"
2473            }),
2474        )
2475        .unwrap();
2476        assert_eq!(spec.action_name, "InstallFlatpak");
2477        assert_eq!(
2478            spec.mechanism,
2479            ActionMechanism::Command {
2480                program: "sudo",
2481                args: vec![
2482                    "/usr/lib/sysknife/action-steps".to_string(),
2483                    "flatpak".to_string(),
2484                    "alice".to_string(),
2485                    "install".to_string(),
2486                    "--user".to_string(),
2487                    "-y".to_string(),
2488                    "flathub".to_string(),
2489                    "org.mozilla.firefox".to_string(),
2490                ],
2491            }
2492        );
2493    }
2494
2495    #[test]
2496    fn build_spec_pin_deployment_injects_index() {
2497        let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2498        assert_eq!(
2499            spec.mechanism,
2500            ActionMechanism::Command {
2501                program: "sudo",
2502                args: vec![
2503                    "ostree".to_string(),
2504                    "admin".to_string(),
2505                    "pin".to_string(),
2506                    "1".to_string()
2507                ],
2508            }
2509        );
2510    }
2511
2512    #[test]
2513    fn build_spec_unpin_deployment_includes_unpin_flag() {
2514        let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2515        assert_eq!(
2516            spec.mechanism,
2517            ActionMechanism::Command {
2518                program: "sudo",
2519                args: vec![
2520                    "ostree".to_string(),
2521                    "admin".to_string(),
2522                    "pin".to_string(),
2523                    "--unpin".to_string(),
2524                    "2".to_string(),
2525                ],
2526            }
2527        );
2528    }
2529
2530    #[test]
2531    fn require_u32_rejects_overflow() {
2532        let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2533        assert!(
2534            matches!(err, ExecutorError::InvalidParam("index")),
2535            "expected InvalidParam(index), got: {err}"
2536        );
2537    }
2538
2539    #[test]
2540    fn build_spec_rebase_system_injects_target_ref() {
2541        let spec = build_action_spec(
2542            "RebaseSystem",
2543            &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
2544        )
2545        .unwrap();
2546        assert_eq!(
2547            spec.mechanism,
2548            ActionMechanism::Command {
2549                program: "sudo",
2550                args: vec![
2551                    "rpm-ostree".to_string(),
2552                    "rebase".to_string(),
2553                    "fedora/41/x86_64/silverblue".to_string(),
2554                ],
2555            }
2556        );
2557    }
2558
2559    #[test]
2560    fn build_spec_set_kernel_arguments_appends_and_deletes() {
2561        let spec = build_action_spec(
2562            "SetKernelArguments",
2563            &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
2564        )
2565        .unwrap();
2566        assert_eq!(
2567            spec.mechanism,
2568            ActionMechanism::Command {
2569                program: "sudo",
2570                args: vec![
2571                    "rpm-ostree".to_string(),
2572                    "kargs".to_string(),
2573                    "--append=nomodeset".to_string(),
2574                    "--delete=quiet".to_string(),
2575                ],
2576            }
2577        );
2578    }
2579
2580    #[test]
2581    fn build_spec_set_kernel_arguments_with_empty_arrays() {
2582        let spec =
2583            build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
2584        assert_eq!(
2585            spec.mechanism,
2586            ActionMechanism::Command {
2587                program: "sudo",
2588                args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2589            }
2590        );
2591    }
2592
2593    #[test]
2594    fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
2595        let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
2596        assert_eq!(
2597            spec.mechanism,
2598            ActionMechanism::Command {
2599                program: "sudo",
2600                args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2601            }
2602        );
2603    }
2604
2605    // ── Critical-account denylist (DeleteUser/DeleteGroup/LockUserAccount) ──
2606
2607    #[test]
2608    fn delete_user_rejects_critical_account() {
2609        let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
2610        assert!(
2611            matches!(err, ExecutorError::InvalidParam("username")),
2612            "expected InvalidParam(username), got: {err}"
2613        );
2614    }
2615
2616    #[test]
2617    fn delete_group_rejects_critical_group() {
2618        let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
2619        assert!(
2620            matches!(err, ExecutorError::InvalidParam("group")),
2621            "expected InvalidParam(group), got: {err}"
2622        );
2623    }
2624
2625    #[test]
2626    fn lock_user_account_rejects_critical_account() {
2627        let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
2628        assert!(
2629            matches!(err, ExecutorError::InvalidParam("username")),
2630            "expected InvalidParam(username), got: {err}"
2631        );
2632    }
2633
2634    #[test]
2635    fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
2636        assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
2637        assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
2638        assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
2639    }
2640
2641    // ── execute_spec ──────────────────────────────────────────────────────
2642
2643    #[test]
2644    fn build_spec_add_package_repository_rejects_path_traversal() {
2645        let err = build_action_spec(
2646            "AddPackageRepository",
2647            &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
2648        )
2649        .unwrap_err();
2650        assert!(
2651            matches!(err, ExecutorError::InvalidParam("repo_id")),
2652            "expected InvalidParam(repo_id), got: {err}"
2653        );
2654    }
2655
2656    #[test]
2657    fn build_spec_add_package_repository_rejects_newline_in_url() {
2658        let err = build_action_spec(
2659            "AddPackageRepository",
2660            &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2661        )
2662        .unwrap_err();
2663        assert!(
2664            matches!(err, ExecutorError::InvalidParam("repo_url")),
2665            "expected InvalidParam(repo_url), got: {err}"
2666        );
2667    }
2668
2669    #[test]
2670    fn build_spec_add_package_repository_accepts_valid_repo_id() {
2671        let spec = build_action_spec(
2672            "AddPackageRepository",
2673            &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2674        )
2675        .unwrap();
2676        assert_eq!(spec.action_name, "AddPackageRepository");
2677    }
2678
2679    #[test]
2680    fn build_spec_remove_package_repository_rejects_path_traversal() {
2681        let err = build_action_spec(
2682            "RemovePackageRepository",
2683            &json!({ "repo_id": "../../etc/passwd" }),
2684        )
2685        .unwrap_err();
2686        assert!(
2687            matches!(err, ExecutorError::InvalidParam("repo_id")),
2688            "expected InvalidParam(repo_id), got: {err}"
2689        );
2690    }
2691
2692    #[tokio::test]
2693    async fn execute_spec_command_captures_stdout() {
2694        let spec = ActionSpec {
2695            action_name: "GetSystemState",
2696            mechanism: ActionMechanism::Command {
2697                program: "echo",
2698                args: vec!["hello".to_string()],
2699            },
2700            risk_level: RiskLevel::Low,
2701            reboot_required: false,
2702            rollback_available: false,
2703        };
2704        let out = execute_spec(&spec).await.unwrap();
2705        assert_eq!(out.stdout.trim(), "hello");
2706        assert_eq!(out.exit_code, 0);
2707    }
2708
2709    /// Regression guard for the live subprocess stdout relay. This coverage
2710    /// previously lived in the dispatcher (`stream_command_sends_job_progress_
2711    /// lines_during_execution`) and was lost when the streaming code moved here;
2712    /// The `ActionNotStopped` message is what an operator reads in the job
2713    /// summary, and the dispatcher keys the rollback veto off this variant, so
2714    /// its fields and wording are load-bearing. No integration test constructs
2715    /// it (the real path needs a root child), so pin it directly.
2716    #[test]
2717    fn action_not_stopped_names_the_group_and_the_timeout() {
2718        let err = ExecutorError::ActionNotStopped {
2719            program: "rpm-ostree".to_string(),
2720            pgid: 4242,
2721            timeout_secs: 7200,
2722        };
2723        let msg = err.to_string();
2724        assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
2725        assert!(msg.contains("7200"), "must name the timeout: {msg}");
2726        assert!(
2727            msg.contains("could not be confirmed stopped"),
2728            "must say what happened: {msg}"
2729        );
2730        assert!(
2731            msg.contains("rollback was skipped"),
2732            "must say the rollback was skipped: {msg}"
2733        );
2734    }
2735
2736    /// A same-uid group is genuinely killable, so `kill_and_reap` must return the
2737    /// plain stopped-timeout `Io`, not `ActionNotStopped`. This pins the
2738    /// discrimination the variant exists for, and that the function returns
2739    /// promptly rather than hanging on the reap.
2740    #[tokio::test]
2741    async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
2742        let mut child = tokio::process::Command::new("sh")
2743            .args(["-c", "sleep 3600 & wait"])
2744            .process_group(0)
2745            .stdin(Stdio::null())
2746            .stdout(Stdio::null())
2747            .stderr(Stdio::null())
2748            .spawn()
2749            .expect("spawn");
2750        SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
2751        let started = tokio::time::Instant::now();
2752        let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
2753        let elapsed = started.elapsed();
2754
2755        assert!(
2756            matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
2757            "a killable group must report a plain stopped-timeout, got: {err:?}"
2758        );
2759        assert!(
2760            elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
2761            "must return promptly, took {elapsed:?}"
2762        );
2763        // A same-uid group is killed by the daemon's own signals, so the
2764        // privileged escalation must NOT fire (a mutation that always escalates
2765        // would shell out to sudo and trip this).
2766        assert_eq!(
2767            SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
2768            0,
2769            "a killable group must not escalate to the privileged reaper"
2770        );
2771    }
2772
2773    /// Losing the ability to READ a privileged child must stop it, exactly as a
2774    /// timeout does. `lines()` fails with `InvalidData` on non-UTF-8 stdout —
2775    /// reachable from any action whose output carries a byte sequence that is not
2776    /// valid UTF-8 (a filename in a foreign encoding is enough). Returning that
2777    /// error without reaping leaves a root process group running while the
2778    /// executor reports the action failed, so the dispatcher's rollback starts a
2779    /// second privileged transaction on top of the first.
2780    #[tokio::test]
2781    async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
2782        let dir = tempfile::tempdir().expect("tempdir");
2783        let pgid_file = dir.path().join("pgid");
2784        // `process_group(0)` makes the shell its own group leader, so `$$` IS the
2785        // pgid. It prints one good line, then a lone \377 (never valid UTF-8),
2786        // then outlives the read failure unless the executor stops it.
2787        let script = format!(
2788            "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
2789            pgid_file.display()
2790        );
2791        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2792        let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
2793            .await
2794            .expect_err("non-UTF-8 stdout must fail the action");
2795        assert!(
2796            matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
2797            "the read failure itself must be surfaced, got: {err:?}"
2798        );
2799
2800        let pgid: i32 = std::fs::read_to_string(&pgid_file)
2801            .expect("the shell must have recorded its pgid")
2802            .trim()
2803            .parse()
2804            .expect("pgid");
2805        assert!(
2806            probe_group(pgid).is_gone(),
2807            "process group {pgid} survived the read failure; \
2808             the privileged child was orphaned"
2809        );
2810    }
2811
2812    #[test]
2813    fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
2814        // rpm-ostree -> cancel (its work is in another cgroup, a signal can't reach it).
2815        assert_eq!(
2816            reap_command(
2817                99,
2818                "sudo",
2819                &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
2820            ),
2821            vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
2822        );
2823        // Everything else -> a root TERM to its own process group.
2824        assert_eq!(
2825            reap_command(
2826                99,
2827                "sudo",
2828                &[
2829                    "env".to_string(),
2830                    "apt-get".to_string(),
2831                    "install".to_string()
2832                ]
2833            ),
2834            root_group_kill_argv("TERM", 99)
2835        );
2836    }
2837
2838    #[test]
2839    fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
2840        // sudo -n /usr/bin/kill -s KILL -- -<pgid>: non-interactive (NOPASSWD),
2841        // options terminated so the negative pgid is a group, not a flag.
2842        assert_eq!(
2843            root_group_kill_argv("KILL", 4242),
2844            vec!["-n", "/usr/bin/kill", "-s", "KILL", "--", "-4242"]
2845        );
2846        assert_eq!(
2847            root_group_kill_argv("TERM", 7)[3..].to_vec(),
2848            vec!["TERM", "--", "-7"]
2849        );
2850    }
2851
2852    #[test]
2853    fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
2854        let sudo = |a: &[&str]| {
2855            is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
2856        };
2857        // rpm-ostree runs behind sudo, absolute or bare, and directly.
2858        assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
2859        assert!(sudo(&[
2860            "rpm-ostree",
2861            "rebase",
2862            "fedora:fedora/40/x86_64/silverblue"
2863        ]));
2864        assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
2865        // Package managers whose work IS in the process group must NOT be
2866        // treated as rpm-ostree (they get the group kill, not `cancel`).
2867        assert!(!sudo(&[
2868            "env",
2869            "DEBIAN_FRONTEND=noninteractive",
2870            "apt-get",
2871            "install",
2872            "vim"
2873        ]));
2874        assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
2875        assert!(!is_rpm_ostree_action("sudo", &[]));
2876        // Absolute path as the top-level program (no sudo wrapper).
2877        assert!(is_rpm_ostree_action(
2878            "/usr/bin/rpm-ostree",
2879            &["deploy".to_string()]
2880        ));
2881        // A path segment must match, not a substring (guards a future refactor
2882        // to `.contains()`).
2883        assert!(!is_rpm_ostree_action(
2884            "sudo",
2885            &["explain-rpm-ostree-usage".to_string()]
2886        ));
2887    }
2888
2889    /// restored at its owning layer. Proves `RealActionExecutor` forwards each
2890    /// stdout line over the mpsc channel as it arrives and reports the exit code.
2891    #[tokio::test]
2892    async fn real_executor_streams_each_stdout_line() {
2893        let spec = ActionSpec {
2894            action_name: "GetSystemState",
2895            mechanism: ActionMechanism::Command {
2896                program: "printf",
2897                args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2898            },
2899            risk_level: RiskLevel::Low,
2900            reboot_required: false,
2901            rollback_available: false,
2902        };
2903        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2904        let out = RealActionExecutor
2905            .execute_with_progress(&spec, tx)
2906            .await
2907            .unwrap();
2908
2909        let mut lines = Vec::new();
2910        while let Ok(line) = rx.try_recv() {
2911            lines.push(line);
2912        }
2913        assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2914        assert_eq!(out.exit_code, 0);
2915        assert!(out.stdout.contains("line-two"));
2916    }
2917
2918    #[tokio::test]
2919    async fn execute_spec_file_write_creates_file() {
2920        let dir = tempdir().unwrap();
2921        let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2922        let spec = ActionSpec {
2923            action_name: "AddPackageRepository",
2924            mechanism: ActionMechanism::FileWrite {
2925                path: path.clone(),
2926                content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2927            },
2928            risk_level: RiskLevel::Medium,
2929            reboot_required: false,
2930            rollback_available: true,
2931        };
2932        let out = execute_spec(&spec).await.unwrap();
2933        assert_eq!(out.exit_code, 0);
2934        assert_eq!(
2935            std::fs::read_to_string(&path).unwrap(),
2936            "[repo]\nbaseurl=https://example.test\n"
2937        );
2938    }
2939
2940    #[tokio::test]
2941    async fn execute_spec_file_patch_replaces_first_occurrence() {
2942        let dir = tempdir().unwrap();
2943        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2944        std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2945        let spec = ActionSpec {
2946            action_name: "EnablePackageRepository",
2947            mechanism: ActionMechanism::FilePatch {
2948                path: path.clone(),
2949                search: "enabled=0".to_string(),
2950                replace: "enabled=1".to_string(),
2951            },
2952            risk_level: RiskLevel::Medium,
2953            reboot_required: false,
2954            rollback_available: true,
2955        };
2956        execute_spec(&spec).await.unwrap();
2957        assert_eq!(
2958            std::fs::read_to_string(&path).unwrap(),
2959            "[myrepo]\nenabled=1\n"
2960        );
2961    }
2962
2963    #[tokio::test]
2964    async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2965        let dir = tempdir().unwrap();
2966        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2967        std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2968        let spec = ActionSpec {
2969            action_name: "EnablePackageRepository",
2970            mechanism: ActionMechanism::FilePatch {
2971                path: path.clone(),
2972                search: "enabled=0".to_string(),
2973                replace: "enabled=1".to_string(),
2974            },
2975            risk_level: RiskLevel::Medium,
2976            reboot_required: false,
2977            rollback_available: true,
2978        };
2979        let out = execute_spec(&spec).await.unwrap();
2980        assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2981        assert!(
2982            out.stderr.contains("search string not found in file"),
2983            "stderr should explain the failure: {}",
2984            out.stderr
2985        );
2986        // File should remain unchanged.
2987        assert_eq!(
2988            std::fs::read_to_string(&path).unwrap(),
2989            "[myrepo]\nenabled=1\n"
2990        );
2991    }
2992
2993    #[tokio::test]
2994    async fn execute_spec_file_patch_allows_empty_search_string() {
2995        // An empty search string triggers replacen's prepend behavior and should
2996        // not be rejected — the caller explicitly asked for a no-op search.
2997        let dir = tempdir().unwrap();
2998        let path = dir.path().join("file.txt").to_string_lossy().into_owned();
2999        std::fs::write(&path, "hello").unwrap();
3000        let spec = ActionSpec {
3001            action_name: "Test",
3002            mechanism: ActionMechanism::FilePatch {
3003                path: path.clone(),
3004                search: String::new(),
3005                replace: "prefix-".to_string(),
3006            },
3007            risk_level: RiskLevel::Low,
3008            reboot_required: false,
3009            rollback_available: false,
3010        };
3011        let out = execute_spec(&spec).await.unwrap();
3012        assert_eq!(out.exit_code, 0);
3013        assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3014    }
3015
3016    #[tokio::test]
3017    async fn execute_spec_file_delete_removes_file() {
3018        let dir = tempdir().unwrap();
3019        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3020        std::fs::write(&path, "[myrepo]\n").unwrap();
3021        let spec = ActionSpec {
3022            action_name: "RemovePackageRepository",
3023            mechanism: ActionMechanism::FileDelete { path: path.clone() },
3024            risk_level: RiskLevel::Medium,
3025            reboot_required: false,
3026            rollback_available: true,
3027        };
3028        execute_spec(&spec).await.unwrap();
3029        assert!(!std::path::Path::new(&path).exists());
3030    }
3031
3032    #[tokio::test]
3033    async fn execute_spec_file_scan_lists_directory_entries() {
3034        let dir = tempdir().unwrap();
3035        std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3036        std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3037        let spec = ActionSpec {
3038            action_name: "ListPackageRepositories",
3039            mechanism: ActionMechanism::FileScan {
3040                path: dir.path().to_string_lossy().into_owned(),
3041            },
3042            risk_level: RiskLevel::Low,
3043            reboot_required: false,
3044            rollback_available: false,
3045        };
3046        let out = execute_spec(&spec).await.unwrap();
3047        assert!(
3048            out.stdout.contains("a.repo"),
3049            "expected a.repo in: {}",
3050            out.stdout
3051        );
3052        assert!(
3053            out.stdout.contains("b.repo"),
3054            "expected b.repo in: {}",
3055            out.stdout
3056        );
3057        assert_eq!(out.exit_code, 0);
3058    }
3059
3060    // ── rollback_spec_for ─────────────────────────────────────────────────────
3061
3062    #[test]
3063    fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3064        let spec = rollback_spec_for("UpdateSystem").unwrap();
3065        assert_eq!(spec.action_name, "RollbackDeployment");
3066        assert!(
3067            matches!(
3068                &spec.mechanism,
3069                ActionMechanism::Command { program: "rpm-ostree", args }
3070                if args == &["rollback".to_string()]
3071            ),
3072            "expected rpm-ostree rollback, got: {:?}",
3073            spec.mechanism
3074        );
3075        assert!(!spec.rollback_available, "rollback spec must not recurse");
3076    }
3077
3078    #[test]
3079    fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3080        assert!(rollback_spec_for("InstallPackages").is_some());
3081    }
3082
3083    #[test]
3084    fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3085        assert!(rollback_spec_for("RemovePackages").is_some());
3086    }
3087
3088    #[test]
3089    fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3090        assert!(rollback_spec_for("RebaseSystem").is_some());
3091    }
3092
3093    #[test]
3094    fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3095        assert!(rollback_spec_for("SetKernelArguments").is_some());
3096    }
3097
3098    #[test]
3099    fn rollback_spec_for_read_only_action_returns_none() {
3100        assert!(rollback_spec_for("GetSystemState").is_none());
3101        assert!(rollback_spec_for("ListUsers").is_none());
3102        assert!(rollback_spec_for("GetFirewallState").is_none());
3103    }
3104
3105    #[test]
3106    fn rollback_spec_for_non_rollbackable_actions_return_none() {
3107        assert!(rollback_spec_for("AddUserToGroup").is_none());
3108        assert!(rollback_spec_for("DeleteUser").is_none());
3109        assert!(rollback_spec_for("CleanupDeployments").is_none());
3110        // No infinite recursion — RollbackDeployment has no rollback of its own
3111        assert!(rollback_spec_for("RollbackDeployment").is_none());
3112    }
3113
3114    // ── validated_public_key ──────────────────────────────────────────────
3115
3116    #[test]
3117    fn public_key_accepts_valid_ed25519() {
3118        let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3119        assert!(validated_public_key(key).is_ok());
3120    }
3121
3122    #[test]
3123    fn public_key_accepts_valid_rsa() {
3124        let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3125        assert!(validated_public_key(key).is_ok());
3126    }
3127
3128    #[test]
3129    fn public_key_rejects_empty() {
3130        assert!(matches!(
3131            validated_public_key(""),
3132            Err(ExecutorError::InvalidParam("public_key"))
3133        ));
3134    }
3135
3136    #[test]
3137    fn public_key_rejects_unknown_prefix() {
3138        assert!(matches!(
3139            validated_public_key("sk-rsa AAAA... user@host"),
3140            Err(ExecutorError::InvalidParam("public_key"))
3141        ));
3142        assert!(matches!(
3143            validated_public_key("AAAAB3Nz... user@host"),
3144            Err(ExecutorError::InvalidParam("public_key"))
3145        ));
3146    }
3147
3148    #[test]
3149    fn public_key_rejects_single_quote() {
3150        let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3151        assert!(matches!(
3152            validated_public_key(key),
3153            Err(ExecutorError::InvalidParam("public_key"))
3154        ));
3155    }
3156
3157    /// The install/remove package actions must refuse a filesystem path, because
3158    /// `apt-get install /tmp/x.deb` / `rpm-ostree install /tmp/x.rpm` install a
3159    /// local file and run its scripts as root. `validate.rs` proves the validator
3160    /// refuses paths; only this proves each action arm actually calls it. Table
3161    /// driven so an arm added later that forgets the validator fails here, which
3162    /// is exactly the regression that shipped `InstallPackages`/`RemovePackages`
3163    /// on the generic safe-arg by mistake.
3164    #[test]
3165    fn package_actions_reject_a_local_file_target() {
3166        let reject: &[(&str, serde_json::Value)] = &[
3167            ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3168            ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3169            ("AptPurge", json!({"package": "./evil.deb"})),
3170            ("AptHold", json!({"package": "/tmp/evil.deb"})),
3171            ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3172            ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3173            ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3174            ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3175            (
3176                "ReplaceLayeredPackage",
3177                json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3178            ),
3179            (
3180                "ReplaceLayeredPackage",
3181                json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3182            ),
3183            ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3184            ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3185        ];
3186        for (action, params) in reject {
3187            assert!(
3188                matches!(
3189                    build_action_spec(action, params),
3190                    Err(ExecutorError::InvalidParam(_))
3191                ),
3192                "{action} must refuse a local-file package target: {params}"
3193            );
3194        }
3195        // Positive twin: real package specs must still build, or the test above
3196        // would pass for a validator that rejects everything.
3197        let accept: &[(&str, serde_json::Value)] = &[
3198            ("AptInstall", json!({"package": "nginx"})),
3199            ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3200            (
3201                "InstallPackages",
3202                json!({"packages": ["nginx", "python3-pip"]}),
3203            ),
3204        ];
3205        for (action, params) in accept {
3206            assert!(
3207                build_action_spec(action, params).is_ok(),
3208                "{action} must accept a real package spec: {params}"
3209            );
3210        }
3211    }
3212
3213    /// The activation verbs must refuse a root-shell unit through the reachable
3214    /// action, and `SetServiceEnabled` must gate only on `enabled: true`. Mutation
3215    /// testing showed reverting these call sites to the loose validator passed the
3216    /// whole suite, so the denylist was pinned only at the validator, not here.
3217    #[test]
3218    fn activating_actions_refuse_a_root_shell_unit() {
3219        for action in ["StartService", "RestartService", "UnmaskService"] {
3220            for unit in [
3221                "debug-shell.service",
3222                "emergency.target",
3223                "rescue.service",
3224                "runlevel1.target",
3225            ] {
3226                assert!(
3227                    matches!(
3228                        build_action_spec(action, &json!({"unit": unit})),
3229                        Err(ExecutorError::InvalidParam("unit"))
3230                    ),
3231                    "{action} must not activate {unit}"
3232                );
3233            }
3234            assert!(
3235                build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3236                "{action} must still activate an ordinary unit"
3237            );
3238        }
3239        assert!(
3240            build_action_spec(
3241                "SetServiceEnabled",
3242                &json!({"unit": "debug-shell.service", "enabled": true})
3243            )
3244            .is_err(),
3245            "enabling a root-shell unit brings it up at boot and must be refused"
3246        );
3247        assert!(
3248            build_action_spec(
3249                "SetServiceEnabled",
3250                &json!({"unit": "debug-shell.service", "enabled": false})
3251            )
3252            .is_ok(),
3253            "disabling a root-shell unit is a mitigation and must stay reachable"
3254        );
3255        // The safe verbs must not have been tightened into the denylist, or an
3256        // operator loses the ability to stop or mask a running root shell.
3257        for action in ["StopService", "MaskService"] {
3258            assert!(
3259                build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3260                "{action} on debug-shell is a mitigation and must stay allowed"
3261            );
3262        }
3263    }
3264
3265    #[test]
3266    fn authorized_keys_actions_reject_a_traversal_username() {
3267        // `actions/ssh.rs` builds `/home/{username}/.ssh/authorized_keys` by
3268        // interpolation, so the only thing keeping the path inside the user's
3269        // home is `validated_username` being called in these three match arms.
3270        // `validate.rs` tests the validator in isolation, and `ssh_key_ops.rs`
3271        // calls the action functions directly — neither proves the guard is
3272        // actually wired to the reachable actions.
3273        for action in [
3274            "GetAuthorizedKeys",
3275            "AddAuthorizedKey",
3276            "RemoveAuthorizedKey",
3277        ] {
3278            for bad in ["../../etc", "..", "root/../../etc"] {
3279                let err = build_action_spec(
3280                    action,
3281                    &json!({
3282                        "username": bad,
3283                        "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h",
3284                    }),
3285                )
3286                .unwrap_err();
3287                assert!(
3288                    matches!(err, ExecutorError::InvalidParam("username")),
3289                    "{action} must reject username {bad:?}, got {err:?}"
3290                );
3291            }
3292        }
3293    }
3294
3295    #[test]
3296    fn ip_taking_actions_reject_a_malformed_address() {
3297        // `ExecutorError::InvalidIpAddress` was constructed by three actions
3298        // and reached by no test.
3299        let cases: [(&str, serde_json::Value); 3] = [
3300            (
3301                "ResolvectlSetDns",
3302                json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
3303            ),
3304            (
3305                "Fail2banBanIp",
3306                json!({ "jail": "sshd", "ip": "999.1.1.1" }),
3307            ),
3308            (
3309                "Fail2banUnbanIp",
3310                json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
3311            ),
3312        ];
3313        for (action, params) in cases {
3314            let err = build_action_spec(action, &params).unwrap_err();
3315            assert!(
3316                matches!(err, ExecutorError::InvalidIpAddress { .. }),
3317                "{action} must reject a malformed IP, got {err:?}"
3318            );
3319        }
3320    }
3321
3322    #[test]
3323    fn public_key_rejects_pipe_metacharacter() {
3324        // '|' is a shell pipe and never appears in a valid key. (It was also
3325        // the sed address delimiter before the key ops were made regex-free;
3326        // the rejection stands on its own merits either way.)
3327        let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
3328        assert!(matches!(
3329            validated_public_key(key),
3330            Err(ExecutorError::InvalidParam("public_key"))
3331        ));
3332    }
3333
3334    #[test]
3335    fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
3336        // Deliberate and load-bearing: `.` is legal inside a key comment
3337        // (`alice@example.com`), so the validator cannot blocklist regex
3338        // metacharacters without rejecting valid keys. Any consumer that
3339        // interprets a key as a pattern is therefore the defect — see
3340        // `actions::ssh::REMOVE_KEY_SCRIPT`, which uses `grep -Fxv` for
3341        // exactly this reason.
3342        let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
3343        assert!(validated_public_key(key).is_ok());
3344        // The wildcard-shaped payload passes validation too. It is safe only
3345        // because no consumer treats it as a pattern.
3346        assert!(validated_public_key("ssh-ed25519 .*").is_ok());
3347    }
3348
3349    #[test]
3350    fn public_key_rejects_shell_metacharacters() {
3351        for (metachar, desc) in [
3352            (';', "semicolon"),
3353            ('`', "backtick"),
3354            ('$', "dollar"),
3355            ('\\', "backslash"),
3356            ('&', "ampersand"),
3357        ] {
3358            let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
3359            assert!(
3360                matches!(
3361                    validated_public_key(&key),
3362                    Err(ExecutorError::InvalidParam("public_key"))
3363                ),
3364                "{desc} should be rejected"
3365            );
3366        }
3367    }
3368
3369    #[test]
3370    fn public_key_rejects_newline() {
3371        let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
3372        assert!(matches!(
3373            validated_public_key(key),
3374            Err(ExecutorError::InvalidParam("public_key"))
3375        ));
3376        let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
3377        assert!(matches!(
3378            validated_public_key(key_cr),
3379            Err(ExecutorError::InvalidParam("public_key"))
3380        ));
3381    }
3382
3383    #[test]
3384    fn public_key_rejects_too_long() {
3385        // Build a key that exceeds MAX_LEN (8192 bytes)
3386        let long_payload = "A".repeat(8192);
3387        let key = format!("ssh-ed25519 {long_payload} user@host");
3388        assert!(matches!(
3389            validated_public_key(&key),
3390            Err(ExecutorError::InvalidParam("public_key"))
3391        ));
3392    }
3393
3394    // ── str_array_or_empty ────────────────────────────────────────────────
3395
3396    #[test]
3397    fn str_array_or_empty_rejects_non_string_element() {
3398        let params = json!({ "packages": ["vim", 42, "curl"] });
3399        assert!(matches!(
3400            str_array_or_empty(&params, "packages"),
3401            Err(ExecutorError::InvalidParam("packages"))
3402        ));
3403    }
3404
3405    #[test]
3406    fn str_array_or_empty_accepts_string_array() {
3407        let params = json!({ "packages": ["vim", "curl"] });
3408        assert_eq!(
3409            str_array_or_empty(&params, "packages").unwrap(),
3410            vec!["vim".to_string(), "curl".to_string()]
3411        );
3412    }
3413
3414    #[test]
3415    fn str_array_or_empty_returns_empty_when_key_absent() {
3416        let params = json!({});
3417        assert_eq!(
3418            str_array_or_empty(&params, "packages").unwrap(),
3419            Vec::<String>::new()
3420        );
3421    }
3422
3423    // ── validated_safe_kernel_arg ─────────────────────────────────────────
3424
3425    #[test]
3426    fn kernel_arg_allows_safe_args() {
3427        assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3428        assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3429        assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3430        assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3431    }
3432
3433    #[test]
3434    fn kernel_arg_blocks_init_override() {
3435        assert!(matches!(
3436            validated_safe_kernel_arg("init=/bin/sh", "add"),
3437            Err(ExecutorError::InvalidParam("add"))
3438        ));
3439        assert!(matches!(
3440            validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3441            Err(ExecutorError::InvalidParam("add"))
3442        ));
3443    }
3444
3445    #[test]
3446    fn kernel_arg_blocks_selinux_disable() {
3447        assert!(matches!(
3448            validated_safe_kernel_arg("selinux=0", "add"),
3449            Err(ExecutorError::InvalidParam("add"))
3450        ));
3451        assert!(matches!(
3452            validated_safe_kernel_arg("enforcing=0", "add"),
3453            Err(ExecutorError::InvalidParam("add"))
3454        ));
3455    }
3456
3457    #[test]
3458    fn kernel_arg_blocks_security_override() {
3459        assert!(matches!(
3460            validated_safe_kernel_arg("security=none", "add"),
3461            Err(ExecutorError::InvalidParam("add"))
3462        ));
3463    }
3464
3465    #[test]
3466    fn kernel_arg_blocks_module_blacklist() {
3467        assert!(matches!(
3468            validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3469            Err(ExecutorError::InvalidParam("add"))
3470        ));
3471    }
3472
3473    // ── validated_safe_kernel_arg_removal ─────────────────────────────────
3474    //
3475    // "Removing an argument is always safe" was the stated premise for exempting
3476    // `remove`/`delete` from any policy check. It is false: several arguments
3477    // protect the host by being PRESENT, so deleting one lands the same end state
3478    // as adding its weakening counterpart, which the add-path already refuses.
3479
3480    #[test]
3481    fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
3482        // KSPP-recommended boot parameters plus the LSM selectors. Each is a
3483        // protection the host loses the moment the token leaves the boot line.
3484        for arg in [
3485            "lockdown=confidentiality",
3486            "module.sig_enforce=1",
3487            "selinux=1",
3488            "enforcing=1",
3489            "apparmor=1",
3490            "security=selinux",
3491            "pti=on",
3492            "mitigations=auto,nosmt",
3493            "init_on_alloc=1",
3494            "init_on_free=1",
3495            "slab_nomerge",
3496            "page_alloc.shuffle=1",
3497            "randomize_kstack_offset=on",
3498            "slub_debug=FZP",
3499            "vsyscall=none",
3500            "debugfs=off",
3501            "iommu=force",
3502            "intel_iommu=on",
3503            "amd_iommu=force_isolation",
3504        ] {
3505            assert!(
3506                matches!(
3507                    validated_safe_kernel_arg_removal(arg, "remove"),
3508                    Err(ExecutorError::InvalidParam("remove"))
3509                ),
3510                "removing {arg} is a boot-security downgrade and must be refused"
3511            );
3512        }
3513    }
3514
3515    /// The screen must not turn into "no removals at all" — an argument that is
3516    /// itself a weakening (the add-path refuses it) has to stay removable, or
3517    /// SysKnife could never undo `mitigations=off` on a host that already has it.
3518    #[test]
3519    fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
3520        for arg in [
3521            "mitigations=off",
3522            "selinux=0",
3523            "enforcing=0",
3524            "apparmor=0",
3525            "pti=off",
3526            "lockdown=none",
3527            "debugfs=on",
3528            "init=/bin/sh",
3529            "single",
3530        ] {
3531            assert!(
3532                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3533                "{arg} weakens the host, so removing it must stay allowed"
3534            );
3535        }
3536    }
3537
3538    #[test]
3539    fn kernel_arg_removal_allows_ordinary_args() {
3540        for arg in [
3541            "quiet",
3542            "splash",
3543            "nomodeset",
3544            "console=ttyS0,115200",
3545            "rd.driver.blacklist=nouveau",
3546        ] {
3547            assert!(
3548                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3549                "{arg} carries no boot-security meaning and must stay removable"
3550            );
3551        }
3552    }
3553
3554    #[test]
3555    fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
3556        assert!(matches!(
3557            validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
3558            Err(ExecutorError::InvalidParam("add"))
3559        ));
3560        assert!(matches!(
3561            validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
3562            Err(ExecutorError::InvalidParam("add"))
3563        ));
3564        assert!(matches!(
3565            validated_safe_kernel_arg("systemd.unit=single.target", "add"),
3566            Err(ExecutorError::InvalidParam("add"))
3567        ));
3568    }
3569
3570    #[test]
3571    fn kernel_arg_blocks_apparmor_disable() {
3572        assert!(matches!(
3573            validated_safe_kernel_arg("apparmor=0", "add"),
3574            Err(ExecutorError::InvalidParam("add"))
3575        ));
3576    }
3577
3578    #[test]
3579    fn kernel_arg_blocks_mitigations_off() {
3580        assert!(matches!(
3581            validated_safe_kernel_arg("mitigations=off", "add"),
3582            Err(ExecutorError::InvalidParam("add"))
3583        ));
3584    }
3585
3586    #[test]
3587    fn kernel_arg_blocks_lockdown_override() {
3588        assert!(matches!(
3589            validated_safe_kernel_arg("lockdown=none", "add"),
3590            Err(ExecutorError::InvalidParam("add"))
3591        ));
3592    }
3593
3594    #[test]
3595    fn kernel_arg_blocks_pti_off() {
3596        assert!(matches!(
3597            validated_safe_kernel_arg("pti=off", "add"),
3598            Err(ExecutorError::InvalidParam("add"))
3599        ));
3600    }
3601
3602    #[test]
3603    fn kernel_arg_blocks_smap_smep_disable() {
3604        assert!(matches!(
3605            validated_safe_kernel_arg("nosmap", "add"),
3606            Err(ExecutorError::InvalidParam("add"))
3607        ));
3608        assert!(matches!(
3609            validated_safe_kernel_arg("nosmep", "add"),
3610            Err(ExecutorError::InvalidParam("add"))
3611        ));
3612    }
3613
3614    #[test]
3615    fn kernel_arg_blocks_single_user_shortcuts() {
3616        // Runlevel shortcuts that drop to a root shell.
3617        assert!(matches!(
3618            validated_safe_kernel_arg("single", "add"),
3619            Err(ExecutorError::InvalidParam("add"))
3620        ));
3621        assert!(matches!(
3622            validated_safe_kernel_arg("s", "add"),
3623            Err(ExecutorError::InvalidParam("add"))
3624        ));
3625        assert!(matches!(
3626            validated_safe_kernel_arg("1", "add"),
3627            Err(ExecutorError::InvalidParam("add"))
3628        ));
3629    }
3630
3631    #[test]
3632    fn signal_process_guardrails() {
3633        // Reject pid 0 (whole process group) and 1 (init/systemd).
3634        for bad_pid in [0, 1] {
3635            let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
3636            assert!(
3637                matches!(err, ExecutorError::InvalidParam("pid")),
3638                "pid {bad_pid} must be rejected, got {err:?}"
3639            );
3640        }
3641        // Reject a signal outside the allowlist.
3642        let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
3643            .unwrap_err();
3644        assert!(
3645            matches!(err, ExecutorError::InvalidParam("signal")),
3646            "got {err:?}"
3647        );
3648        // Missing pid is a MissingParam.
3649        assert!(matches!(
3650            build_action_spec("SignalProcess", &json!({})).unwrap_err(),
3651            ExecutorError::MissingParam("pid")
3652        ));
3653        // A valid pid + signal builds, and accepts a numeric string + SIG prefix.
3654        let spec = build_action_spec(
3655            "SignalProcess",
3656            &json!({ "pid": "4242", "signal": "sigkill" }),
3657        )
3658        .unwrap();
3659        assert_eq!(spec.action_name, "SignalProcess");
3660        assert_eq!(
3661            spec.mechanism,
3662            ActionMechanism::Command {
3663                program: "sudo",
3664                args: vec![
3665                    "kill".to_string(),
3666                    "-s".to_string(),
3667                    "KILL".to_string(),
3668                    "4242".to_string()
3669                ],
3670            }
3671        );
3672        // Default signal is TERM when omitted.
3673        let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
3674        if let ActionMechanism::Command { args, .. } = spec.mechanism {
3675            assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
3676        } else {
3677            panic!("expected Command mechanism");
3678        }
3679    }
3680
3681    #[test]
3682    fn create_scheduled_job_validates_name_command_schedule() {
3683        // A newline in the command would inject extra unit directives.
3684        assert!(matches!(
3685            build_action_spec(
3686                "CreateScheduledJob",
3687                &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
3688            )
3689            .unwrap_err(),
3690            ExecutorError::InvalidParam("command")
3691        ));
3692        // A path-like / dotted name is rejected (must be a safe unit stem).
3693        assert!(matches!(
3694            build_action_spec(
3695                "CreateScheduledJob",
3696                &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
3697            )
3698            .unwrap_err(),
3699            ExecutorError::InvalidParam("name")
3700        ));
3701        // A schedule with shell metacharacters is rejected by the charset gate.
3702        assert!(matches!(
3703            build_action_spec(
3704                "CreateScheduledJob",
3705                &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
3706            )
3707            .unwrap_err(),
3708            ExecutorError::InvalidParam("schedule")
3709        ));
3710        // A valid job routes through the scoped helper with the right argv.
3711        let spec = build_action_spec(
3712            "CreateScheduledJob",
3713            &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
3714        )
3715        .unwrap();
3716        assert_eq!(
3717            spec.mechanism,
3718            ActionMechanism::Command {
3719                program: "sudo",
3720                args: vec![
3721                    "/usr/lib/sysknife/scheduled-job-edit".to_string(),
3722                    "--name".to_string(),
3723                    "nightly-backup".to_string(),
3724                    "--command".to_string(),
3725                    "/usr/bin/backup --full".to_string(),
3726                    "--schedule".to_string(),
3727                    "*-*-* 02:00:00".to_string(),
3728                ],
3729            }
3730        );
3731    }
3732
3733    #[test]
3734    fn set_sshd_option_allowlist_guardrails() {
3735        // Non-allowlisted option rejected.
3736        assert!(matches!(
3737            build_action_spec(
3738                "SetSshdOption",
3739                &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
3740            )
3741            .unwrap_err(),
3742            ExecutorError::InvalidParam("option")
3743        ));
3744        // Allowlisted option, disallowed value.
3745        assert!(matches!(
3746            build_action_spec(
3747                "SetSshdOption",
3748                &json!({ "option": "PermitRootLogin", "value": "maybe" })
3749            )
3750            .unwrap_err(),
3751            ExecutorError::InvalidParam("value")
3752        ));
3753        // Valid combo routes through the scoped helper.
3754        let spec = build_action_spec(
3755            "SetSshdOption",
3756            &json!({ "option": "PasswordAuthentication", "value": "no" }),
3757        )
3758        .unwrap();
3759        assert_eq!(
3760            spec.mechanism,
3761            ActionMechanism::Command {
3762                program: "sudo",
3763                args: vec![
3764                    "/usr/lib/sysknife/sshd-option-edit".to_string(),
3765                    "--option".to_string(),
3766                    "PasswordAuthentication".to_string(),
3767                    "--value".to_string(),
3768                    "no".to_string(),
3769                ],
3770            }
3771        );
3772    }
3773
3774    #[test]
3775    fn configure_unattended_upgrades_toggles_helper_flag() {
3776        for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
3777            let spec = build_action_spec(
3778                "ConfigureUnattendedUpgrades",
3779                &json!({ "enabled": enabled }),
3780            )
3781            .unwrap();
3782            assert_eq!(
3783                spec.mechanism,
3784                ActionMechanism::Command {
3785                    program: "sudo",
3786                    args: vec![
3787                        "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
3788                        flag.to_string(),
3789                    ],
3790                }
3791            );
3792        }
3793        assert!(matches!(
3794            build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
3795            ExecutorError::MissingParam("enabled")
3796        ));
3797    }
3798
3799    #[test]
3800    fn grub_set_kargs_append_blocks_single_user_shortcut() {
3801        // Regression: GrubSetKargs previously used only the charset validator,
3802        // which accepts the bare `single`/`s`/`1` runlevel shortcuts. The
3803        // kernel-arg denylist must apply to `append` too — booting into a
3804        // single-user root shell is exactly the SetKernelArguments threat.
3805        // Every BLOCKED_EXACT entry is valueless, so it passes the charset
3806        // check and this denylist call is the only thing stopping it — the
3807        // call is load-bearing here, not redundant with `validated_safe_arg`.
3808        for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
3809            let err = build_action_spec(
3810                "GrubSetKargs",
3811                &json!({ "append": [dangerous], "delete": [] }),
3812            )
3813            .unwrap_err();
3814            assert!(
3815                matches!(err, ExecutorError::InvalidParam("append")),
3816                "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
3817            );
3818        }
3819        // A benign flag still builds successfully.
3820        assert!(build_action_spec(
3821            "GrubSetKargs",
3822            &json!({ "append": ["quiet"], "delete": [] })
3823        )
3824        .is_ok());
3825    }
3826
3827    #[test]
3828    fn kernel_arg_build_spec_rejects_dangerous_arg() {
3829        // End-to-end: build_action_spec must propagate the blocklist error.
3830        // This action does NOT apply the `validated_safe_arg` charset check,
3831        // so `init=/bin/bash` reaches `validated_safe_kernel_arg` and is
3832        // caught by BLOCKED_PREFIXES — the `=`-bearing half of that denylist
3833        // is live here even though GrubSetKargs never reaches it.
3834        let err = build_action_spec(
3835            "SetKernelArguments",
3836            &json!({ "add": ["init=/bin/bash"], "remove": [] }),
3837        )
3838        .unwrap_err();
3839        assert!(
3840            matches!(err, ExecutorError::InvalidParam("add")),
3841            "expected InvalidParam(add), got {err:?}"
3842        );
3843    }
3844
3845    #[test]
3846    fn grub_set_kargs_cannot_delete_a_protective_karg() {
3847        // A review flagged `delete: ["apparmor=1"]` as a way to strip AppArmor
3848        // because the kernel-arg denylist is not applied to `delete`. It is
3849        // not reachable: `delete` goes through `validated_safe_arg`, whose
3850        // charset excludes `=`, so any `key=value` token is refused before the
3851        // denylist would matter. Pinned here so a future widening of that
3852        // charset cannot silently open the hole.
3853        for karg in [
3854            "apparmor=1",
3855            "lockdown=confidentiality",
3856            "mitigations=auto",
3857            "pti=on",
3858            "selinux=1",
3859        ] {
3860            let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
3861                .unwrap_err();
3862            assert!(
3863                matches!(err, ExecutorError::InvalidParam("delete")),
3864                "deleting {karg:?} must be refused, got {err:?}"
3865            );
3866        }
3867    }
3868
3869    /// Every action that claims `rollback_available: true` MUST have a
3870    /// corresponding entry in `rollback_spec_for()`; every action that claims
3871    /// `false` MUST NOT. This prevents the spec and the executor from
3872    /// drifting apart.
3873    #[test]
3874    fn rollback_available_matches_rollback_spec_for_all_actions() {
3875        // Iterate the FULL catalogue (`crate::actions::all_specs`), not a
3876        // hand-picked subset of families — a manually-chained list here once
3877        // silently omitted `ppa`, `netplan`, `grub`, and `ubuntu_pro`, which is
3878        // exactly how `AddPpa`/`RemovePpa`/`NetplanSet`/`GrubSetKargs`/
3879        // `ProAttach`/`ProDetach` were able to claim `rollback_available: true`
3880        // with no matching `rollback_spec_for` entry for years without this
3881        // test catching it.
3882        for spec in crate::actions::all_specs() {
3883            let has_rollback = rollback_spec_for(spec.action_name).is_some();
3884            assert_eq!(
3885                spec.rollback_available,
3886                has_rollback,
3887                "action {:?}: rollback_available={} but rollback_spec_for returns {}",
3888                spec.action_name,
3889                spec.rollback_available,
3890                if has_rollback { "Some" } else { "None" },
3891            );
3892        }
3893    }
3894
3895    // `home` reaches `useradd --create-home --home-dir <home>` as root, so it is
3896    // a path the caller chooses and root acts on. It was validated by
3897    // `validated_safe_arg`, which enforces a charset and rejects a leading dash
3898    // and accepts both `..` and a relative path. `validated_absolute_path`, in
3899    // the same file, rejects both, and every other root-acting path parameter
3900    // already uses it. The weaker of two adjacent validators was guarding the
3901    // more dangerous parameter.
3902    #[test]
3903    fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
3904        for home in [
3905            "../../etc/skel",
3906            "/home/../etc",
3907            "home/alice",
3908            "/home/alice/../../../root",
3909        ] {
3910            let got =
3911                build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
3912            assert!(
3913                got.is_err(),
3914                "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
3915            );
3916        }
3917        // An ordinary home directory must still work, or the guard has eaten
3918        // the feature rather than secured it.
3919        build_action_spec(
3920            "CreateUser",
3921            &json!({ "username": "alice", "home": "/home/alice" }),
3922        )
3923        .expect("a plain absolute home must still be accepted");
3924    }
3925
3926    // ── Risk level reclassification (NIST 800-53 / CIS Controls v8.1) ────────
3927    // These five actions were incorrectly classified Medium; they must be High.
3928    // T1136.001 (CreateUser), T1562.004 (ConfigureFirewall), T1562.001 (MaskService),
3929    // supply-chain vector (AddPackageRepository), T1557 path (SetDnsServers).
3930
3931    #[test]
3932    fn create_user_is_high_risk() {
3933        let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
3934        assert_eq!(
3935            spec.risk_level,
3936            RiskLevel::High,
3937            "CreateUser must be High (T1136.001 Persistence)"
3938        );
3939    }
3940
3941    #[test]
3942    fn configure_firewall_is_high_risk() {
3943        let spec = build_action_spec(
3944            "ConfigureFirewall",
3945            &json!({ "zone": "public", "service": "ssh", "enabled": true }),
3946        )
3947        .unwrap();
3948        assert_eq!(
3949            spec.risk_level,
3950            RiskLevel::High,
3951            "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
3952        );
3953    }
3954
3955    #[test]
3956    fn mask_service_is_high_risk() {
3957        let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
3958        assert_eq!(
3959            spec.risk_level,
3960            RiskLevel::High,
3961            "MaskService must be High (T1562.001 Impair Defenses)"
3962        );
3963    }
3964
3965    #[test]
3966    fn add_package_repository_is_high_risk() {
3967        let spec = build_action_spec(
3968            "AddPackageRepository",
3969            &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
3970        )
3971        .unwrap();
3972        assert_eq!(
3973            spec.risk_level,
3974            RiskLevel::High,
3975            "AddPackageRepository must be High (supply-chain vector)"
3976        );
3977    }
3978
3979    #[test]
3980    fn set_dns_servers_is_high_risk() {
3981        let spec = build_action_spec(
3982            "SetDnsServers",
3983            &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
3984        )
3985        .unwrap();
3986        assert_eq!(
3987            spec.risk_level,
3988            RiskLevel::High,
3989            "SetDnsServers must be High (DNS hijacking / T1557)"
3990        );
3991    }
3992}