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