Skip to main content

sysknife_daemon/
executor.rs

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