Skip to main content

sysknife_daemon/
executor.rs

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