Skip to main content

sysknife_daemon/
executor.rs

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