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