1use crate::actions::{
2 apparmor, apt, apt_preferences, auditd, certbot, cloudinit, containers, deployment, distrobox,
3 fail2ban, filesystem, flatpak, grub, identity, journald, layering, livepatch, logging, lvm,
4 mounts, multipass, netplan, network, package_repos, pam, ppa, processes, reboot,
5 release_upgrade, resolvectl, services, snap, ssh, sudoers, sysctl, system_info, toolbox,
6 ubuntu_pro, ufw, users,
7 validate::{
8 validated_apparmor_profile, validated_apt_package, validated_apt_pin_expr,
9 validated_apt_pin_name, validated_audit_path, validated_audit_perms, validated_cpu_quota,
10 validated_domain, validated_email, validated_fstype, validated_group,
11 validated_group_not_critical, validated_hostname, validated_journal_grep,
12 validated_journal_priority, validated_journal_time, validated_locale, validated_log_path,
13 validated_lvm_name, validated_lvm_size, validated_memory_limit, validated_mount_device,
14 validated_mount_options, validated_mount_point, validated_port_or_service,
15 validated_ppa_name, validated_pro_service, validated_safe_arg, validated_sudo_commands,
16 validated_sudoers_name, validated_swap_path, validated_sysctl_key, validated_sysctl_value,
17 validated_syslog_host, validated_tasks_max, validated_timezone, validated_unit_name,
18 validated_username, validated_username_not_critical,
19 },
20 ActionMechanism, ActionSpec,
21};
22use async_trait::async_trait;
23use serde_json::Value;
24use std::io;
25use std::net::IpAddr;
26use std::process::Stdio;
27use std::str::FromStr;
28use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
29use tokio::sync::mpsc::UnboundedSender;
30
31#[derive(Debug, thiserror::Error)]
32pub enum ExecutorError {
33 #[error("unknown action: {0}")]
34 UnknownAction(String),
35
36 #[error("missing required param: {0}")]
37 MissingParam(&'static str),
38
39 #[error("invalid param type for: {0}")]
40 InvalidParam(&'static str),
41
42 #[error("invalid IP address for param '{param}': '{value}'")]
48 InvalidIpAddress { param: &'static str, value: String },
49
50 #[error("io error: {0}")]
51 Io(#[from] io::Error),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ExecutionOutput {
64 pub stdout: String,
65 pub stderr: String,
66 pub exit_code: i32,
67}
68
69impl ExecutionOutput {
70 pub fn is_success(&self) -> bool {
72 self.exit_code == 0
73 }
74
75 pub fn is_nonzero(&self) -> bool {
77 self.exit_code != 0
78 }
79}
80
81#[async_trait]
88pub trait ActionExecutor: Send + Sync {
89 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
91
92 async fn execute_with_progress(
98 &self,
99 spec: &ActionSpec,
100 progress: UnboundedSender<String>,
101 ) -> Result<ExecutionOutput, ExecutorError> {
102 let output = self.execute(spec).await?;
103 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
104 let _ = progress.send(line.to_string());
105 }
106 Ok(output)
107 }
108}
109
110pub struct RealActionExecutor;
112
113#[async_trait]
114impl ActionExecutor for RealActionExecutor {
115 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
116 execute_spec(spec).await
117 }
118
119 async fn execute_with_progress(
120 &self,
121 spec: &ActionSpec,
122 progress: UnboundedSender<String>,
123 ) -> Result<ExecutionOutput, ExecutorError> {
124 match &spec.mechanism {
125 ActionMechanism::Command { program, args } => {
126 execute_command_with_progress(program, args, progress).await
127 }
128 _ => execute_spec(spec).await,
129 }
130 }
131}
132
133async fn execute_command_with_progress(
134 program: &'static str,
135 args: &[String],
136 progress: UnboundedSender<String>,
137) -> Result<ExecutionOutput, ExecutorError> {
138 let mut child = tokio::process::Command::new(program)
139 .args(args)
140 .stdout(Stdio::piped())
141 .stderr(Stdio::piped())
142 .spawn()
143 .map_err(ExecutorError::Io)?;
144
145 let stdout = child.stdout.take().expect("stdout was piped");
146 let stderr = child.stderr.take().expect("stderr was piped");
147 let stderr_task = tokio::spawn(async move {
148 let mut buf = Vec::new();
149 BufReader::new(stderr)
150 .read_to_end(&mut buf)
151 .await
152 .map(|_| buf)
153 });
154
155 let mut lines = BufReader::new(stdout).lines();
156 let mut stdout_buf = String::new();
157 while let Some(line) = lines.next_line().await.map_err(ExecutorError::Io)? {
158 if !line.is_empty() {
159 let _ = progress.send(line.clone());
160 }
161 stdout_buf.push_str(&line);
162 stdout_buf.push('\n');
163 }
164
165 let exit_status = child.wait().await.map_err(ExecutorError::Io)?;
166 let stderr_bytes = stderr_task
167 .await
168 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
169 .map_err(ExecutorError::Io)?;
170
171 Ok(ExecutionOutput {
172 stdout: stdout_buf,
173 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
174 exit_code: exit_status.code().unwrap_or(-1),
175 })
176}
177
178pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
183 match action_name {
184 "GetSystemState" => Ok(deployment::get_system_state()),
186 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
187 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
188 "ListDeployments" => Ok(deployment::list_deployments()),
189 "UpdateSystem" => Ok(deployment::update_system()),
190 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
191 "RebootSystem" => Ok(deployment::reboot_system()),
192 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
193 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
194
195 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
197 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
198 "RebaseSystem" => {
199 let target_ref = require_str(params, "target_ref")?;
200 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
201 Ok(deployment::rebase_system(&target_ref))
202 }
203 "SetKernelArguments" => {
204 let add = str_array_or_empty(params, "add")?;
205 let remove = str_array_or_empty(params, "remove")?;
206 for arg in add.iter() {
209 validated_safe_kernel_arg(arg, "add")?;
210 }
211 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
212 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
213 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
214 }
215
216 "ListFlatpakRemotes" => {
222 let username = validated_username(resolve_username(params)?, "username")?;
223 Ok(flatpak::list_flatpak_remotes(&username))
224 }
225 "InstallFlatpak" => {
226 let username = validated_username(resolve_username(params)?, "username")?;
227 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
228 let remote = params
232 .get("remote")
233 .and_then(|v| v.as_str())
234 .unwrap_or("flathub");
235 let remote = validated_safe_arg(remote, "remote")?;
236 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
237 }
238 "RemoveFlatpak" => {
239 let username = validated_username(resolve_username(params)?, "username")?;
240 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
241 Ok(flatpak::remove_flatpak(&username, &app_id))
242 }
243 "SearchFlatpakApps" => {
244 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
245 Ok(flatpak::search_flatpak_apps(&term))
246 }
247 "AddFlatpakRemote" => {
248 let username = validated_username(resolve_username(params)?, "username")?;
249 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
250 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
251 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
252 }
253 "RemoveFlatpakRemote" => {
254 let username = validated_username(resolve_username(params)?, "username")?;
255 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
256 Ok(flatpak::remove_flatpak_remote(&username, &remote))
257 }
258 "GetFlatpakAppInfo" => {
259 let username = validated_username(resolve_username(params)?, "username")?;
260 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
261 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
262 }
263 "ListInstalledFlatpaks" => {
264 let username = validated_username(resolve_username(params)?, "username")?;
265 Ok(flatpak::list_installed_flatpaks(&username))
266 }
267 "UpdateFlatpak" => {
268 let username = validated_username(resolve_username(params)?, "username")?;
269 let app_id = params
272 .get("app_id")
273 .and_then(|v| v.as_str())
274 .filter(|id| !id.is_empty())
275 .map(|id| validated_safe_arg(id, "app_id"))
276 .transpose()?;
277 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
278 }
279
280 "ListContainers" => {
286 let username = validated_username(resolve_username(params)?, "username")?;
287 Ok(containers::list_containers(&username))
288 }
289 "CreateContainer" => {
290 let username = validated_username(resolve_username(params)?, "username")?;
291 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
292 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
293 Ok(containers::create_container(&username, &name, &image))
294 }
295 "StartContainer" => {
296 let username = validated_username(resolve_username(params)?, "username")?;
297 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
298 Ok(containers::start_container(&username, &name))
299 }
300 "StopContainer" => {
301 let username = validated_username(resolve_username(params)?, "username")?;
302 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
303 Ok(containers::stop_container(&username, &name))
304 }
305 "RemoveContainer" => {
306 let username = validated_username(resolve_username(params)?, "username")?;
307 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
308 Ok(containers::remove_container(&username, &name))
309 }
310 "GetContainerInfo" => {
311 let username = validated_username(resolve_username(params)?, "username")?;
312 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
313 Ok(containers::get_container_info(&username, &name))
314 }
315
316 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
318 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
319 "InstallPackages" => {
320 let pkgs = str_array_or_empty(params, "packages")?;
321 let validated: Vec<String> = pkgs
322 .iter()
323 .map(|p| validated_safe_arg(p, "packages"))
324 .collect::<Result<_, _>>()?;
325 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
326 Ok(layering::install_packages(&refs))
327 }
328 "RemovePackages" => {
329 let pkgs = str_array_or_empty(params, "packages")?;
330 let validated: Vec<String> = pkgs
331 .iter()
332 .map(|p| validated_safe_arg(p, "packages"))
333 .collect::<Result<_, _>>()?;
334 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
335 Ok(layering::remove_packages(&refs))
336 }
337 "AddLayeredPackage" => {
338 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
339 Ok(layering::add_layered_package(&package))
340 }
341 "RemoveLayeredPackage" => {
342 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
343 Ok(layering::remove_layered_package(&package))
344 }
345 "ReplaceLayeredPackage" => {
346 let old = validated_safe_arg(require_str(params, "old")?, "old")?;
347 let new = validated_safe_arg(require_str(params, "new")?, "new")?;
348 Ok(layering::replace_layered_package(&old, &new))
349 }
350 "RemoveBasePackage" => {
351 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
352 Ok(layering::remove_base_package(&package))
353 }
354 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
355
356 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
358 "AddPackageRepository" => Ok(package_repos::add_package_repository(
359 validated_repo_id(params)?,
360 validated_no_newline(params, "repo_url")?,
361 )),
362 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
363 validated_repo_id(params)?,
364 )),
365 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
366 validated_repo_id(params)?,
367 )),
368 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
369 validated_repo_id(params)?,
370 )),
371
372 "ListServices" => Ok(services::list_services()),
374 "StartService" => {
375 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
376 Ok(services::start_service(&unit))
377 }
378 "StopService" => {
379 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
380 Ok(services::stop_service(&unit))
381 }
382 "RestartService" => {
383 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
384 Ok(services::restart_service(&unit))
385 }
386 "SetServiceEnabled" => {
387 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
388 Ok(services::set_service_enabled(
389 &unit,
390 require_bool(params, "enabled")?,
391 ))
392 }
393 "MaskService" => {
394 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
395 Ok(services::mask_service(&unit))
396 }
397 "UnmaskService" => {
398 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
399 Ok(services::unmask_service(&unit))
400 }
401 "GetServiceLogs" => {
402 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
403 Ok(services::get_service_logs(&unit))
404 }
405 "GetServiceStatus" => {
406 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
407 Ok(services::get_service_status(&unit))
408 }
409 "ReloadService" => {
410 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
411 Ok(services::reload_service(&unit))
412 }
413 "ListTimers" => Ok(services::list_timers()),
414 "ReloadDaemon" => Ok(services::reload_daemon()),
415 "CreateScheduledJob" => {
416 let name = require_str(params, "name")?;
418 if name.is_empty()
419 || name.len() > 64
420 || !name
421 .chars()
422 .next()
423 .is_some_and(|c| c.is_ascii_alphanumeric())
424 || !name
425 .chars()
426 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
427 {
428 return Err(ExecutorError::InvalidParam("name"));
429 }
430 let command = require_str(params, "command")?;
433 if command.is_empty() || command.len() > 512 || command.chars().any(|c| c.is_control())
434 {
435 return Err(ExecutorError::InvalidParam("command"));
436 }
437 let schedule = require_str(params, "schedule")?;
440 if schedule.is_empty()
441 || schedule.len() > 128
442 || !schedule.chars().all(|c| {
443 c.is_ascii_alphanumeric()
444 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
445 })
446 {
447 return Err(ExecutorError::InvalidParam("schedule"));
448 }
449 Ok(services::create_scheduled_job(name, command, schedule))
450 }
451 "GetServiceResourceLimits" => {
452 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
453 Ok(services::get_service_resource_limits(&unit))
454 }
455 "SetServiceResourceLimits" => {
456 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
457 let mut assignments = Vec::new();
460 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
461 assignments.push(format!("MemoryMax={v}"));
462 }
463 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
464 assignments.push(format!("MemoryHigh={v}"));
465 }
466 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
467 assignments.push(format!("CPUQuota={v}"));
468 }
469 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
470 assignments.push(format!("TasksMax={v}"));
471 }
472 if assignments.is_empty() {
473 return Err(ExecutorError::MissingParam("memory_max"));
474 }
475 Ok(services::set_service_resource_limits(&unit, &assignments))
476 }
477
478 "ListToolboxes" => {
482 let username = validated_username(resolve_username(params)?, "username")?;
483 Ok(toolbox::list_toolboxes(&username))
484 }
485 "CreateToolbox" => {
486 let username = validated_username(resolve_username(params)?, "username")?;
487 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
488 let image = params
489 .get("image")
490 .and_then(|v| v.as_str())
491 .map(|img| validated_safe_arg(img, "image"))
492 .transpose()?;
493 let release = params
494 .get("release")
495 .and_then(|v| v.as_str())
496 .map(|r| validated_safe_arg(r, "release"))
497 .transpose()?;
498 Ok(toolbox::create_toolbox(
499 &username,
500 &name,
501 release.as_deref(),
502 image.as_deref(),
503 ))
504 }
505 "RemoveToolbox" => {
506 let username = validated_username(resolve_username(params)?, "username")?;
507 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
508 Ok(toolbox::remove_toolbox(&username, &name))
509 }
510
511 "GetDateTime" => Ok(identity::get_datetime()),
513 "SetHostname" => {
514 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
515 Ok(identity::set_hostname(&hostname))
516 }
517 "SetTimezone" => {
518 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
519 Ok(identity::set_timezone(&timezone))
520 }
521 "SetLocale" => {
522 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
523 Ok(identity::set_locale(&locale))
524 }
525 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
526
527 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
529
530 "ListProcesses" => Ok(processes::list_processes_spec()),
532 "SignalProcess" => {
533 let pid = params
535 .get("pid")
536 .and_then(|v| {
537 v.as_u64()
538 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
539 })
540 .ok_or(ExecutorError::MissingParam("pid"))?;
541 if pid < 2 || pid > u32::MAX as u64 {
544 return Err(ExecutorError::InvalidParam("pid"));
545 }
546 let signal = validated_kill_signal(
547 params
548 .get("signal")
549 .and_then(|v| v.as_str())
550 .unwrap_or("TERM"),
551 )?;
552 Ok(processes::signal_process(pid as u32, signal))
553 }
554
555 "GetJournalLog" => {
557 let unit = optional_validated(params, "unit", validated_unit_name)?;
558 let priority = optional_validated(params, "priority", validated_journal_priority)?;
559 let since = optional_validated(params, "since", validated_journal_time)?;
560 let until = optional_validated(params, "until", validated_journal_time)?;
561 let grep = optional_validated(params, "grep", validated_journal_grep)?;
562 let lines = params
565 .get("lines")
566 .and_then(|v| v.as_u64())
567 .unwrap_or(100)
568 .clamp(1, 10_000) as u32;
569 let boot = params
570 .get("boot")
571 .and_then(|v| v.as_bool())
572 .unwrap_or(false);
573 let kernel = params
574 .get("kernel")
575 .and_then(|v| v.as_bool())
576 .unwrap_or(false);
577 Ok(journald::get_journal_log(&journald::JournalQuery {
578 lines,
579 unit: unit.as_deref(),
580 priority: priority.as_deref(),
581 boot,
582 kernel,
583 since: since.as_deref(),
584 until: until.as_deref(),
585 grep: grep.as_deref(),
586 }))
587 }
588 "VacuumJournal" => {
589 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
591 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
592 match (size_mb, retain_days) {
593 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
594 Ok(journald::vacuum_journal_by_size(mb as u32))
595 }
596 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
597 Ok(journald::vacuum_journal_by_time(days as u32))
598 }
599 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
600 _ => Err(ExecutorError::InvalidParam("size_mb")),
602 }
603 }
604
605 "GetLvmReport" => Ok(lvm::get_lvm_report()),
607 "ExtendLogicalVolume" => {
608 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
609 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
610 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
611 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
612 }
613 "CreateLogicalVolume" => {
614 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
615 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
616 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
617 Ok(lvm::create_logical_volume(&vg, &name, &size))
618 }
619 "CreateLvSnapshot" => {
620 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
621 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
622 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
623 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
624 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
625 }
626
627 "GetSysctl" => {
629 let key = optional_validated(params, "key", validated_sysctl_key)?;
631 Ok(sysctl::get_sysctl(key.as_deref()))
632 }
633 "SetSysctl" => {
634 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
635 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
636 Ok(sysctl::set_sysctl(&key, &value))
637 }
638
639 "GetMounts" => Ok(mounts::get_mounts()),
641 "AddMount" => {
642 let device = validated_mount_device(require_str(params, "device")?, "device")?;
643 let mountpoint =
644 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
645 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
646 let options = optional_validated(params, "options", validated_mount_options)?;
647 Ok(mounts::add_mount(
648 &device,
649 &mountpoint,
650 &fstype,
651 options.as_deref(),
652 ))
653 }
654 "RemoveMount" => {
655 let mountpoint =
656 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
657 Ok(mounts::remove_mount(&mountpoint))
658 }
659 "AddSwap" => {
660 let file = validated_swap_path(require_str(params, "file")?, "file")?;
661 let size_mb = require_u32(params, "size_mb")?;
662 if !(1..=1_048_576).contains(&size_mb) {
664 return Err(ExecutorError::InvalidParam("size_mb"));
665 }
666 Ok(mounts::add_swap(&file, size_mb))
667 }
668 "RemoveSwap" => {
669 let file = validated_swap_path(require_str(params, "file")?, "file")?;
670 Ok(mounts::remove_swap(&file))
671 }
672
673 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
675 "GrantSudoAccess" => {
676 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
677 let user = validated_username(require_str(params, "user")?, "user")?;
678 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
679 let runas = match params
681 .get("runas")
682 .and_then(|v| v.as_str())
683 .filter(|s| !s.is_empty())
684 {
685 Some("ALL") => Some("ALL".to_string()),
686 Some(u) => Some(validated_username(u, "runas")?),
687 None => None,
688 };
689 let nopasswd = params
690 .get("nopasswd")
691 .and_then(|v| v.as_bool())
692 .unwrap_or(false);
693 Ok(sudoers::grant_sudo_access(
694 &name,
695 &user,
696 &commands,
697 runas.as_deref(),
698 nopasswd,
699 ))
700 }
701 "RevokeSudoAccess" => {
702 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
703 Ok(sudoers::revoke_sudo_access(&name))
704 }
705
706 "GetAptPins" => {
708 let package = optional_validated(params, "package", validated_apt_package)?;
709 Ok(apt_preferences::get_apt_pins(package.as_deref()))
710 }
711 "SetAptPin" => {
712 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
713 let package = validated_apt_package(require_str(params, "package")?, "package")?;
714 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
715 let priority = params
716 .get("priority")
717 .and_then(|v| v.as_i64())
718 .ok_or(ExecutorError::MissingParam("priority"))?;
719 if !(-1..=1000).contains(&priority) {
720 return Err(ExecutorError::InvalidParam("priority"));
721 }
722 Ok(apt_preferences::set_apt_pin(
723 &name, &package, &pin, priority,
724 ))
725 }
726 "RemoveAptPin" => {
727 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
728 Ok(apt_preferences::remove_apt_pin(&name))
729 }
730
731 "GetLogrotateStatus" => {
733 let config = optional_validated(params, "config", validated_log_path)?;
734 Ok(logging::get_logrotate_status(config.as_deref()))
735 }
736 "ConfigureLogRotation" => {
737 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
738 let path = validated_log_path(require_str(params, "path")?, "path")?;
739 let frequency = require_str(params, "frequency")?;
740 if !matches!(frequency, "daily" | "weekly" | "monthly") {
741 return Err(ExecutorError::InvalidParam("frequency"));
742 }
743 let rotate = require_u32(params, "rotate")?;
744 if rotate > 1000 {
745 return Err(ExecutorError::InvalidParam("rotate"));
746 }
747 let compress = params
748 .get("compress")
749 .and_then(|v| v.as_bool())
750 .unwrap_or(false);
751 Ok(logging::configure_log_rotation(
752 &name, &path, frequency, rotate, compress,
753 ))
754 }
755 "RemoveLogRotation" => {
756 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
757 Ok(logging::remove_log_rotation(&name))
758 }
759 "ConfigureRemoteSyslog" => {
760 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
761 let port = require_u32(params, "port")?;
762 if !(1..=65535).contains(&port) {
763 return Err(ExecutorError::InvalidParam("port"));
764 }
765 let protocol = require_str(params, "protocol")?;
766 if !matches!(protocol, "tcp" | "udp") {
767 return Err(ExecutorError::InvalidParam("protocol"));
768 }
769 Ok(logging::configure_remote_syslog(
770 &host,
771 port as u16,
772 protocol,
773 ))
774 }
775 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
776
777 "GetPasswordAging" => {
779 let user = validated_username(require_str(params, "user")?, "user")?;
780 Ok(pam::get_password_aging(&user))
781 }
782 "SetPasswordAging" => {
783 let user = validated_username(require_str(params, "user")?, "user")?;
784 let mut flags = Vec::new();
786 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
787 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
788 if n > 99999 {
789 return Err(ExecutorError::InvalidParam(key));
790 }
791 flags.push(opt.to_string());
792 flags.push(n.to_string());
793 }
794 }
795 if flags.is_empty() {
796 return Err(ExecutorError::MissingParam("max_days"));
797 }
798 Ok(pam::set_password_aging(&user, &flags))
799 }
800 "SetPasswordPolicy" => {
801 let mut extra = Vec::new();
804 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
805 if !(1..=128).contains(&n) {
806 return Err(ExecutorError::InvalidParam("minlen"));
807 }
808 extra.push("--minlen".to_string());
809 extra.push(n.to_string());
810 }
811 for (key, flag) in [
812 ("dcredit", "--dcredit"),
813 ("ucredit", "--ucredit"),
814 ("lcredit", "--lcredit"),
815 ("ocredit", "--ocredit"),
816 ] {
817 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
818 if !(-64..=64).contains(&n) {
819 return Err(ExecutorError::InvalidParam(key));
820 }
821 extra.push(flag.to_string());
822 extra.push(n.to_string());
823 }
824 }
825 if extra.is_empty() {
826 return Err(ExecutorError::MissingParam("minlen"));
827 }
828 Ok(pam::set_password_policy(&extra))
829 }
830 "SetAccountLockout" => {
831 let mut extra = Vec::new();
833 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
834 if !(1..=1000).contains(&n) {
835 return Err(ExecutorError::InvalidParam("deny"));
836 }
837 extra.push("--deny".to_string());
838 extra.push(n.to_string());
839 }
840 for (key, flag) in [
841 ("unlock_time", "--unlock-time"),
842 ("fail_interval", "--fail-interval"),
843 ] {
844 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
845 if n > 604800 {
846 return Err(ExecutorError::InvalidParam(key));
847 }
848 extra.push(flag.to_string());
849 extra.push(n.to_string());
850 }
851 }
852 if extra.is_empty() {
853 return Err(ExecutorError::MissingParam("deny"));
854 }
855 Ok(pam::set_account_lockout(&extra))
856 }
857
858 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
860
861 "GetFirewallState" => Ok(network::get_firewall_state()),
863 "GetNetworkStatus" => Ok(network::get_network_status()),
864 "GetListeningPorts" => Ok(network::get_listening_ports()),
865 "ConfigureWifi" => {
866 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
867 let password = params
869 .get("password")
870 .and_then(|v| v.as_str())
871 .filter(|p| !p.is_empty())
872 .map(|p| validated_safe_arg(p, "password"))
873 .transpose()?;
874 Ok(network::configure_wifi(&ssid, password.as_deref()))
875 }
876 "SetDnsServers" => {
877 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
878 let servers = str_array_or_empty(params, "servers")?;
879 let validated: Vec<String> = servers
880 .iter()
881 .map(|s| validated_safe_arg(s, "servers"))
882 .collect::<Result<_, _>>()?;
883 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
884 Ok(network::set_dns_servers(&interface, &refs))
885 }
886 "ConfigureFirewall" => {
887 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
888 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
889 Ok(network::configure_firewall(
890 &zone,
891 &service,
892 require_bool(params, "enabled")?,
893 ))
894 }
895
896 "ListUsers" => Ok(users::list_users()),
898 "ListGroups" => Ok(users::list_groups()),
899 "CreateUser" => {
900 let username = validated_username(resolve_username(params)?, "username")?;
901 let shell = params
902 .get("shell")
903 .and_then(|v| v.as_str())
904 .map(|s| validated_safe_arg(s, "shell"))
905 .transpose()?;
906 let home = params
907 .get("home")
908 .and_then(|v| v.as_str())
909 .map(|h| validated_safe_arg(h, "home"))
910 .transpose()?;
911 Ok(users::create_user(
912 &username,
913 shell.as_deref(),
914 home.as_deref(),
915 ))
916 }
917 "DeleteUser" => {
918 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
919 Ok(users::delete_user(&username))
920 }
921 "AddUserToGroup" => {
922 let username = validated_username(resolve_username(params)?, "username")?;
923 let group = validated_group(require_str(params, "group")?, "group")?;
924 Ok(users::add_user_to_group(&username, &group))
925 }
926 "RemoveUserFromGroup" => {
927 let username = validated_username(resolve_username(params)?, "username")?;
928 let group = validated_group(require_str(params, "group")?, "group")?;
929 Ok(users::remove_user_from_group(&username, &group))
930 }
931 "CreateGroup" => {
932 let group = validated_group(require_str(params, "group")?, "group")?;
933 let system = params
934 .get("system")
935 .and_then(|v| v.as_bool())
936 .unwrap_or(false);
937 Ok(users::create_group(&group, system))
938 }
939 "DeleteGroup" => {
940 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
941 Ok(users::delete_group(&group))
942 }
943 "LockUserAccount" => {
944 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
945 Ok(users::lock_user_account(&username))
946 }
947 "UnlockUserAccount" => {
948 let username = validated_username(resolve_username(params)?, "username")?;
949 Ok(users::unlock_user_account(&username))
950 }
951
952 "GetAuthorizedKeys" => {
954 let username = validated_username(resolve_username(params)?, "username")?;
955 Ok(ssh::get_authorized_keys(&username))
956 }
957 "AddAuthorizedKey" => {
958 let username = validated_username(resolve_username(params)?, "username")?;
959 let public_key = validated_public_key(require_str(params, "public_key")?)?;
960 Ok(ssh::add_authorized_key(&username, &public_key))
961 }
962 "RemoveAuthorizedKey" => {
963 let username = validated_username(resolve_username(params)?, "username")?;
964 let public_key = validated_public_key(require_str(params, "public_key")?)?;
965 Ok(ssh::remove_authorized_key(&username, &public_key))
966 }
967 "SetSshdOption" => {
968 let option = require_str(params, "option")?;
972 let value = require_str(params, "value")?;
973 let allowed_values: &[&str] = match option {
974 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
975 "PasswordAuthentication"
976 | "PubkeyAuthentication"
977 | "X11Forwarding"
978 | "PermitEmptyPasswords" => &["yes", "no"],
979 _ => return Err(ExecutorError::InvalidParam("option")),
980 };
981 if !allowed_values.contains(&value) {
982 return Err(ExecutorError::InvalidParam("value"));
983 }
984 Ok(ssh::set_sshd_option(option, value))
985 }
986
987 "AptUpdate" => Ok(apt::apt_update()),
989 "AptUpgrade" => Ok(apt::apt_upgrade()),
990 "AptInstall" => {
991 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
992 Ok(apt::apt_install(&package))
993 }
994 "AptRemove" => {
995 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
996 Ok(apt::apt_remove(&package))
997 }
998 "AptPurge" => {
999 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1000 Ok(apt::apt_purge(&package))
1001 }
1002 "AptAutoremove" => Ok(apt::apt_autoremove()),
1003 "AptHold" => {
1004 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1005 Ok(apt::apt_hold(&package))
1006 }
1007 "AptUnhold" => {
1008 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1009 Ok(apt::apt_unhold(&package))
1010 }
1011 "AptSearch" => {
1012 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1013 Ok(apt::apt_search(&term))
1014 }
1015 "AptListInstalled" => Ok(apt::apt_list_installed()),
1016 "AptShow" => {
1017 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1018 Ok(apt::apt_show(&package))
1019 }
1020 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1021 "AptHistoryList" => Ok(apt::apt_history_list()),
1022 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1023 params, "enabled",
1024 )?)),
1025
1026 "AddPpa" => {
1028 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1029 Ok(ppa::add_ppa(&name))
1030 }
1031 "RemovePpa" => {
1032 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1033 Ok(ppa::remove_ppa(&name))
1034 }
1035
1036 "SnapInstall" => {
1038 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1039 let channel = params
1040 .get("channel")
1041 .and_then(|v| v.as_str())
1042 .filter(|s| !s.is_empty())
1043 .map(|s| validated_safe_arg(s, "channel"))
1044 .transpose()?;
1045 let auto_update = params
1046 .get("auto_update")
1047 .and_then(|v| v.as_bool())
1048 .unwrap_or(false);
1049 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1050 snap::SnapInstallError::InvalidArg { param, .. } => {
1051 ExecutorError::InvalidParam(param)
1052 }
1053 })
1054 }
1055 "SnapRemove" => {
1056 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1057 Ok(snap::snap_remove(&name))
1058 }
1059 "SnapRefresh" => {
1060 let name = params
1061 .get("name")
1062 .and_then(|v| v.as_str())
1063 .filter(|s| !s.is_empty())
1064 .map(|s| validated_safe_arg(s, "name"))
1065 .transpose()?;
1066 Ok(snap::snap_refresh(name.as_deref()))
1067 }
1068 "SnapHold" => {
1069 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1070 Ok(snap::snap_hold(&name))
1071 }
1072 "SnapUnhold" => {
1073 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1074 Ok(snap::snap_unhold(&name))
1075 }
1076 "SnapList" => Ok(snap::snap_list()),
1077 "SnapInfo" => {
1078 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1079 Ok(snap::snap_info(&name))
1080 }
1081 "SnapRevert" => {
1082 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1083 Ok(snap::snap_revert(&name))
1084 }
1085 "SnapClassicInstall" => {
1086 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1087 Ok(snap::snap_classic_install(&name))
1088 }
1089
1090 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1092 "GrubSetKargs" => {
1093 let append: Vec<String> = params
1094 .get("append")
1095 .and_then(|v| v.as_array())
1096 .map(|arr| {
1097 arr.iter()
1098 .filter_map(|v| v.as_str())
1099 .map(String::from)
1100 .collect()
1101 })
1102 .unwrap_or_default();
1103 let delete: Vec<String> = params
1104 .get("delete")
1105 .and_then(|v| v.as_array())
1106 .map(|arr| {
1107 arr.iter()
1108 .filter_map(|v| v.as_str())
1109 .map(String::from)
1110 .collect()
1111 })
1112 .unwrap_or_default();
1113 for a in &append {
1122 validated_safe_arg(a, "append")?;
1123 validated_safe_kernel_arg(a, "append")?;
1124 }
1125 for d in &delete {
1126 validated_safe_arg(d, "delete")?;
1127 }
1128 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1131 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1132 grub::grub_set_kargs(&append_refs, &delete_refs)
1133 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1134 }
1135
1136 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1138
1139 "UfwEnable" => Ok(ufw::ufw_enable()),
1141 "UfwDisable" => Ok(ufw::ufw_disable()),
1142 "UfwAllow" => {
1143 let port_or_service = validated_port_or_service(
1144 require_str(params, "port_or_service")?,
1145 "port_or_service",
1146 )?;
1147 Ok(ufw::ufw_allow(&port_or_service))
1148 }
1149 "UfwDeny" => {
1150 let port_or_service = validated_port_or_service(
1151 require_str(params, "port_or_service")?,
1152 "port_or_service",
1153 )?;
1154 Ok(ufw::ufw_deny(&port_or_service))
1155 }
1156 "UfwReset" => Ok(ufw::ufw_reset()),
1157 "UfwStatus" => Ok(ufw::ufw_status()),
1158
1159 "DistroboxList" => Ok(distrobox::distrobox_list()),
1161 "DistroboxCreate" => {
1162 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1163 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1164 Ok(distrobox::distrobox_create(&name, &image))
1165 }
1166 "DistroboxRemove" => {
1167 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1168 Ok(distrobox::distrobox_remove(&name))
1169 }
1170
1171 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1173 "NetplanApply" => Ok(netplan::netplan_apply()),
1174 "NetplanSet" => {
1175 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1176 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1177 Ok(netplan::netplan_set(&key, &value))
1178 }
1179 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1180
1181 "UfwDeleteRule" => {
1183 let rule_number = require_positive_u32(params, "rule_number")?;
1184 ufw::ufw_delete_rule(rule_number)
1185 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1186 }
1187 "UfwLimit" => {
1188 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1189 Ok(ufw::ufw_limit(&target))
1190 }
1191
1192 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1194 "ProAttach" => {
1195 let token = require_str(params, "token")?;
1197 let token = validated_safe_arg(token, "token")?;
1199 Ok(ubuntu_pro::pro_attach(&token))
1200 }
1201 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1202 "EnableProService" => {
1203 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1204 Ok(ubuntu_pro::enable_pro_service(&service))
1205 }
1206 "DisableProService" => {
1207 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1208 Ok(ubuntu_pro::disable_pro_service(&service))
1209 }
1210
1211 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1213
1214 "MultipassList" => Ok(multipass::multipass_list()),
1216
1217 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1219
1220 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1222 "ResolvectlSetDns" => {
1223 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1224 let raw_servers: Vec<String> = params
1225 .get("servers")
1226 .and_then(|v| v.as_array())
1227 .map(|arr| {
1228 arr.iter()
1229 .filter_map(|v| v.as_str())
1230 .map(String::from)
1231 .collect()
1232 })
1233 .unwrap_or_default();
1234 if raw_servers.is_empty() {
1235 return Err(ExecutorError::MissingParam("servers"));
1236 }
1237 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1241 for s in &raw_servers {
1242 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1243 param: "servers",
1244 value: s.clone(),
1245 })?;
1246 parsed_servers.push(addr);
1247 }
1248 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1249 }
1250
1251 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1253 "AppArmorEnforce" => {
1254 let profile_path =
1255 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1256 Ok(apparmor::apparmor_enforce(&profile_path))
1257 }
1258 "AppArmorComplain" => {
1259 let profile_path =
1260 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1261 Ok(apparmor::apparmor_complain(&profile_path))
1262 }
1263
1264 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1266
1267 "UbuntuInstallFlatpak" => {
1269 let username = validated_username(resolve_username(params)?, "username")?;
1270 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1271 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1272 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1273 }
1274 "UbuntuRemoveFlatpak" => {
1275 let username = validated_username(resolve_username(params)?, "username")?;
1276 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1277 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1278 }
1279 "UbuntuUpdateFlatpak" => {
1280 let username = validated_username(resolve_username(params)?, "username")?;
1281 let app_id = params
1282 .get("app_id")
1283 .and_then(|v| v.as_str())
1284 .filter(|s| !s.is_empty())
1285 .map(|s| validated_safe_arg(s, "app_id"))
1286 .transpose()?;
1287 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1288 }
1289 "UbuntuListFlatpaks" => {
1290 let username = validated_username(resolve_username(params)?, "username")?;
1291 Ok(flatpak::ubuntu_list_flatpaks(&username))
1292 }
1293
1294 "Fail2banStatus" => {
1296 let jail = params
1297 .get("jail")
1298 .and_then(|v| v.as_str())
1299 .filter(|s| !s.is_empty())
1300 .map(|s| validated_safe_arg(s, "jail"))
1301 .transpose()?;
1302 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1303 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1304 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1305 })
1306 }
1307 "Fail2banBanIp" => {
1308 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1309 let ip = require_str(params, "ip")?;
1310 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1311 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1312 param: "ip",
1313 value: v,
1314 },
1315 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1316 })
1317 }
1318 "Fail2banUnbanIp" => {
1319 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1320 let ip = require_str(params, "ip")?;
1321 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1322 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1323 param: "ip",
1324 value: v,
1325 },
1326 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1327 })
1328 }
1329 "ConfigureFail2banJail" => {
1330 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1331 let mut extra = Vec::new();
1332 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1333 extra.push("--enabled".to_string());
1334 extra.push(if n { "true" } else { "false" }.to_string());
1335 }
1336 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1337 if !(1..=100).contains(&n) {
1338 return Err(ExecutorError::InvalidParam("maxretry"));
1339 }
1340 extra.push("--maxretry".to_string());
1341 extra.push(n.to_string());
1342 }
1343 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1344 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1345 if n > 2_592_000 {
1346 return Err(ExecutorError::InvalidParam(key));
1347 }
1348 extra.push(flag.to_string());
1349 extra.push(n.to_string());
1350 }
1351 }
1352 if extra.is_empty() {
1353 return Err(ExecutorError::MissingParam("maxretry"));
1354 }
1355 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
1356 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
1357 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
1358 })
1359 }
1360
1361 "GetAuditRules" => Ok(auditd::get_audit_rules()),
1363 "AddAuditRule" => {
1364 let path = validated_audit_path(require_str(params, "path")?, "path")?;
1365 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1366 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1367 Ok(auditd::add_audit_rule(&path, &perms, &key))
1368 }
1369 "RemoveAuditRule" => {
1370 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1371 Ok(auditd::remove_audit_rule(&key))
1372 }
1373
1374 "GetCertificates" => Ok(certbot::get_certificates()),
1376 "ObtainCertificate" => {
1377 let mut domains = Vec::new();
1379 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1380 for d in arr {
1381 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1382 domains.push(validated_domain(s, "domains")?);
1383 }
1384 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1385 domains.push(validated_domain(d, "domain")?);
1386 }
1387 if domains.is_empty() {
1388 return Err(ExecutorError::MissingParam("domains"));
1389 }
1390 let email = validated_email(require_str(params, "email")?, "email")?;
1391 let challenge = params
1392 .get("challenge")
1393 .and_then(|v| v.as_str())
1394 .unwrap_or("standalone");
1395 if !matches!(challenge, "standalone" | "nginx" | "apache") {
1396 return Err(ExecutorError::InvalidParam("challenge"));
1397 }
1398 Ok(certbot::obtain_certificate(&domains, &email, challenge))
1399 }
1400 "RenewCertificates" => Ok(certbot::renew_certificates()),
1401
1402 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1403 }
1404}
1405
1406pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1412 match &spec.mechanism {
1413 ActionMechanism::Command { program, args } => {
1414 let output = tokio::process::Command::new(program)
1415 .args(args)
1416 .output()
1417 .await?;
1418 Ok(ExecutionOutput {
1419 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1420 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1421 exit_code: output.status.code().unwrap_or(-1),
1422 })
1423 }
1424 ActionMechanism::FileScan { path } => {
1425 let mut entries = tokio::fs::read_dir(path).await?;
1426 let mut names = Vec::new();
1427 while let Some(entry) = entries.next_entry().await? {
1428 names.push(entry.file_name().to_string_lossy().into_owned());
1429 }
1430 names.sort();
1431 Ok(ExecutionOutput {
1432 stdout: names.join("\n"),
1433 stderr: String::new(),
1434 exit_code: 0,
1435 })
1436 }
1437 ActionMechanism::FileWrite { path, content } => {
1438 if let Some(parent) = std::path::Path::new(path).parent() {
1439 tokio::fs::create_dir_all(parent).await?;
1440 }
1441 tokio::fs::write(path, content).await?;
1442 Ok(ExecutionOutput {
1443 stdout: String::new(),
1444 stderr: String::new(),
1445 exit_code: 0,
1446 })
1447 }
1448 ActionMechanism::FilePatch {
1449 path,
1450 search,
1451 replace,
1452 } => {
1453 let content = tokio::fs::read_to_string(path).await?;
1454 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1455 if patched == content && !search.is_empty() {
1456 return Ok(ExecutionOutput {
1457 stdout: String::new(),
1458 stderr: format!("search string not found in file: {}", path),
1459 exit_code: 1,
1460 });
1461 }
1462 tokio::fs::write(path, patched).await?;
1463 Ok(ExecutionOutput {
1464 stdout: String::new(),
1465 stderr: String::new(),
1466 exit_code: 0,
1467 })
1468 }
1469 ActionMechanism::FileDelete { path } => {
1470 tokio::fs::remove_file(path).await?;
1471 Ok(ExecutionOutput {
1472 stdout: String::new(),
1473 stderr: String::new(),
1474 exit_code: 0,
1475 })
1476 }
1477 }
1478}
1479
1480fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
1481 match params.get(key) {
1482 None => Err(ExecutorError::MissingParam(key)),
1483 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
1484 }
1485}
1486
1487fn optional_validated<F>(
1491 params: &Value,
1492 key: &'static str,
1493 validator: F,
1494) -> Result<Option<String>, ExecutorError>
1495where
1496 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
1497{
1498 match params
1499 .get(key)
1500 .and_then(|v| v.as_str())
1501 .filter(|s| !s.is_empty())
1502 {
1503 Some(s) => Ok(Some(validator(s, key)?)),
1504 None => Ok(None),
1505 }
1506}
1507
1508fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
1518 params
1519 .get("username")
1520 .or_else(|| params.get("user"))
1521 .ok_or(ExecutorError::MissingParam("username"))
1522 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
1523}
1524
1525fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
1529 let id = require_str(params, "repo_id")?;
1530 let valid = !id.is_empty()
1531 && id
1532 .chars()
1533 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1534 if valid {
1535 Ok(id)
1536 } else {
1537 Err(ExecutorError::InvalidParam("repo_id"))
1538 }
1539}
1540
1541fn validated_no_newline<'a>(
1544 params: &'a Value,
1545 key: &'static str,
1546) -> Result<&'a str, ExecutorError> {
1547 let val = require_str(params, key)?;
1548 if val.contains('\n') || val.contains('\r') {
1549 Err(ExecutorError::InvalidParam(key))
1550 } else {
1551 Ok(val)
1552 }
1553}
1554
1555fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
1559 const MAX_LEN: usize = 8192;
1560 const ALLOWED_PREFIXES: &[&str] = &[
1561 "ssh-rsa",
1562 "ssh-ed25519",
1563 "ssh-ed25519-sk",
1564 "ecdsa-sha2-nistp256",
1565 "ecdsa-sha2-nistp384",
1566 "ecdsa-sha2-nistp521",
1567 "sk-ssh-ed25519",
1568 "sk-ecdsa-sha2-nistp256",
1569 ];
1570
1571 if s.is_empty() || s.len() > MAX_LEN {
1572 return Err(ExecutorError::InvalidParam("public_key"));
1573 }
1574 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
1575 return Err(ExecutorError::InvalidParam("public_key"));
1576 }
1577 if s.chars().any(|c| {
1591 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
1592 || !c.is_ascii()
1593 || c.is_ascii_control()
1594 }) {
1595 return Err(ExecutorError::InvalidParam("public_key"));
1596 }
1597 Ok(s.to_string())
1598}
1599
1600fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
1601 match params.get(key) {
1602 None => Err(ExecutorError::MissingParam(key)),
1603 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
1604 }
1605}
1606
1607fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1608 match params.get(key) {
1609 None => Err(ExecutorError::MissingParam(key)),
1610 Some(v) => {
1611 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
1612 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
1613 }
1614 }
1615}
1616
1617fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1621 let n = require_u32(params, key)?;
1622 if n == 0 {
1623 return Err(ExecutorError::InvalidParam(key));
1624 }
1625 Ok(n)
1626}
1627
1628fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
1632 match params.get(key) {
1633 None | Some(Value::Null) => Ok(vec![]),
1634 Some(Value::Array(arr)) => arr
1635 .iter()
1636 .map(|v| {
1637 v.as_str()
1638 .map(String::from)
1639 .ok_or(ExecutorError::InvalidParam(key))
1640 })
1641 .collect(),
1642 _ => Err(ExecutorError::InvalidParam(key)),
1643 }
1644}
1645
1646fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
1679 const BLOCKED_PREFIXES: &[&str] = &[
1680 "init=",
1681 "selinux=0",
1682 "enforcing=0",
1683 "security=",
1684 "module_blacklist=",
1685 "apparmor=0",
1686 "mitigations=off",
1687 "lockdown=",
1688 "pti=off",
1689 ];
1690 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
1691 const BLOCKED_UNIT_PREFIXES: &[&str] = &["emergency", "rescue", "single"];
1692
1693 let lower = arg.to_lowercase();
1694 let base = lower.split('=').next().unwrap_or(&lower);
1696
1697 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
1698 return Err(ExecutorError::InvalidParam(param));
1699 }
1700 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
1701 return Err(ExecutorError::InvalidParam(param));
1702 }
1703 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
1705 if BLOCKED_UNIT_PREFIXES
1706 .iter()
1707 .any(|u| unit_val.starts_with(u))
1708 {
1709 return Err(ExecutorError::InvalidParam(param));
1710 }
1711 }
1712 if BLOCKED_EXACT.contains(&base) {
1714 return Err(ExecutorError::InvalidParam(param));
1715 }
1716 Ok(())
1717}
1718
1719fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
1727 let normalized = s.trim().to_ascii_uppercase();
1728 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
1729 "TERM" => Ok("TERM"),
1730 "KILL" => Ok("KILL"),
1731 "HUP" => Ok("HUP"),
1732 "INT" => Ok("INT"),
1733 _ => Err(ExecutorError::InvalidParam("signal")),
1734 }
1735}
1736
1737pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
1747 match action_name {
1748 "UpdateSystem"
1749 | "InstallPackages"
1750 | "RemovePackages"
1751 | "RebaseSystem"
1752 | "SetKernelArguments"
1753 | "AddLayeredPackage"
1754 | "RemoveLayeredPackage"
1755 | "ReplaceLayeredPackage"
1756 | "ResetLayeredPackageOverride"
1757 | "RemoveBasePackage" => Some(ActionSpec {
1758 action_name: "RollbackDeployment",
1759 mechanism: ActionMechanism::Command {
1760 program: "rpm-ostree",
1761 args: vec!["rollback".to_string()],
1762 },
1763 risk_level: sysknife_types::RiskLevel::High,
1764 reboot_required: true,
1765 rollback_available: false,
1766 }),
1767 _ => None,
1768 }
1769}
1770
1771#[cfg(test)]
1772mod tests {
1773 use super::*;
1774 use serde_json::json;
1775 use sysknife_types::RiskLevel;
1776 use tempfile::tempdir;
1777
1778 #[test]
1781 fn build_spec_no_params_for_get_system_state() {
1782 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
1783 assert_eq!(spec.action_name, "GetSystemState");
1784 assert_eq!(spec.risk_level, RiskLevel::Low);
1785 }
1786
1787 #[test]
1788 fn build_spec_get_datetime_is_low_risk() {
1789 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
1790 assert_eq!(spec.action_name, "GetDateTime");
1791 assert_eq!(spec.risk_level, RiskLevel::Low);
1792 assert!(!spec.reboot_required);
1793 }
1794
1795 #[test]
1796 fn build_spec_unknown_action_returns_error() {
1797 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
1798 assert!(
1799 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
1800 "expected UnknownAction, got: {err}"
1801 );
1802 }
1803
1804 #[test]
1805 fn build_spec_missing_param_for_install_flatpak() {
1806 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
1808 assert!(
1809 matches!(err, ExecutorError::MissingParam("username")),
1810 "expected MissingParam(username), got: {err}"
1811 );
1812 }
1813
1814 #[test]
1818 fn build_spec_flatpak_accepts_user_alias() {
1819 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
1820 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
1821 }
1822
1823 #[test]
1825 fn build_spec_resolve_username_prefers_explicit_username() {
1826 let spec = build_action_spec(
1827 "ListInstalledFlatpaks",
1828 &json!({ "username": "alice", "user": "bob" }),
1829 )
1830 .unwrap();
1831 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
1833 }
1834
1835 #[test]
1838 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
1839 let spec = build_action_spec(
1840 "InstallFlatpak",
1841 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
1842 )
1843 .unwrap();
1844 assert_eq!(
1845 spec.mechanism,
1846 ActionMechanism::Command {
1847 program: "sudo",
1848 args: vec![
1849 "runuser".to_string(),
1850 "-u".to_string(),
1851 "alice".to_string(),
1852 "--".to_string(),
1853 "flatpak".to_string(),
1854 "install".to_string(),
1855 "--user".to_string(),
1856 "-y".to_string(),
1857 "flathub".to_string(),
1858 "org.mozilla.firefox".to_string(),
1859 ],
1860 }
1861 );
1862 }
1863
1864 #[test]
1865 fn build_spec_install_flatpak_injects_app_and_remote() {
1866 let spec = build_action_spec(
1867 "InstallFlatpak",
1868 &json!({
1869 "username": "alice",
1870 "app_id": "org.mozilla.firefox",
1871 "remote": "flathub"
1872 }),
1873 )
1874 .unwrap();
1875 assert_eq!(spec.action_name, "InstallFlatpak");
1876 assert_eq!(
1877 spec.mechanism,
1878 ActionMechanism::Command {
1879 program: "sudo",
1880 args: vec![
1881 "runuser".to_string(),
1882 "-u".to_string(),
1883 "alice".to_string(),
1884 "--".to_string(),
1885 "flatpak".to_string(),
1886 "install".to_string(),
1887 "--user".to_string(),
1888 "-y".to_string(),
1889 "flathub".to_string(),
1890 "org.mozilla.firefox".to_string(),
1891 ],
1892 }
1893 );
1894 }
1895
1896 #[test]
1897 fn build_spec_pin_deployment_injects_index() {
1898 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
1899 assert_eq!(
1900 spec.mechanism,
1901 ActionMechanism::Command {
1902 program: "sudo",
1903 args: vec![
1904 "ostree".to_string(),
1905 "admin".to_string(),
1906 "pin".to_string(),
1907 "1".to_string()
1908 ],
1909 }
1910 );
1911 }
1912
1913 #[test]
1914 fn build_spec_unpin_deployment_includes_unpin_flag() {
1915 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
1916 assert_eq!(
1917 spec.mechanism,
1918 ActionMechanism::Command {
1919 program: "sudo",
1920 args: vec![
1921 "ostree".to_string(),
1922 "admin".to_string(),
1923 "pin".to_string(),
1924 "--unpin".to_string(),
1925 "2".to_string(),
1926 ],
1927 }
1928 );
1929 }
1930
1931 #[test]
1932 fn require_u32_rejects_overflow() {
1933 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
1934 assert!(
1935 matches!(err, ExecutorError::InvalidParam("index")),
1936 "expected InvalidParam(index), got: {err}"
1937 );
1938 }
1939
1940 #[test]
1941 fn build_spec_rebase_system_injects_target_ref() {
1942 let spec = build_action_spec(
1943 "RebaseSystem",
1944 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
1945 )
1946 .unwrap();
1947 assert_eq!(
1948 spec.mechanism,
1949 ActionMechanism::Command {
1950 program: "sudo",
1951 args: vec![
1952 "rpm-ostree".to_string(),
1953 "rebase".to_string(),
1954 "fedora/41/x86_64/silverblue".to_string(),
1955 ],
1956 }
1957 );
1958 }
1959
1960 #[test]
1961 fn build_spec_set_kernel_arguments_appends_and_deletes() {
1962 let spec = build_action_spec(
1963 "SetKernelArguments",
1964 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
1965 )
1966 .unwrap();
1967 assert_eq!(
1968 spec.mechanism,
1969 ActionMechanism::Command {
1970 program: "sudo",
1971 args: vec![
1972 "rpm-ostree".to_string(),
1973 "kargs".to_string(),
1974 "--append=nomodeset".to_string(),
1975 "--delete=quiet".to_string(),
1976 ],
1977 }
1978 );
1979 }
1980
1981 #[test]
1982 fn build_spec_set_kernel_arguments_with_empty_arrays() {
1983 let spec =
1984 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
1985 assert_eq!(
1986 spec.mechanism,
1987 ActionMechanism::Command {
1988 program: "sudo",
1989 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
1990 }
1991 );
1992 }
1993
1994 #[test]
1995 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
1996 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
1997 assert_eq!(
1998 spec.mechanism,
1999 ActionMechanism::Command {
2000 program: "sudo",
2001 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2002 }
2003 );
2004 }
2005
2006 #[test]
2009 fn delete_user_rejects_critical_account() {
2010 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
2011 assert!(
2012 matches!(err, ExecutorError::InvalidParam("username")),
2013 "expected InvalidParam(username), got: {err}"
2014 );
2015 }
2016
2017 #[test]
2018 fn delete_group_rejects_critical_group() {
2019 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
2020 assert!(
2021 matches!(err, ExecutorError::InvalidParam("group")),
2022 "expected InvalidParam(group), got: {err}"
2023 );
2024 }
2025
2026 #[test]
2027 fn lock_user_account_rejects_critical_account() {
2028 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
2029 assert!(
2030 matches!(err, ExecutorError::InvalidParam("username")),
2031 "expected InvalidParam(username), got: {err}"
2032 );
2033 }
2034
2035 #[test]
2036 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
2037 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
2038 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
2039 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
2040 }
2041
2042 #[test]
2045 fn build_spec_add_package_repository_rejects_path_traversal() {
2046 let err = build_action_spec(
2047 "AddPackageRepository",
2048 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
2049 )
2050 .unwrap_err();
2051 assert!(
2052 matches!(err, ExecutorError::InvalidParam("repo_id")),
2053 "expected InvalidParam(repo_id), got: {err}"
2054 );
2055 }
2056
2057 #[test]
2058 fn build_spec_add_package_repository_rejects_newline_in_url() {
2059 let err = build_action_spec(
2060 "AddPackageRepository",
2061 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2062 )
2063 .unwrap_err();
2064 assert!(
2065 matches!(err, ExecutorError::InvalidParam("repo_url")),
2066 "expected InvalidParam(repo_url), got: {err}"
2067 );
2068 }
2069
2070 #[test]
2071 fn build_spec_add_package_repository_accepts_valid_repo_id() {
2072 let spec = build_action_spec(
2073 "AddPackageRepository",
2074 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2075 )
2076 .unwrap();
2077 assert_eq!(spec.action_name, "AddPackageRepository");
2078 }
2079
2080 #[test]
2081 fn build_spec_remove_package_repository_rejects_path_traversal() {
2082 let err = build_action_spec(
2083 "RemovePackageRepository",
2084 &json!({ "repo_id": "../../etc/passwd" }),
2085 )
2086 .unwrap_err();
2087 assert!(
2088 matches!(err, ExecutorError::InvalidParam("repo_id")),
2089 "expected InvalidParam(repo_id), got: {err}"
2090 );
2091 }
2092
2093 #[tokio::test]
2094 async fn execute_spec_command_captures_stdout() {
2095 let spec = ActionSpec {
2096 action_name: "GetSystemState",
2097 mechanism: ActionMechanism::Command {
2098 program: "echo",
2099 args: vec!["hello".to_string()],
2100 },
2101 risk_level: RiskLevel::Low,
2102 reboot_required: false,
2103 rollback_available: false,
2104 };
2105 let out = execute_spec(&spec).await.unwrap();
2106 assert_eq!(out.stdout.trim(), "hello");
2107 assert_eq!(out.exit_code, 0);
2108 }
2109
2110 #[tokio::test]
2116 async fn real_executor_streams_each_stdout_line() {
2117 let spec = ActionSpec {
2118 action_name: "GetSystemState",
2119 mechanism: ActionMechanism::Command {
2120 program: "printf",
2121 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2122 },
2123 risk_level: RiskLevel::Low,
2124 reboot_required: false,
2125 rollback_available: false,
2126 };
2127 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2128 let out = RealActionExecutor
2129 .execute_with_progress(&spec, tx)
2130 .await
2131 .unwrap();
2132
2133 let mut lines = Vec::new();
2134 while let Ok(line) = rx.try_recv() {
2135 lines.push(line);
2136 }
2137 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2138 assert_eq!(out.exit_code, 0);
2139 assert!(out.stdout.contains("line-two"));
2140 }
2141
2142 #[tokio::test]
2143 async fn execute_spec_file_write_creates_file() {
2144 let dir = tempdir().unwrap();
2145 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2146 let spec = ActionSpec {
2147 action_name: "AddPackageRepository",
2148 mechanism: ActionMechanism::FileWrite {
2149 path: path.clone(),
2150 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2151 },
2152 risk_level: RiskLevel::Medium,
2153 reboot_required: false,
2154 rollback_available: true,
2155 };
2156 let out = execute_spec(&spec).await.unwrap();
2157 assert_eq!(out.exit_code, 0);
2158 assert_eq!(
2159 std::fs::read_to_string(&path).unwrap(),
2160 "[repo]\nbaseurl=https://example.test\n"
2161 );
2162 }
2163
2164 #[tokio::test]
2165 async fn execute_spec_file_patch_replaces_first_occurrence() {
2166 let dir = tempdir().unwrap();
2167 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2168 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2169 let spec = ActionSpec {
2170 action_name: "EnablePackageRepository",
2171 mechanism: ActionMechanism::FilePatch {
2172 path: path.clone(),
2173 search: "enabled=0".to_string(),
2174 replace: "enabled=1".to_string(),
2175 },
2176 risk_level: RiskLevel::Medium,
2177 reboot_required: false,
2178 rollback_available: true,
2179 };
2180 execute_spec(&spec).await.unwrap();
2181 assert_eq!(
2182 std::fs::read_to_string(&path).unwrap(),
2183 "[myrepo]\nenabled=1\n"
2184 );
2185 }
2186
2187 #[tokio::test]
2188 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2189 let dir = tempdir().unwrap();
2190 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2191 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2192 let spec = ActionSpec {
2193 action_name: "EnablePackageRepository",
2194 mechanism: ActionMechanism::FilePatch {
2195 path: path.clone(),
2196 search: "enabled=0".to_string(),
2197 replace: "enabled=1".to_string(),
2198 },
2199 risk_level: RiskLevel::Medium,
2200 reboot_required: false,
2201 rollback_available: true,
2202 };
2203 let out = execute_spec(&spec).await.unwrap();
2204 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2205 assert!(
2206 out.stderr.contains("search string not found in file"),
2207 "stderr should explain the failure: {}",
2208 out.stderr
2209 );
2210 assert_eq!(
2212 std::fs::read_to_string(&path).unwrap(),
2213 "[myrepo]\nenabled=1\n"
2214 );
2215 }
2216
2217 #[tokio::test]
2218 async fn execute_spec_file_patch_allows_empty_search_string() {
2219 let dir = tempdir().unwrap();
2222 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
2223 std::fs::write(&path, "hello").unwrap();
2224 let spec = ActionSpec {
2225 action_name: "Test",
2226 mechanism: ActionMechanism::FilePatch {
2227 path: path.clone(),
2228 search: String::new(),
2229 replace: "prefix-".to_string(),
2230 },
2231 risk_level: RiskLevel::Low,
2232 reboot_required: false,
2233 rollback_available: false,
2234 };
2235 let out = execute_spec(&spec).await.unwrap();
2236 assert_eq!(out.exit_code, 0);
2237 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
2238 }
2239
2240 #[tokio::test]
2241 async fn execute_spec_file_delete_removes_file() {
2242 let dir = tempdir().unwrap();
2243 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2244 std::fs::write(&path, "[myrepo]\n").unwrap();
2245 let spec = ActionSpec {
2246 action_name: "RemovePackageRepository",
2247 mechanism: ActionMechanism::FileDelete { path: path.clone() },
2248 risk_level: RiskLevel::Medium,
2249 reboot_required: false,
2250 rollback_available: true,
2251 };
2252 execute_spec(&spec).await.unwrap();
2253 assert!(!std::path::Path::new(&path).exists());
2254 }
2255
2256 #[tokio::test]
2257 async fn execute_spec_file_scan_lists_directory_entries() {
2258 let dir = tempdir().unwrap();
2259 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
2260 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
2261 let spec = ActionSpec {
2262 action_name: "ListPackageRepositories",
2263 mechanism: ActionMechanism::FileScan {
2264 path: dir.path().to_string_lossy().into_owned(),
2265 },
2266 risk_level: RiskLevel::Low,
2267 reboot_required: false,
2268 rollback_available: false,
2269 };
2270 let out = execute_spec(&spec).await.unwrap();
2271 assert!(
2272 out.stdout.contains("a.repo"),
2273 "expected a.repo in: {}",
2274 out.stdout
2275 );
2276 assert!(
2277 out.stdout.contains("b.repo"),
2278 "expected b.repo in: {}",
2279 out.stdout
2280 );
2281 assert_eq!(out.exit_code, 0);
2282 }
2283
2284 #[test]
2287 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
2288 let spec = rollback_spec_for("UpdateSystem").unwrap();
2289 assert_eq!(spec.action_name, "RollbackDeployment");
2290 assert!(
2291 matches!(
2292 &spec.mechanism,
2293 ActionMechanism::Command { program: "rpm-ostree", args }
2294 if args == &["rollback".to_string()]
2295 ),
2296 "expected rpm-ostree rollback, got: {:?}",
2297 spec.mechanism
2298 );
2299 assert!(!spec.rollback_available, "rollback spec must not recurse");
2300 }
2301
2302 #[test]
2303 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
2304 assert!(rollback_spec_for("InstallPackages").is_some());
2305 }
2306
2307 #[test]
2308 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
2309 assert!(rollback_spec_for("RemovePackages").is_some());
2310 }
2311
2312 #[test]
2313 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
2314 assert!(rollback_spec_for("RebaseSystem").is_some());
2315 }
2316
2317 #[test]
2318 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
2319 assert!(rollback_spec_for("SetKernelArguments").is_some());
2320 }
2321
2322 #[test]
2323 fn rollback_spec_for_read_only_action_returns_none() {
2324 assert!(rollback_spec_for("GetSystemState").is_none());
2325 assert!(rollback_spec_for("ListUsers").is_none());
2326 assert!(rollback_spec_for("GetFirewallState").is_none());
2327 }
2328
2329 #[test]
2330 fn rollback_spec_for_non_rollbackable_actions_return_none() {
2331 assert!(rollback_spec_for("AddUserToGroup").is_none());
2332 assert!(rollback_spec_for("DeleteUser").is_none());
2333 assert!(rollback_spec_for("CleanupDeployments").is_none());
2334 assert!(rollback_spec_for("RollbackDeployment").is_none());
2336 }
2337
2338 #[test]
2341 fn public_key_accepts_valid_ed25519() {
2342 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
2343 assert!(validated_public_key(key).is_ok());
2344 }
2345
2346 #[test]
2347 fn public_key_accepts_valid_rsa() {
2348 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
2349 assert!(validated_public_key(key).is_ok());
2350 }
2351
2352 #[test]
2353 fn public_key_rejects_empty() {
2354 assert!(matches!(
2355 validated_public_key(""),
2356 Err(ExecutorError::InvalidParam("public_key"))
2357 ));
2358 }
2359
2360 #[test]
2361 fn public_key_rejects_unknown_prefix() {
2362 assert!(matches!(
2363 validated_public_key("sk-rsa AAAA... user@host"),
2364 Err(ExecutorError::InvalidParam("public_key"))
2365 ));
2366 assert!(matches!(
2367 validated_public_key("AAAAB3Nz... user@host"),
2368 Err(ExecutorError::InvalidParam("public_key"))
2369 ));
2370 }
2371
2372 #[test]
2373 fn public_key_rejects_single_quote() {
2374 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
2375 assert!(matches!(
2376 validated_public_key(key),
2377 Err(ExecutorError::InvalidParam("public_key"))
2378 ));
2379 }
2380
2381 #[test]
2382 fn public_key_rejects_pipe_sed_injection() {
2383 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
2386 assert!(matches!(
2387 validated_public_key(key),
2388 Err(ExecutorError::InvalidParam("public_key"))
2389 ));
2390 }
2391
2392 #[test]
2393 fn public_key_rejects_shell_metacharacters() {
2394 for (metachar, desc) in [
2395 (';', "semicolon"),
2396 ('`', "backtick"),
2397 ('$', "dollar"),
2398 ('\\', "backslash"),
2399 ('&', "ampersand"),
2400 ] {
2401 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
2402 assert!(
2403 matches!(
2404 validated_public_key(&key),
2405 Err(ExecutorError::InvalidParam("public_key"))
2406 ),
2407 "{desc} should be rejected"
2408 );
2409 }
2410 }
2411
2412 #[test]
2413 fn public_key_rejects_newline() {
2414 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
2415 assert!(matches!(
2416 validated_public_key(key),
2417 Err(ExecutorError::InvalidParam("public_key"))
2418 ));
2419 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
2420 assert!(matches!(
2421 validated_public_key(key_cr),
2422 Err(ExecutorError::InvalidParam("public_key"))
2423 ));
2424 }
2425
2426 #[test]
2427 fn public_key_rejects_too_long() {
2428 let long_payload = "A".repeat(8192);
2430 let key = format!("ssh-ed25519 {long_payload} user@host");
2431 assert!(matches!(
2432 validated_public_key(&key),
2433 Err(ExecutorError::InvalidParam("public_key"))
2434 ));
2435 }
2436
2437 #[test]
2440 fn str_array_or_empty_rejects_non_string_element() {
2441 let params = json!({ "packages": ["vim", 42, "curl"] });
2442 assert!(matches!(
2443 str_array_or_empty(¶ms, "packages"),
2444 Err(ExecutorError::InvalidParam("packages"))
2445 ));
2446 }
2447
2448 #[test]
2449 fn str_array_or_empty_accepts_string_array() {
2450 let params = json!({ "packages": ["vim", "curl"] });
2451 assert_eq!(
2452 str_array_or_empty(¶ms, "packages").unwrap(),
2453 vec!["vim".to_string(), "curl".to_string()]
2454 );
2455 }
2456
2457 #[test]
2458 fn str_array_or_empty_returns_empty_when_key_absent() {
2459 let params = json!({});
2460 assert_eq!(
2461 str_array_or_empty(¶ms, "packages").unwrap(),
2462 Vec::<String>::new()
2463 );
2464 }
2465
2466 #[test]
2469 fn kernel_arg_allows_safe_args() {
2470 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
2471 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
2472 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
2473 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
2474 }
2475
2476 #[test]
2477 fn kernel_arg_blocks_init_override() {
2478 assert!(matches!(
2479 validated_safe_kernel_arg("init=/bin/sh", "add"),
2480 Err(ExecutorError::InvalidParam("add"))
2481 ));
2482 assert!(matches!(
2483 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
2484 Err(ExecutorError::InvalidParam("add"))
2485 ));
2486 }
2487
2488 #[test]
2489 fn kernel_arg_blocks_selinux_disable() {
2490 assert!(matches!(
2491 validated_safe_kernel_arg("selinux=0", "add"),
2492 Err(ExecutorError::InvalidParam("add"))
2493 ));
2494 assert!(matches!(
2495 validated_safe_kernel_arg("enforcing=0", "add"),
2496 Err(ExecutorError::InvalidParam("add"))
2497 ));
2498 }
2499
2500 #[test]
2501 fn kernel_arg_blocks_security_override() {
2502 assert!(matches!(
2503 validated_safe_kernel_arg("security=none", "add"),
2504 Err(ExecutorError::InvalidParam("add"))
2505 ));
2506 }
2507
2508 #[test]
2509 fn kernel_arg_blocks_module_blacklist() {
2510 assert!(matches!(
2511 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
2512 Err(ExecutorError::InvalidParam("add"))
2513 ));
2514 }
2515
2516 #[test]
2517 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
2518 assert!(matches!(
2519 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
2520 Err(ExecutorError::InvalidParam("add"))
2521 ));
2522 assert!(matches!(
2523 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
2524 Err(ExecutorError::InvalidParam("add"))
2525 ));
2526 assert!(matches!(
2527 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
2528 Err(ExecutorError::InvalidParam("add"))
2529 ));
2530 }
2531
2532 #[test]
2533 fn kernel_arg_blocks_apparmor_disable() {
2534 assert!(matches!(
2535 validated_safe_kernel_arg("apparmor=0", "add"),
2536 Err(ExecutorError::InvalidParam("add"))
2537 ));
2538 }
2539
2540 #[test]
2541 fn kernel_arg_blocks_mitigations_off() {
2542 assert!(matches!(
2543 validated_safe_kernel_arg("mitigations=off", "add"),
2544 Err(ExecutorError::InvalidParam("add"))
2545 ));
2546 }
2547
2548 #[test]
2549 fn kernel_arg_blocks_lockdown_override() {
2550 assert!(matches!(
2551 validated_safe_kernel_arg("lockdown=none", "add"),
2552 Err(ExecutorError::InvalidParam("add"))
2553 ));
2554 }
2555
2556 #[test]
2557 fn kernel_arg_blocks_pti_off() {
2558 assert!(matches!(
2559 validated_safe_kernel_arg("pti=off", "add"),
2560 Err(ExecutorError::InvalidParam("add"))
2561 ));
2562 }
2563
2564 #[test]
2565 fn kernel_arg_blocks_smap_smep_disable() {
2566 assert!(matches!(
2567 validated_safe_kernel_arg("nosmap", "add"),
2568 Err(ExecutorError::InvalidParam("add"))
2569 ));
2570 assert!(matches!(
2571 validated_safe_kernel_arg("nosmep", "add"),
2572 Err(ExecutorError::InvalidParam("add"))
2573 ));
2574 }
2575
2576 #[test]
2577 fn kernel_arg_blocks_single_user_shortcuts() {
2578 assert!(matches!(
2580 validated_safe_kernel_arg("single", "add"),
2581 Err(ExecutorError::InvalidParam("add"))
2582 ));
2583 assert!(matches!(
2584 validated_safe_kernel_arg("s", "add"),
2585 Err(ExecutorError::InvalidParam("add"))
2586 ));
2587 assert!(matches!(
2588 validated_safe_kernel_arg("1", "add"),
2589 Err(ExecutorError::InvalidParam("add"))
2590 ));
2591 }
2592
2593 #[test]
2594 fn signal_process_guardrails() {
2595 for bad_pid in [0, 1] {
2597 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
2598 assert!(
2599 matches!(err, ExecutorError::InvalidParam("pid")),
2600 "pid {bad_pid} must be rejected, got {err:?}"
2601 );
2602 }
2603 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
2605 .unwrap_err();
2606 assert!(
2607 matches!(err, ExecutorError::InvalidParam("signal")),
2608 "got {err:?}"
2609 );
2610 assert!(matches!(
2612 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
2613 ExecutorError::MissingParam("pid")
2614 ));
2615 let spec = build_action_spec(
2617 "SignalProcess",
2618 &json!({ "pid": "4242", "signal": "sigkill" }),
2619 )
2620 .unwrap();
2621 assert_eq!(spec.action_name, "SignalProcess");
2622 assert_eq!(
2623 spec.mechanism,
2624 ActionMechanism::Command {
2625 program: "sudo",
2626 args: vec![
2627 "kill".to_string(),
2628 "-s".to_string(),
2629 "KILL".to_string(),
2630 "4242".to_string()
2631 ],
2632 }
2633 );
2634 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
2636 if let ActionMechanism::Command { args, .. } = spec.mechanism {
2637 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
2638 } else {
2639 panic!("expected Command mechanism");
2640 }
2641 }
2642
2643 #[test]
2644 fn create_scheduled_job_validates_name_command_schedule() {
2645 assert!(matches!(
2647 build_action_spec(
2648 "CreateScheduledJob",
2649 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
2650 )
2651 .unwrap_err(),
2652 ExecutorError::InvalidParam("command")
2653 ));
2654 assert!(matches!(
2656 build_action_spec(
2657 "CreateScheduledJob",
2658 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
2659 )
2660 .unwrap_err(),
2661 ExecutorError::InvalidParam("name")
2662 ));
2663 assert!(matches!(
2665 build_action_spec(
2666 "CreateScheduledJob",
2667 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
2668 )
2669 .unwrap_err(),
2670 ExecutorError::InvalidParam("schedule")
2671 ));
2672 let spec = build_action_spec(
2674 "CreateScheduledJob",
2675 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
2676 )
2677 .unwrap();
2678 assert_eq!(
2679 spec.mechanism,
2680 ActionMechanism::Command {
2681 program: "sudo",
2682 args: vec![
2683 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
2684 "--name".to_string(),
2685 "nightly-backup".to_string(),
2686 "--command".to_string(),
2687 "/usr/bin/backup --full".to_string(),
2688 "--schedule".to_string(),
2689 "*-*-* 02:00:00".to_string(),
2690 ],
2691 }
2692 );
2693 }
2694
2695 #[test]
2696 fn set_sshd_option_allowlist_guardrails() {
2697 assert!(matches!(
2699 build_action_spec(
2700 "SetSshdOption",
2701 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
2702 )
2703 .unwrap_err(),
2704 ExecutorError::InvalidParam("option")
2705 ));
2706 assert!(matches!(
2708 build_action_spec(
2709 "SetSshdOption",
2710 &json!({ "option": "PermitRootLogin", "value": "maybe" })
2711 )
2712 .unwrap_err(),
2713 ExecutorError::InvalidParam("value")
2714 ));
2715 let spec = build_action_spec(
2717 "SetSshdOption",
2718 &json!({ "option": "PasswordAuthentication", "value": "no" }),
2719 )
2720 .unwrap();
2721 assert_eq!(
2722 spec.mechanism,
2723 ActionMechanism::Command {
2724 program: "sudo",
2725 args: vec![
2726 "/usr/lib/sysknife/sshd-option-edit".to_string(),
2727 "--option".to_string(),
2728 "PasswordAuthentication".to_string(),
2729 "--value".to_string(),
2730 "no".to_string(),
2731 ],
2732 }
2733 );
2734 }
2735
2736 #[test]
2737 fn configure_unattended_upgrades_toggles_helper_flag() {
2738 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
2739 let spec = build_action_spec(
2740 "ConfigureUnattendedUpgrades",
2741 &json!({ "enabled": enabled }),
2742 )
2743 .unwrap();
2744 assert_eq!(
2745 spec.mechanism,
2746 ActionMechanism::Command {
2747 program: "sudo",
2748 args: vec![
2749 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
2750 flag.to_string(),
2751 ],
2752 }
2753 );
2754 }
2755 assert!(matches!(
2756 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
2757 ExecutorError::MissingParam("enabled")
2758 ));
2759 }
2760
2761 #[test]
2762 fn grub_set_kargs_append_blocks_single_user_shortcut() {
2763 for dangerous in ["single", "s", "1"] {
2768 let err = build_action_spec(
2769 "GrubSetKargs",
2770 &json!({ "append": [dangerous], "delete": [] }),
2771 )
2772 .unwrap_err();
2773 assert!(
2774 matches!(err, ExecutorError::InvalidParam("append")),
2775 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
2776 );
2777 }
2778 assert!(build_action_spec(
2780 "GrubSetKargs",
2781 &json!({ "append": ["quiet"], "delete": [] })
2782 )
2783 .is_ok());
2784 }
2785
2786 #[test]
2787 fn kernel_arg_build_spec_rejects_dangerous_arg() {
2788 let err = build_action_spec(
2790 "SetKernelArguments",
2791 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
2792 )
2793 .unwrap_err();
2794 assert!(
2795 matches!(err, ExecutorError::InvalidParam("add")),
2796 "expected InvalidParam(add), got {err:?}"
2797 );
2798 }
2799
2800 #[test]
2805 fn rollback_available_matches_rollback_spec_for_all_actions() {
2806 for spec in crate::actions::all_specs() {
2814 let has_rollback = rollback_spec_for(spec.action_name).is_some();
2815 assert_eq!(
2816 spec.rollback_available,
2817 has_rollback,
2818 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
2819 spec.action_name,
2820 spec.rollback_available,
2821 if has_rollback { "Some" } else { "None" },
2822 );
2823 }
2824 }
2825
2826 #[test]
2832 fn create_user_is_high_risk() {
2833 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
2834 assert_eq!(
2835 spec.risk_level,
2836 RiskLevel::High,
2837 "CreateUser must be High (T1136.001 Persistence)"
2838 );
2839 }
2840
2841 #[test]
2842 fn configure_firewall_is_high_risk() {
2843 let spec = build_action_spec(
2844 "ConfigureFirewall",
2845 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
2846 )
2847 .unwrap();
2848 assert_eq!(
2849 spec.risk_level,
2850 RiskLevel::High,
2851 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
2852 );
2853 }
2854
2855 #[test]
2856 fn mask_service_is_high_risk() {
2857 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
2858 assert_eq!(
2859 spec.risk_level,
2860 RiskLevel::High,
2861 "MaskService must be High (T1562.001 Impair Defenses)"
2862 );
2863 }
2864
2865 #[test]
2866 fn add_package_repository_is_high_risk() {
2867 let spec = build_action_spec(
2868 "AddPackageRepository",
2869 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
2870 )
2871 .unwrap();
2872 assert_eq!(
2873 spec.risk_level,
2874 RiskLevel::High,
2875 "AddPackageRepository must be High (supply-chain vector)"
2876 );
2877 }
2878
2879 #[test]
2880 fn set_dns_servers_is_high_risk() {
2881 let spec = build_action_spec(
2882 "SetDnsServers",
2883 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
2884 )
2885 .unwrap();
2886 assert_eq!(
2887 spec.risk_level,
2888 RiskLevel::High,
2889 "SetDnsServers must be High (DNS hijacking / T1557)"
2890 );
2891 }
2892}