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