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