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/// Parameter names accepted by each action, including deliberate aliases.
609/// Keep this next to the builder so optional/defaulted parameters cannot mask
610/// misspelled keys. Dispatcher-only actions use the same boundary check.
611fn allowed_action_params(action_name: &str) -> Option<&'static [&'static str]> {
612    Some(match action_name {
613        "GetSystemState"
614        | "CollectDiagnostics"
615        | "GetDeploymentHistory"
616        | "ListDeployments"
617        | "UpdateSystem"
618        | "CleanupDeployments"
619        | "RebootSystem"
620        | "RollbackDeployment"
621        | "GetKernelArguments"
622        | "GetLayeredPackages"
623        | "ResetLayeredPackageOverride"
624        | "GetPendingUpdates"
625        | "ListPackageRepositories"
626        | "ListServices"
627        | "ListTimers"
628        | "ReloadDaemon"
629        | "GetDateTime"
630        | "GetDiskUsage"
631        | "ListProcesses"
632        | "GetLvmReport"
633        | "GetMounts"
634        | "GetSudoGrants"
635        | "RemoveRemoteSyslog"
636        | "GetMemoryInfo"
637        | "GetHostState"
638        | "GetFirewallState"
639        | "GetNetworkStatus"
640        | "GetListeningPorts"
641        | "GetNftablesRuleset"
642        | "GetFirewallBackendState"
643        | "ListUsers"
644        | "ListGroups"
645        | "AptUpdate"
646        | "AptUpgrade"
647        | "AptAutoremove"
648        | "AptListInstalled"
649        | "AptListUpgradable"
650        | "AptHistoryList"
651        | "SnapList"
652        | "GrubGetKargs"
653        | "CheckPendingReboot"
654        | "UfwEnable"
655        | "UfwDisable"
656        | "UfwReset"
657        | "DistroboxList"
658        | "NetplanGetConfig"
659        | "NetplanApply"
660        | "NetplanGenerate"
661        | "ProStatus"
662        | "ProDetach"
663        | "LivepatchStatus"
664        | "MultipassList"
665        | "UbuntuReleaseUpgrade"
666        | "ResolvectlStatus"
667        | "AppArmorStatus"
668        | "CloudInitStatus"
669        | "GetAuditRules"
670        | "GetCertificates"
671        | "RenewCertificates" => &[],
672        "PinDeployment" | "UnpinDeployment" => &["index"],
673        "RebaseSystem" => &["target_ref"],
674        "SetKernelArguments" => &["add", "remove"],
675        "ListFlatpakRemotes"
676        | "ListInstalledFlatpaks"
677        | "ListContainers"
678        | "ListToolboxes"
679        | "DeleteUser"
680        | "LockUserAccount"
681        | "UnlockUserAccount"
682        | "GetAuthorizedKeys"
683        | "UbuntuListFlatpaks" => &["user", "username"],
684        "InstallFlatpak" | "UbuntuInstallFlatpak" => &["app_id", "remote", "user", "username"],
685        "RemoveFlatpak"
686        | "GetFlatpakAppInfo"
687        | "UpdateFlatpak"
688        | "UbuntuRemoveFlatpak"
689        | "UbuntuUpdateFlatpak" => &["app_id", "user", "username"],
690        "SearchFlatpakApps" | "AptSearch" => &["term"],
691        "AddFlatpakRemote" => &["remote", "url", "user", "username"],
692        "RemoveFlatpakRemote" => &["remote", "user", "username"],
693        "CreateContainer" => &["image", "name", "user", "username"],
694        "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
695        | "RemoveToolbox" => &["name", "user", "username"],
696        "InstallPackages" | "RemovePackages" => &["packages"],
697        "AddLayeredPackage"
698        | "RemoveLayeredPackage"
699        | "RemoveBasePackage"
700        | "GetAptPins"
701        | "AptInstall"
702        | "AptRemove"
703        | "AptPurge"
704        | "AptHold"
705        | "AptUnhold"
706        | "AptShow" => &["package"],
707        "ReplaceLayeredPackage" => &["new", "old"],
708        "AddPackageRepository" => &["repo_id", "repo_url"],
709        "RemovePackageRepository" | "EnablePackageRepository" | "DisablePackageRepository" => {
710            &["repo_id"]
711        }
712        "StartService"
713        | "StopService"
714        | "RestartService"
715        | "MaskService"
716        | "UnmaskService"
717        | "GetServiceLogs"
718        | "GetServiceStatus"
719        | "ReloadService"
720        | "GetServiceResourceLimits" => &["unit"],
721        "SetServiceEnabled" => &["enabled", "unit"],
722        "CreateScheduledJob" => &["command", "name", "schedule"],
723        "SetServiceResourceLimits" => &[
724            "cpu_quota",
725            "memory_high",
726            "memory_max",
727            "tasks_max",
728            "unit",
729        ],
730        "CreateToolbox" => &["image", "name", "release", "user", "username"],
731        "SetHostname" => &["hostname"],
732        "SetTimezone" => &["timezone"],
733        "SetLocale" => &["locale"],
734        "SetNtp" | "ConfigureUnattendedUpgrades" => &["enabled"],
735        "SignalProcess" => &["pid", "signal"],
736        "GetJournalLog" => &[
737            "boot", "grep", "kernel", "lines", "priority", "since", "unit", "until",
738        ],
739        "VacuumJournal" => &["retain_days", "size_mb"],
740        "ExtendLogicalVolume" => &["lv", "size", "vg"],
741        "CreateLogicalVolume" => &["name", "size", "vg"],
742        "CreateLvSnapshot" => &["origin", "size", "snapshot", "vg"],
743        "GetSysctl" | "RemoveAuditRule" => &["key"],
744        "SetSysctl" | "NetplanSet" => &["key", "value"],
745        "AddMount" => &["device", "fstype", "mountpoint", "options"],
746        "RemoveMount" => &["mountpoint"],
747        "AddSwap" => &["file", "size_mb"],
748        "RemoveSwap" => &["file"],
749        "GrantSudoAccess" => &["commands", "name", "nopasswd", "runas", "user"],
750        "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" | "AddPpa" | "RemovePpa"
751        | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo" | "SnapRevert"
752        | "SnapClassicInstall" | "DistroboxRemove" => &["name"],
753        "SetAptPin" => &["name", "package", "pin", "priority"],
754        "GetLogrotateStatus" => &["config"],
755        "ConfigureLogRotation" => &["compress", "frequency", "name", "path", "rotate"],
756        "ConfigureRemoteSyslog" => &["host", "port", "protocol"],
757        "GetPasswordAging" => &["user"],
758        "SetPasswordAging" => &["max_days", "min_days", "user", "warn_days"],
759        "SetPasswordPolicy" => &["dcredit", "lcredit", "minlen", "ocredit", "ucredit"],
760        "SetAccountLockout" => &["deny", "fail_interval", "unlock_time"],
761        "ConfigureWifi" => &["password", "ssid"],
762        "SetDnsServers" | "ResolvectlSetDns" => &["interface", "servers"],
763        "ConfigureFirewall" => &["enabled", "service", "zone"],
764        "CreateUser" => &["home", "shell", "user", "username"],
765        "AddUserToGroup" | "RemoveUserFromGroup" => &["group", "user", "username"],
766        "CreateGroup" => &["group", "system"],
767        "DeleteGroup" => &["group"],
768        "AddAuthorizedKey" | "RemoveAuthorizedKey" => &["public_key", "user", "username"],
769        "SetSshdOption" => &["option", "value"],
770        "SnapInstall" => &["auto_update", "channel", "name"],
771        "GrubSetKargs" => &["append", "delete"],
772        "UfwAllow" | "UfwDeny" => &["port_or_service"],
773        "UfwStatus" => &["numbered"],
774        "DistroboxCreate" => &["image", "name"],
775        "UfwDeleteRule" => &["rule_number"],
776        "UfwLimit" => &["target"],
777        "ProAttach" => &["token"],
778        "EnableProService" | "DisableProService" => &["service"],
779        "AppArmorEnforce" | "AppArmorComplain" => &["profile_path"],
780        "Fail2banStatus" => &["jail"],
781        "Fail2banBanIp" | "Fail2banUnbanIp" => &["ip", "jail"],
782        "ConfigureFail2banJail" => &["bantime", "enabled", "findtime", "maxretry", "name"],
783        "AddAuditRule" => &["key", "path", "perms"],
784        "ObtainCertificate" => &["challenge", "domain", "domains", "email"],
785        "ListJobHistory" => &[
786            "limit",
787            "status_filter",
788            "action_filter",
789            "since_hours",
790            "since",
791        ],
792        _ => return None,
793    })
794}
795
796pub(crate) fn validate_action_params(
797    action_name: &str,
798    params: &Value,
799) -> Result<(), ExecutorError> {
800    let allowed = allowed_action_params(action_name)
801        .ok_or_else(|| ExecutorError::UnknownAction(action_name.to_string()))?;
802    let object = params
803        .as_object()
804        .ok_or(ExecutorError::InvalidParam("params"))?;
805    if object.keys().any(|key| !allowed.contains(&key.as_str())) {
806        return Err(ExecutorError::InvalidParam("unknown parameter"));
807    }
808    Ok(())
809}
810
811/// Map an action name and JSON params to an [`ActionSpec`].
812///
813/// Returns [`ExecutorError::UnknownAction`] for unrecognised names and
814/// [`ExecutorError::MissingParam`] when a required param is absent, or
815/// [`ExecutorError::InvalidParam`] for non-object params or unrecognised keys.
816pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
817    validate_action_params(action_name, params)?;
818    match action_name {
819        // ── Deployment: no params ─────────────────────────────────────────
820        "GetSystemState" => Ok(deployment::get_system_state()),
821        "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
822        "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
823        "ListDeployments" => Ok(deployment::list_deployments()),
824        "UpdateSystem" => Ok(deployment::update_system()),
825        "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
826        "RebootSystem" => Ok(deployment::reboot_system()),
827        "RollbackDeployment" => Ok(deployment::rollback_deployment()),
828        "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
829
830        // ── Deployment: parameterized ─────────────────────────────────────
831        "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
832        "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
833        "RebaseSystem" => {
834            let target_ref = require_str(params, "target_ref")?;
835            let target_ref = validated_safe_arg(target_ref, "target_ref")?;
836            Ok(deployment::rebase_system(&target_ref))
837        }
838        "SetKernelArguments" => {
839            let add = str_array_or_empty(params, "add")?;
840            let remove = str_array_or_empty(params, "remove")?;
841            // Reject dangerous kernel arguments that could bypass security
842            // mechanisms or give unauthenticated root access on next boot.
843            for arg in add.iter() {
844                validated_safe_kernel_arg(arg, "add")?;
845            }
846            // Removal is screened too: stripping a protective arg reaches the
847            // same end state as adding its weakening counterpart.
848            for arg in remove.iter() {
849                validated_safe_kernel_arg_removal(arg, "remove")?;
850            }
851            let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
852            let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
853            Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
854        }
855
856        // ── Flatpak ───────────────────────────────────────────────────────
857        // All user-scoped Flatpak operations require a `username` param so the
858        // daemon can switch to that user's environment via `runuser -l`. This
859        // ensures operations target the user's Flatpak installation
860        // (~/.local/share/flatpak/) rather than the system store.
861        "ListFlatpakRemotes" => {
862            let username = validated_username(resolve_username(params)?, "username")?;
863            Ok(flatpak::list_flatpak_remotes(&username))
864        }
865        "InstallFlatpak" => {
866            let username = validated_username(resolve_username(params)?, "username")?;
867            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
868            // `remote` defaults to "flathub" — the universal Flatpak remote.
869            // Models frequently omit it; accepting the default avoids a
870            // MissingParam failure for the most common install case.
871            let remote = params
872                .get("remote")
873                .and_then(|v| v.as_str())
874                .unwrap_or("flathub");
875            let remote = validated_safe_arg(remote, "remote")?;
876            Ok(flatpak::install_flatpak(&username, &app_id, &remote))
877        }
878        "RemoveFlatpak" => {
879            let username = validated_username(resolve_username(params)?, "username")?;
880            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
881            Ok(flatpak::remove_flatpak(&username, &app_id))
882        }
883        "SearchFlatpakApps" => {
884            let term = validated_safe_arg(require_str(params, "term")?, "term")?;
885            Ok(flatpak::search_flatpak_apps(&term))
886        }
887        "AddFlatpakRemote" => {
888            let username = validated_username(resolve_username(params)?, "username")?;
889            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
890            let url = validated_safe_arg(require_str(params, "url")?, "url")?;
891            Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
892        }
893        "RemoveFlatpakRemote" => {
894            let username = validated_username(resolve_username(params)?, "username")?;
895            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
896            Ok(flatpak::remove_flatpak_remote(&username, &remote))
897        }
898        "GetFlatpakAppInfo" => {
899            let username = validated_username(resolve_username(params)?, "username")?;
900            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
901            Ok(flatpak::get_flatpak_app_info(&username, &app_id))
902        }
903        "ListInstalledFlatpaks" => {
904            let username = validated_username(resolve_username(params)?, "username")?;
905            Ok(flatpak::list_installed_flatpaks(&username))
906        }
907        "UpdateFlatpak" => {
908            let username = validated_username(resolve_username(params)?, "username")?;
909            // app_id is optional — omitting it updates all installed Flatpaks.
910            // Empty string is treated as absent (no app specified → update all).
911            let app_id = params
912                .get("app_id")
913                .and_then(|v| v.as_str())
914                .filter(|id| !id.is_empty())
915                .map(|id| validated_safe_arg(id, "app_id"))
916                .transpose()?;
917            Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
918        }
919
920        // ── Containers ────────────────────────────────────────────────────
921        // All container operations require a `username` param so the daemon can
922        // switch to that user's rootless Podman environment via `runuser -l`.
923        // Podman storage is per-user; running as the `sysknife` system user
924        // would see an empty, unrelated container store.
925        "ListContainers" => {
926            let username = validated_username(resolve_username(params)?, "username")?;
927            Ok(containers::list_containers(&username))
928        }
929        "CreateContainer" => {
930            let username = validated_username(resolve_username(params)?, "username")?;
931            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
932            let image = validated_safe_arg(require_str(params, "image")?, "image")?;
933            Ok(containers::create_container(&username, &name, &image))
934        }
935        "StartContainer" => {
936            let username = validated_username(resolve_username(params)?, "username")?;
937            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
938            Ok(containers::start_container(&username, &name))
939        }
940        "StopContainer" => {
941            let username = validated_username(resolve_username(params)?, "username")?;
942            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
943            Ok(containers::stop_container(&username, &name))
944        }
945        "RemoveContainer" => {
946            let username = validated_username(resolve_username(params)?, "username")?;
947            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
948            Ok(containers::remove_container(&username, &name))
949        }
950        "GetContainerInfo" => {
951            let username = validated_username(resolve_username(params)?, "username")?;
952            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
953            Ok(containers::get_container_info(&username, &name))
954        }
955
956        // ── Layering ──────────────────────────────────────────────────────
957        "GetLayeredPackages" => Ok(layering::get_layered_packages()),
958        "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
959        "InstallPackages" => {
960            let pkgs = str_array_or_empty(params, "packages")?;
961            let validated: Vec<String> = pkgs
962                .iter()
963                .map(|p| validated_install_package(p, "packages"))
964                .collect::<Result<_, _>>()?;
965            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
966            Ok(layering::install_packages(&refs))
967        }
968        "RemovePackages" => {
969            let pkgs = str_array_or_empty(params, "packages")?;
970            let validated: Vec<String> = pkgs
971                .iter()
972                .map(|p| validated_install_package(p, "packages"))
973                .collect::<Result<_, _>>()?;
974            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
975            Ok(layering::remove_packages(&refs))
976        }
977        "AddLayeredPackage" => {
978            let package = validated_install_package(require_str(params, "package")?, "package")?;
979            Ok(layering::add_layered_package(&package))
980        }
981        "RemoveLayeredPackage" => {
982            let package = validated_install_package(require_str(params, "package")?, "package")?;
983            Ok(layering::remove_layered_package(&package))
984        }
985        "ReplaceLayeredPackage" => {
986            let old = validated_install_package(require_str(params, "old")?, "old")?;
987            let new = validated_install_package(require_str(params, "new")?, "new")?;
988            Ok(layering::replace_layered_package(&old, &new))
989        }
990        "RemoveBasePackage" => {
991            let package = validated_install_package(require_str(params, "package")?, "package")?;
992            Ok(layering::remove_base_package(&package))
993        }
994        "GetPendingUpdates" => Ok(layering::get_pending_updates()),
995
996        // ── Package repositories ──────────────────────────────────────────
997        "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
998        "AddPackageRepository" => Ok(package_repos::add_package_repository(
999            validated_repo_id(params)?,
1000            validated_no_newline(params, "repo_url")?,
1001        )),
1002        "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
1003            validated_repo_id(params)?,
1004        )),
1005        "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
1006            validated_repo_id(params)?,
1007        )),
1008        "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
1009            validated_repo_id(params)?,
1010        )),
1011
1012        // ── Services ─────────────────────────────────────────────────────
1013        "ListServices" => Ok(services::list_services()),
1014        "StartService" => {
1015            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1016            Ok(services::start_service(&unit))
1017        }
1018        "StopService" => {
1019            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1020            Ok(services::stop_service(&unit))
1021        }
1022        "RestartService" => {
1023            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1024            Ok(services::restart_service(&unit))
1025        }
1026        "SetServiceEnabled" => {
1027            let enabled = require_bool(params, "enabled")?;
1028            // Enabling brings the unit up at boot, so it must clear the root-shell
1029            // denylist; disabling one is a mitigation and stays allowed.
1030            let unit = if enabled {
1031                validated_activatable_unit(require_str(params, "unit")?, "unit")?
1032            } else {
1033                validated_unit_name(require_str(params, "unit")?, "unit")?
1034            };
1035            Ok(services::set_service_enabled(&unit, enabled))
1036        }
1037        "MaskService" => {
1038            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1039            Ok(services::mask_service(&unit))
1040        }
1041        "UnmaskService" => {
1042            let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1043            Ok(services::unmask_service(&unit))
1044        }
1045        "GetServiceLogs" => {
1046            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1047            Ok(services::get_service_logs(&unit))
1048        }
1049        "GetServiceStatus" => {
1050            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1051            Ok(services::get_service_status(&unit))
1052        }
1053        "ReloadService" => {
1054            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1055            Ok(services::reload_service(&unit))
1056        }
1057        "ListTimers" => Ok(services::list_timers()),
1058        "ReloadDaemon" => Ok(services::reload_daemon()),
1059        "CreateScheduledJob" => {
1060            // Job name: safe logical name. The service module owns the
1061            // exact sysknife-<name> unit-path rendering used by preview.
1062            let name = require_str(params, "name")?;
1063            if services::scheduled_job_unit_paths(name).is_none() {
1064                return Err(ExecutorError::InvalidParam("name"));
1065            }
1066            // Command: reject control characters (newlines would inject extra
1067            // unit directives). systemd argv-splits ExecStart with no shell.
1068            let command = require_str(params, "command")?;
1069            if command.is_empty()
1070                || command.len() > MAX_SCHEDULED_COMMAND_LEN
1071                || command.chars().any(|c| c.is_control())
1072            {
1073                return Err(ExecutorError::InvalidParam("command"));
1074            }
1075            // Schedule: OnCalendar charset; the helper validates it semantically
1076            // with `systemd-analyze calendar`.
1077            let schedule = require_str(params, "schedule")?;
1078            if schedule.is_empty()
1079                || schedule.len() > MAX_SCHEDULE_EXPR_LEN
1080                || !schedule.chars().all(|c| {
1081                    c.is_ascii_alphanumeric()
1082                        || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
1083                })
1084            {
1085                return Err(ExecutorError::InvalidParam("schedule"));
1086            }
1087            Ok(services::create_scheduled_job(name, command, schedule))
1088        }
1089        "GetServiceResourceLimits" => {
1090            let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1091            Ok(services::get_service_resource_limits(&unit))
1092        }
1093        "SetServiceResourceLimits" => {
1094            // Not `validated_unit_name`: setting a property is neither an
1095            // activation nor a read, and this action is Medium-risk, so a
1096            // Dev-tier caller reaches it. See RESOURCE_LIMIT_PROTECTED_UNITS.
1097            let unit = validated_resource_limit_unit(require_str(params, "unit")?, "unit")?;
1098            // Build validated PROPERTY=VALUE assignments from whichever limits
1099            // were supplied; at least one is required.
1100            let mut assignments = Vec::new();
1101            if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
1102                assignments.push(format!("MemoryMax={v}"));
1103            }
1104            if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
1105                assignments.push(format!("MemoryHigh={v}"));
1106            }
1107            if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
1108                assignments.push(format!("CPUQuota={v}"));
1109            }
1110            if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
1111                assignments.push(format!("TasksMax={v}"));
1112            }
1113            if assignments.is_empty() {
1114                return Err(ExecutorError::MissingParam("memory_max"));
1115            }
1116            Ok(services::set_service_resource_limits(&unit, &assignments))
1117        }
1118
1119        // ── Toolbox ───────────────────────────────────────────────────────
1120        // Toolbox operations require a `username` param — toolbox containers are
1121        // per-user (rootless Podman) and must be managed in the user's context.
1122        "ListToolboxes" => {
1123            let username = validated_username(resolve_username(params)?, "username")?;
1124            Ok(toolbox::list_toolboxes(&username))
1125        }
1126        "CreateToolbox" => {
1127            let username = validated_username(resolve_username(params)?, "username")?;
1128            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1129            let image = params
1130                .get("image")
1131                .and_then(|v| v.as_str())
1132                .map(|img| validated_safe_arg(img, "image"))
1133                .transpose()?;
1134            let release = params
1135                .get("release")
1136                .and_then(|v| v.as_str())
1137                .map(|r| validated_safe_arg(r, "release"))
1138                .transpose()?;
1139            Ok(toolbox::create_toolbox(
1140                &username,
1141                &name,
1142                release.as_deref(),
1143                image.as_deref(),
1144            ))
1145        }
1146        "RemoveToolbox" => {
1147            let username = validated_username(resolve_username(params)?, "username")?;
1148            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1149            Ok(toolbox::remove_toolbox(&username, &name))
1150        }
1151
1152        // ── Identity ─────────────────────────────────────────────────────
1153        "GetDateTime" => Ok(identity::get_datetime()),
1154        "SetHostname" => {
1155            let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
1156            Ok(identity::set_hostname(&hostname))
1157        }
1158        "SetTimezone" => {
1159            let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
1160            Ok(identity::set_timezone(&timezone))
1161        }
1162        "SetLocale" => {
1163            let locale = validated_locale(require_str(params, "locale")?, "locale")?;
1164            Ok(identity::set_locale(&locale))
1165        }
1166        "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
1167
1168        // ── Filesystem ────────────────────────────────────────────────────
1169        "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
1170
1171        // ── Processes ────────────────────────────────────────────────────
1172        "ListProcesses" => Ok(processes::list_processes_spec()),
1173        "SignalProcess" => {
1174            // pid may arrive as a JSON number or a numeric string.
1175            let pid = params
1176                .get("pid")
1177                .and_then(|v| {
1178                    v.as_u64()
1179                        .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
1180                })
1181                .ok_or(ExecutorError::MissingParam("pid"))?;
1182            // Reject pid 0 (whole process group) and 1 (init/systemd); anything
1183            // outside the u32 pid space is invalid.
1184            if pid < 2 || pid > u32::MAX as u64 {
1185                return Err(ExecutorError::InvalidParam("pid"));
1186            }
1187            let signal = validated_kill_signal(
1188                params
1189                    .get("signal")
1190                    .and_then(|v| v.as_str())
1191                    .unwrap_or("TERM"),
1192            )?;
1193            Ok(processes::signal_process(pid as u32, signal))
1194        }
1195
1196        // ── Journald ──────────────────────────────────────────────────────
1197        "GetJournalLog" => {
1198            let unit = optional_validated(params, "unit", validated_unit_name)?;
1199            let priority = optional_validated(params, "priority", validated_journal_priority)?;
1200            let since = optional_validated(params, "since", validated_journal_time)?;
1201            let until = optional_validated(params, "until", validated_journal_time)?;
1202            let grep = optional_validated(params, "grep", validated_journal_grep)?;
1203            // `lines` defaults to 100 and is clamped so an enormous value cannot
1204            // make the daemon buffer an unbounded journal dump.
1205            let lines = params
1206                .get("lines")
1207                .and_then(|v| v.as_u64())
1208                .unwrap_or(100)
1209                .clamp(1, MAX_JOURNAL_LINES) as u32;
1210            let boot = params
1211                .get("boot")
1212                .and_then(|v| v.as_bool())
1213                .unwrap_or(false);
1214            let kernel = params
1215                .get("kernel")
1216                .and_then(|v| v.as_bool())
1217                .unwrap_or(false);
1218            Ok(journald::get_journal_log(&journald::JournalQuery {
1219                lines,
1220                unit: unit.as_deref(),
1221                priority: priority.as_deref(),
1222                boot,
1223                kernel,
1224                since: since.as_deref(),
1225                until: until.as_deref(),
1226                grep: grep.as_deref(),
1227            }))
1228        }
1229        "VacuumJournal" => {
1230            // Exactly one of size_mb / retain_days selects the vacuum mode.
1231            let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1232            let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1233            match (size_mb, retain_days) {
1234                (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1235                    Ok(journald::vacuum_journal_by_size(mb as u32))
1236                }
1237                (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1238                    Ok(journald::vacuum_journal_by_time(days as u32))
1239                }
1240                (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1241                // both supplied, or an out-of-range value
1242                _ => Err(ExecutorError::InvalidParam("size_mb")),
1243            }
1244        }
1245
1246        // ── Storage / LVM ───────────────────────────────────────────────────
1247        "GetLvmReport" => Ok(lvm::get_lvm_report()),
1248        "ExtendLogicalVolume" => {
1249            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1250            let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1251            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1252            Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1253        }
1254        "CreateLogicalVolume" => {
1255            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1256            let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1257            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1258            Ok(lvm::create_logical_volume(&vg, &name, &size))
1259        }
1260        "CreateLvSnapshot" => {
1261            let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1262            let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1263            let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1264            let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1265            Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1266        }
1267
1268        // ── Kernel / sysctl ─────────────────────────────────────────────────
1269        "GetSysctl" => {
1270            // `key` is optional — absent means dump the whole table (sysctl -a).
1271            let key = optional_validated(params, "key", validated_sysctl_key)?;
1272            Ok(sysctl::get_sysctl(key.as_deref()))
1273        }
1274        "SetSysctl" => {
1275            let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1276            let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1277            Ok(sysctl::set_sysctl(&key, &value))
1278        }
1279
1280        // ── Filesystem mounts / swap ────────────────────────────────────────
1281        "GetMounts" => Ok(mounts::get_mounts()),
1282        "AddMount" => {
1283            let device = validated_mount_device(require_str(params, "device")?, "device")?;
1284            let mountpoint =
1285                validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1286            let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1287            let options = optional_validated(params, "options", validated_mount_options)?;
1288            Ok(mounts::add_mount(
1289                &device,
1290                &mountpoint,
1291                &fstype,
1292                options.as_deref(),
1293            ))
1294        }
1295        "RemoveMount" => {
1296            let mountpoint =
1297                validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1298            Ok(mounts::remove_mount(&mountpoint))
1299        }
1300        "AddSwap" => {
1301            let file = validated_swap_path(require_str(params, "file")?, "file")?;
1302            let size_mb = require_u32(params, "size_mb")?;
1303            // 1 MiB .. 1 TiB — reject 0 (empty) and absurdly large requests.
1304            if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1305                return Err(ExecutorError::InvalidParam("size_mb"));
1306            }
1307            Ok(mounts::add_swap(&file, size_mb))
1308        }
1309        "RemoveSwap" => {
1310            let file = validated_swap_path(require_str(params, "file")?, "file")?;
1311            Ok(mounts::remove_swap(&file))
1312        }
1313
1314        // ── Scoped sudoers.d ────────────────────────────────────────────────
1315        "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1316        "GrantSudoAccess" => {
1317            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1318            let user = validated_username(require_str(params, "user")?, "user")?;
1319            let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1320            // runas defaults to root; if given it must be "ALL" or a username.
1321            let runas = match params
1322                .get("runas")
1323                .and_then(|v| v.as_str())
1324                .filter(|s| !s.is_empty())
1325            {
1326                Some("ALL") => Some("ALL".to_string()),
1327                Some(u) => Some(validated_username(u, "runas")?),
1328                None => None,
1329            };
1330            let nopasswd = params
1331                .get("nopasswd")
1332                .and_then(|v| v.as_bool())
1333                .unwrap_or(false);
1334            // `commands = "ALL"` together with `nopasswd` mints a standing,
1335            // passwordless, unrestricted root credential (`user ALL=(ALL)
1336            // NOPASSWD: ALL`). Unlike every other action here, the effect
1337            // outlives the job: it is a permanent privilege grant, not a
1338            // one-time change, and it is indistinguishable from the daemon's
1339            // own authority thereafter. Require the caller to give up one of
1340            // the two dimensions — scope the commands, or keep the password
1341            // prompt. `visudo` would happily accept the combination, so this
1342            // is the only place it can be refused.
1343            //
1344            // `"ALL"` was the only spelling this refused, and
1345            // `validated_sudo_commands` accepts `/bin/bash`, which is the same
1346            // capability written differently: `user ALL=(root) NOPASSWD:
1347            // /bin/bash` is a standing passwordless root shell and `visudo -cf`
1348            // takes it without complaint. A shell-equivalent list is held to
1349            // the same line as `"ALL"` now. See SHELL_EQUIVALENT_COMMANDS for
1350            // what that list covers and what it deliberately leaves to the
1351            // preview.
1352            let shell_equivalent = shell_equivalent_sudo_command(&commands);
1353            if (commands == "ALL" || shell_equivalent.is_some()) && nopasswd {
1354                return Err(ExecutorError::InvalidParam("commands"));
1355            }
1356            Ok(sudoers::grant_sudo_access(
1357                &name,
1358                &user,
1359                &commands,
1360                runas.as_deref(),
1361                nopasswd,
1362            ))
1363        }
1364        "RevokeSudoAccess" => {
1365            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1366            Ok(sudoers::revoke_sudo_access(&name))
1367        }
1368
1369        // ── apt pinning (preferences.d) ─────────────────────────────────────
1370        "GetAptPins" => {
1371            let package = optional_validated(params, "package", validated_apt_package)?;
1372            Ok(apt_preferences::get_apt_pins(package.as_deref()))
1373        }
1374        "SetAptPin" => {
1375            let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1376            let package = validated_apt_package(require_str(params, "package")?, "package")?;
1377            let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1378            let priority = params
1379                .get("priority")
1380                .and_then(|v| v.as_i64())
1381                .ok_or(ExecutorError::MissingParam("priority"))?;
1382            if !(-1..=1000).contains(&priority) {
1383                return Err(ExecutorError::InvalidParam("priority"));
1384            }
1385            Ok(apt_preferences::set_apt_pin(
1386                &name, &package, &pin, priority,
1387            ))
1388        }
1389        "RemoveAptPin" => {
1390            let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1391            Ok(apt_preferences::remove_apt_pin(&name))
1392        }
1393
1394        // ── Log management (logrotate + rsyslog) ────────────────────────────
1395        "GetLogrotateStatus" => {
1396            let config = optional_validated(params, "config", validated_log_path)?;
1397            Ok(logging::get_logrotate_status(config.as_deref()))
1398        }
1399        "ConfigureLogRotation" => {
1400            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1401            let path = validated_log_path(require_str(params, "path")?, "path")?;
1402            let frequency = require_str(params, "frequency")?;
1403            if !matches!(frequency, "daily" | "weekly" | "monthly") {
1404                return Err(ExecutorError::InvalidParam("frequency"));
1405            }
1406            let rotate = require_u32(params, "rotate")?;
1407            if rotate > 1000 {
1408                return Err(ExecutorError::InvalidParam("rotate"));
1409            }
1410            let compress = params
1411                .get("compress")
1412                .and_then(|v| v.as_bool())
1413                .unwrap_or(false);
1414            Ok(logging::configure_log_rotation(
1415                &name, &path, frequency, rotate, compress,
1416            ))
1417        }
1418        "RemoveLogRotation" => {
1419            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1420            Ok(logging::remove_log_rotation(&name))
1421        }
1422        "ConfigureRemoteSyslog" => {
1423            let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1424            let port = require_u32(params, "port")?;
1425            if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1426                return Err(ExecutorError::InvalidParam("port"));
1427            }
1428            let protocol = require_str(params, "protocol")?;
1429            if !matches!(protocol, "tcp" | "udp") {
1430                return Err(ExecutorError::InvalidParam("protocol"));
1431            }
1432            Ok(logging::configure_remote_syslog(
1433                &host,
1434                port as u16,
1435                protocol,
1436            ))
1437        }
1438        "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1439
1440        // ── PAM password policy ───────────────────────────────────────────
1441        "GetPasswordAging" => {
1442            let user = validated_username(require_str(params, "user")?, "user")?;
1443            Ok(pam::get_password_aging(&user))
1444        }
1445        "SetPasswordAging" => {
1446            let user = validated_username(require_str(params, "user")?, "user")?;
1447            // chage days: 0..=99999 (chage's own ceiling). At least one required.
1448            let mut flags = Vec::new();
1449            for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1450                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1451                    if n > MAX_PASSWORD_AGE_DAYS {
1452                        return Err(ExecutorError::InvalidParam(key));
1453                    }
1454                    flags.push(opt.to_string());
1455                    flags.push(n.to_string());
1456                }
1457            }
1458            if flags.is_empty() {
1459                return Err(ExecutorError::MissingParam("max_days"));
1460            }
1461            Ok(pam::set_password_aging(&user, &flags))
1462        }
1463        "SetPasswordPolicy" => {
1464            // minlen 1..=128; *credit knobs -64..=64 (negative = require that
1465            // many chars of the class). At least one required.
1466            let mut extra = Vec::new();
1467            if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1468                if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1469                    return Err(ExecutorError::InvalidParam("minlen"));
1470                }
1471                extra.push("--minlen".to_string());
1472                extra.push(n.to_string());
1473            }
1474            for (key, flag) in [
1475                ("dcredit", "--dcredit"),
1476                ("ucredit", "--ucredit"),
1477                ("lcredit", "--lcredit"),
1478                ("ocredit", "--ocredit"),
1479            ] {
1480                if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1481                    if !(-64..=64).contains(&n) {
1482                        return Err(ExecutorError::InvalidParam(key));
1483                    }
1484                    extra.push(flag.to_string());
1485                    extra.push(n.to_string());
1486                }
1487            }
1488            if extra.is_empty() {
1489                return Err(ExecutorError::MissingParam("minlen"));
1490            }
1491            Ok(pam::set_password_policy(&extra))
1492        }
1493        "SetAccountLockout" => {
1494            // deny 1..=1000; unlock_time/fail_interval seconds 0..=604800 (7d).
1495            let mut extra = Vec::new();
1496            if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1497                if !(1..=1000).contains(&n) {
1498                    return Err(ExecutorError::InvalidParam("deny"));
1499                }
1500                extra.push("--deny".to_string());
1501                extra.push(n.to_string());
1502            }
1503            for (key, flag) in [
1504                ("unlock_time", "--unlock-time"),
1505                ("fail_interval", "--fail-interval"),
1506            ] {
1507                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1508                    if n > MAX_LOCKOUT_WINDOW_SECS {
1509                        return Err(ExecutorError::InvalidParam(key));
1510                    }
1511                    extra.push(flag.to_string());
1512                    extra.push(n.to_string());
1513                }
1514            }
1515            if extra.is_empty() {
1516                return Err(ExecutorError::MissingParam("deny"));
1517            }
1518            Ok(pam::set_account_lockout(&extra))
1519        }
1520
1521        // ── System info ──────────────────────────────────────────────────
1522        "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1523        "GetHostState" => Ok(system_info::get_host_state()),
1524
1525        // ── Network ───────────────────────────────────────────────────────
1526        "GetFirewallState" => Ok(network::get_firewall_state()),
1527        "GetNetworkStatus" => Ok(network::get_network_status()),
1528        "GetListeningPorts" => Ok(network::get_listening_ports()),
1529        "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1530        "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1531        "ConfigureWifi" => {
1532            let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1533            // password is optional — open networks connect without one.
1534            let password = params
1535                .get("password")
1536                .and_then(|v| v.as_str())
1537                .filter(|p| !p.is_empty())
1538                .map(|p| validated_safe_arg(p, "password"))
1539                .transpose()?;
1540            Ok(network::configure_wifi(&ssid, password.as_deref()))
1541        }
1542        "SetDnsServers" => {
1543            let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1544            let servers = str_array_or_empty(params, "servers")?;
1545            let validated: Vec<String> = servers
1546                .iter()
1547                .map(|s| validated_safe_arg(s, "servers"))
1548                .collect::<Result<_, _>>()?;
1549            let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1550            Ok(network::set_dns_servers(&interface, &refs))
1551        }
1552        "ConfigureFirewall" => {
1553            let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1554            let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1555            Ok(network::configure_firewall(
1556                &zone,
1557                &service,
1558                require_bool(params, "enabled")?,
1559            ))
1560        }
1561
1562        // ── Users ─────────────────────────────────────────────────────────
1563        "ListUsers" => Ok(users::list_users()),
1564        "ListGroups" => Ok(users::list_groups()),
1565        "CreateUser" => {
1566            let username = validated_username(resolve_username(params)?, "username")?;
1567            let shell = params
1568                .get("shell")
1569                .and_then(|v| v.as_str())
1570                .map(|s| validated_safe_arg(s, "shell"))
1571                .transpose()?;
1572            let home = params
1573                .get("home")
1574                .and_then(|v| v.as_str())
1575                .map(|h| validated_home_dir(h, "home"))
1576                .transpose()?;
1577            Ok(users::create_user(
1578                &username,
1579                shell.as_deref(),
1580                home.as_deref(),
1581            ))
1582        }
1583        "DeleteUser" => {
1584            let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1585            Ok(users::delete_user(&username))
1586        }
1587        "AddUserToGroup" => {
1588            let username = validated_username(resolve_username(params)?, "username")?;
1589            let group = validated_group(require_str(params, "group")?, "group")?;
1590            Ok(users::add_user_to_group(&username, &group))
1591        }
1592        "RemoveUserFromGroup" => {
1593            let username = validated_username(resolve_username(params)?, "username")?;
1594            let group = validated_group(require_str(params, "group")?, "group")?;
1595            Ok(users::remove_user_from_group(&username, &group))
1596        }
1597        "CreateGroup" => {
1598            let group = validated_group(require_str(params, "group")?, "group")?;
1599            let system = params
1600                .get("system")
1601                .and_then(|v| v.as_bool())
1602                .unwrap_or(false);
1603            Ok(users::create_group(&group, system))
1604        }
1605        "DeleteGroup" => {
1606            let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1607            Ok(users::delete_group(&group))
1608        }
1609        "LockUserAccount" => {
1610            let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1611            Ok(users::lock_user_account(&username))
1612        }
1613        "UnlockUserAccount" => {
1614            let username = validated_username(resolve_username(params)?, "username")?;
1615            Ok(users::unlock_user_account(&username))
1616        }
1617
1618        // ── SSH ──────────────────────────────────────────────────────────
1619        "GetAuthorizedKeys" => {
1620            let username = validated_username(resolve_username(params)?, "username")?;
1621            Ok(ssh::get_authorized_keys(&username))
1622        }
1623        "AddAuthorizedKey" => {
1624            let username = validated_username(resolve_username(params)?, "username")?;
1625            let public_key = validated_public_key(require_str(params, "public_key")?)?;
1626            Ok(ssh::add_authorized_key(&username, &public_key))
1627        }
1628        "RemoveAuthorizedKey" => {
1629            let username = validated_username(resolve_username(params)?, "username")?;
1630            let public_key = validated_public_key(require_str(params, "public_key")?)?;
1631            Ok(ssh::remove_authorized_key(&username, &public_key))
1632        }
1633        "SetSshdOption" => {
1634            // Allowlist the option name and its permitted values (the helper
1635            // re-validates as defense-in-depth). This is deliberately not an
1636            // arbitrary sshd_config editor.
1637            let option = require_str(params, "option")?;
1638            let value = require_str(params, "value")?;
1639            let allowed_values: &[&str] = match option {
1640                "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1641                "PasswordAuthentication"
1642                | "PubkeyAuthentication"
1643                | "X11Forwarding"
1644                | "PermitEmptyPasswords" => &["yes", "no"],
1645                _ => return Err(ExecutorError::InvalidParam("option")),
1646            };
1647            if !allowed_values.contains(&value) {
1648                return Err(ExecutorError::InvalidParam("value"));
1649            }
1650            Ok(ssh::set_sshd_option(option, value))
1651        }
1652
1653        // ── apt ──────────────────────────────────────────────────────────
1654        "AptUpdate" => Ok(apt::apt_update()),
1655        "AptUpgrade" => Ok(apt::apt_upgrade()),
1656        "AptInstall" => {
1657            let package = validated_install_package(require_str(params, "package")?, "package")?;
1658            Ok(apt::apt_install(&package))
1659        }
1660        "AptRemove" => {
1661            let package = validated_install_package(require_str(params, "package")?, "package")?;
1662            Ok(apt::apt_remove(&package))
1663        }
1664        "AptPurge" => {
1665            let package = validated_install_package(require_str(params, "package")?, "package")?;
1666            Ok(apt::apt_purge(&package))
1667        }
1668        "AptAutoremove" => Ok(apt::apt_autoremove()),
1669        "AptHold" => {
1670            let package = validated_install_package(require_str(params, "package")?, "package")?;
1671            Ok(apt::apt_hold(&package))
1672        }
1673        "AptUnhold" => {
1674            let package = validated_install_package(require_str(params, "package")?, "package")?;
1675            Ok(apt::apt_unhold(&package))
1676        }
1677        "AptSearch" => {
1678            let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1679            Ok(apt::apt_search(&term))
1680        }
1681        "AptListInstalled" => Ok(apt::apt_list_installed()),
1682        "AptShow" => {
1683            let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1684            Ok(apt::apt_show(&package))
1685        }
1686        "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1687        "AptHistoryList" => Ok(apt::apt_history_list()),
1688        "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1689            params, "enabled",
1690        )?)),
1691
1692        // ── ppa ──────────────────────────────────────────────────────────
1693        "AddPpa" => {
1694            let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1695            Ok(ppa::add_ppa(&name))
1696        }
1697        "RemovePpa" => {
1698            let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1699            Ok(ppa::remove_ppa(&name))
1700        }
1701
1702        // ── snap ─────────────────────────────────────────────────────────
1703        "SnapInstall" => {
1704            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1705            let channel = params
1706                .get("channel")
1707                .and_then(|v| v.as_str())
1708                .filter(|s| !s.is_empty())
1709                .map(|s| validated_safe_arg(s, "channel"))
1710                .transpose()?;
1711            let auto_update = params
1712                .get("auto_update")
1713                .and_then(|v| v.as_bool())
1714                .unwrap_or(false);
1715            snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1716                snap::SnapInstallError::InvalidArg { param, .. } => {
1717                    ExecutorError::InvalidParam(param)
1718                }
1719            })
1720        }
1721        "SnapRemove" => {
1722            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1723            Ok(snap::snap_remove(&name))
1724        }
1725        "SnapRefresh" => {
1726            let name = params
1727                .get("name")
1728                .and_then(|v| v.as_str())
1729                .filter(|s| !s.is_empty())
1730                .map(|s| validated_safe_arg(s, "name"))
1731                .transpose()?;
1732            Ok(snap::snap_refresh(name.as_deref()))
1733        }
1734        "SnapHold" => {
1735            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1736            Ok(snap::snap_hold(&name))
1737        }
1738        "SnapUnhold" => {
1739            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1740            Ok(snap::snap_unhold(&name))
1741        }
1742        "SnapList" => Ok(snap::snap_list()),
1743        "SnapInfo" => {
1744            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1745            Ok(snap::snap_info(&name))
1746        }
1747        "SnapRevert" => {
1748            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1749            Ok(snap::snap_revert(&name))
1750        }
1751        "SnapClassicInstall" => {
1752            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1753            Ok(snap::snap_classic_install(&name))
1754        }
1755
1756        // ── grub ─────────────────────────────────────────────────────────
1757        "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1758        "GrubSetKargs" => {
1759            let append: Vec<String> = params
1760                .get("append")
1761                .and_then(|v| v.as_array())
1762                .map(|arr| {
1763                    arr.iter()
1764                        .filter_map(|v| v.as_str())
1765                        .map(String::from)
1766                        .collect()
1767                })
1768                .unwrap_or_default();
1769            let delete: Vec<String> = params
1770                .get("delete")
1771                .and_then(|v| v.as_array())
1772                .map(|arr| {
1773                    arr.iter()
1774                        .filter_map(|v| v.as_str())
1775                        .map(String::from)
1776                        .collect()
1777                })
1778                .unwrap_or_default();
1779            // Validate each arg in both lists. `append` gets the charset check
1780            // (rejects `=`, `,`, shell metacharacters — the latter also blocks
1781            // CSV injection into the helper's comma-separated list) *and* the
1782            // kernel-arg denylist, so a bare `single`/`s`/`1` cannot boot the
1783            // host into a single-user root shell — parity with
1784            // SetKernelArguments. `delete` gets the charset check plus the
1785            // removal screen: dropping `module.sig_enforce=1` or `lockdown=`
1786            // weakens the next boot exactly as adding its counterpart would.
1787            for a in &append {
1788                validated_safe_arg(a, "append")?;
1789                validated_safe_kernel_arg(a, "append")?;
1790            }
1791            for d in &delete {
1792                validated_safe_arg(d, "delete")?;
1793                validated_safe_kernel_arg_removal(d, "delete")?;
1794            }
1795            // The constructor itself enforces "at least one of append/delete
1796            // non-empty" — this is the single source of truth for the invariant.
1797            let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1798            let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1799            grub::grub_set_kargs(&append_refs, &delete_refs)
1800                .map_err(|_| ExecutorError::MissingParam("append or delete"))
1801        }
1802
1803        // ── reboot ────────────────────────────────────────────────────────
1804        "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1805
1806        // ── ufw ──────────────────────────────────────────────────────────
1807        "UfwEnable" => Ok(ufw::ufw_enable()),
1808        "UfwDisable" => Ok(ufw::ufw_disable()),
1809        "UfwAllow" => {
1810            let port_or_service = validated_port_or_service(
1811                require_str(params, "port_or_service")?,
1812                "port_or_service",
1813            )?;
1814            Ok(ufw::ufw_allow(&port_or_service))
1815        }
1816        "UfwDeny" => {
1817            let port_or_service = validated_port_or_service(
1818                require_str(params, "port_or_service")?,
1819                "port_or_service",
1820            )?;
1821            Ok(ufw::ufw_deny(&port_or_service))
1822        }
1823        "UfwReset" => Ok(ufw::ufw_reset()),
1824        "UfwStatus" => {
1825            let numbered = match params.get("numbered") {
1826                None => false,
1827                Some(value) => value
1828                    .as_bool()
1829                    .ok_or(ExecutorError::InvalidParam("numbered"))?,
1830            };
1831            Ok(ufw::ufw_status_with_numbered(numbered))
1832        }
1833
1834        // ── distrobox ────────────────────────────────────────────────────
1835        "DistroboxList" => Ok(distrobox::distrobox_list()),
1836        "DistroboxCreate" => {
1837            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1838            let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1839            Ok(distrobox::distrobox_create(&name, &image))
1840        }
1841        "DistroboxRemove" => {
1842            let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1843            Ok(distrobox::distrobox_remove(&name))
1844        }
1845
1846        // ── netplan ──────────────────────────────────────────────────────
1847        "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1848        "NetplanApply" => Ok(netplan::netplan_apply()),
1849        "NetplanSet" => {
1850            let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1851            let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1852            Ok(netplan::netplan_set(&key, &value))
1853        }
1854        "NetplanGenerate" => Ok(netplan::netplan_generate()),
1855
1856        // ── ufw Tier 3 ────────────────────────────────────────────────────
1857        "UfwDeleteRule" => {
1858            let rule_number = require_positive_u32(params, "rule_number")?;
1859            ufw::ufw_delete_rule(rule_number)
1860                .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1861        }
1862        "UfwLimit" => {
1863            let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1864            Ok(ufw::ufw_limit(&target))
1865        }
1866
1867        // ── Ubuntu Pro ────────────────────────────────────────────────────
1868        "ProStatus" => Ok(ubuntu_pro::pro_status()),
1869        "ProAttach" => {
1870            // token is a credential: read it from params but do NOT log it.
1871            let token = require_str(params, "token")?;
1872            // Minimal structural validation: non-empty, no shell metacharacters.
1873            let token = validated_safe_arg(token, "token")?;
1874            Ok(ubuntu_pro::pro_attach(&token))
1875        }
1876        "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1877        "EnableProService" => {
1878            let service = validated_pro_service(require_str(params, "service")?, "service")?;
1879            Ok(ubuntu_pro::enable_pro_service(&service))
1880        }
1881        "DisableProService" => {
1882            let service = validated_pro_service(require_str(params, "service")?, "service")?;
1883            Ok(ubuntu_pro::disable_pro_service(&service))
1884        }
1885
1886        // ── Livepatch ─────────────────────────────────────────────────────
1887        "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1888
1889        // ── Multipass ─────────────────────────────────────────────────────
1890        "MultipassList" => Ok(multipass::multipass_list()),
1891
1892        // ── Release upgrade ───────────────────────────────────────────────
1893        "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1894
1895        // ── resolvectl (cross-distro / systemd-resolved) ──────────────────
1896        "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1897        "ResolvectlSetDns" => {
1898            let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1899            let raw_servers: Vec<String> = params
1900                .get("servers")
1901                .and_then(|v| v.as_array())
1902                .map(|arr| {
1903                    arr.iter()
1904                        .filter_map(|v| v.as_str())
1905                        .map(String::from)
1906                        .collect()
1907                })
1908                .unwrap_or_default();
1909            if raw_servers.is_empty() {
1910                return Err(ExecutorError::MissingParam("servers"));
1911            }
1912            // Parse every server string as a typed IpAddr before passing to the
1913            // constructor. This rejects leading-dash strings (flag injection) and
1914            // malformed addresses that would silently misconfigure systemd-resolved.
1915            let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1916            for s in &raw_servers {
1917                let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1918                    param: "servers",
1919                    value: s.clone(),
1920                })?;
1921                parsed_servers.push(addr);
1922            }
1923            Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1924        }
1925
1926        // ── apparmor ──────────────────────────────────────────────────────
1927        "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1928        "AppArmorEnforce" => {
1929            let profile_path =
1930                validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1931            Ok(apparmor::apparmor_enforce(&profile_path))
1932        }
1933        "AppArmorComplain" => {
1934            let profile_path =
1935                validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1936            Ok(apparmor::apparmor_complain(&profile_path))
1937        }
1938
1939        // ── cloud-init ────────────────────────────────────────────────────
1940        "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1941
1942        // ── Ubuntu Flatpak ─────────────────────────────────────────────────
1943        "UbuntuInstallFlatpak" => {
1944            let username = validated_username(resolve_username(params)?, "username")?;
1945            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1946            let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1947            Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1948        }
1949        "UbuntuRemoveFlatpak" => {
1950            let username = validated_username(resolve_username(params)?, "username")?;
1951            let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1952            Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1953        }
1954        "UbuntuUpdateFlatpak" => {
1955            let username = validated_username(resolve_username(params)?, "username")?;
1956            let app_id = params
1957                .get("app_id")
1958                .and_then(|v| v.as_str())
1959                .filter(|s| !s.is_empty())
1960                .map(|s| validated_safe_arg(s, "app_id"))
1961                .transpose()?;
1962            Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1963        }
1964        "UbuntuListFlatpaks" => {
1965            let username = validated_username(resolve_username(params)?, "username")?;
1966            Ok(flatpak::ubuntu_list_flatpaks(&username))
1967        }
1968
1969        // ── fail2ban ──────────────────────────────────────────────────────
1970        "Fail2banStatus" => {
1971            let jail = params
1972                .get("jail")
1973                .and_then(|v| v.as_str())
1974                .filter(|s| !s.is_empty())
1975                .map(|s| validated_safe_arg(s, "jail"))
1976                .transpose()?;
1977            fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1978                fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1979                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1980            })
1981        }
1982        "Fail2banBanIp" => {
1983            let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1984            let ip = require_str(params, "ip")?;
1985            fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1986                fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1987                    param: "ip",
1988                    value: v,
1989                },
1990                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1991            })
1992        }
1993        "Fail2banUnbanIp" => {
1994            let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1995            let ip = require_str(params, "ip")?;
1996            fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1997                fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1998                    param: "ip",
1999                    value: v,
2000                },
2001                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
2002            })
2003        }
2004        "ConfigureFail2banJail" => {
2005            let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
2006            let mut extra = Vec::new();
2007            if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
2008                extra.push("--enabled".to_string());
2009                extra.push(if n { "true" } else { "false" }.to_string());
2010            }
2011            if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
2012                if !(1..=100).contains(&n) {
2013                    return Err(ExecutorError::InvalidParam("maxretry"));
2014                }
2015                extra.push("--maxretry".to_string());
2016                extra.push(n.to_string());
2017            }
2018            for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
2019                if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
2020                    if n > MAX_FAIL2BAN_WINDOW_SECS {
2021                        return Err(ExecutorError::InvalidParam(key));
2022                    }
2023                    extra.push(flag.to_string());
2024                    extra.push(n.to_string());
2025                }
2026            }
2027            if extra.is_empty() {
2028                return Err(ExecutorError::MissingParam("maxretry"));
2029            }
2030            fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
2031                fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
2032                fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
2033            })
2034        }
2035
2036        // ── auditd file-watch rules ───────────────────────────────────────
2037        "GetAuditRules" => Ok(auditd::get_audit_rules()),
2038        "AddAuditRule" => {
2039            let path = validated_audit_path(require_str(params, "path")?, "path")?;
2040            let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
2041            let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2042            Ok(auditd::add_audit_rule(&path, &perms, &key))
2043        }
2044        "RemoveAuditRule" => {
2045            let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2046            Ok(auditd::remove_audit_rule(&key))
2047        }
2048
2049        // ── certbot / ACME ────────────────────────────────────────────────
2050        "GetCertificates" => Ok(certbot::get_certificates()),
2051        "ObtainCertificate" => {
2052            // Accept a "domains" array or a single "domain" string; ≥1 required.
2053            let mut domains = Vec::new();
2054            if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
2055                for d in arr {
2056                    let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
2057                    domains.push(validated_domain(s, "domains")?);
2058                }
2059            } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
2060                domains.push(validated_domain(d, "domain")?);
2061            }
2062            if domains.is_empty() {
2063                return Err(ExecutorError::MissingParam("domains"));
2064            }
2065            let email = validated_email(require_str(params, "email")?, "email")?;
2066            let challenge = params
2067                .get("challenge")
2068                .and_then(|v| v.as_str())
2069                .unwrap_or("standalone");
2070            if !matches!(challenge, "standalone" | "nginx" | "apache") {
2071                return Err(ExecutorError::InvalidParam("challenge"));
2072            }
2073            Ok(certbot::obtain_certificate(&domains, &email, challenge))
2074        }
2075        "RenewCertificates" => Ok(certbot::renew_certificates()),
2076
2077        _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
2078    }
2079}
2080
2081/// Execute an [`ActionSpec`] and return the output.
2082///
2083/// For `Command` mechanisms, the process is spawned and its stdout/stderr
2084/// are captured. For file mechanisms, the operation is performed directly
2085/// on the filesystem and an empty stdout is returned.
2086pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
2087    match &spec.mechanism {
2088        ActionMechanism::Command { program, args } => {
2089            // Spawned rather than `.output()`ed so the child can be killed on
2090            // timeout; `.output()` gives no handle to kill, so a hung process
2091            // would hold the task forever.
2092            let mut child = tokio::process::Command::new(program)
2093                .args(args)
2094                .stdin(Stdio::null())
2095                // Own process group: the deadline below must be able to stop the
2096                // whole tree, not just the pid we spawned. See issue #140.
2097                .process_group(0)
2098                .stdout(Stdio::piped())
2099                .stderr(Stdio::piped())
2100                .spawn()?;
2101
2102            // `wait_with_output` consumes the child, which would leave nothing
2103            // to kill when the deadline fires. Drain the pipes in their own
2104            // tasks instead — draining also stops a chatty process from
2105            // blocking on a full pipe buffer while we wait.
2106            let stdout_h = child.stdout.take().expect("stdout was piped");
2107            let stderr_h = child.stderr.take().expect("stderr was piped");
2108            let stdout_task = tokio::spawn(async move {
2109                let mut buf = Vec::new();
2110                BufReader::new(stdout_h)
2111                    .read_to_end(&mut buf)
2112                    .await
2113                    .map(|_| buf)
2114            });
2115            let stderr_task = tokio::spawn(async move {
2116                let mut buf = Vec::new();
2117                BufReader::new(stderr_h)
2118                    .read_to_end(&mut buf)
2119                    .await
2120                    .map(|_| buf)
2121            });
2122
2123            let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
2124                Ok(Ok(status)) => status,
2125                // Same rule as the streaming path: a `wait` that fails leaves a
2126                // privileged child in an unknown state, so stop the group before
2127                // reporting the failure upward.
2128                Ok(Err(err)) => {
2129                    return Err(kill_and_reap(
2130                        &mut child,
2131                        program,
2132                        args,
2133                        StopCause::Unreadable(err),
2134                    )
2135                    .await)
2136                }
2137                Err(_) => {
2138                    return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
2139                }
2140            };
2141            let join =
2142                |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
2143            let stdout = stdout_task.await.map_err(join)??;
2144            let stderr = stderr_task.await.map_err(join)??;
2145
2146            Ok(ExecutionOutput {
2147                stdout: String::from_utf8_lossy(&stdout).into_owned(),
2148                stderr: String::from_utf8_lossy(&stderr).into_owned(),
2149                exit_code: status.code().unwrap_or(-1),
2150            })
2151        }
2152        ActionMechanism::FileScan { path } => {
2153            let mut entries = tokio::fs::read_dir(path).await?;
2154            let mut names = Vec::new();
2155            while let Some(entry) = entries.next_entry().await? {
2156                names.push(entry.file_name().to_string_lossy().into_owned());
2157            }
2158            names.sort();
2159            Ok(ExecutionOutput {
2160                stdout: names.join("\n"),
2161                stderr: String::new(),
2162                exit_code: 0,
2163            })
2164        }
2165        ActionMechanism::FileWrite { path, content } => {
2166            if let Some(parent) = std::path::Path::new(path).parent() {
2167                tokio::fs::create_dir_all(parent).await?;
2168            }
2169            tokio::fs::write(path, content).await?;
2170            Ok(ExecutionOutput {
2171                stdout: String::new(),
2172                stderr: String::new(),
2173                exit_code: 0,
2174            })
2175        }
2176        ActionMechanism::FilePatch {
2177            path,
2178            search,
2179            replace,
2180        } => {
2181            let content = tokio::fs::read_to_string(path).await?;
2182            let patched = content.replacen(search.as_str(), replace.as_str(), 1);
2183            if patched == content && !search.is_empty() {
2184                return Ok(ExecutionOutput {
2185                    stdout: String::new(),
2186                    stderr: format!("search string not found in file: {}", path),
2187                    exit_code: 1,
2188                });
2189            }
2190            tokio::fs::write(path, patched).await?;
2191            Ok(ExecutionOutput {
2192                stdout: String::new(),
2193                stderr: String::new(),
2194                exit_code: 0,
2195            })
2196        }
2197        ActionMechanism::FileDelete { path } => {
2198            tokio::fs::remove_file(path).await?;
2199            Ok(ExecutionOutput {
2200                stdout: String::new(),
2201                stderr: String::new(),
2202                exit_code: 0,
2203            })
2204        }
2205    }
2206}
2207
2208fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
2209    match params.get(key) {
2210        None => Err(ExecutorError::MissingParam(key)),
2211        Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
2212    }
2213}
2214
2215/// Extract an optional string param and validate it. An absent key or an empty
2216/// string yields `None` (the filter is simply omitted); a present non-empty
2217/// value is passed through `validator`, propagating any validation error.
2218fn optional_validated<F>(
2219    params: &Value,
2220    key: &'static str,
2221    validator: F,
2222) -> Result<Option<String>, ExecutorError>
2223where
2224    F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2225{
2226    match params
2227        .get(key)
2228        .and_then(|v| v.as_str())
2229        .filter(|s| !s.is_empty())
2230    {
2231        Some(s) => Ok(Some(validator(s, key)?)),
2232        None => Ok(None),
2233    }
2234}
2235
2236/// Extract the username from params, accepting either `"username"` or `"user"`
2237/// as the key.  The `"username"` key takes precedence.
2238///
2239/// Tolerates the `"user"` alias because LLMs trained on general Linux tooling
2240/// frequently produce `"user"` — accepting both here eliminates an entire class
2241/// of Describe/Execute failures without requiring the model to be perfect.
2242///
2243/// Returns [`ExecutorError::MissingParam`] if neither key is present,
2244/// [`ExecutorError::InvalidParam`] if the value is not a string.
2245fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2246    params
2247        .get("username")
2248        .or_else(|| params.get("user"))
2249        .ok_or(ExecutorError::MissingParam("username"))
2250        .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2251}
2252
2253/// Validate a repo_id: must be non-empty and contain only ASCII letters,
2254/// digits, hyphens, and underscores. Rejects `/`, `.`, and whitespace to
2255/// prevent path traversal (e.g. `../cron.d/evil`) and shell injection.
2256fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2257    let id = require_str(params, "repo_id")?;
2258    let valid = !id.is_empty()
2259        && id
2260            .chars()
2261            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2262    if valid {
2263        Ok(id)
2264    } else {
2265        Err(ExecutorError::InvalidParam("repo_id"))
2266    }
2267}
2268
2269/// Validate that a string contains no newlines. Used for repo_url to prevent
2270/// INI-section injection into `.repo` file content.
2271fn validated_no_newline<'a>(
2272    params: &'a Value,
2273    key: &'static str,
2274) -> Result<&'a str, ExecutorError> {
2275    let val = require_str(params, key)?;
2276    if val.contains('\n') || val.contains('\r') {
2277        Err(ExecutorError::InvalidParam(key))
2278    } else {
2279        Ok(val)
2280    }
2281}
2282
2283/// Validate an SSH public key: must start with a known key-type prefix,
2284/// contain only printable ASCII, no newlines, no single quotes (to prevent
2285/// shell injection in `sh -c` scripts), and be at most 8192 characters.
2286fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2287    const MAX_LEN: usize = 8192;
2288    const ALLOWED_PREFIXES: &[&str] = &[
2289        "ssh-rsa",
2290        "ssh-ed25519",
2291        "ssh-ed25519-sk",
2292        "ecdsa-sha2-nistp256",
2293        "ecdsa-sha2-nistp384",
2294        "ecdsa-sha2-nistp521",
2295        "sk-ssh-ed25519",
2296        "sk-ecdsa-sha2-nistp256",
2297    ];
2298
2299    if s.is_empty() || s.len() > MAX_LEN {
2300        return Err(ExecutorError::InvalidParam("public_key"));
2301    }
2302    if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2303        return Err(ExecutorError::InvalidParam("public_key"));
2304    }
2305    // No newlines, no shell metacharacters, only printable ASCII.
2306    //
2307    // Blocked characters and why:
2308    //   '\''  — breaks single-quoted shell strings in add_authorized_key
2309    //   '|'   — shell pipe (the ssh key ops no longer build a sed address,
2310    //           but '|' never appears in a valid key, so keep rejecting it)
2311    //   ';'   — shell command separator
2312    //   '`'   — shell command substitution
2313    //   '$'   — shell variable expansion
2314    //   '\\'  — shell escape; could be used to smuggle other metacharacters
2315    //   '&'   — shell background / AND operator
2316    //
2317    // None of these characters appear in valid SSH public key data (type prefix,
2318    // base64 body, or ASCII comment) so this list is safe to block unconditionally.
2319    if s.chars().any(|c| {
2320        matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2321            || !c.is_ascii()
2322            || c.is_ascii_control()
2323    }) {
2324        return Err(ExecutorError::InvalidParam("public_key"));
2325    }
2326    Ok(s.to_string())
2327}
2328
2329fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2330    match params.get(key) {
2331        None => Err(ExecutorError::MissingParam(key)),
2332        Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2333    }
2334}
2335
2336fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2337    match params.get(key) {
2338        None => Err(ExecutorError::MissingParam(key)),
2339        Some(v) => {
2340            let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2341            u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2342        }
2343    }
2344}
2345
2346/// Like [`require_u32`] but additionally rejects zero.
2347///
2348/// Used for rule numbers and similar 1-based indices where 0 is never valid.
2349fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2350    let n = require_u32(params, key)?;
2351    if n == 0 {
2352        return Err(ExecutorError::InvalidParam(key));
2353    }
2354    Ok(n)
2355}
2356
2357/// Returns a vec of owned strings from a JSON array, or an empty vec if the
2358/// key is absent or null. Returns [`ExecutorError::InvalidParam`] if the key
2359/// is present but not an array of strings.
2360fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2361    match params.get(key) {
2362        None | Some(Value::Null) => Ok(vec![]),
2363        Some(Value::Array(arr)) => arr
2364            .iter()
2365            .map(|v| {
2366                v.as_str()
2367                    .map(String::from)
2368                    .ok_or(ExecutorError::InvalidParam(key))
2369            })
2370            .collect(),
2371        _ => Err(ExecutorError::InvalidParam(key)),
2372    }
2373}
2374
2375/// Reject kernel command-line arguments that could bypass security mechanisms
2376/// or drop to an unauthenticated root shell on next boot. Applies only to
2377/// arguments being *added* (`SetKernelArguments`'s `add`, `GrubSetKargs`'s
2378/// `append`) — removing an existing argument is always safe.
2379///
2380/// `param` names the request field being validated so the error points at the
2381/// caller's actual parameter (`"add"` for `SetKernelArguments`, `"append"` for
2382/// `GrubSetKargs`).
2383///
2384/// This is a *denylist* layered on top of the caller's charset validation —
2385/// both callers run their charset check first. In particular `GrubSetKargs`
2386/// runs [`validated_safe_arg`] (which already rejects `=` and `,`), so on that
2387/// path the load-bearing checks here are the bare runlevel shortcuts
2388/// (`single`/`s`/`1`).
2389///
2390/// Blocked (case-insensitive):
2391///
2392/// - `init=`           — replaces init, can give a root shell
2393/// - `selinux=0`       — disables SELinux
2394/// - `enforcing=0`     — sets SELinux to permissive
2395/// - `security=`       — overrides LSM module selection
2396/// - `apparmor=0`      — disables the AppArmor LSM (Ubuntu's default MAC)
2397/// - `systemd.unit=emergency` / `systemd.unit=rescue` / `systemd.unit=single`
2398///   — unprotected root shell
2399/// - `single` / `1` / `s` — single-user mode (root without password)
2400/// - `module_blacklist=` — can disable security-critical kernel modules
2401/// - `mitigations=off` — disables CPU speculative-execution vulnerability
2402///   mitigations (Spectre/Meltdown/MDS/etc.)
2403/// - `lockdown=`       — weakens or disables the kernel lockdown LSM
2404/// - `pti=off`         — disables Page Table Isolation (Meltdown mitigation)
2405/// - `nosmap` / `nosmep` — disable SMAP/SMEP, CPU features that block a large
2406///   class of kernel-exploitation techniques
2407/// - `systemd.debug_shell` — the same root shell as `systemd.unit=debug-shell`,
2408///   asked for by a different name
2409fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2410    const BLOCKED_PREFIXES: &[&str] = &[
2411        "init=",
2412        "selinux=0",
2413        "enforcing=0",
2414        "security=",
2415        "module_blacklist=",
2416        "apparmor=0",
2417        "mitigations=off",
2418        "lockdown=",
2419        "pti=off",
2420        // `systemd.unit=` is not the only way to ask for `debug-shell.service`.
2421        // systemd-debug-generator also honours `systemd.debug_shell`, which
2422        // pulls the unit into the boot transaction and spawns a root shell on
2423        // tty9 before anyone logs in — the identical end state the
2424        // `BLOCKED_UNIT_PREFIXES` screen below exists to prevent. Underscore is
2425        // the real spelling per systemd-debug-generator(8); `rd.` is the
2426        // initrd-honoured variant, and the hyphen forms are listed so a
2427        // near-miss cannot creep back.
2428        "systemd.debug_shell",
2429        "systemd.debug-shell",
2430        "rd.systemd.debug_shell",
2431        "rd.systemd.debug-shell",
2432    ];
2433    const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2434    // Shared with `validated_activatable_unit`, deliberately: these two screens
2435    // encode the same idea from opposite directions — one refuses to START a
2436    // root-shell unit, the other refuses to BOOT into it — and keeping two lists
2437    // let them drift until `debug-shell` and `runlevel1` were blocked on one path
2438    // and reachable on the other.
2439    use crate::actions::validate::ROOT_SHELL_UNITS as BLOCKED_UNIT_PREFIXES;
2440
2441    let lower = arg.to_lowercase();
2442    // Strip optional value (e.g. "quiet=1" → "quiet") for exact matches.
2443    let base = lower.split('=').next().unwrap_or(&lower);
2444
2445    if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2446        return Err(ExecutorError::InvalidParam(param));
2447    }
2448    if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2449        return Err(ExecutorError::InvalidParam(param));
2450    }
2451    // Block systemd.unit= pointing to emergency/rescue/single targets.
2452    if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2453        if BLOCKED_UNIT_PREFIXES
2454            .iter()
2455            .any(|u| unit_val.starts_with(u))
2456        {
2457            return Err(ExecutorError::InvalidParam(param));
2458        }
2459    }
2460    // Guard against the base arg matching dangerous exact values even with =.
2461    if BLOCKED_EXACT.contains(&base) {
2462        return Err(ExecutorError::InvalidParam(param));
2463    }
2464    Ok(())
2465}
2466
2467/// Validate a kernel argument that is about to be **removed** from the boot line.
2468///
2469/// [`validated_safe_kernel_arg`] screens additions. Removal was left unscreened
2470/// on the premise that "removing a dangerous arg is always safe", which inverts
2471/// the risk: the dangerous move is removing a *protective* arg. Deleting
2472/// `module.sig_enforce=1` or `lockdown=confidentiality` reaches the same end
2473/// state as adding its weakening counterpart, which the add-path refuses — so
2474/// leaving `remove` open handed back every guarantee the add-path buys.
2475///
2476/// The prefixes below are the KSPP-recommended boot parameters plus the LSM
2477/// selectors; each is a protection the host loses the moment the token leaves
2478/// the boot line. `WEAKENING` is the escape hatch that keeps the screen from
2479/// collapsing into "no removals at all": an argument that is itself a downgrade
2480/// must stay removable, or SysKnife could never harden a host that already boots
2481/// with `mitigations=off`.
2482fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2483    /// Removing one of these hardens the host, so removal stays allowed even
2484    /// though the token matches a protective prefix below.
2485    const WEAKENING: &[&str] = &[
2486        "selinux=0",
2487        "enforcing=0",
2488        "apparmor=0",
2489        "mitigations=off",
2490        "pti=off",
2491        "lockdown=none",
2492        "debugfs=on",
2493        "iommu=off",
2494        "intel_iommu=off",
2495        "module.sig_enforce=0",
2496        "init_on_alloc=0",
2497        "init_on_free=0",
2498        "randomize_kstack_offset=off",
2499        "page_alloc.shuffle=0",
2500    ];
2501    const PROTECTIVE_PREFIXES: &[&str] = &[
2502        "lockdown=",
2503        "module.sig_enforce=",
2504        "security=",
2505        "selinux=",
2506        "enforcing=",
2507        "apparmor=",
2508        "pti=",
2509        "mitigations=",
2510        "init_on_alloc=",
2511        "init_on_free=",
2512        "page_alloc.shuffle=",
2513        "randomize_kstack_offset=",
2514        "slub_debug=",
2515        "page_poison=",
2516        "vsyscall=",
2517        "debugfs=",
2518        "iommu=",
2519        "intel_iommu=",
2520        "amd_iommu=",
2521    ];
2522    const PROTECTIVE_EXACT: &[&str] = &["slab_nomerge", "nosmt", "kaslr"];
2523
2524    let lower = arg.to_lowercase();
2525    if WEAKENING.contains(&lower.as_str()) {
2526        return Ok(());
2527    }
2528    // Protective wins over the add-path inversion below. `security=` is refused
2529    // by BOTH screens: adding it picks an LSM, removing it drops the one in use.
2530    if PROTECTIVE_PREFIXES.iter().any(|p| lower.starts_with(p))
2531        || PROTECTIVE_EXACT.contains(&lower.as_str())
2532    {
2533        return Err(ExecutorError::InvalidParam(param));
2534    }
2535    // Everything else — ordinary boot arguments, and the add-path's own denylist
2536    // (`init=`, a bare `single`, an emergency `systemd.unit=`, all weakenings the
2537    // host is better off without) — stays removable.
2538    Ok(())
2539}
2540
2541/// Validate a `kill` signal against a strict allowlist, returning the canonical
2542/// signal name for `kill -s <name>`.
2543///
2544/// Only stop/reload signals are permitted (`TERM`, `KILL`, `HUP`, `INT`); this
2545/// blocks exotic or numeric signals and, combined with the caller's `pid >= 2`
2546/// check, keeps `SignalProcess` from becoming an arbitrary-signal primitive.
2547/// Accepts case-insensitive input with an optional `SIG` prefix.
2548fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2549    let normalized = s.trim().to_ascii_uppercase();
2550    match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2551        "TERM" => Ok("TERM"),
2552        "KILL" => Ok("KILL"),
2553        "HUP" => Ok("HUP"),
2554        "INT" => Ok("INT"),
2555        _ => Err(ExecutorError::InvalidParam("signal")),
2556    }
2557}
2558
2559/// Return the rollback [`ActionSpec`] for `action_name`, or `None` if no
2560/// automatic rollback is defined.
2561///
2562/// Only the rpm-ostree deployment and layering actions support rollback —
2563/// they all revert via `rpm-ostree rollback`. All other actions either have
2564/// no sensible rollback or are low-risk enough that a rollback would be
2565/// net-harmful.
2566///
2567/// `RollbackDeployment` itself is excluded to prevent infinite recursion.
2568pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2569    match action_name {
2570        "UpdateSystem"
2571        | "InstallPackages"
2572        | "RemovePackages"
2573        | "RebaseSystem"
2574        | "SetKernelArguments"
2575        | "AddLayeredPackage"
2576        | "RemoveLayeredPackage"
2577        | "ReplaceLayeredPackage"
2578        | "ResetLayeredPackageOverride"
2579        | "RemoveBasePackage" => Some(ActionSpec {
2580            action_name: "RollbackDeployment",
2581            mechanism: ActionMechanism::Command {
2582                program: "rpm-ostree",
2583                args: vec!["rollback".to_string()],
2584            },
2585            risk_level: sysknife_types::RiskLevel::High,
2586            reboot_required: true,
2587            rollback_available: false,
2588        }),
2589        _ => None,
2590    }
2591}
2592
2593#[cfg(test)]
2594mod tests {
2595    use super::*;
2596    use serde_json::json;
2597    use sysknife_types::RiskLevel;
2598    use tempfile::tempdir;
2599
2600    // ── build_action_spec ─────────────────────────────────────────────────
2601
2602    #[test]
2603    fn build_spec_rejects_unknown_keys_across_catalogue() {
2604        for name in sysknife_types::KNOWN_ACTION_NAMES {
2605            if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2606                continue;
2607            }
2608            assert!(
2609                matches!(
2610                    build_action_spec(name, &json!({"__unknown_param": 1})),
2611                    Err(ExecutorError::InvalidParam(_))
2612                ),
2613                "{name} must reject unknown keys before reading required params"
2614            );
2615        }
2616    }
2617
2618    #[test]
2619    fn build_spec_rejects_non_object_params_across_catalogue() {
2620        for name in sysknife_types::KNOWN_ACTION_NAMES {
2621            if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2622                continue;
2623            }
2624            for params in [json!(null), json!([]), json!(42), json!(true), json!("{}")] {
2625                assert!(
2626                    matches!(
2627                        build_action_spec(name, &params),
2628                        Err(ExecutorError::InvalidParam(_))
2629                    ),
2630                    "{name} must reject non-object params: {params}"
2631                );
2632            }
2633        }
2634    }
2635
2636    #[test]
2637    fn build_spec_rejects_cross_distro_kernel_argument_key() {
2638        assert!(matches!(
2639            build_action_spec("SetKernelArguments", &json!({"append": ["quiet"]})),
2640            Err(ExecutorError::InvalidParam(_))
2641        ));
2642        assert!(matches!(
2643            build_action_spec("UfwEnable", &json!({"x": 1})),
2644            Err(ExecutorError::InvalidParam(_))
2645        ));
2646    }
2647
2648    #[test]
2649    fn build_spec_accepts_catalogue_params_and_username_aliases() {
2650        for name in sysknife_types::KNOWN_ACTION_NAMES {
2651            if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2652                continue;
2653            }
2654            // Complete valid examples exercise every parameter, including optional
2655            // keys; future required parameters make this catalogue test fail.
2656            let params = match *name {
2657                "PinDeployment" | "UnpinDeployment" => json!({"index": 0}),
2658                "RebaseSystem" => json!({"target_ref": "fedora-stable"}),
2659                "SetKernelArguments" => json!({"add": ["quiet"], "remove": ["splash"]}),
2660                "ListFlatpakRemotes"
2661                | "ListInstalledFlatpaks"
2662                | "ListContainers"
2663                | "ListToolboxes"
2664                | "DeleteUser"
2665                | "LockUserAccount"
2666                | "UnlockUserAccount"
2667                | "GetAuthorizedKeys"
2668                | "UbuntuListFlatpaks" => json!({"username": "alice"}),
2669                "InstallFlatpak" | "UbuntuInstallFlatpak" => {
2670                    json!({"username": "alice", "app_id": "org.mozilla.firefox", "remote": "flathub"})
2671                }
2672                "RemoveFlatpak"
2673                | "GetFlatpakAppInfo"
2674                | "UbuntuRemoveFlatpak"
2675                | "UpdateFlatpak"
2676                | "UbuntuUpdateFlatpak" => {
2677                    json!({"username": "alice", "app_id": "org.mozilla.firefox"})
2678                }
2679                "SearchFlatpakApps" | "AptSearch" => json!({"term": "vim"}),
2680                "AddFlatpakRemote" => {
2681                    json!({"username": "alice", "remote": "flathub", "url": "https://flathub.org/repo/flathub.flatpakrepo"})
2682                }
2683                "RemoveFlatpakRemote" => json!({"username": "alice", "remote": "flathub"}),
2684                "CreateContainer" => {
2685                    json!({"username": "alice", "name": "test", "image": "alpine"})
2686                }
2687                "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
2688                | "RemoveToolbox" => json!({"username": "alice", "name": "test"}),
2689                "InstallPackages" | "RemovePackages" => json!({"packages": ["vim"]}),
2690                "AddLayeredPackage"
2691                | "RemoveLayeredPackage"
2692                | "RemoveBasePackage"
2693                | "AptInstall"
2694                | "AptRemove"
2695                | "AptPurge"
2696                | "AptHold"
2697                | "AptUnhold"
2698                | "AptShow"
2699                | "GetAptPins" => json!({"package": "vim"}),
2700                "ReplaceLayeredPackage" => json!({"old": "vim", "new": "nano"}),
2701                "AddPackageRepository" => {
2702                    json!({"repo_id": "test", "repo_url": "https://example.com/repo"})
2703                }
2704                "RemovePackageRepository"
2705                | "EnablePackageRepository"
2706                | "DisablePackageRepository" => json!({"repo_id": "test"}),
2707                "StartService"
2708                | "StopService"
2709                | "RestartService"
2710                | "MaskService"
2711                | "UnmaskService"
2712                | "GetServiceLogs"
2713                | "GetServiceStatus"
2714                | "ReloadService"
2715                | "GetServiceResourceLimits" => json!({"unit": "nginx.service"}),
2716                "SetServiceEnabled" => json!({"unit": "nginx.service", "enabled": true}),
2717                "CreateScheduledJob" => {
2718                    json!({"name": "test", "command": "/usr/bin/true", "schedule": "daily"})
2719                }
2720                "SetServiceResourceLimits" => {
2721                    json!({"unit": "nginx.service", "memory_max": "1G", "memory_high": "512M", "cpu_quota": "50%", "tasks_max": "100"})
2722                }
2723                "CreateToolbox" => {
2724                    json!({"username": "alice", "name": "test", "image": "fedora-toolbox", "release": "41"})
2725                }
2726                "SetHostname" => json!({"hostname": "testhost"}),
2727                "SetTimezone" => json!({"timezone": "Europe/London"}),
2728                "SetLocale" => json!({"locale": "en_US.UTF-8"}),
2729                "SetNtp" | "ConfigureUnattendedUpgrades" => json!({"enabled": true}),
2730                "SignalProcess" => json!({"pid": 42, "signal": "TERM"}),
2731                "GetJournalLog" => {
2732                    json!({"unit": "nginx.service", "priority": "warning", "since": "yesterday", "until": "now", "grep": "error", "lines": 10, "boot": true, "kernel": false})
2733                }
2734                "VacuumJournal" => json!({"size_mb": 10}),
2735                "ExtendLogicalVolume" => json!({"vg": "vg0", "lv": "data", "size": "+1G"}),
2736                "CreateLogicalVolume" => json!({"vg": "vg0", "name": "data", "size": "1G"}),
2737                "CreateLvSnapshot" => {
2738                    json!({"vg": "vg0", "origin": "data", "snapshot": "backup", "size": "1G"})
2739                }
2740                "GetSysctl" => json!({"key": "net.ipv4.ip_forward"}),
2741                "SetSysctl" => json!({"key": "net.ipv4.ip_forward", "value": "1"}),
2742                "AddMount" => {
2743                    json!({"device": "/dev/sdb1", "mountpoint": "/mnt/data", "fstype": "ext4", "options": "defaults"})
2744                }
2745                "RemoveMount" => json!({"mountpoint": "/mnt/data"}),
2746                "AddSwap" => json!({"file": "/swapfile", "size_mb": 1024}),
2747                "RemoveSwap" => json!({"file": "/swapfile"}),
2748                "GrantSudoAccess" => {
2749                    json!({"name": "test", "user": "alice", "commands": "/usr/bin/uptime", "runas": "root", "nopasswd": true})
2750                }
2751                "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" => {
2752                    json!({"name": "test"})
2753                }
2754                "SetAptPin" => {
2755                    json!({"name": "test", "package": "vim", "pin": "version 1.*", "priority": 500})
2756                }
2757                "GetLogrotateStatus" => json!({"config": "/var/log/app.log"}),
2758                "ConfigureLogRotation" => {
2759                    json!({"name": "test", "path": "/var/log/app.log", "frequency": "daily", "rotate": 7, "compress": true})
2760                }
2761                "ConfigureRemoteSyslog" => {
2762                    json!({"host": "logs.example.com", "port": 514, "protocol": "tcp"})
2763                }
2764                "GetPasswordAging" => json!({"user": "alice"}),
2765                "SetPasswordAging" => {
2766                    json!({"user": "alice", "max_days": 90, "min_days": 1, "warn_days": 7})
2767                }
2768                "SetPasswordPolicy" => {
2769                    json!({"minlen": 12, "dcredit": -1, "ucredit": -1, "lcredit": -1, "ocredit": -1})
2770                }
2771                "SetAccountLockout" => json!({"deny": 5, "unlock_time": 600, "fail_interval": 900}),
2772                "ConfigureWifi" => json!({"ssid": "testwifi", "password": "examplepassword"}),
2773                "SetDnsServers" | "ResolvectlSetDns" => {
2774                    json!({"interface": "eth0", "servers": ["1.1.1.1"]})
2775                }
2776                "ConfigureFirewall" => json!({"zone": "public", "service": "ssh", "enabled": true}),
2777                "CreateUser" => {
2778                    json!({"username": "alice", "shell": "/bin/bash", "home": "/home/alice"})
2779                }
2780                "AddUserToGroup" | "RemoveUserFromGroup" => {
2781                    json!({"username": "alice", "group": "developers"})
2782                }
2783                "CreateGroup" => json!({"group": "developers", "system": true}),
2784                "DeleteGroup" => json!({"group": "developers"}),
2785                "AddAuthorizedKey" | "RemoveAuthorizedKey" => {
2786                    json!({"username": "alice", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey alice"})
2787                }
2788                "SetSshdOption" => json!({"option": "PasswordAuthentication", "value": "no"}),
2789                "AddPpa" | "RemovePpa" => json!({"name": "deadsnakes/ppa"}),
2790                "SnapInstall" => json!({"name": "test", "channel": "stable", "auto_update": true}),
2791                "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo"
2792                | "SnapRevert" | "SnapClassicInstall" | "DistroboxRemove" => {
2793                    json!({"name": "test"})
2794                }
2795                "GrubSetKargs" => json!({"append": ["quiet"], "delete": ["splash"]}),
2796                "UfwAllow" | "UfwDeny" => json!({"port_or_service": "22/tcp"}),
2797                "UfwStatus" => json!({"numbered": true}),
2798                "DistroboxCreate" => json!({"name": "test", "image": "ubuntu"}),
2799                "NetplanSet" => json!({"key": "ethernets.eth0.dhcp4", "value": "true"}),
2800                "UfwDeleteRule" => json!({"rule_number": 1}),
2801                "UfwLimit" => json!({"target": "ssh"}),
2802                "ProAttach" => json!({"token": "exampletoken"}),
2803                "EnableProService" | "DisableProService" => json!({"service": "esm-apps"}),
2804                "AppArmorEnforce" | "AppArmorComplain" => {
2805                    json!({"profile_path": "/etc/apparmor.d/usr.bin.test"})
2806                }
2807                "Fail2banStatus" => json!({"jail": "sshd"}),
2808                "Fail2banBanIp" | "Fail2banUnbanIp" => json!({"jail": "sshd", "ip": "192.0.2.1"}),
2809                "ConfigureFail2banJail" => {
2810                    json!({"name": "sshd", "enabled": true, "maxretry": 5, "bantime": 600, "findtime": 900})
2811                }
2812                "AddAuditRule" => json!({"path": "/etc/passwd", "perms": "wa", "key": "identity"}),
2813                "RemoveAuditRule" => json!({"key": "identity"}),
2814                "ObtainCertificate" => {
2815                    json!({"domains": ["example.com"], "domain": "example.com", "email": "admin@example.com", "challenge": "standalone"})
2816                }
2817                _ => json!({}),
2818            };
2819            build_action_spec(name, &params)
2820                .unwrap_or_else(|err| panic!("valid parameters rejected for {name}: {err}"));
2821            if params.get("username").is_some() {
2822                let mut aliased = params;
2823                let username = aliased.as_object_mut().unwrap().remove("username").unwrap();
2824                aliased["user"] = username;
2825                build_action_spec(name, &aliased)
2826                    .unwrap_or_else(|err| panic!("user alias rejected for {name}: {err}"));
2827            }
2828        }
2829        for params in [json!({"retain_days": 7}), json!({"size_mb": 10})] {
2830            build_action_spec("VacuumJournal", &params).unwrap();
2831        }
2832        build_action_spec(
2833            "ObtainCertificate",
2834            &json!({"domain": "example.com", "email": "admin@example.com"}),
2835        )
2836        .unwrap();
2837        build_action_spec("SignalProcess", &json!({"pid": "42"})).unwrap();
2838        build_action_spec("SetKernelArguments", &json!({"add": null, "remove": null})).unwrap();
2839    }
2840
2841    #[test]
2842    fn build_spec_no_params_for_get_system_state() {
2843        let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2844        assert_eq!(spec.action_name, "GetSystemState");
2845        assert_eq!(spec.risk_level, RiskLevel::Low);
2846    }
2847
2848    #[test]
2849    fn build_spec_get_datetime_is_low_risk() {
2850        let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2851        assert_eq!(spec.action_name, "GetDateTime");
2852        assert_eq!(spec.risk_level, RiskLevel::Low);
2853        assert!(!spec.reboot_required);
2854    }
2855
2856    #[test]
2857    fn build_spec_unknown_action_returns_error() {
2858        let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2859        assert!(
2860            matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2861            "expected UnknownAction, got: {err}"
2862        );
2863    }
2864
2865    #[test]
2866    fn build_spec_missing_param_for_install_flatpak() {
2867        // username is the first required param; its absence is reported first.
2868        let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2869        assert!(
2870            matches!(err, ExecutorError::MissingParam("username")),
2871            "expected MissingParam(username), got: {err}"
2872        );
2873    }
2874
2875    /// LLMs trained on standard Linux tooling frequently produce `"user"` instead
2876    /// of `"username"`.  `resolve_username` accepts both keys so these actions
2877    /// never fail with a spurious MissingParam.
2878    #[test]
2879    fn build_spec_flatpak_accepts_user_alias() {
2880        let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2881        assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2882    }
2883
2884    /// `resolve_username` prefers `"username"` when both keys are present.
2885    #[test]
2886    fn build_spec_resolve_username_prefers_explicit_username() {
2887        let spec = build_action_spec(
2888            "ListInstalledFlatpaks",
2889            &json!({ "username": "alice", "user": "bob" }),
2890        )
2891        .unwrap();
2892        // Verify it didn't error — the "alice" value passes validation.
2893        assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2894    }
2895
2896    /// `remote` defaults to "flathub" when absent — eliminates the most common
2897    /// model omission without changing behaviour when the param is explicit.
2898    #[test]
2899    fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2900        let spec = build_action_spec(
2901            "InstallFlatpak",
2902            &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2903        )
2904        .unwrap();
2905        assert_eq!(
2906            spec.mechanism,
2907            ActionMechanism::Command {
2908                program: "sudo",
2909                args: vec![
2910                    "/usr/lib/sysknife/action-steps".to_string(),
2911                    "flatpak".to_string(),
2912                    "alice".to_string(),
2913                    "install".to_string(),
2914                    "--user".to_string(),
2915                    "-y".to_string(),
2916                    "flathub".to_string(),
2917                    "org.mozilla.firefox".to_string(),
2918                ],
2919            }
2920        );
2921    }
2922
2923    #[test]
2924    fn build_spec_install_flatpak_injects_app_and_remote() {
2925        let spec = build_action_spec(
2926            "InstallFlatpak",
2927            &json!({
2928                "username": "alice",
2929                "app_id": "org.mozilla.firefox",
2930                "remote": "flathub"
2931            }),
2932        )
2933        .unwrap();
2934        assert_eq!(spec.action_name, "InstallFlatpak");
2935        assert_eq!(
2936            spec.mechanism,
2937            ActionMechanism::Command {
2938                program: "sudo",
2939                args: vec![
2940                    "/usr/lib/sysknife/action-steps".to_string(),
2941                    "flatpak".to_string(),
2942                    "alice".to_string(),
2943                    "install".to_string(),
2944                    "--user".to_string(),
2945                    "-y".to_string(),
2946                    "flathub".to_string(),
2947                    "org.mozilla.firefox".to_string(),
2948                ],
2949            }
2950        );
2951    }
2952
2953    #[test]
2954    fn build_spec_pin_deployment_injects_index() {
2955        let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2956        assert_eq!(
2957            spec.mechanism,
2958            ActionMechanism::Command {
2959                program: "sudo",
2960                args: vec![
2961                    "ostree".to_string(),
2962                    "admin".to_string(),
2963                    "pin".to_string(),
2964                    "1".to_string()
2965                ],
2966            }
2967        );
2968    }
2969
2970    #[test]
2971    fn build_spec_unpin_deployment_includes_unpin_flag() {
2972        let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2973        assert_eq!(
2974            spec.mechanism,
2975            ActionMechanism::Command {
2976                program: "sudo",
2977                args: vec![
2978                    "ostree".to_string(),
2979                    "admin".to_string(),
2980                    "pin".to_string(),
2981                    "--unpin".to_string(),
2982                    "2".to_string(),
2983                ],
2984            }
2985        );
2986    }
2987
2988    #[test]
2989    fn require_u32_rejects_overflow() {
2990        let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2991        assert!(
2992            matches!(err, ExecutorError::InvalidParam("index")),
2993            "expected InvalidParam(index), got: {err}"
2994        );
2995    }
2996
2997    #[test]
2998    fn build_spec_rebase_system_injects_target_ref() {
2999        let spec = build_action_spec(
3000            "RebaseSystem",
3001            &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
3002        )
3003        .unwrap();
3004        assert_eq!(
3005            spec.mechanism,
3006            ActionMechanism::Command {
3007                program: "sudo",
3008                args: vec![
3009                    "rpm-ostree".to_string(),
3010                    "rebase".to_string(),
3011                    "fedora/41/x86_64/silverblue".to_string(),
3012                ],
3013            }
3014        );
3015    }
3016
3017    #[test]
3018    fn build_spec_set_kernel_arguments_appends_and_deletes() {
3019        let spec = build_action_spec(
3020            "SetKernelArguments",
3021            &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
3022        )
3023        .unwrap();
3024        assert_eq!(
3025            spec.mechanism,
3026            ActionMechanism::Command {
3027                program: "sudo",
3028                args: vec![
3029                    "rpm-ostree".to_string(),
3030                    "kargs".to_string(),
3031                    "--append=nomodeset".to_string(),
3032                    "--delete=quiet".to_string(),
3033                ],
3034            }
3035        );
3036    }
3037
3038    #[test]
3039    fn build_spec_set_kernel_arguments_with_empty_arrays() {
3040        let spec =
3041            build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
3042        assert_eq!(
3043            spec.mechanism,
3044            ActionMechanism::Command {
3045                program: "sudo",
3046                args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
3047            }
3048        );
3049    }
3050
3051    #[test]
3052    fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
3053        let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
3054        assert_eq!(
3055            spec.mechanism,
3056            ActionMechanism::Command {
3057                program: "sudo",
3058                args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
3059            }
3060        );
3061    }
3062
3063    // ── Critical-account denylist (DeleteUser/DeleteGroup/LockUserAccount) ──
3064
3065    #[test]
3066    fn delete_user_rejects_critical_account() {
3067        let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
3068        assert!(
3069            matches!(err, ExecutorError::InvalidParam("username")),
3070            "expected InvalidParam(username), got: {err}"
3071        );
3072    }
3073
3074    #[test]
3075    fn delete_group_rejects_critical_group() {
3076        let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
3077        assert!(
3078            matches!(err, ExecutorError::InvalidParam("group")),
3079            "expected InvalidParam(group), got: {err}"
3080        );
3081    }
3082
3083    #[test]
3084    fn lock_user_account_rejects_critical_account() {
3085        let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
3086        assert!(
3087            matches!(err, ExecutorError::InvalidParam("username")),
3088            "expected InvalidParam(username), got: {err}"
3089        );
3090    }
3091
3092    #[test]
3093    fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
3094        assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
3095        assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
3096        assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
3097    }
3098
3099    // ── execute_spec ──────────────────────────────────────────────────────
3100
3101    #[test]
3102    fn build_spec_add_package_repository_rejects_path_traversal() {
3103        let err = build_action_spec(
3104            "AddPackageRepository",
3105            &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
3106        )
3107        .unwrap_err();
3108        assert!(
3109            matches!(err, ExecutorError::InvalidParam("repo_id")),
3110            "expected InvalidParam(repo_id), got: {err}"
3111        );
3112    }
3113
3114    #[test]
3115    fn build_spec_add_package_repository_rejects_newline_in_url() {
3116        let err = build_action_spec(
3117            "AddPackageRepository",
3118            &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
3119        )
3120        .unwrap_err();
3121        assert!(
3122            matches!(err, ExecutorError::InvalidParam("repo_url")),
3123            "expected InvalidParam(repo_url), got: {err}"
3124        );
3125    }
3126
3127    #[test]
3128    fn build_spec_add_package_repository_accepts_valid_repo_id() {
3129        let spec = build_action_spec(
3130            "AddPackageRepository",
3131            &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
3132        )
3133        .unwrap();
3134        assert_eq!(spec.action_name, "AddPackageRepository");
3135    }
3136
3137    #[test]
3138    fn build_spec_remove_package_repository_rejects_path_traversal() {
3139        let err = build_action_spec(
3140            "RemovePackageRepository",
3141            &json!({ "repo_id": "../../etc/passwd" }),
3142        )
3143        .unwrap_err();
3144        assert!(
3145            matches!(err, ExecutorError::InvalidParam("repo_id")),
3146            "expected InvalidParam(repo_id), got: {err}"
3147        );
3148    }
3149
3150    #[tokio::test]
3151    async fn execute_spec_command_captures_stdout() {
3152        let spec = ActionSpec {
3153            action_name: "GetSystemState",
3154            mechanism: ActionMechanism::Command {
3155                program: "echo",
3156                args: vec!["hello".to_string()],
3157            },
3158            risk_level: RiskLevel::Low,
3159            reboot_required: false,
3160            rollback_available: false,
3161        };
3162        let out = execute_spec(&spec).await.unwrap();
3163        assert_eq!(out.stdout.trim(), "hello");
3164        assert_eq!(out.exit_code, 0);
3165    }
3166
3167    /// Regression guard for the live subprocess stdout relay. This coverage
3168    /// previously lived in the dispatcher (`stream_command_sends_job_progress_
3169    /// lines_during_execution`) and was lost when the streaming code moved here;
3170    /// The `ActionNotStopped` message is what an operator reads in the job
3171    /// summary, and the dispatcher keys the rollback veto off this variant, so
3172    /// its fields and wording are load-bearing. No integration test constructs
3173    /// it (the real path needs a root child), so pin it directly.
3174    #[test]
3175    fn action_not_stopped_names_the_group_and_the_timeout() {
3176        let err = ExecutorError::ActionNotStopped {
3177            program: "rpm-ostree".to_string(),
3178            pgid: 4242,
3179            timeout_secs: 7200,
3180        };
3181        let msg = err.to_string();
3182        assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
3183        assert!(msg.contains("7200"), "must name the timeout: {msg}");
3184        assert!(
3185            msg.contains("could not be confirmed stopped"),
3186            "must say what happened: {msg}"
3187        );
3188        assert!(
3189            msg.contains("rollback was skipped"),
3190            "must say the rollback was skipped: {msg}"
3191        );
3192    }
3193
3194    /// A same-uid group is genuinely killable, so `kill_and_reap` must return the
3195    /// plain stopped-timeout `Io`, not `ActionNotStopped`. This pins the
3196    /// discrimination the variant exists for, and that the function returns
3197    /// promptly rather than hanging on the reap.
3198    #[tokio::test]
3199    async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
3200        let mut child = tokio::process::Command::new("sh")
3201            .args(["-c", "sleep 3600 & wait"])
3202            .process_group(0)
3203            .stdin(Stdio::null())
3204            .stdout(Stdio::null())
3205            .stderr(Stdio::null())
3206            .spawn()
3207            .expect("spawn");
3208        SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
3209        let started = tokio::time::Instant::now();
3210        let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
3211        let elapsed = started.elapsed();
3212
3213        assert!(
3214            matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
3215            "a killable group must report a plain stopped-timeout, got: {err:?}"
3216        );
3217        assert!(
3218            elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
3219            "must return promptly, took {elapsed:?}"
3220        );
3221        // A same-uid group is killed by the daemon's own signals, so the
3222        // privileged escalation must NOT fire (a mutation that always escalates
3223        // would shell out to sudo and trip this).
3224        assert_eq!(
3225            SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
3226            0,
3227            "a killable group must not escalate to the privileged reaper"
3228        );
3229    }
3230
3231    /// Losing the ability to READ a privileged child must stop it, exactly as a
3232    /// timeout does. `lines()` fails with `InvalidData` on non-UTF-8 stdout —
3233    /// reachable from any action whose output carries a byte sequence that is not
3234    /// valid UTF-8 (a filename in a foreign encoding is enough). Returning that
3235    /// error without reaping leaves a root process group running while the
3236    /// executor reports the action failed, so the dispatcher's rollback starts a
3237    /// second privileged transaction on top of the first.
3238    #[tokio::test]
3239    async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
3240        let dir = tempfile::tempdir().expect("tempdir");
3241        let pgid_file = dir.path().join("pgid");
3242        // `process_group(0)` makes the shell its own group leader, so `$$` IS the
3243        // pgid. It prints one good line, then a lone \377 (never valid UTF-8),
3244        // then outlives the read failure unless the executor stops it.
3245        let script = format!(
3246            "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
3247            pgid_file.display()
3248        );
3249        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3250        let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
3251            .await
3252            .expect_err("non-UTF-8 stdout must fail the action");
3253        assert!(
3254            matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
3255            "the read failure itself must be surfaced, got: {err:?}"
3256        );
3257
3258        let pgid: i32 = std::fs::read_to_string(&pgid_file)
3259            .expect("the shell must have recorded its pgid")
3260            .trim()
3261            .parse()
3262            .expect("pgid");
3263        assert!(
3264            probe_group(pgid).is_gone(),
3265            "process group {pgid} survived the read failure; \
3266             the privileged child was orphaned"
3267        );
3268    }
3269
3270    #[test]
3271    fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
3272        // rpm-ostree -> cancel (its work is in another cgroup, a signal can't reach it).
3273        assert_eq!(
3274            reap_command(
3275                99,
3276                "sudo",
3277                &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
3278            ),
3279            vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
3280        );
3281        // Everything else -> a root TERM to its own process group.
3282        assert_eq!(
3283            reap_command(
3284                99,
3285                "sudo",
3286                &[
3287                    "env".to_string(),
3288                    "apt-get".to_string(),
3289                    "install".to_string()
3290                ]
3291            ),
3292            root_group_kill_argv("TERM", 99)
3293        );
3294    }
3295
3296    #[test]
3297    fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
3298        // sudo -n /usr/bin/kill -s KILL -- -<pgid>: non-interactive (NOPASSWD),
3299        // options terminated so the negative pgid is a group, not a flag.
3300        assert_eq!(
3301            root_group_kill_argv("KILL", 4242),
3302            vec!["-n", "/usr/bin/kill", "-s", "KILL", "--", "-4242"]
3303        );
3304        assert_eq!(
3305            root_group_kill_argv("TERM", 7)[3..].to_vec(),
3306            vec!["TERM", "--", "-7"]
3307        );
3308    }
3309
3310    #[test]
3311    fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
3312        let sudo = |a: &[&str]| {
3313            is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
3314        };
3315        // rpm-ostree runs behind sudo, absolute or bare, and directly.
3316        assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
3317        assert!(sudo(&[
3318            "rpm-ostree",
3319            "rebase",
3320            "fedora:fedora/40/x86_64/silverblue"
3321        ]));
3322        assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
3323        // Package managers whose work IS in the process group must NOT be
3324        // treated as rpm-ostree (they get the group kill, not `cancel`).
3325        assert!(!sudo(&[
3326            "env",
3327            "DEBIAN_FRONTEND=noninteractive",
3328            "apt-get",
3329            "install",
3330            "vim"
3331        ]));
3332        assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
3333        assert!(!is_rpm_ostree_action("sudo", &[]));
3334        // Absolute path as the top-level program (no sudo wrapper).
3335        assert!(is_rpm_ostree_action(
3336            "/usr/bin/rpm-ostree",
3337            &["deploy".to_string()]
3338        ));
3339        // A path segment must match, not a substring (guards a future refactor
3340        // to `.contains()`).
3341        assert!(!is_rpm_ostree_action(
3342            "sudo",
3343            &["explain-rpm-ostree-usage".to_string()]
3344        ));
3345    }
3346
3347    /// restored at its owning layer. Proves `RealActionExecutor` forwards each
3348    /// stdout line over the mpsc channel as it arrives and reports the exit code.
3349    #[tokio::test]
3350    async fn real_executor_streams_each_stdout_line() {
3351        let spec = ActionSpec {
3352            action_name: "GetSystemState",
3353            mechanism: ActionMechanism::Command {
3354                program: "printf",
3355                args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
3356            },
3357            risk_level: RiskLevel::Low,
3358            reboot_required: false,
3359            rollback_available: false,
3360        };
3361        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3362        let out = RealActionExecutor
3363            .execute_with_progress(&spec, tx)
3364            .await
3365            .unwrap();
3366
3367        let mut lines = Vec::new();
3368        while let Ok(line) = rx.try_recv() {
3369            lines.push(line);
3370        }
3371        assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
3372        assert_eq!(out.exit_code, 0);
3373        assert!(out.stdout.contains("line-two"));
3374    }
3375
3376    #[tokio::test]
3377    async fn execute_spec_file_write_creates_file() {
3378        let dir = tempdir().unwrap();
3379        let path = dir.path().join("test.conf").to_string_lossy().into_owned();
3380        let spec = ActionSpec {
3381            action_name: "AddPackageRepository",
3382            mechanism: ActionMechanism::FileWrite {
3383                path: path.clone(),
3384                content: "[repo]\nbaseurl=https://example.test\n".to_string(),
3385            },
3386            risk_level: RiskLevel::Medium,
3387            reboot_required: false,
3388            rollback_available: true,
3389        };
3390        let out = execute_spec(&spec).await.unwrap();
3391        assert_eq!(out.exit_code, 0);
3392        assert_eq!(
3393            std::fs::read_to_string(&path).unwrap(),
3394            "[repo]\nbaseurl=https://example.test\n"
3395        );
3396    }
3397
3398    #[tokio::test]
3399    async fn execute_spec_file_patch_replaces_first_occurrence() {
3400        let dir = tempdir().unwrap();
3401        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3402        std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
3403        let spec = ActionSpec {
3404            action_name: "EnablePackageRepository",
3405            mechanism: ActionMechanism::FilePatch {
3406                path: path.clone(),
3407                search: "enabled=0".to_string(),
3408                replace: "enabled=1".to_string(),
3409            },
3410            risk_level: RiskLevel::Medium,
3411            reboot_required: false,
3412            rollback_available: true,
3413        };
3414        execute_spec(&spec).await.unwrap();
3415        assert_eq!(
3416            std::fs::read_to_string(&path).unwrap(),
3417            "[myrepo]\nenabled=1\n"
3418        );
3419    }
3420
3421    #[tokio::test]
3422    async fn execute_spec_file_patch_returns_error_when_search_not_found() {
3423        let dir = tempdir().unwrap();
3424        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3425        std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
3426        let spec = ActionSpec {
3427            action_name: "EnablePackageRepository",
3428            mechanism: ActionMechanism::FilePatch {
3429                path: path.clone(),
3430                search: "enabled=0".to_string(),
3431                replace: "enabled=1".to_string(),
3432            },
3433            risk_level: RiskLevel::Medium,
3434            reboot_required: false,
3435            rollback_available: true,
3436        };
3437        let out = execute_spec(&spec).await.unwrap();
3438        assert_eq!(out.exit_code, 1, "should fail when search string is absent");
3439        assert!(
3440            out.stderr.contains("search string not found in file"),
3441            "stderr should explain the failure: {}",
3442            out.stderr
3443        );
3444        // File should remain unchanged.
3445        assert_eq!(
3446            std::fs::read_to_string(&path).unwrap(),
3447            "[myrepo]\nenabled=1\n"
3448        );
3449    }
3450
3451    #[tokio::test]
3452    async fn execute_spec_file_patch_allows_empty_search_string() {
3453        // An empty search string triggers replacen's prepend behavior and should
3454        // not be rejected — the caller explicitly asked for a no-op search.
3455        let dir = tempdir().unwrap();
3456        let path = dir.path().join("file.txt").to_string_lossy().into_owned();
3457        std::fs::write(&path, "hello").unwrap();
3458        let spec = ActionSpec {
3459            action_name: "Test",
3460            mechanism: ActionMechanism::FilePatch {
3461                path: path.clone(),
3462                search: String::new(),
3463                replace: "prefix-".to_string(),
3464            },
3465            risk_level: RiskLevel::Low,
3466            reboot_required: false,
3467            rollback_available: false,
3468        };
3469        let out = execute_spec(&spec).await.unwrap();
3470        assert_eq!(out.exit_code, 0);
3471        assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3472    }
3473
3474    #[tokio::test]
3475    async fn execute_spec_file_delete_removes_file() {
3476        let dir = tempdir().unwrap();
3477        let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3478        std::fs::write(&path, "[myrepo]\n").unwrap();
3479        let spec = ActionSpec {
3480            action_name: "RemovePackageRepository",
3481            mechanism: ActionMechanism::FileDelete { path: path.clone() },
3482            risk_level: RiskLevel::Medium,
3483            reboot_required: false,
3484            rollback_available: true,
3485        };
3486        execute_spec(&spec).await.unwrap();
3487        assert!(!std::path::Path::new(&path).exists());
3488    }
3489
3490    #[tokio::test]
3491    async fn execute_spec_file_scan_lists_directory_entries() {
3492        let dir = tempdir().unwrap();
3493        std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3494        std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3495        let spec = ActionSpec {
3496            action_name: "ListPackageRepositories",
3497            mechanism: ActionMechanism::FileScan {
3498                path: dir.path().to_string_lossy().into_owned(),
3499            },
3500            risk_level: RiskLevel::Low,
3501            reboot_required: false,
3502            rollback_available: false,
3503        };
3504        let out = execute_spec(&spec).await.unwrap();
3505        assert!(
3506            out.stdout.contains("a.repo"),
3507            "expected a.repo in: {}",
3508            out.stdout
3509        );
3510        assert!(
3511            out.stdout.contains("b.repo"),
3512            "expected b.repo in: {}",
3513            out.stdout
3514        );
3515        assert_eq!(out.exit_code, 0);
3516    }
3517
3518    // ── rollback_spec_for ─────────────────────────────────────────────────────
3519
3520    #[test]
3521    fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3522        let spec = rollback_spec_for("UpdateSystem").unwrap();
3523        assert_eq!(spec.action_name, "RollbackDeployment");
3524        assert!(
3525            matches!(
3526                &spec.mechanism,
3527                ActionMechanism::Command { program: "rpm-ostree", args }
3528                if args == &["rollback".to_string()]
3529            ),
3530            "expected rpm-ostree rollback, got: {:?}",
3531            spec.mechanism
3532        );
3533        assert!(!spec.rollback_available, "rollback spec must not recurse");
3534    }
3535
3536    #[test]
3537    fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3538        assert!(rollback_spec_for("InstallPackages").is_some());
3539    }
3540
3541    #[test]
3542    fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3543        assert!(rollback_spec_for("RemovePackages").is_some());
3544    }
3545
3546    #[test]
3547    fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3548        assert!(rollback_spec_for("RebaseSystem").is_some());
3549    }
3550
3551    #[test]
3552    fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3553        assert!(rollback_spec_for("SetKernelArguments").is_some());
3554    }
3555
3556    #[test]
3557    fn rollback_spec_for_read_only_action_returns_none() {
3558        assert!(rollback_spec_for("GetSystemState").is_none());
3559        assert!(rollback_spec_for("ListUsers").is_none());
3560        assert!(rollback_spec_for("GetFirewallState").is_none());
3561    }
3562
3563    #[test]
3564    fn rollback_spec_for_non_rollbackable_actions_return_none() {
3565        assert!(rollback_spec_for("AddUserToGroup").is_none());
3566        assert!(rollback_spec_for("DeleteUser").is_none());
3567        assert!(rollback_spec_for("CleanupDeployments").is_none());
3568        // No infinite recursion — RollbackDeployment has no rollback of its own
3569        assert!(rollback_spec_for("RollbackDeployment").is_none());
3570    }
3571
3572    // ── validated_public_key ──────────────────────────────────────────────
3573
3574    #[test]
3575    fn public_key_accepts_valid_ed25519() {
3576        let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3577        assert!(validated_public_key(key).is_ok());
3578    }
3579
3580    #[test]
3581    fn public_key_accepts_valid_rsa() {
3582        let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3583        assert!(validated_public_key(key).is_ok());
3584    }
3585
3586    #[test]
3587    fn public_key_rejects_empty() {
3588        assert!(matches!(
3589            validated_public_key(""),
3590            Err(ExecutorError::InvalidParam("public_key"))
3591        ));
3592    }
3593
3594    #[test]
3595    fn public_key_rejects_unknown_prefix() {
3596        assert!(matches!(
3597            validated_public_key("sk-rsa AAAA... user@host"),
3598            Err(ExecutorError::InvalidParam("public_key"))
3599        ));
3600        assert!(matches!(
3601            validated_public_key("AAAAB3Nz... user@host"),
3602            Err(ExecutorError::InvalidParam("public_key"))
3603        ));
3604    }
3605
3606    #[test]
3607    fn public_key_rejects_single_quote() {
3608        let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3609        assert!(matches!(
3610            validated_public_key(key),
3611            Err(ExecutorError::InvalidParam("public_key"))
3612        ));
3613    }
3614
3615    /// The install/remove package actions must refuse a filesystem path, because
3616    /// `apt-get install /tmp/x.deb` / `rpm-ostree install /tmp/x.rpm` install a
3617    /// local file and run its scripts as root. `validate.rs` proves the validator
3618    /// refuses paths; only this proves each action arm actually calls it. Table
3619    /// driven so an arm added later that forgets the validator fails here, which
3620    /// is exactly the regression that shipped `InstallPackages`/`RemovePackages`
3621    /// on the generic safe-arg by mistake.
3622    #[test]
3623    fn package_actions_reject_a_local_file_target() {
3624        let reject: &[(&str, serde_json::Value)] = &[
3625            ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3626            ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3627            ("AptPurge", json!({"package": "./evil.deb"})),
3628            ("AptHold", json!({"package": "/tmp/evil.deb"})),
3629            ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3630            ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3631            ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3632            ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3633            (
3634                "ReplaceLayeredPackage",
3635                json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3636            ),
3637            (
3638                "ReplaceLayeredPackage",
3639                json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3640            ),
3641            ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3642            ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3643        ];
3644        for (action, params) in reject {
3645            assert!(
3646                matches!(
3647                    build_action_spec(action, params),
3648                    Err(ExecutorError::InvalidParam(_))
3649                ),
3650                "{action} must refuse a local-file package target: {params}"
3651            );
3652        }
3653        // Positive twin: real package specs must still build, or the test above
3654        // would pass for a validator that rejects everything.
3655        let accept: &[(&str, serde_json::Value)] = &[
3656            ("AptInstall", json!({"package": "nginx"})),
3657            ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3658            (
3659                "InstallPackages",
3660                json!({"packages": ["nginx", "python3-pip"]}),
3661            ),
3662        ];
3663        for (action, params) in accept {
3664            assert!(
3665                build_action_spec(action, params).is_ok(),
3666                "{action} must accept a real package spec: {params}"
3667            );
3668        }
3669    }
3670
3671    /// The activation verbs must refuse a root-shell unit through the reachable
3672    /// action, and `SetServiceEnabled` must gate only on `enabled: true`. Mutation
3673    /// testing showed reverting these call sites to the loose validator passed the
3674    /// whole suite, so the denylist was pinned only at the validator, not here.
3675    #[test]
3676    fn activating_actions_refuse_a_root_shell_unit() {
3677        for action in ["StartService", "RestartService", "UnmaskService"] {
3678            for unit in [
3679                "debug-shell.service",
3680                "emergency.target",
3681                "rescue.service",
3682                "runlevel1.target",
3683            ] {
3684                assert!(
3685                    matches!(
3686                        build_action_spec(action, &json!({"unit": unit})),
3687                        Err(ExecutorError::InvalidParam("unit"))
3688                    ),
3689                    "{action} must not activate {unit}"
3690                );
3691            }
3692            assert!(
3693                build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3694                "{action} must still activate an ordinary unit"
3695            );
3696        }
3697        assert!(
3698            build_action_spec(
3699                "SetServiceEnabled",
3700                &json!({"unit": "debug-shell.service", "enabled": true})
3701            )
3702            .is_err(),
3703            "enabling a root-shell unit brings it up at boot and must be refused"
3704        );
3705        assert!(
3706            build_action_spec(
3707                "SetServiceEnabled",
3708                &json!({"unit": "debug-shell.service", "enabled": false})
3709            )
3710            .is_ok(),
3711            "disabling a root-shell unit is a mitigation and must stay reachable"
3712        );
3713        // The safe verbs must not have been tightened into the denylist, or an
3714        // operator loses the ability to stop or mask a running root shell.
3715        for action in ["StopService", "MaskService"] {
3716            assert!(
3717                build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3718                "{action} on debug-shell is a mitigation and must stay allowed"
3719            );
3720        }
3721    }
3722
3723    #[test]
3724    fn authorized_keys_actions_reject_a_traversal_username() {
3725        // `actions/ssh.rs` builds `/home/{username}/.ssh/authorized_keys` by
3726        // interpolation, so the only thing keeping the path inside the user's
3727        // home is `validated_username` being called in these three match arms.
3728        // `validate.rs` tests the validator in isolation, and `ssh_key_ops.rs`
3729        // calls the action functions directly — neither proves the guard is
3730        // actually wired to the reachable actions.
3731        for action in [
3732            "GetAuthorizedKeys",
3733            "AddAuthorizedKey",
3734            "RemoveAuthorizedKey",
3735        ] {
3736            for bad in ["../../etc", "..", "root/../../etc"] {
3737                let mut params = json!({"username": bad});
3738                if action != "GetAuthorizedKeys" {
3739                    params["public_key"] =
3740                        json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h");
3741                }
3742                let err = build_action_spec(action, &params).unwrap_err();
3743                assert!(
3744                    matches!(err, ExecutorError::InvalidParam("username")),
3745                    "{action} must reject username {bad:?}, got {err:?}"
3746                );
3747            }
3748        }
3749    }
3750
3751    #[test]
3752    fn ip_taking_actions_reject_a_malformed_address() {
3753        // `ExecutorError::InvalidIpAddress` was constructed by three actions
3754        // and reached by no test.
3755        let cases: [(&str, serde_json::Value); 3] = [
3756            (
3757                "ResolvectlSetDns",
3758                json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
3759            ),
3760            (
3761                "Fail2banBanIp",
3762                json!({ "jail": "sshd", "ip": "999.1.1.1" }),
3763            ),
3764            (
3765                "Fail2banUnbanIp",
3766                json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
3767            ),
3768        ];
3769        for (action, params) in cases {
3770            let err = build_action_spec(action, &params).unwrap_err();
3771            assert!(
3772                matches!(err, ExecutorError::InvalidIpAddress { .. }),
3773                "{action} must reject a malformed IP, got {err:?}"
3774            );
3775        }
3776    }
3777
3778    #[test]
3779    fn public_key_rejects_pipe_metacharacter() {
3780        // '|' is a shell pipe and never appears in a valid key. (It was also
3781        // the sed address delimiter before the key ops were made regex-free;
3782        // the rejection stands on its own merits either way.)
3783        let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
3784        assert!(matches!(
3785            validated_public_key(key),
3786            Err(ExecutorError::InvalidParam("public_key"))
3787        ));
3788    }
3789
3790    #[test]
3791    fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
3792        // Deliberate and load-bearing: `.` is legal inside a key comment
3793        // (`alice@example.com`), so the validator cannot blocklist regex
3794        // metacharacters without rejecting valid keys. Any consumer that
3795        // interprets a key as a pattern is therefore the defect — see
3796        // `actions::ssh::REMOVE_KEY_SCRIPT`, which uses `grep -Fxv` for
3797        // exactly this reason.
3798        let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
3799        assert!(validated_public_key(key).is_ok());
3800        // The wildcard-shaped payload passes validation too. It is safe only
3801        // because no consumer treats it as a pattern.
3802        assert!(validated_public_key("ssh-ed25519 .*").is_ok());
3803    }
3804
3805    #[test]
3806    fn public_key_rejects_shell_metacharacters() {
3807        for (metachar, desc) in [
3808            (';', "semicolon"),
3809            ('`', "backtick"),
3810            ('$', "dollar"),
3811            ('\\', "backslash"),
3812            ('&', "ampersand"),
3813        ] {
3814            let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
3815            assert!(
3816                matches!(
3817                    validated_public_key(&key),
3818                    Err(ExecutorError::InvalidParam("public_key"))
3819                ),
3820                "{desc} should be rejected"
3821            );
3822        }
3823    }
3824
3825    #[test]
3826    fn public_key_rejects_newline() {
3827        let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
3828        assert!(matches!(
3829            validated_public_key(key),
3830            Err(ExecutorError::InvalidParam("public_key"))
3831        ));
3832        let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
3833        assert!(matches!(
3834            validated_public_key(key_cr),
3835            Err(ExecutorError::InvalidParam("public_key"))
3836        ));
3837    }
3838
3839    #[test]
3840    fn public_key_rejects_too_long() {
3841        // Build a key that exceeds MAX_LEN (8192 bytes)
3842        let long_payload = "A".repeat(8192);
3843        let key = format!("ssh-ed25519 {long_payload} user@host");
3844        assert!(matches!(
3845            validated_public_key(&key),
3846            Err(ExecutorError::InvalidParam("public_key"))
3847        ));
3848    }
3849
3850    // ── str_array_or_empty ────────────────────────────────────────────────
3851
3852    #[test]
3853    fn str_array_or_empty_rejects_non_string_element() {
3854        let params = json!({ "packages": ["vim", 42, "curl"] });
3855        assert!(matches!(
3856            str_array_or_empty(&params, "packages"),
3857            Err(ExecutorError::InvalidParam("packages"))
3858        ));
3859    }
3860
3861    #[test]
3862    fn str_array_or_empty_accepts_string_array() {
3863        let params = json!({ "packages": ["vim", "curl"] });
3864        assert_eq!(
3865            str_array_or_empty(&params, "packages").unwrap(),
3866            vec!["vim".to_string(), "curl".to_string()]
3867        );
3868    }
3869
3870    #[test]
3871    fn str_array_or_empty_returns_empty_when_key_absent() {
3872        let params = json!({});
3873        assert_eq!(
3874            str_array_or_empty(&params, "packages").unwrap(),
3875            Vec::<String>::new()
3876        );
3877    }
3878
3879    // ── validated_safe_kernel_arg ─────────────────────────────────────────
3880
3881    #[test]
3882    fn kernel_arg_allows_safe_args() {
3883        assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3884        assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3885        assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3886        assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3887    }
3888
3889    #[test]
3890    fn kernel_arg_blocks_init_override() {
3891        assert!(matches!(
3892            validated_safe_kernel_arg("init=/bin/sh", "add"),
3893            Err(ExecutorError::InvalidParam("add"))
3894        ));
3895        assert!(matches!(
3896            validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3897            Err(ExecutorError::InvalidParam("add"))
3898        ));
3899    }
3900
3901    #[test]
3902    fn kernel_arg_blocks_selinux_disable() {
3903        assert!(matches!(
3904            validated_safe_kernel_arg("selinux=0", "add"),
3905            Err(ExecutorError::InvalidParam("add"))
3906        ));
3907        assert!(matches!(
3908            validated_safe_kernel_arg("enforcing=0", "add"),
3909            Err(ExecutorError::InvalidParam("add"))
3910        ));
3911    }
3912
3913    #[test]
3914    fn kernel_arg_blocks_security_override() {
3915        assert!(matches!(
3916            validated_safe_kernel_arg("security=none", "add"),
3917            Err(ExecutorError::InvalidParam("add"))
3918        ));
3919    }
3920
3921    #[test]
3922    fn kernel_arg_blocks_module_blacklist() {
3923        assert!(matches!(
3924            validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3925            Err(ExecutorError::InvalidParam("add"))
3926        ));
3927    }
3928
3929    // ── validated_safe_kernel_arg_removal ─────────────────────────────────
3930    //
3931    // "Removing an argument is always safe" was the stated premise for exempting
3932    // `remove`/`delete` from any policy check. It is false: several arguments
3933    // protect the host by being PRESENT, so deleting one lands the same end state
3934    // as adding its weakening counterpart, which the add-path already refuses.
3935
3936    #[test]
3937    fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
3938        // KSPP-recommended boot parameters plus the LSM selectors. Each is a
3939        // protection the host loses the moment the token leaves the boot line.
3940        for arg in [
3941            "lockdown=confidentiality",
3942            "module.sig_enforce=1",
3943            "selinux=1",
3944            "enforcing=1",
3945            "apparmor=1",
3946            "security=selinux",
3947            "pti=on",
3948            "mitigations=auto,nosmt",
3949            "init_on_alloc=1",
3950            "init_on_free=1",
3951            "slab_nomerge",
3952            "page_alloc.shuffle=1",
3953            "randomize_kstack_offset=on",
3954            "slub_debug=FZP",
3955            "vsyscall=none",
3956            "debugfs=off",
3957            "iommu=force",
3958            "intel_iommu=on",
3959            "amd_iommu=force_isolation",
3960        ] {
3961            assert!(
3962                matches!(
3963                    validated_safe_kernel_arg_removal(arg, "remove"),
3964                    Err(ExecutorError::InvalidParam("remove"))
3965                ),
3966                "removing {arg} is a boot-security downgrade and must be refused"
3967            );
3968        }
3969    }
3970
3971    /// The screen must not turn into "no removals at all" — an argument that is
3972    /// itself a weakening (the add-path refuses it) has to stay removable, or
3973    /// SysKnife could never undo `mitigations=off` on a host that already has it.
3974    #[test]
3975    fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
3976        for arg in [
3977            "mitigations=off",
3978            "selinux=0",
3979            "enforcing=0",
3980            "apparmor=0",
3981            "pti=off",
3982            "lockdown=none",
3983            "debugfs=on",
3984            "init=/bin/sh",
3985            "single",
3986        ] {
3987            assert!(
3988                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3989                "{arg} weakens the host, so removing it must stay allowed"
3990            );
3991        }
3992    }
3993
3994    #[test]
3995    fn kernel_arg_removal_allows_ordinary_args() {
3996        for arg in [
3997            "quiet",
3998            "splash",
3999            "nomodeset",
4000            "console=ttyS0,115200",
4001            "rd.driver.blacklist=nouveau",
4002        ] {
4003            assert!(
4004                validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4005                "{arg} carries no boot-security meaning and must stay removable"
4006            );
4007        }
4008    }
4009
4010    #[test]
4011    fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
4012        assert!(matches!(
4013            validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
4014            Err(ExecutorError::InvalidParam("add"))
4015        ));
4016        assert!(matches!(
4017            validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
4018            Err(ExecutorError::InvalidParam("add"))
4019        ));
4020        assert!(matches!(
4021            validated_safe_kernel_arg("systemd.unit=single.target", "add"),
4022            Err(ExecutorError::InvalidParam("add"))
4023        ));
4024    }
4025
4026    #[test]
4027    fn kernel_arg_blocks_apparmor_disable() {
4028        assert!(matches!(
4029            validated_safe_kernel_arg("apparmor=0", "add"),
4030            Err(ExecutorError::InvalidParam("add"))
4031        ));
4032    }
4033
4034    #[test]
4035    fn kernel_arg_blocks_mitigations_off() {
4036        assert!(matches!(
4037            validated_safe_kernel_arg("mitigations=off", "add"),
4038            Err(ExecutorError::InvalidParam("add"))
4039        ));
4040    }
4041
4042    #[test]
4043    fn kernel_arg_blocks_lockdown_override() {
4044        assert!(matches!(
4045            validated_safe_kernel_arg("lockdown=none", "add"),
4046            Err(ExecutorError::InvalidParam("add"))
4047        ));
4048    }
4049
4050    #[test]
4051    fn kernel_arg_blocks_pti_off() {
4052        assert!(matches!(
4053            validated_safe_kernel_arg("pti=off", "add"),
4054            Err(ExecutorError::InvalidParam("add"))
4055        ));
4056    }
4057
4058    #[test]
4059    fn kernel_arg_blocks_smap_smep_disable() {
4060        assert!(matches!(
4061            validated_safe_kernel_arg("nosmap", "add"),
4062            Err(ExecutorError::InvalidParam("add"))
4063        ));
4064        assert!(matches!(
4065            validated_safe_kernel_arg("nosmep", "add"),
4066            Err(ExecutorError::InvalidParam("add"))
4067        ));
4068    }
4069
4070    #[test]
4071    fn kernel_arg_blocks_single_user_shortcuts() {
4072        // Runlevel shortcuts that drop to a root shell.
4073        assert!(matches!(
4074            validated_safe_kernel_arg("single", "add"),
4075            Err(ExecutorError::InvalidParam("add"))
4076        ));
4077        assert!(matches!(
4078            validated_safe_kernel_arg("s", "add"),
4079            Err(ExecutorError::InvalidParam("add"))
4080        ));
4081        assert!(matches!(
4082            validated_safe_kernel_arg("1", "add"),
4083            Err(ExecutorError::InvalidParam("add"))
4084        ));
4085    }
4086
4087    #[test]
4088    fn signal_process_guardrails() {
4089        // Reject pid 0 (whole process group) and 1 (init/systemd).
4090        for bad_pid in [0, 1] {
4091            let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
4092            assert!(
4093                matches!(err, ExecutorError::InvalidParam("pid")),
4094                "pid {bad_pid} must be rejected, got {err:?}"
4095            );
4096        }
4097        // Reject a signal outside the allowlist.
4098        let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
4099            .unwrap_err();
4100        assert!(
4101            matches!(err, ExecutorError::InvalidParam("signal")),
4102            "got {err:?}"
4103        );
4104        // Missing pid is a MissingParam.
4105        assert!(matches!(
4106            build_action_spec("SignalProcess", &json!({})).unwrap_err(),
4107            ExecutorError::MissingParam("pid")
4108        ));
4109        // A valid pid + signal builds, and accepts a numeric string + SIG prefix.
4110        let spec = build_action_spec(
4111            "SignalProcess",
4112            &json!({ "pid": "4242", "signal": "sigkill" }),
4113        )
4114        .unwrap();
4115        assert_eq!(spec.action_name, "SignalProcess");
4116        assert_eq!(
4117            spec.mechanism,
4118            ActionMechanism::Command {
4119                program: "sudo",
4120                args: vec![
4121                    "kill".to_string(),
4122                    "-s".to_string(),
4123                    "KILL".to_string(),
4124                    "4242".to_string()
4125                ],
4126            }
4127        );
4128        // Default signal is TERM when omitted.
4129        let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
4130        if let ActionMechanism::Command { args, .. } = spec.mechanism {
4131            assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
4132        } else {
4133            panic!("expected Command mechanism");
4134        }
4135    }
4136
4137    #[test]
4138    fn create_scheduled_job_validates_name_command_schedule() {
4139        // A newline in the command would inject extra unit directives.
4140        assert!(matches!(
4141            build_action_spec(
4142                "CreateScheduledJob",
4143                &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
4144            )
4145            .unwrap_err(),
4146            ExecutorError::InvalidParam("command")
4147        ));
4148        // A path-like / dotted name is rejected (must be a safe unit stem).
4149        assert!(matches!(
4150            build_action_spec(
4151                "CreateScheduledJob",
4152                &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
4153            )
4154            .unwrap_err(),
4155            ExecutorError::InvalidParam("name")
4156        ));
4157        // A schedule with shell metacharacters is rejected by the charset gate.
4158        assert!(matches!(
4159            build_action_spec(
4160                "CreateScheduledJob",
4161                &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
4162            )
4163            .unwrap_err(),
4164            ExecutorError::InvalidParam("schedule")
4165        ));
4166        // A valid job routes through the scoped helper with the right argv.
4167        let spec = build_action_spec(
4168            "CreateScheduledJob",
4169            &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
4170        )
4171        .unwrap();
4172        assert_eq!(
4173            spec.mechanism,
4174            ActionMechanism::Command {
4175                program: "sudo",
4176                args: vec![
4177                    "/usr/lib/sysknife/scheduled-job-edit".to_string(),
4178                    "--name".to_string(),
4179                    "nightly-backup".to_string(),
4180                    "--command".to_string(),
4181                    "/usr/bin/backup --full".to_string(),
4182                    "--schedule".to_string(),
4183                    "*-*-* 02:00:00".to_string(),
4184                ],
4185            }
4186        );
4187    }
4188
4189    #[test]
4190    fn set_sshd_option_allowlist_guardrails() {
4191        // Non-allowlisted option rejected.
4192        assert!(matches!(
4193            build_action_spec(
4194                "SetSshdOption",
4195                &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
4196            )
4197            .unwrap_err(),
4198            ExecutorError::InvalidParam("option")
4199        ));
4200        // Allowlisted option, disallowed value.
4201        assert!(matches!(
4202            build_action_spec(
4203                "SetSshdOption",
4204                &json!({ "option": "PermitRootLogin", "value": "maybe" })
4205            )
4206            .unwrap_err(),
4207            ExecutorError::InvalidParam("value")
4208        ));
4209        // Valid combo routes through the scoped helper.
4210        let spec = build_action_spec(
4211            "SetSshdOption",
4212            &json!({ "option": "PasswordAuthentication", "value": "no" }),
4213        )
4214        .unwrap();
4215        assert_eq!(
4216            spec.mechanism,
4217            ActionMechanism::Command {
4218                program: "sudo",
4219                args: vec![
4220                    "/usr/lib/sysknife/sshd-option-edit".to_string(),
4221                    "--option".to_string(),
4222                    "PasswordAuthentication".to_string(),
4223                    "--value".to_string(),
4224                    "no".to_string(),
4225                ],
4226            }
4227        );
4228    }
4229
4230    #[test]
4231    fn configure_unattended_upgrades_toggles_helper_flag() {
4232        for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
4233            let spec = build_action_spec(
4234                "ConfigureUnattendedUpgrades",
4235                &json!({ "enabled": enabled }),
4236            )
4237            .unwrap();
4238            assert_eq!(
4239                spec.mechanism,
4240                ActionMechanism::Command {
4241                    program: "sudo",
4242                    args: vec![
4243                        "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
4244                        flag.to_string(),
4245                    ],
4246                }
4247            );
4248        }
4249        assert!(matches!(
4250            build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
4251            ExecutorError::MissingParam("enabled")
4252        ));
4253    }
4254
4255    #[test]
4256    fn grub_set_kargs_append_blocks_single_user_shortcut() {
4257        // Regression: GrubSetKargs previously used only the charset validator,
4258        // which accepts the bare `single`/`s`/`1` runlevel shortcuts. The
4259        // kernel-arg denylist must apply to `append` too — booting into a
4260        // single-user root shell is exactly the SetKernelArguments threat.
4261        // Every BLOCKED_EXACT entry is valueless, so it passes the charset
4262        // check and this denylist call is the only thing stopping it — the
4263        // call is load-bearing here, not redundant with `validated_safe_arg`.
4264        for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
4265            let err = build_action_spec(
4266                "GrubSetKargs",
4267                &json!({ "append": [dangerous], "delete": [] }),
4268            )
4269            .unwrap_err();
4270            assert!(
4271                matches!(err, ExecutorError::InvalidParam("append")),
4272                "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
4273            );
4274        }
4275        // A benign flag still builds successfully.
4276        assert!(build_action_spec(
4277            "GrubSetKargs",
4278            &json!({ "append": ["quiet"], "delete": [] })
4279        )
4280        .is_ok());
4281    }
4282
4283    #[test]
4284    fn kernel_arg_build_spec_rejects_dangerous_arg() {
4285        // End-to-end: build_action_spec must propagate the blocklist error.
4286        // This action does NOT apply the `validated_safe_arg` charset check,
4287        // so `init=/bin/bash` reaches `validated_safe_kernel_arg` and is
4288        // caught by BLOCKED_PREFIXES — the `=`-bearing half of that denylist
4289        // is live here even though GrubSetKargs never reaches it.
4290        let err = build_action_spec(
4291            "SetKernelArguments",
4292            &json!({ "add": ["init=/bin/bash"], "remove": [] }),
4293        )
4294        .unwrap_err();
4295        assert!(
4296            matches!(err, ExecutorError::InvalidParam("add")),
4297            "expected InvalidParam(add), got {err:?}"
4298        );
4299    }
4300
4301    #[test]
4302    fn grub_set_kargs_cannot_delete_a_protective_karg() {
4303        // A review flagged `delete: ["apparmor=1"]` as a way to strip AppArmor
4304        // because the kernel-arg denylist is not applied to `delete`. It is
4305        // not reachable: `delete` goes through `validated_safe_arg`, whose
4306        // charset excludes `=`, so any `key=value` token is refused before the
4307        // denylist would matter. Pinned here so a future widening of that
4308        // charset cannot silently open the hole.
4309        for karg in [
4310            "apparmor=1",
4311            "lockdown=confidentiality",
4312            "mitigations=auto",
4313            "pti=on",
4314            "selinux=1",
4315        ] {
4316            let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
4317                .unwrap_err();
4318            assert!(
4319                matches!(err, ExecutorError::InvalidParam("delete")),
4320                "deleting {karg:?} must be refused, got {err:?}"
4321            );
4322        }
4323    }
4324
4325    /// Every action that claims `rollback_available: true` MUST have a
4326    /// corresponding entry in `rollback_spec_for()`; every action that claims
4327    /// `false` MUST NOT. This prevents the spec and the executor from
4328    /// drifting apart.
4329    #[test]
4330    fn rollback_available_matches_rollback_spec_for_all_actions() {
4331        // Iterate the FULL catalogue (`crate::actions::all_specs`), not a
4332        // hand-picked subset of families — a manually-chained list here once
4333        // silently omitted `ppa`, `netplan`, `grub`, and `ubuntu_pro`, which is
4334        // exactly how `AddPpa`/`RemovePpa`/`NetplanSet`/`GrubSetKargs`/
4335        // `ProAttach`/`ProDetach` were able to claim `rollback_available: true`
4336        // with no matching `rollback_spec_for` entry for years without this
4337        // test catching it.
4338        for spec in crate::actions::all_specs() {
4339            let has_rollback = rollback_spec_for(spec.action_name).is_some();
4340            assert_eq!(
4341                spec.rollback_available,
4342                has_rollback,
4343                "action {:?}: rollback_available={} but rollback_spec_for returns {}",
4344                spec.action_name,
4345                spec.rollback_available,
4346                if has_rollback { "Some" } else { "None" },
4347            );
4348        }
4349    }
4350
4351    // `home` reaches `useradd --create-home --home-dir <home>` as root, so it is
4352    // a path the caller chooses and root acts on. It was validated by
4353    // `validated_safe_arg`, which enforces a charset and rejects a leading dash
4354    // and accepts both `..` and a relative path. `validated_absolute_path`, in
4355    // the same file, rejects both, and every other root-acting path parameter
4356    // already uses it. The weaker of two adjacent validators was guarding the
4357    // more dangerous parameter.
4358    #[test]
4359    fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
4360        for home in [
4361            "../../etc/skel",
4362            "/home/../etc",
4363            "home/alice",
4364            "/home/alice/../../../root",
4365        ] {
4366            let got =
4367                build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
4368            assert!(
4369                got.is_err(),
4370                "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
4371            );
4372        }
4373        // An ordinary home directory must still work, or the guard has eaten
4374        // the feature rather than secured it.
4375        build_action_spec(
4376            "CreateUser",
4377            &json!({ "username": "alice", "home": "/home/alice" }),
4378        )
4379        .expect("a plain absolute home must still be accepted");
4380    }
4381
4382    // ── Risk level reclassification (NIST 800-53 / CIS Controls v8.1) ────────
4383    // These five actions were incorrectly classified Medium; they must be High.
4384    // T1136.001 (CreateUser), T1562.004 (ConfigureFirewall), T1562.001 (MaskService),
4385    // supply-chain vector (AddPackageRepository), T1557 path (SetDnsServers).
4386
4387    #[test]
4388    fn create_user_is_high_risk() {
4389        let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
4390        assert_eq!(
4391            spec.risk_level,
4392            RiskLevel::High,
4393            "CreateUser must be High (T1136.001 Persistence)"
4394        );
4395    }
4396
4397    #[test]
4398    fn configure_firewall_is_high_risk() {
4399        let spec = build_action_spec(
4400            "ConfigureFirewall",
4401            &json!({ "zone": "public", "service": "ssh", "enabled": true }),
4402        )
4403        .unwrap();
4404        assert_eq!(
4405            spec.risk_level,
4406            RiskLevel::High,
4407            "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
4408        );
4409    }
4410
4411    #[test]
4412    fn mask_service_is_high_risk() {
4413        let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
4414        assert_eq!(
4415            spec.risk_level,
4416            RiskLevel::High,
4417            "MaskService must be High (T1562.001 Impair Defenses)"
4418        );
4419    }
4420
4421    #[test]
4422    fn add_package_repository_is_high_risk() {
4423        let spec = build_action_spec(
4424            "AddPackageRepository",
4425            &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
4426        )
4427        .unwrap();
4428        assert_eq!(
4429            spec.risk_level,
4430            RiskLevel::High,
4431            "AddPackageRepository must be High (supply-chain vector)"
4432        );
4433    }
4434
4435    #[test]
4436    fn set_dns_servers_is_high_risk() {
4437        let spec = build_action_spec(
4438            "SetDnsServers",
4439            &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
4440        )
4441        .unwrap();
4442        assert_eq!(
4443            spec.risk_level,
4444            RiskLevel::High,
4445            "SetDnsServers must be High (DNS hijacking / T1557)"
4446        );
4447    }
4448}