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