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_activatable_unit, validated_apparmor_profile, validated_apt_package,
9 validated_apt_pin_expr, validated_apt_pin_name, validated_audit_path,
10 validated_audit_perms, validated_cpu_quota, validated_domain, validated_email,
11 validated_fstype, validated_group, validated_group_not_critical, validated_hostname,
12 validated_install_package, validated_journal_grep, validated_journal_priority,
13 validated_journal_time, validated_locale, validated_log_path, validated_lvm_name,
14 validated_lvm_size, validated_memory_limit, validated_mount_device,
15 validated_mount_options, validated_mount_point, validated_port_or_service,
16 validated_ppa_name, validated_pro_service, validated_safe_arg, validated_sudo_commands,
17 validated_sudoers_name, validated_swap_path, validated_sysctl_key, validated_sysctl_value,
18 validated_syslog_host, validated_tasks_max, validated_timezone, validated_unit_name,
19 validated_username, validated_username_not_critical,
20 },
21 ActionMechanism, ActionSpec,
22};
23use async_trait::async_trait;
24use serde_json::Value;
25use std::io;
26use std::net::IpAddr;
27use std::process::Stdio;
28use std::str::FromStr;
29use std::time::Duration;
30use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
31use tokio::sync::mpsc::UnboundedSender;
32
33const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
44const MAX_SCHEDULE_EXPR_LEN: usize = 128;
46const MAX_JOURNAL_LINES: u64 = 10_000;
49const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
51const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
53const MAX_PASSWORD_MINLEN: u64 = 128;
55const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
57const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
59
60#[derive(Debug, thiserror::Error)]
61pub enum ExecutorError {
62 #[error("unknown action: {0}")]
63 UnknownAction(String),
64
65 #[error("missing required param: {0}")]
66 MissingParam(&'static str),
67
68 #[error("invalid param type for: {0}")]
69 InvalidParam(&'static str),
70
71 #[error("invalid IP address for param '{param}': '{value}'")]
77 InvalidIpAddress { param: &'static str, value: String },
78
79 #[error("io error: {0}")]
80 Io(#[from] io::Error),
81
82 #[error(
91 "{program} exceeded the {timeout_secs}s action timeout and could not be \
92 confirmed stopped: process group {pgid} still has members. Automatic \
93 rollback was skipped because the original action may still be running; \
94 inspect the host before retrying"
95 )]
96 ActionNotStopped {
97 program: String,
98 pgid: i32,
99 timeout_secs: u64,
100 },
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ExecutionOutput {
113 pub stdout: String,
114 pub stderr: String,
115 pub exit_code: i32,
116}
117
118impl ExecutionOutput {
119 pub fn is_success(&self) -> bool {
121 self.exit_code == 0
122 }
123
124 pub fn is_nonzero(&self) -> bool {
126 self.exit_code != 0
127 }
128}
129
130#[async_trait]
137pub trait ActionExecutor: Send + Sync {
138 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
140
141 async fn execute_with_progress(
147 &self,
148 spec: &ActionSpec,
149 progress: UnboundedSender<String>,
150 ) -> Result<ExecutionOutput, ExecutorError> {
151 let output = self.execute(spec).await?;
152 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
153 let _ = progress.send(line.to_string());
154 }
155 Ok(output)
156 }
157}
158
159pub struct RealActionExecutor;
161
162#[async_trait]
163impl ActionExecutor for RealActionExecutor {
164 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
165 execute_spec(spec).await
166 }
167
168 async fn execute_with_progress(
169 &self,
170 spec: &ActionSpec,
171 progress: UnboundedSender<String>,
172 ) -> Result<ExecutionOutput, ExecutorError> {
173 match &spec.mechanism {
174 ActionMechanism::Command { program, args } => {
175 execute_command_with_progress(program, args, progress).await
176 }
177 _ => execute_spec(spec).await,
178 }
179 }
180}
181
182const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
196
197fn action_timeout() -> Duration {
198 match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
199 Ok(raw) => match raw.trim().parse::<u64>() {
200 Ok(secs) if secs > 0 => Duration::from_secs(secs),
201 _ => {
202 eprintln!(
203 "[sysknife-daemon] WARNING: ignoring invalid \
204 SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
205 );
206 DEFAULT_ACTION_TIMEOUT
207 }
208 },
209 Err(_) => DEFAULT_ACTION_TIMEOUT,
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215enum GroupState {
216 Gone,
218 AliveReachable,
220 AliveUnreachable,
225 Unknown(i32),
228}
229
230impl GroupState {
231 fn is_gone(self) -> bool {
232 matches!(self, GroupState::Gone)
233 }
234}
235
236fn probe_group(pgid: i32) -> GroupState {
238 let rc = unsafe { libc::killpg(pgid, 0) };
243 if rc == 0 {
244 return GroupState::AliveReachable;
245 }
246 match io::Error::last_os_error().raw_os_error() {
247 Some(libc::ESRCH) => GroupState::Gone,
248 Some(libc::EPERM) => GroupState::AliveUnreachable,
249 other => GroupState::Unknown(other.unwrap_or(0)),
250 }
251}
252
253fn signal_group(pgid: i32, signal: i32) -> Result<(), io::Error> {
255 let rc = unsafe { libc::killpg(pgid, signal) };
257 if rc == 0 {
258 return Ok(());
259 }
260 let err = io::Error::last_os_error();
261 if err.raw_os_error() == Some(libc::ESRCH) {
262 return Ok(());
263 }
264 Err(err)
265}
266
267const GROUP_TERM_GRACE: Duration = Duration::from_secs(5);
272
273fn is_rpm_ostree_action(program: &str, args: &[String]) -> bool {
279 let is_ostree = |s: &str| s == "rpm-ostree" || s.ends_with("/rpm-ostree");
280 is_ostree(program) || args.iter().any(|a| is_ostree(a))
281}
282
283fn root_group_kill_argv(signal: &str, pgid: i32) -> Vec<String> {
289 vec![
290 "-n".to_string(),
291 "/usr/bin/kill".to_string(),
292 "-s".to_string(),
293 signal.to_string(),
294 "--".to_string(),
295 format!("-{pgid}"),
296 ]
297}
298
299#[cfg(test)]
302static SUDO_INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
303
304async fn run_sudo(args: Vec<String>) {
311 #[cfg(test)]
312 SUDO_INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
313 let fut = tokio::process::Command::new("sudo")
314 .args(&args)
315 .stdin(Stdio::null())
316 .stdout(Stdio::null())
317 .stderr(Stdio::null())
318 .status();
319 let joined = args.join(" ");
320 match tokio::time::timeout(REAP_WAIT_CAP, fut).await {
321 Ok(Ok(status)) if status.success() => {}
322 Ok(Ok(status)) => {
323 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` exited {status}")
324 }
325 Ok(Err(e)) => {
326 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` could not run: {e}")
327 }
328 Err(_) => eprintln!(
329 "[sysknife-daemon] privileged reap `sudo {joined}` timed out after {}s",
330 REAP_WAIT_CAP.as_secs()
331 ),
332 }
333}
334
335fn reap_command(pgid: i32, program: &str, args: &[String]) -> Vec<String> {
341 if is_rpm_ostree_action(program, args) {
342 vec![
343 "-n".to_string(),
344 "/usr/bin/rpm-ostree".to_string(),
345 "cancel".to_string(),
346 ]
347 } else {
348 root_group_kill_argv("TERM", pgid)
349 }
350}
351
352async fn privileged_reap(pgid: i32, program: &str, args: &[String]) {
358 run_sudo(reap_command(pgid, program, args)).await;
359 if is_rpm_ostree_action(program, args) {
366 return;
367 }
368 tokio::time::sleep(Duration::from_millis(300)).await;
369 if !probe_group(pgid).is_gone() {
370 run_sudo(root_group_kill_argv("KILL", pgid)).await;
371 }
372}
373
374const REAP_WAIT_CAP: Duration = Duration::from_secs(2);
382
383async fn kill_and_reap(
401 child: &mut tokio::process::Child,
402 program: &str,
403 args: &[String],
404) -> ExecutorError {
405 let secs = action_timeout().as_secs();
406 let Some(pgid) = child.id().map(|pid| pid as i32) else {
407 eprintln!(
410 "[sysknife-daemon] {program} exceeded the {secs}s action timeout; already exited"
411 );
412 return timed_out(program, secs);
413 };
414
415 let _ = child.start_kill();
419 if let Err(err) = signal_group(pgid, libc::SIGTERM) {
420 eprintln!("[sysknife-daemon] SIGTERM to process group {pgid} failed: {err}");
421 }
422
423 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
427
428 let grace = tokio::time::Instant::now() + GROUP_TERM_GRACE;
429 while tokio::time::Instant::now() < grace && !probe_group(pgid).is_gone() {
430 tokio::time::sleep(Duration::from_millis(50)).await;
431 }
432 if !probe_group(pgid).is_gone() {
433 if let Err(err) = signal_group(pgid, libc::SIGKILL) {
434 eprintln!("[sysknife-daemon] SIGKILL to process group {pgid} failed: {err}");
435 }
436 tokio::time::sleep(Duration::from_millis(200)).await;
437 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
438 }
439
440 if probe_group(pgid) == GroupState::AliveUnreachable {
456 eprintln!(
457 "[sysknife-daemon] {program} process group {pgid} survived as root; \
458 escalating to a privileged reap"
459 );
460 privileged_reap(pgid, program, args).await;
461 tokio::time::sleep(Duration::from_millis(200)).await;
462 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
463 }
464
465 match probe_group(pgid) {
466 GroupState::Gone => {
467 eprintln!(
468 "[sysknife-daemon] {program} exceeded the {secs}s action timeout; \
469 process group {pgid} stopped"
470 );
471 timed_out(program, secs)
472 }
473 state => {
474 eprintln!(
475 "[sysknife-daemon] {program} exceeded the {secs}s action timeout and \
476 process group {pgid} could not be confirmed stopped ({state:?})"
477 );
478 ExecutorError::ActionNotStopped {
479 program: program.to_string(),
480 pgid,
481 timeout_secs: secs,
482 }
483 }
484 }
485}
486
487fn timed_out(program: &str, secs: u64) -> ExecutorError {
489 ExecutorError::Io(io::Error::new(
490 io::ErrorKind::TimedOut,
491 format!("{program} exceeded the {secs}s action timeout and was stopped"),
492 ))
493}
494
495async fn execute_command_with_progress(
496 program: &'static str,
497 args: &[String],
498 progress: UnboundedSender<String>,
499) -> Result<ExecutionOutput, ExecutorError> {
500 let mut child = tokio::process::Command::new(program)
501 .args(args)
502 .process_group(0)
507 .stdin(Stdio::null())
512 .stdout(Stdio::piped())
513 .stderr(Stdio::piped())
514 .spawn()
515 .map_err(ExecutorError::Io)?;
516
517 let stdout = child.stdout.take().expect("stdout was piped");
518 let stderr = child.stderr.take().expect("stderr was piped");
519 let stderr_task = tokio::spawn(async move {
520 let mut buf = Vec::new();
521 BufReader::new(stderr)
522 .read_to_end(&mut buf)
523 .await
524 .map(|_| buf)
525 });
526
527 let deadline = tokio::time::Instant::now() + action_timeout();
528
529 let mut lines = BufReader::new(stdout).lines();
530 let mut stdout_buf = String::new();
531 loop {
532 let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
535 Ok(result) => result.map_err(ExecutorError::Io)?,
536 Err(_) => return Err(kill_and_reap(&mut child, program, args).await),
537 };
538 let Some(line) = next else { break };
539 if !line.is_empty() {
540 let _ = progress.send(line.clone());
541 }
542 stdout_buf.push_str(&line);
543 stdout_buf.push('\n');
544 }
545
546 let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
547 Ok(status) => status.map_err(ExecutorError::Io)?,
548 Err(_) => return Err(kill_and_reap(&mut child, program, args).await),
549 };
550 let stderr_bytes = stderr_task
551 .await
552 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
553 .map_err(ExecutorError::Io)?;
554
555 Ok(ExecutionOutput {
556 stdout: stdout_buf,
557 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
558 exit_code: exit_status.code().unwrap_or(-1),
559 })
560}
561
562pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
567 match action_name {
568 "GetSystemState" => Ok(deployment::get_system_state()),
570 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
571 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
572 "ListDeployments" => Ok(deployment::list_deployments()),
573 "UpdateSystem" => Ok(deployment::update_system()),
574 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
575 "RebootSystem" => Ok(deployment::reboot_system()),
576 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
577 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
578
579 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
581 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
582 "RebaseSystem" => {
583 let target_ref = require_str(params, "target_ref")?;
584 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
585 Ok(deployment::rebase_system(&target_ref))
586 }
587 "SetKernelArguments" => {
588 let add = str_array_or_empty(params, "add")?;
589 let remove = str_array_or_empty(params, "remove")?;
590 for arg in add.iter() {
593 validated_safe_kernel_arg(arg, "add")?;
594 }
595 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
596 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
597 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
598 }
599
600 "ListFlatpakRemotes" => {
606 let username = validated_username(resolve_username(params)?, "username")?;
607 Ok(flatpak::list_flatpak_remotes(&username))
608 }
609 "InstallFlatpak" => {
610 let username = validated_username(resolve_username(params)?, "username")?;
611 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
612 let remote = params
616 .get("remote")
617 .and_then(|v| v.as_str())
618 .unwrap_or("flathub");
619 let remote = validated_safe_arg(remote, "remote")?;
620 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
621 }
622 "RemoveFlatpak" => {
623 let username = validated_username(resolve_username(params)?, "username")?;
624 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
625 Ok(flatpak::remove_flatpak(&username, &app_id))
626 }
627 "SearchFlatpakApps" => {
628 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
629 Ok(flatpak::search_flatpak_apps(&term))
630 }
631 "AddFlatpakRemote" => {
632 let username = validated_username(resolve_username(params)?, "username")?;
633 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
634 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
635 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
636 }
637 "RemoveFlatpakRemote" => {
638 let username = validated_username(resolve_username(params)?, "username")?;
639 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
640 Ok(flatpak::remove_flatpak_remote(&username, &remote))
641 }
642 "GetFlatpakAppInfo" => {
643 let username = validated_username(resolve_username(params)?, "username")?;
644 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
645 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
646 }
647 "ListInstalledFlatpaks" => {
648 let username = validated_username(resolve_username(params)?, "username")?;
649 Ok(flatpak::list_installed_flatpaks(&username))
650 }
651 "UpdateFlatpak" => {
652 let username = validated_username(resolve_username(params)?, "username")?;
653 let app_id = params
656 .get("app_id")
657 .and_then(|v| v.as_str())
658 .filter(|id| !id.is_empty())
659 .map(|id| validated_safe_arg(id, "app_id"))
660 .transpose()?;
661 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
662 }
663
664 "ListContainers" => {
670 let username = validated_username(resolve_username(params)?, "username")?;
671 Ok(containers::list_containers(&username))
672 }
673 "CreateContainer" => {
674 let username = validated_username(resolve_username(params)?, "username")?;
675 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
676 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
677 Ok(containers::create_container(&username, &name, &image))
678 }
679 "StartContainer" => {
680 let username = validated_username(resolve_username(params)?, "username")?;
681 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
682 Ok(containers::start_container(&username, &name))
683 }
684 "StopContainer" => {
685 let username = validated_username(resolve_username(params)?, "username")?;
686 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
687 Ok(containers::stop_container(&username, &name))
688 }
689 "RemoveContainer" => {
690 let username = validated_username(resolve_username(params)?, "username")?;
691 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
692 Ok(containers::remove_container(&username, &name))
693 }
694 "GetContainerInfo" => {
695 let username = validated_username(resolve_username(params)?, "username")?;
696 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
697 Ok(containers::get_container_info(&username, &name))
698 }
699
700 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
702 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
703 "InstallPackages" => {
704 let pkgs = str_array_or_empty(params, "packages")?;
705 let validated: Vec<String> = pkgs
706 .iter()
707 .map(|p| validated_install_package(p, "packages"))
708 .collect::<Result<_, _>>()?;
709 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
710 Ok(layering::install_packages(&refs))
711 }
712 "RemovePackages" => {
713 let pkgs = str_array_or_empty(params, "packages")?;
714 let validated: Vec<String> = pkgs
715 .iter()
716 .map(|p| validated_install_package(p, "packages"))
717 .collect::<Result<_, _>>()?;
718 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
719 Ok(layering::remove_packages(&refs))
720 }
721 "AddLayeredPackage" => {
722 let package = validated_install_package(require_str(params, "package")?, "package")?;
723 Ok(layering::add_layered_package(&package))
724 }
725 "RemoveLayeredPackage" => {
726 let package = validated_install_package(require_str(params, "package")?, "package")?;
727 Ok(layering::remove_layered_package(&package))
728 }
729 "ReplaceLayeredPackage" => {
730 let old = validated_install_package(require_str(params, "old")?, "old")?;
731 let new = validated_install_package(require_str(params, "new")?, "new")?;
732 Ok(layering::replace_layered_package(&old, &new))
733 }
734 "RemoveBasePackage" => {
735 let package = validated_install_package(require_str(params, "package")?, "package")?;
736 Ok(layering::remove_base_package(&package))
737 }
738 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
739
740 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
742 "AddPackageRepository" => Ok(package_repos::add_package_repository(
743 validated_repo_id(params)?,
744 validated_no_newline(params, "repo_url")?,
745 )),
746 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
747 validated_repo_id(params)?,
748 )),
749 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
750 validated_repo_id(params)?,
751 )),
752 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
753 validated_repo_id(params)?,
754 )),
755
756 "ListServices" => Ok(services::list_services()),
758 "StartService" => {
759 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
760 Ok(services::start_service(&unit))
761 }
762 "StopService" => {
763 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
764 Ok(services::stop_service(&unit))
765 }
766 "RestartService" => {
767 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
768 Ok(services::restart_service(&unit))
769 }
770 "SetServiceEnabled" => {
771 let enabled = require_bool(params, "enabled")?;
772 let unit = if enabled {
775 validated_activatable_unit(require_str(params, "unit")?, "unit")?
776 } else {
777 validated_unit_name(require_str(params, "unit")?, "unit")?
778 };
779 Ok(services::set_service_enabled(&unit, enabled))
780 }
781 "MaskService" => {
782 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
783 Ok(services::mask_service(&unit))
784 }
785 "UnmaskService" => {
786 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
787 Ok(services::unmask_service(&unit))
788 }
789 "GetServiceLogs" => {
790 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
791 Ok(services::get_service_logs(&unit))
792 }
793 "GetServiceStatus" => {
794 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
795 Ok(services::get_service_status(&unit))
796 }
797 "ReloadService" => {
798 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
799 Ok(services::reload_service(&unit))
800 }
801 "ListTimers" => Ok(services::list_timers()),
802 "ReloadDaemon" => Ok(services::reload_daemon()),
803 "CreateScheduledJob" => {
804 let name = require_str(params, "name")?;
806 if name.is_empty()
807 || name.len() > 64
808 || !name
809 .chars()
810 .next()
811 .is_some_and(|c| c.is_ascii_alphanumeric())
812 || !name
813 .chars()
814 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
815 {
816 return Err(ExecutorError::InvalidParam("name"));
817 }
818 let command = require_str(params, "command")?;
821 if command.is_empty()
822 || command.len() > MAX_SCHEDULED_COMMAND_LEN
823 || command.chars().any(|c| c.is_control())
824 {
825 return Err(ExecutorError::InvalidParam("command"));
826 }
827 let schedule = require_str(params, "schedule")?;
830 if schedule.is_empty()
831 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
832 || !schedule.chars().all(|c| {
833 c.is_ascii_alphanumeric()
834 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
835 })
836 {
837 return Err(ExecutorError::InvalidParam("schedule"));
838 }
839 Ok(services::create_scheduled_job(name, command, schedule))
840 }
841 "GetServiceResourceLimits" => {
842 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
843 Ok(services::get_service_resource_limits(&unit))
844 }
845 "SetServiceResourceLimits" => {
846 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
847 let mut assignments = Vec::new();
850 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
851 assignments.push(format!("MemoryMax={v}"));
852 }
853 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
854 assignments.push(format!("MemoryHigh={v}"));
855 }
856 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
857 assignments.push(format!("CPUQuota={v}"));
858 }
859 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
860 assignments.push(format!("TasksMax={v}"));
861 }
862 if assignments.is_empty() {
863 return Err(ExecutorError::MissingParam("memory_max"));
864 }
865 Ok(services::set_service_resource_limits(&unit, &assignments))
866 }
867
868 "ListToolboxes" => {
872 let username = validated_username(resolve_username(params)?, "username")?;
873 Ok(toolbox::list_toolboxes(&username))
874 }
875 "CreateToolbox" => {
876 let username = validated_username(resolve_username(params)?, "username")?;
877 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
878 let image = params
879 .get("image")
880 .and_then(|v| v.as_str())
881 .map(|img| validated_safe_arg(img, "image"))
882 .transpose()?;
883 let release = params
884 .get("release")
885 .and_then(|v| v.as_str())
886 .map(|r| validated_safe_arg(r, "release"))
887 .transpose()?;
888 Ok(toolbox::create_toolbox(
889 &username,
890 &name,
891 release.as_deref(),
892 image.as_deref(),
893 ))
894 }
895 "RemoveToolbox" => {
896 let username = validated_username(resolve_username(params)?, "username")?;
897 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
898 Ok(toolbox::remove_toolbox(&username, &name))
899 }
900
901 "GetDateTime" => Ok(identity::get_datetime()),
903 "SetHostname" => {
904 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
905 Ok(identity::set_hostname(&hostname))
906 }
907 "SetTimezone" => {
908 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
909 Ok(identity::set_timezone(&timezone))
910 }
911 "SetLocale" => {
912 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
913 Ok(identity::set_locale(&locale))
914 }
915 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
916
917 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
919
920 "ListProcesses" => Ok(processes::list_processes_spec()),
922 "SignalProcess" => {
923 let pid = params
925 .get("pid")
926 .and_then(|v| {
927 v.as_u64()
928 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
929 })
930 .ok_or(ExecutorError::MissingParam("pid"))?;
931 if pid < 2 || pid > u32::MAX as u64 {
934 return Err(ExecutorError::InvalidParam("pid"));
935 }
936 let signal = validated_kill_signal(
937 params
938 .get("signal")
939 .and_then(|v| v.as_str())
940 .unwrap_or("TERM"),
941 )?;
942 Ok(processes::signal_process(pid as u32, signal))
943 }
944
945 "GetJournalLog" => {
947 let unit = optional_validated(params, "unit", validated_unit_name)?;
948 let priority = optional_validated(params, "priority", validated_journal_priority)?;
949 let since = optional_validated(params, "since", validated_journal_time)?;
950 let until = optional_validated(params, "until", validated_journal_time)?;
951 let grep = optional_validated(params, "grep", validated_journal_grep)?;
952 let lines = params
955 .get("lines")
956 .and_then(|v| v.as_u64())
957 .unwrap_or(100)
958 .clamp(1, MAX_JOURNAL_LINES) as u32;
959 let boot = params
960 .get("boot")
961 .and_then(|v| v.as_bool())
962 .unwrap_or(false);
963 let kernel = params
964 .get("kernel")
965 .and_then(|v| v.as_bool())
966 .unwrap_or(false);
967 Ok(journald::get_journal_log(&journald::JournalQuery {
968 lines,
969 unit: unit.as_deref(),
970 priority: priority.as_deref(),
971 boot,
972 kernel,
973 since: since.as_deref(),
974 until: until.as_deref(),
975 grep: grep.as_deref(),
976 }))
977 }
978 "VacuumJournal" => {
979 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
981 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
982 match (size_mb, retain_days) {
983 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
984 Ok(journald::vacuum_journal_by_size(mb as u32))
985 }
986 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
987 Ok(journald::vacuum_journal_by_time(days as u32))
988 }
989 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
990 _ => Err(ExecutorError::InvalidParam("size_mb")),
992 }
993 }
994
995 "GetLvmReport" => Ok(lvm::get_lvm_report()),
997 "ExtendLogicalVolume" => {
998 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
999 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1000 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1001 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1002 }
1003 "CreateLogicalVolume" => {
1004 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1005 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1006 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1007 Ok(lvm::create_logical_volume(&vg, &name, &size))
1008 }
1009 "CreateLvSnapshot" => {
1010 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1011 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1012 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1013 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1014 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1015 }
1016
1017 "GetSysctl" => {
1019 let key = optional_validated(params, "key", validated_sysctl_key)?;
1021 Ok(sysctl::get_sysctl(key.as_deref()))
1022 }
1023 "SetSysctl" => {
1024 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1025 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1026 Ok(sysctl::set_sysctl(&key, &value))
1027 }
1028
1029 "GetMounts" => Ok(mounts::get_mounts()),
1031 "AddMount" => {
1032 let device = validated_mount_device(require_str(params, "device")?, "device")?;
1033 let mountpoint =
1034 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1035 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1036 let options = optional_validated(params, "options", validated_mount_options)?;
1037 Ok(mounts::add_mount(
1038 &device,
1039 &mountpoint,
1040 &fstype,
1041 options.as_deref(),
1042 ))
1043 }
1044 "RemoveMount" => {
1045 let mountpoint =
1046 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1047 Ok(mounts::remove_mount(&mountpoint))
1048 }
1049 "AddSwap" => {
1050 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1051 let size_mb = require_u32(params, "size_mb")?;
1052 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1054 return Err(ExecutorError::InvalidParam("size_mb"));
1055 }
1056 Ok(mounts::add_swap(&file, size_mb))
1057 }
1058 "RemoveSwap" => {
1059 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1060 Ok(mounts::remove_swap(&file))
1061 }
1062
1063 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1065 "GrantSudoAccess" => {
1066 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1067 let user = validated_username(require_str(params, "user")?, "user")?;
1068 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1069 let runas = match params
1071 .get("runas")
1072 .and_then(|v| v.as_str())
1073 .filter(|s| !s.is_empty())
1074 {
1075 Some("ALL") => Some("ALL".to_string()),
1076 Some(u) => Some(validated_username(u, "runas")?),
1077 None => None,
1078 };
1079 let nopasswd = params
1080 .get("nopasswd")
1081 .and_then(|v| v.as_bool())
1082 .unwrap_or(false);
1083 if commands == "ALL" && nopasswd {
1093 return Err(ExecutorError::InvalidParam("commands"));
1094 }
1095 Ok(sudoers::grant_sudo_access(
1096 &name,
1097 &user,
1098 &commands,
1099 runas.as_deref(),
1100 nopasswd,
1101 ))
1102 }
1103 "RevokeSudoAccess" => {
1104 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1105 Ok(sudoers::revoke_sudo_access(&name))
1106 }
1107
1108 "GetAptPins" => {
1110 let package = optional_validated(params, "package", validated_apt_package)?;
1111 Ok(apt_preferences::get_apt_pins(package.as_deref()))
1112 }
1113 "SetAptPin" => {
1114 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1115 let package = validated_apt_package(require_str(params, "package")?, "package")?;
1116 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1117 let priority = params
1118 .get("priority")
1119 .and_then(|v| v.as_i64())
1120 .ok_or(ExecutorError::MissingParam("priority"))?;
1121 if !(-1..=1000).contains(&priority) {
1122 return Err(ExecutorError::InvalidParam("priority"));
1123 }
1124 Ok(apt_preferences::set_apt_pin(
1125 &name, &package, &pin, priority,
1126 ))
1127 }
1128 "RemoveAptPin" => {
1129 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1130 Ok(apt_preferences::remove_apt_pin(&name))
1131 }
1132
1133 "GetLogrotateStatus" => {
1135 let config = optional_validated(params, "config", validated_log_path)?;
1136 Ok(logging::get_logrotate_status(config.as_deref()))
1137 }
1138 "ConfigureLogRotation" => {
1139 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1140 let path = validated_log_path(require_str(params, "path")?, "path")?;
1141 let frequency = require_str(params, "frequency")?;
1142 if !matches!(frequency, "daily" | "weekly" | "monthly") {
1143 return Err(ExecutorError::InvalidParam("frequency"));
1144 }
1145 let rotate = require_u32(params, "rotate")?;
1146 if rotate > 1000 {
1147 return Err(ExecutorError::InvalidParam("rotate"));
1148 }
1149 let compress = params
1150 .get("compress")
1151 .and_then(|v| v.as_bool())
1152 .unwrap_or(false);
1153 Ok(logging::configure_log_rotation(
1154 &name, &path, frequency, rotate, compress,
1155 ))
1156 }
1157 "RemoveLogRotation" => {
1158 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1159 Ok(logging::remove_log_rotation(&name))
1160 }
1161 "ConfigureRemoteSyslog" => {
1162 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1163 let port = require_u32(params, "port")?;
1164 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1165 return Err(ExecutorError::InvalidParam("port"));
1166 }
1167 let protocol = require_str(params, "protocol")?;
1168 if !matches!(protocol, "tcp" | "udp") {
1169 return Err(ExecutorError::InvalidParam("protocol"));
1170 }
1171 Ok(logging::configure_remote_syslog(
1172 &host,
1173 port as u16,
1174 protocol,
1175 ))
1176 }
1177 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1178
1179 "GetPasswordAging" => {
1181 let user = validated_username(require_str(params, "user")?, "user")?;
1182 Ok(pam::get_password_aging(&user))
1183 }
1184 "SetPasswordAging" => {
1185 let user = validated_username(require_str(params, "user")?, "user")?;
1186 let mut flags = Vec::new();
1188 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1189 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1190 if n > MAX_PASSWORD_AGE_DAYS {
1191 return Err(ExecutorError::InvalidParam(key));
1192 }
1193 flags.push(opt.to_string());
1194 flags.push(n.to_string());
1195 }
1196 }
1197 if flags.is_empty() {
1198 return Err(ExecutorError::MissingParam("max_days"));
1199 }
1200 Ok(pam::set_password_aging(&user, &flags))
1201 }
1202 "SetPasswordPolicy" => {
1203 let mut extra = Vec::new();
1206 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1207 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1208 return Err(ExecutorError::InvalidParam("minlen"));
1209 }
1210 extra.push("--minlen".to_string());
1211 extra.push(n.to_string());
1212 }
1213 for (key, flag) in [
1214 ("dcredit", "--dcredit"),
1215 ("ucredit", "--ucredit"),
1216 ("lcredit", "--lcredit"),
1217 ("ocredit", "--ocredit"),
1218 ] {
1219 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1220 if !(-64..=64).contains(&n) {
1221 return Err(ExecutorError::InvalidParam(key));
1222 }
1223 extra.push(flag.to_string());
1224 extra.push(n.to_string());
1225 }
1226 }
1227 if extra.is_empty() {
1228 return Err(ExecutorError::MissingParam("minlen"));
1229 }
1230 Ok(pam::set_password_policy(&extra))
1231 }
1232 "SetAccountLockout" => {
1233 let mut extra = Vec::new();
1235 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1236 if !(1..=1000).contains(&n) {
1237 return Err(ExecutorError::InvalidParam("deny"));
1238 }
1239 extra.push("--deny".to_string());
1240 extra.push(n.to_string());
1241 }
1242 for (key, flag) in [
1243 ("unlock_time", "--unlock-time"),
1244 ("fail_interval", "--fail-interval"),
1245 ] {
1246 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1247 if n > MAX_LOCKOUT_WINDOW_SECS {
1248 return Err(ExecutorError::InvalidParam(key));
1249 }
1250 extra.push(flag.to_string());
1251 extra.push(n.to_string());
1252 }
1253 }
1254 if extra.is_empty() {
1255 return Err(ExecutorError::MissingParam("deny"));
1256 }
1257 Ok(pam::set_account_lockout(&extra))
1258 }
1259
1260 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1262
1263 "GetFirewallState" => Ok(network::get_firewall_state()),
1265 "GetNetworkStatus" => Ok(network::get_network_status()),
1266 "GetListeningPorts" => Ok(network::get_listening_ports()),
1267 "ConfigureWifi" => {
1268 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1269 let password = params
1271 .get("password")
1272 .and_then(|v| v.as_str())
1273 .filter(|p| !p.is_empty())
1274 .map(|p| validated_safe_arg(p, "password"))
1275 .transpose()?;
1276 Ok(network::configure_wifi(&ssid, password.as_deref()))
1277 }
1278 "SetDnsServers" => {
1279 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1280 let servers = str_array_or_empty(params, "servers")?;
1281 let validated: Vec<String> = servers
1282 .iter()
1283 .map(|s| validated_safe_arg(s, "servers"))
1284 .collect::<Result<_, _>>()?;
1285 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1286 Ok(network::set_dns_servers(&interface, &refs))
1287 }
1288 "ConfigureFirewall" => {
1289 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1290 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1291 Ok(network::configure_firewall(
1292 &zone,
1293 &service,
1294 require_bool(params, "enabled")?,
1295 ))
1296 }
1297
1298 "ListUsers" => Ok(users::list_users()),
1300 "ListGroups" => Ok(users::list_groups()),
1301 "CreateUser" => {
1302 let username = validated_username(resolve_username(params)?, "username")?;
1303 let shell = params
1304 .get("shell")
1305 .and_then(|v| v.as_str())
1306 .map(|s| validated_safe_arg(s, "shell"))
1307 .transpose()?;
1308 let home = params
1309 .get("home")
1310 .and_then(|v| v.as_str())
1311 .map(|h| validated_safe_arg(h, "home"))
1312 .transpose()?;
1313 Ok(users::create_user(
1314 &username,
1315 shell.as_deref(),
1316 home.as_deref(),
1317 ))
1318 }
1319 "DeleteUser" => {
1320 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1321 Ok(users::delete_user(&username))
1322 }
1323 "AddUserToGroup" => {
1324 let username = validated_username(resolve_username(params)?, "username")?;
1325 let group = validated_group(require_str(params, "group")?, "group")?;
1326 Ok(users::add_user_to_group(&username, &group))
1327 }
1328 "RemoveUserFromGroup" => {
1329 let username = validated_username(resolve_username(params)?, "username")?;
1330 let group = validated_group(require_str(params, "group")?, "group")?;
1331 Ok(users::remove_user_from_group(&username, &group))
1332 }
1333 "CreateGroup" => {
1334 let group = validated_group(require_str(params, "group")?, "group")?;
1335 let system = params
1336 .get("system")
1337 .and_then(|v| v.as_bool())
1338 .unwrap_or(false);
1339 Ok(users::create_group(&group, system))
1340 }
1341 "DeleteGroup" => {
1342 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1343 Ok(users::delete_group(&group))
1344 }
1345 "LockUserAccount" => {
1346 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1347 Ok(users::lock_user_account(&username))
1348 }
1349 "UnlockUserAccount" => {
1350 let username = validated_username(resolve_username(params)?, "username")?;
1351 Ok(users::unlock_user_account(&username))
1352 }
1353
1354 "GetAuthorizedKeys" => {
1356 let username = validated_username(resolve_username(params)?, "username")?;
1357 Ok(ssh::get_authorized_keys(&username))
1358 }
1359 "AddAuthorizedKey" => {
1360 let username = validated_username(resolve_username(params)?, "username")?;
1361 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1362 Ok(ssh::add_authorized_key(&username, &public_key))
1363 }
1364 "RemoveAuthorizedKey" => {
1365 let username = validated_username(resolve_username(params)?, "username")?;
1366 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1367 Ok(ssh::remove_authorized_key(&username, &public_key))
1368 }
1369 "SetSshdOption" => {
1370 let option = require_str(params, "option")?;
1374 let value = require_str(params, "value")?;
1375 let allowed_values: &[&str] = match option {
1376 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1377 "PasswordAuthentication"
1378 | "PubkeyAuthentication"
1379 | "X11Forwarding"
1380 | "PermitEmptyPasswords" => &["yes", "no"],
1381 _ => return Err(ExecutorError::InvalidParam("option")),
1382 };
1383 if !allowed_values.contains(&value) {
1384 return Err(ExecutorError::InvalidParam("value"));
1385 }
1386 Ok(ssh::set_sshd_option(option, value))
1387 }
1388
1389 "AptUpdate" => Ok(apt::apt_update()),
1391 "AptUpgrade" => Ok(apt::apt_upgrade()),
1392 "AptInstall" => {
1393 let package = validated_install_package(require_str(params, "package")?, "package")?;
1394 Ok(apt::apt_install(&package))
1395 }
1396 "AptRemove" => {
1397 let package = validated_install_package(require_str(params, "package")?, "package")?;
1398 Ok(apt::apt_remove(&package))
1399 }
1400 "AptPurge" => {
1401 let package = validated_install_package(require_str(params, "package")?, "package")?;
1402 Ok(apt::apt_purge(&package))
1403 }
1404 "AptAutoremove" => Ok(apt::apt_autoremove()),
1405 "AptHold" => {
1406 let package = validated_install_package(require_str(params, "package")?, "package")?;
1407 Ok(apt::apt_hold(&package))
1408 }
1409 "AptUnhold" => {
1410 let package = validated_install_package(require_str(params, "package")?, "package")?;
1411 Ok(apt::apt_unhold(&package))
1412 }
1413 "AptSearch" => {
1414 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1415 Ok(apt::apt_search(&term))
1416 }
1417 "AptListInstalled" => Ok(apt::apt_list_installed()),
1418 "AptShow" => {
1419 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1420 Ok(apt::apt_show(&package))
1421 }
1422 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1423 "AptHistoryList" => Ok(apt::apt_history_list()),
1424 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1425 params, "enabled",
1426 )?)),
1427
1428 "AddPpa" => {
1430 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1431 Ok(ppa::add_ppa(&name))
1432 }
1433 "RemovePpa" => {
1434 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1435 Ok(ppa::remove_ppa(&name))
1436 }
1437
1438 "SnapInstall" => {
1440 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1441 let channel = params
1442 .get("channel")
1443 .and_then(|v| v.as_str())
1444 .filter(|s| !s.is_empty())
1445 .map(|s| validated_safe_arg(s, "channel"))
1446 .transpose()?;
1447 let auto_update = params
1448 .get("auto_update")
1449 .and_then(|v| v.as_bool())
1450 .unwrap_or(false);
1451 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1452 snap::SnapInstallError::InvalidArg { param, .. } => {
1453 ExecutorError::InvalidParam(param)
1454 }
1455 })
1456 }
1457 "SnapRemove" => {
1458 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1459 Ok(snap::snap_remove(&name))
1460 }
1461 "SnapRefresh" => {
1462 let name = params
1463 .get("name")
1464 .and_then(|v| v.as_str())
1465 .filter(|s| !s.is_empty())
1466 .map(|s| validated_safe_arg(s, "name"))
1467 .transpose()?;
1468 Ok(snap::snap_refresh(name.as_deref()))
1469 }
1470 "SnapHold" => {
1471 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1472 Ok(snap::snap_hold(&name))
1473 }
1474 "SnapUnhold" => {
1475 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1476 Ok(snap::snap_unhold(&name))
1477 }
1478 "SnapList" => Ok(snap::snap_list()),
1479 "SnapInfo" => {
1480 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1481 Ok(snap::snap_info(&name))
1482 }
1483 "SnapRevert" => {
1484 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1485 Ok(snap::snap_revert(&name))
1486 }
1487 "SnapClassicInstall" => {
1488 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1489 Ok(snap::snap_classic_install(&name))
1490 }
1491
1492 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1494 "GrubSetKargs" => {
1495 let append: Vec<String> = params
1496 .get("append")
1497 .and_then(|v| v.as_array())
1498 .map(|arr| {
1499 arr.iter()
1500 .filter_map(|v| v.as_str())
1501 .map(String::from)
1502 .collect()
1503 })
1504 .unwrap_or_default();
1505 let delete: Vec<String> = params
1506 .get("delete")
1507 .and_then(|v| v.as_array())
1508 .map(|arr| {
1509 arr.iter()
1510 .filter_map(|v| v.as_str())
1511 .map(String::from)
1512 .collect()
1513 })
1514 .unwrap_or_default();
1515 for a in &append {
1524 validated_safe_arg(a, "append")?;
1525 validated_safe_kernel_arg(a, "append")?;
1526 }
1527 for d in &delete {
1528 validated_safe_arg(d, "delete")?;
1529 }
1530 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1533 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1534 grub::grub_set_kargs(&append_refs, &delete_refs)
1535 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1536 }
1537
1538 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1540
1541 "UfwEnable" => Ok(ufw::ufw_enable()),
1543 "UfwDisable" => Ok(ufw::ufw_disable()),
1544 "UfwAllow" => {
1545 let port_or_service = validated_port_or_service(
1546 require_str(params, "port_or_service")?,
1547 "port_or_service",
1548 )?;
1549 Ok(ufw::ufw_allow(&port_or_service))
1550 }
1551 "UfwDeny" => {
1552 let port_or_service = validated_port_or_service(
1553 require_str(params, "port_or_service")?,
1554 "port_or_service",
1555 )?;
1556 Ok(ufw::ufw_deny(&port_or_service))
1557 }
1558 "UfwReset" => Ok(ufw::ufw_reset()),
1559 "UfwStatus" => Ok(ufw::ufw_status()),
1560
1561 "DistroboxList" => Ok(distrobox::distrobox_list()),
1563 "DistroboxCreate" => {
1564 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1565 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1566 Ok(distrobox::distrobox_create(&name, &image))
1567 }
1568 "DistroboxRemove" => {
1569 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1570 Ok(distrobox::distrobox_remove(&name))
1571 }
1572
1573 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1575 "NetplanApply" => Ok(netplan::netplan_apply()),
1576 "NetplanSet" => {
1577 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1578 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1579 Ok(netplan::netplan_set(&key, &value))
1580 }
1581 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1582
1583 "UfwDeleteRule" => {
1585 let rule_number = require_positive_u32(params, "rule_number")?;
1586 ufw::ufw_delete_rule(rule_number)
1587 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1588 }
1589 "UfwLimit" => {
1590 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1591 Ok(ufw::ufw_limit(&target))
1592 }
1593
1594 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1596 "ProAttach" => {
1597 let token = require_str(params, "token")?;
1599 let token = validated_safe_arg(token, "token")?;
1601 Ok(ubuntu_pro::pro_attach(&token))
1602 }
1603 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1604 "EnableProService" => {
1605 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1606 Ok(ubuntu_pro::enable_pro_service(&service))
1607 }
1608 "DisableProService" => {
1609 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1610 Ok(ubuntu_pro::disable_pro_service(&service))
1611 }
1612
1613 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1615
1616 "MultipassList" => Ok(multipass::multipass_list()),
1618
1619 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1621
1622 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1624 "ResolvectlSetDns" => {
1625 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1626 let raw_servers: Vec<String> = params
1627 .get("servers")
1628 .and_then(|v| v.as_array())
1629 .map(|arr| {
1630 arr.iter()
1631 .filter_map(|v| v.as_str())
1632 .map(String::from)
1633 .collect()
1634 })
1635 .unwrap_or_default();
1636 if raw_servers.is_empty() {
1637 return Err(ExecutorError::MissingParam("servers"));
1638 }
1639 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1643 for s in &raw_servers {
1644 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1645 param: "servers",
1646 value: s.clone(),
1647 })?;
1648 parsed_servers.push(addr);
1649 }
1650 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1651 }
1652
1653 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1655 "AppArmorEnforce" => {
1656 let profile_path =
1657 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1658 Ok(apparmor::apparmor_enforce(&profile_path))
1659 }
1660 "AppArmorComplain" => {
1661 let profile_path =
1662 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1663 Ok(apparmor::apparmor_complain(&profile_path))
1664 }
1665
1666 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1668
1669 "UbuntuInstallFlatpak" => {
1671 let username = validated_username(resolve_username(params)?, "username")?;
1672 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1673 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1674 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1675 }
1676 "UbuntuRemoveFlatpak" => {
1677 let username = validated_username(resolve_username(params)?, "username")?;
1678 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1679 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1680 }
1681 "UbuntuUpdateFlatpak" => {
1682 let username = validated_username(resolve_username(params)?, "username")?;
1683 let app_id = params
1684 .get("app_id")
1685 .and_then(|v| v.as_str())
1686 .filter(|s| !s.is_empty())
1687 .map(|s| validated_safe_arg(s, "app_id"))
1688 .transpose()?;
1689 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1690 }
1691 "UbuntuListFlatpaks" => {
1692 let username = validated_username(resolve_username(params)?, "username")?;
1693 Ok(flatpak::ubuntu_list_flatpaks(&username))
1694 }
1695
1696 "Fail2banStatus" => {
1698 let jail = params
1699 .get("jail")
1700 .and_then(|v| v.as_str())
1701 .filter(|s| !s.is_empty())
1702 .map(|s| validated_safe_arg(s, "jail"))
1703 .transpose()?;
1704 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1705 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1706 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1707 })
1708 }
1709 "Fail2banBanIp" => {
1710 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1711 let ip = require_str(params, "ip")?;
1712 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1713 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1714 param: "ip",
1715 value: v,
1716 },
1717 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1718 })
1719 }
1720 "Fail2banUnbanIp" => {
1721 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1722 let ip = require_str(params, "ip")?;
1723 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1724 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1725 param: "ip",
1726 value: v,
1727 },
1728 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1729 })
1730 }
1731 "ConfigureFail2banJail" => {
1732 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1733 let mut extra = Vec::new();
1734 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1735 extra.push("--enabled".to_string());
1736 extra.push(if n { "true" } else { "false" }.to_string());
1737 }
1738 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1739 if !(1..=100).contains(&n) {
1740 return Err(ExecutorError::InvalidParam("maxretry"));
1741 }
1742 extra.push("--maxretry".to_string());
1743 extra.push(n.to_string());
1744 }
1745 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1746 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1747 if n > MAX_FAIL2BAN_WINDOW_SECS {
1748 return Err(ExecutorError::InvalidParam(key));
1749 }
1750 extra.push(flag.to_string());
1751 extra.push(n.to_string());
1752 }
1753 }
1754 if extra.is_empty() {
1755 return Err(ExecutorError::MissingParam("maxretry"));
1756 }
1757 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
1758 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
1759 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
1760 })
1761 }
1762
1763 "GetAuditRules" => Ok(auditd::get_audit_rules()),
1765 "AddAuditRule" => {
1766 let path = validated_audit_path(require_str(params, "path")?, "path")?;
1767 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1768 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1769 Ok(auditd::add_audit_rule(&path, &perms, &key))
1770 }
1771 "RemoveAuditRule" => {
1772 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1773 Ok(auditd::remove_audit_rule(&key))
1774 }
1775
1776 "GetCertificates" => Ok(certbot::get_certificates()),
1778 "ObtainCertificate" => {
1779 let mut domains = Vec::new();
1781 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1782 for d in arr {
1783 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1784 domains.push(validated_domain(s, "domains")?);
1785 }
1786 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1787 domains.push(validated_domain(d, "domain")?);
1788 }
1789 if domains.is_empty() {
1790 return Err(ExecutorError::MissingParam("domains"));
1791 }
1792 let email = validated_email(require_str(params, "email")?, "email")?;
1793 let challenge = params
1794 .get("challenge")
1795 .and_then(|v| v.as_str())
1796 .unwrap_or("standalone");
1797 if !matches!(challenge, "standalone" | "nginx" | "apache") {
1798 return Err(ExecutorError::InvalidParam("challenge"));
1799 }
1800 Ok(certbot::obtain_certificate(&domains, &email, challenge))
1801 }
1802 "RenewCertificates" => Ok(certbot::renew_certificates()),
1803
1804 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1805 }
1806}
1807
1808pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1814 match &spec.mechanism {
1815 ActionMechanism::Command { program, args } => {
1816 let mut child = tokio::process::Command::new(program)
1820 .args(args)
1821 .stdin(Stdio::null())
1822 .process_group(0)
1825 .stdout(Stdio::piped())
1826 .stderr(Stdio::piped())
1827 .spawn()?;
1828
1829 let stdout_h = child.stdout.take().expect("stdout was piped");
1834 let stderr_h = child.stderr.take().expect("stderr was piped");
1835 let stdout_task = tokio::spawn(async move {
1836 let mut buf = Vec::new();
1837 BufReader::new(stdout_h)
1838 .read_to_end(&mut buf)
1839 .await
1840 .map(|_| buf)
1841 });
1842 let stderr_task = tokio::spawn(async move {
1843 let mut buf = Vec::new();
1844 BufReader::new(stderr_h)
1845 .read_to_end(&mut buf)
1846 .await
1847 .map(|_| buf)
1848 });
1849
1850 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
1851 Ok(status) => status?,
1852 Err(_) => return Err(kill_and_reap(&mut child, program, args).await),
1853 };
1854 let join =
1855 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
1856 let stdout = stdout_task.await.map_err(join)??;
1857 let stderr = stderr_task.await.map_err(join)??;
1858
1859 Ok(ExecutionOutput {
1860 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1861 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1862 exit_code: status.code().unwrap_or(-1),
1863 })
1864 }
1865 ActionMechanism::FileScan { path } => {
1866 let mut entries = tokio::fs::read_dir(path).await?;
1867 let mut names = Vec::new();
1868 while let Some(entry) = entries.next_entry().await? {
1869 names.push(entry.file_name().to_string_lossy().into_owned());
1870 }
1871 names.sort();
1872 Ok(ExecutionOutput {
1873 stdout: names.join("\n"),
1874 stderr: String::new(),
1875 exit_code: 0,
1876 })
1877 }
1878 ActionMechanism::FileWrite { path, content } => {
1879 if let Some(parent) = std::path::Path::new(path).parent() {
1880 tokio::fs::create_dir_all(parent).await?;
1881 }
1882 tokio::fs::write(path, content).await?;
1883 Ok(ExecutionOutput {
1884 stdout: String::new(),
1885 stderr: String::new(),
1886 exit_code: 0,
1887 })
1888 }
1889 ActionMechanism::FilePatch {
1890 path,
1891 search,
1892 replace,
1893 } => {
1894 let content = tokio::fs::read_to_string(path).await?;
1895 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1896 if patched == content && !search.is_empty() {
1897 return Ok(ExecutionOutput {
1898 stdout: String::new(),
1899 stderr: format!("search string not found in file: {}", path),
1900 exit_code: 1,
1901 });
1902 }
1903 tokio::fs::write(path, patched).await?;
1904 Ok(ExecutionOutput {
1905 stdout: String::new(),
1906 stderr: String::new(),
1907 exit_code: 0,
1908 })
1909 }
1910 ActionMechanism::FileDelete { path } => {
1911 tokio::fs::remove_file(path).await?;
1912 Ok(ExecutionOutput {
1913 stdout: String::new(),
1914 stderr: String::new(),
1915 exit_code: 0,
1916 })
1917 }
1918 }
1919}
1920
1921fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
1922 match params.get(key) {
1923 None => Err(ExecutorError::MissingParam(key)),
1924 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
1925 }
1926}
1927
1928fn optional_validated<F>(
1932 params: &Value,
1933 key: &'static str,
1934 validator: F,
1935) -> Result<Option<String>, ExecutorError>
1936where
1937 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
1938{
1939 match params
1940 .get(key)
1941 .and_then(|v| v.as_str())
1942 .filter(|s| !s.is_empty())
1943 {
1944 Some(s) => Ok(Some(validator(s, key)?)),
1945 None => Ok(None),
1946 }
1947}
1948
1949fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
1959 params
1960 .get("username")
1961 .or_else(|| params.get("user"))
1962 .ok_or(ExecutorError::MissingParam("username"))
1963 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
1964}
1965
1966fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
1970 let id = require_str(params, "repo_id")?;
1971 let valid = !id.is_empty()
1972 && id
1973 .chars()
1974 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1975 if valid {
1976 Ok(id)
1977 } else {
1978 Err(ExecutorError::InvalidParam("repo_id"))
1979 }
1980}
1981
1982fn validated_no_newline<'a>(
1985 params: &'a Value,
1986 key: &'static str,
1987) -> Result<&'a str, ExecutorError> {
1988 let val = require_str(params, key)?;
1989 if val.contains('\n') || val.contains('\r') {
1990 Err(ExecutorError::InvalidParam(key))
1991 } else {
1992 Ok(val)
1993 }
1994}
1995
1996fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2000 const MAX_LEN: usize = 8192;
2001 const ALLOWED_PREFIXES: &[&str] = &[
2002 "ssh-rsa",
2003 "ssh-ed25519",
2004 "ssh-ed25519-sk",
2005 "ecdsa-sha2-nistp256",
2006 "ecdsa-sha2-nistp384",
2007 "ecdsa-sha2-nistp521",
2008 "sk-ssh-ed25519",
2009 "sk-ecdsa-sha2-nistp256",
2010 ];
2011
2012 if s.is_empty() || s.len() > MAX_LEN {
2013 return Err(ExecutorError::InvalidParam("public_key"));
2014 }
2015 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2016 return Err(ExecutorError::InvalidParam("public_key"));
2017 }
2018 if s.chars().any(|c| {
2033 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2034 || !c.is_ascii()
2035 || c.is_ascii_control()
2036 }) {
2037 return Err(ExecutorError::InvalidParam("public_key"));
2038 }
2039 Ok(s.to_string())
2040}
2041
2042fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2043 match params.get(key) {
2044 None => Err(ExecutorError::MissingParam(key)),
2045 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2046 }
2047}
2048
2049fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2050 match params.get(key) {
2051 None => Err(ExecutorError::MissingParam(key)),
2052 Some(v) => {
2053 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2054 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2055 }
2056 }
2057}
2058
2059fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2063 let n = require_u32(params, key)?;
2064 if n == 0 {
2065 return Err(ExecutorError::InvalidParam(key));
2066 }
2067 Ok(n)
2068}
2069
2070fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2074 match params.get(key) {
2075 None | Some(Value::Null) => Ok(vec![]),
2076 Some(Value::Array(arr)) => arr
2077 .iter()
2078 .map(|v| {
2079 v.as_str()
2080 .map(String::from)
2081 .ok_or(ExecutorError::InvalidParam(key))
2082 })
2083 .collect(),
2084 _ => Err(ExecutorError::InvalidParam(key)),
2085 }
2086}
2087
2088fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2121 const BLOCKED_PREFIXES: &[&str] = &[
2122 "init=",
2123 "selinux=0",
2124 "enforcing=0",
2125 "security=",
2126 "module_blacklist=",
2127 "apparmor=0",
2128 "mitigations=off",
2129 "lockdown=",
2130 "pti=off",
2131 ];
2132 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2133 const BLOCKED_UNIT_PREFIXES: &[&str] = &["emergency", "rescue", "single"];
2134
2135 let lower = arg.to_lowercase();
2136 let base = lower.split('=').next().unwrap_or(&lower);
2138
2139 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2140 return Err(ExecutorError::InvalidParam(param));
2141 }
2142 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2143 return Err(ExecutorError::InvalidParam(param));
2144 }
2145 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2147 if BLOCKED_UNIT_PREFIXES
2148 .iter()
2149 .any(|u| unit_val.starts_with(u))
2150 {
2151 return Err(ExecutorError::InvalidParam(param));
2152 }
2153 }
2154 if BLOCKED_EXACT.contains(&base) {
2156 return Err(ExecutorError::InvalidParam(param));
2157 }
2158 Ok(())
2159}
2160
2161fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2169 let normalized = s.trim().to_ascii_uppercase();
2170 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2171 "TERM" => Ok("TERM"),
2172 "KILL" => Ok("KILL"),
2173 "HUP" => Ok("HUP"),
2174 "INT" => Ok("INT"),
2175 _ => Err(ExecutorError::InvalidParam("signal")),
2176 }
2177}
2178
2179pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2189 match action_name {
2190 "UpdateSystem"
2191 | "InstallPackages"
2192 | "RemovePackages"
2193 | "RebaseSystem"
2194 | "SetKernelArguments"
2195 | "AddLayeredPackage"
2196 | "RemoveLayeredPackage"
2197 | "ReplaceLayeredPackage"
2198 | "ResetLayeredPackageOverride"
2199 | "RemoveBasePackage" => Some(ActionSpec {
2200 action_name: "RollbackDeployment",
2201 mechanism: ActionMechanism::Command {
2202 program: "rpm-ostree",
2203 args: vec!["rollback".to_string()],
2204 },
2205 risk_level: sysknife_types::RiskLevel::High,
2206 reboot_required: true,
2207 rollback_available: false,
2208 }),
2209 _ => None,
2210 }
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215 use super::*;
2216 use serde_json::json;
2217 use sysknife_types::RiskLevel;
2218 use tempfile::tempdir;
2219
2220 #[test]
2223 fn build_spec_no_params_for_get_system_state() {
2224 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2225 assert_eq!(spec.action_name, "GetSystemState");
2226 assert_eq!(spec.risk_level, RiskLevel::Low);
2227 }
2228
2229 #[test]
2230 fn build_spec_get_datetime_is_low_risk() {
2231 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2232 assert_eq!(spec.action_name, "GetDateTime");
2233 assert_eq!(spec.risk_level, RiskLevel::Low);
2234 assert!(!spec.reboot_required);
2235 }
2236
2237 #[test]
2238 fn build_spec_unknown_action_returns_error() {
2239 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2240 assert!(
2241 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2242 "expected UnknownAction, got: {err}"
2243 );
2244 }
2245
2246 #[test]
2247 fn build_spec_missing_param_for_install_flatpak() {
2248 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2250 assert!(
2251 matches!(err, ExecutorError::MissingParam("username")),
2252 "expected MissingParam(username), got: {err}"
2253 );
2254 }
2255
2256 #[test]
2260 fn build_spec_flatpak_accepts_user_alias() {
2261 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2262 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2263 }
2264
2265 #[test]
2267 fn build_spec_resolve_username_prefers_explicit_username() {
2268 let spec = build_action_spec(
2269 "ListInstalledFlatpaks",
2270 &json!({ "username": "alice", "user": "bob" }),
2271 )
2272 .unwrap();
2273 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2275 }
2276
2277 #[test]
2280 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2281 let spec = build_action_spec(
2282 "InstallFlatpak",
2283 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2284 )
2285 .unwrap();
2286 assert_eq!(
2287 spec.mechanism,
2288 ActionMechanism::Command {
2289 program: "sudo",
2290 args: vec![
2291 "runuser".to_string(),
2292 "-u".to_string(),
2293 "alice".to_string(),
2294 "--".to_string(),
2295 "flatpak".to_string(),
2296 "install".to_string(),
2297 "--user".to_string(),
2298 "-y".to_string(),
2299 "flathub".to_string(),
2300 "org.mozilla.firefox".to_string(),
2301 ],
2302 }
2303 );
2304 }
2305
2306 #[test]
2307 fn build_spec_install_flatpak_injects_app_and_remote() {
2308 let spec = build_action_spec(
2309 "InstallFlatpak",
2310 &json!({
2311 "username": "alice",
2312 "app_id": "org.mozilla.firefox",
2313 "remote": "flathub"
2314 }),
2315 )
2316 .unwrap();
2317 assert_eq!(spec.action_name, "InstallFlatpak");
2318 assert_eq!(
2319 spec.mechanism,
2320 ActionMechanism::Command {
2321 program: "sudo",
2322 args: vec![
2323 "runuser".to_string(),
2324 "-u".to_string(),
2325 "alice".to_string(),
2326 "--".to_string(),
2327 "flatpak".to_string(),
2328 "install".to_string(),
2329 "--user".to_string(),
2330 "-y".to_string(),
2331 "flathub".to_string(),
2332 "org.mozilla.firefox".to_string(),
2333 ],
2334 }
2335 );
2336 }
2337
2338 #[test]
2339 fn build_spec_pin_deployment_injects_index() {
2340 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2341 assert_eq!(
2342 spec.mechanism,
2343 ActionMechanism::Command {
2344 program: "sudo",
2345 args: vec![
2346 "ostree".to_string(),
2347 "admin".to_string(),
2348 "pin".to_string(),
2349 "1".to_string()
2350 ],
2351 }
2352 );
2353 }
2354
2355 #[test]
2356 fn build_spec_unpin_deployment_includes_unpin_flag() {
2357 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2358 assert_eq!(
2359 spec.mechanism,
2360 ActionMechanism::Command {
2361 program: "sudo",
2362 args: vec![
2363 "ostree".to_string(),
2364 "admin".to_string(),
2365 "pin".to_string(),
2366 "--unpin".to_string(),
2367 "2".to_string(),
2368 ],
2369 }
2370 );
2371 }
2372
2373 #[test]
2374 fn require_u32_rejects_overflow() {
2375 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2376 assert!(
2377 matches!(err, ExecutorError::InvalidParam("index")),
2378 "expected InvalidParam(index), got: {err}"
2379 );
2380 }
2381
2382 #[test]
2383 fn build_spec_rebase_system_injects_target_ref() {
2384 let spec = build_action_spec(
2385 "RebaseSystem",
2386 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
2387 )
2388 .unwrap();
2389 assert_eq!(
2390 spec.mechanism,
2391 ActionMechanism::Command {
2392 program: "sudo",
2393 args: vec![
2394 "rpm-ostree".to_string(),
2395 "rebase".to_string(),
2396 "fedora/41/x86_64/silverblue".to_string(),
2397 ],
2398 }
2399 );
2400 }
2401
2402 #[test]
2403 fn build_spec_set_kernel_arguments_appends_and_deletes() {
2404 let spec = build_action_spec(
2405 "SetKernelArguments",
2406 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
2407 )
2408 .unwrap();
2409 assert_eq!(
2410 spec.mechanism,
2411 ActionMechanism::Command {
2412 program: "sudo",
2413 args: vec![
2414 "rpm-ostree".to_string(),
2415 "kargs".to_string(),
2416 "--append=nomodeset".to_string(),
2417 "--delete=quiet".to_string(),
2418 ],
2419 }
2420 );
2421 }
2422
2423 #[test]
2424 fn build_spec_set_kernel_arguments_with_empty_arrays() {
2425 let spec =
2426 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
2427 assert_eq!(
2428 spec.mechanism,
2429 ActionMechanism::Command {
2430 program: "sudo",
2431 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2432 }
2433 );
2434 }
2435
2436 #[test]
2437 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
2438 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
2439 assert_eq!(
2440 spec.mechanism,
2441 ActionMechanism::Command {
2442 program: "sudo",
2443 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2444 }
2445 );
2446 }
2447
2448 #[test]
2451 fn delete_user_rejects_critical_account() {
2452 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
2453 assert!(
2454 matches!(err, ExecutorError::InvalidParam("username")),
2455 "expected InvalidParam(username), got: {err}"
2456 );
2457 }
2458
2459 #[test]
2460 fn delete_group_rejects_critical_group() {
2461 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
2462 assert!(
2463 matches!(err, ExecutorError::InvalidParam("group")),
2464 "expected InvalidParam(group), got: {err}"
2465 );
2466 }
2467
2468 #[test]
2469 fn lock_user_account_rejects_critical_account() {
2470 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
2471 assert!(
2472 matches!(err, ExecutorError::InvalidParam("username")),
2473 "expected InvalidParam(username), got: {err}"
2474 );
2475 }
2476
2477 #[test]
2478 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
2479 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
2480 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
2481 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
2482 }
2483
2484 #[test]
2487 fn build_spec_add_package_repository_rejects_path_traversal() {
2488 let err = build_action_spec(
2489 "AddPackageRepository",
2490 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
2491 )
2492 .unwrap_err();
2493 assert!(
2494 matches!(err, ExecutorError::InvalidParam("repo_id")),
2495 "expected InvalidParam(repo_id), got: {err}"
2496 );
2497 }
2498
2499 #[test]
2500 fn build_spec_add_package_repository_rejects_newline_in_url() {
2501 let err = build_action_spec(
2502 "AddPackageRepository",
2503 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2504 )
2505 .unwrap_err();
2506 assert!(
2507 matches!(err, ExecutorError::InvalidParam("repo_url")),
2508 "expected InvalidParam(repo_url), got: {err}"
2509 );
2510 }
2511
2512 #[test]
2513 fn build_spec_add_package_repository_accepts_valid_repo_id() {
2514 let spec = build_action_spec(
2515 "AddPackageRepository",
2516 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2517 )
2518 .unwrap();
2519 assert_eq!(spec.action_name, "AddPackageRepository");
2520 }
2521
2522 #[test]
2523 fn build_spec_remove_package_repository_rejects_path_traversal() {
2524 let err = build_action_spec(
2525 "RemovePackageRepository",
2526 &json!({ "repo_id": "../../etc/passwd" }),
2527 )
2528 .unwrap_err();
2529 assert!(
2530 matches!(err, ExecutorError::InvalidParam("repo_id")),
2531 "expected InvalidParam(repo_id), got: {err}"
2532 );
2533 }
2534
2535 #[tokio::test]
2536 async fn execute_spec_command_captures_stdout() {
2537 let spec = ActionSpec {
2538 action_name: "GetSystemState",
2539 mechanism: ActionMechanism::Command {
2540 program: "echo",
2541 args: vec!["hello".to_string()],
2542 },
2543 risk_level: RiskLevel::Low,
2544 reboot_required: false,
2545 rollback_available: false,
2546 };
2547 let out = execute_spec(&spec).await.unwrap();
2548 assert_eq!(out.stdout.trim(), "hello");
2549 assert_eq!(out.exit_code, 0);
2550 }
2551
2552 #[test]
2560 fn action_not_stopped_names_the_group_and_the_timeout() {
2561 let err = ExecutorError::ActionNotStopped {
2562 program: "rpm-ostree".to_string(),
2563 pgid: 4242,
2564 timeout_secs: 7200,
2565 };
2566 let msg = err.to_string();
2567 assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
2568 assert!(msg.contains("7200"), "must name the timeout: {msg}");
2569 assert!(
2570 msg.contains("could not be confirmed stopped"),
2571 "must say what happened: {msg}"
2572 );
2573 assert!(
2574 msg.contains("rollback was skipped"),
2575 "must say the rollback was skipped: {msg}"
2576 );
2577 }
2578
2579 #[tokio::test]
2584 async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
2585 let mut child = tokio::process::Command::new("sh")
2586 .args(["-c", "sleep 3600 & wait"])
2587 .process_group(0)
2588 .stdin(Stdio::null())
2589 .stdout(Stdio::null())
2590 .stderr(Stdio::null())
2591 .spawn()
2592 .expect("spawn");
2593 SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
2594 let started = tokio::time::Instant::now();
2595 let err = kill_and_reap(&mut child, "sh", &[]).await;
2596 let elapsed = started.elapsed();
2597
2598 assert!(
2599 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
2600 "a killable group must report a plain stopped-timeout, got: {err:?}"
2601 );
2602 assert!(
2603 elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
2604 "must return promptly, took {elapsed:?}"
2605 );
2606 assert_eq!(
2610 SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
2611 0,
2612 "a killable group must not escalate to the privileged reaper"
2613 );
2614 }
2615
2616 #[test]
2617 fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
2618 assert_eq!(
2620 reap_command(
2621 99,
2622 "sudo",
2623 &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
2624 ),
2625 vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
2626 );
2627 assert_eq!(
2629 reap_command(
2630 99,
2631 "sudo",
2632 &[
2633 "env".to_string(),
2634 "apt-get".to_string(),
2635 "install".to_string()
2636 ]
2637 ),
2638 root_group_kill_argv("TERM", 99)
2639 );
2640 }
2641
2642 #[test]
2643 fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
2644 assert_eq!(
2647 root_group_kill_argv("KILL", 4242),
2648 vec!["-n", "/usr/bin/kill", "-s", "KILL", "--", "-4242"]
2649 );
2650 assert_eq!(
2651 root_group_kill_argv("TERM", 7)[3..].to_vec(),
2652 vec!["TERM", "--", "-7"]
2653 );
2654 }
2655
2656 #[test]
2657 fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
2658 let sudo = |a: &[&str]| {
2659 is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
2660 };
2661 assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
2663 assert!(sudo(&[
2664 "rpm-ostree",
2665 "rebase",
2666 "fedora:fedora/40/x86_64/silverblue"
2667 ]));
2668 assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
2669 assert!(!sudo(&[
2672 "env",
2673 "DEBIAN_FRONTEND=noninteractive",
2674 "apt-get",
2675 "install",
2676 "vim"
2677 ]));
2678 assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
2679 assert!(!is_rpm_ostree_action("sudo", &[]));
2680 assert!(is_rpm_ostree_action(
2682 "/usr/bin/rpm-ostree",
2683 &["deploy".to_string()]
2684 ));
2685 assert!(!is_rpm_ostree_action(
2688 "sudo",
2689 &["explain-rpm-ostree-usage".to_string()]
2690 ));
2691 }
2692
2693 #[tokio::test]
2696 async fn real_executor_streams_each_stdout_line() {
2697 let spec = ActionSpec {
2698 action_name: "GetSystemState",
2699 mechanism: ActionMechanism::Command {
2700 program: "printf",
2701 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2702 },
2703 risk_level: RiskLevel::Low,
2704 reboot_required: false,
2705 rollback_available: false,
2706 };
2707 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2708 let out = RealActionExecutor
2709 .execute_with_progress(&spec, tx)
2710 .await
2711 .unwrap();
2712
2713 let mut lines = Vec::new();
2714 while let Ok(line) = rx.try_recv() {
2715 lines.push(line);
2716 }
2717 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2718 assert_eq!(out.exit_code, 0);
2719 assert!(out.stdout.contains("line-two"));
2720 }
2721
2722 #[tokio::test]
2723 async fn execute_spec_file_write_creates_file() {
2724 let dir = tempdir().unwrap();
2725 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2726 let spec = ActionSpec {
2727 action_name: "AddPackageRepository",
2728 mechanism: ActionMechanism::FileWrite {
2729 path: path.clone(),
2730 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2731 },
2732 risk_level: RiskLevel::Medium,
2733 reboot_required: false,
2734 rollback_available: true,
2735 };
2736 let out = execute_spec(&spec).await.unwrap();
2737 assert_eq!(out.exit_code, 0);
2738 assert_eq!(
2739 std::fs::read_to_string(&path).unwrap(),
2740 "[repo]\nbaseurl=https://example.test\n"
2741 );
2742 }
2743
2744 #[tokio::test]
2745 async fn execute_spec_file_patch_replaces_first_occurrence() {
2746 let dir = tempdir().unwrap();
2747 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2748 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2749 let spec = ActionSpec {
2750 action_name: "EnablePackageRepository",
2751 mechanism: ActionMechanism::FilePatch {
2752 path: path.clone(),
2753 search: "enabled=0".to_string(),
2754 replace: "enabled=1".to_string(),
2755 },
2756 risk_level: RiskLevel::Medium,
2757 reboot_required: false,
2758 rollback_available: true,
2759 };
2760 execute_spec(&spec).await.unwrap();
2761 assert_eq!(
2762 std::fs::read_to_string(&path).unwrap(),
2763 "[myrepo]\nenabled=1\n"
2764 );
2765 }
2766
2767 #[tokio::test]
2768 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2769 let dir = tempdir().unwrap();
2770 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2771 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2772 let spec = ActionSpec {
2773 action_name: "EnablePackageRepository",
2774 mechanism: ActionMechanism::FilePatch {
2775 path: path.clone(),
2776 search: "enabled=0".to_string(),
2777 replace: "enabled=1".to_string(),
2778 },
2779 risk_level: RiskLevel::Medium,
2780 reboot_required: false,
2781 rollback_available: true,
2782 };
2783 let out = execute_spec(&spec).await.unwrap();
2784 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2785 assert!(
2786 out.stderr.contains("search string not found in file"),
2787 "stderr should explain the failure: {}",
2788 out.stderr
2789 );
2790 assert_eq!(
2792 std::fs::read_to_string(&path).unwrap(),
2793 "[myrepo]\nenabled=1\n"
2794 );
2795 }
2796
2797 #[tokio::test]
2798 async fn execute_spec_file_patch_allows_empty_search_string() {
2799 let dir = tempdir().unwrap();
2802 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
2803 std::fs::write(&path, "hello").unwrap();
2804 let spec = ActionSpec {
2805 action_name: "Test",
2806 mechanism: ActionMechanism::FilePatch {
2807 path: path.clone(),
2808 search: String::new(),
2809 replace: "prefix-".to_string(),
2810 },
2811 risk_level: RiskLevel::Low,
2812 reboot_required: false,
2813 rollback_available: false,
2814 };
2815 let out = execute_spec(&spec).await.unwrap();
2816 assert_eq!(out.exit_code, 0);
2817 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
2818 }
2819
2820 #[tokio::test]
2821 async fn execute_spec_file_delete_removes_file() {
2822 let dir = tempdir().unwrap();
2823 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2824 std::fs::write(&path, "[myrepo]\n").unwrap();
2825 let spec = ActionSpec {
2826 action_name: "RemovePackageRepository",
2827 mechanism: ActionMechanism::FileDelete { path: path.clone() },
2828 risk_level: RiskLevel::Medium,
2829 reboot_required: false,
2830 rollback_available: true,
2831 };
2832 execute_spec(&spec).await.unwrap();
2833 assert!(!std::path::Path::new(&path).exists());
2834 }
2835
2836 #[tokio::test]
2837 async fn execute_spec_file_scan_lists_directory_entries() {
2838 let dir = tempdir().unwrap();
2839 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
2840 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
2841 let spec = ActionSpec {
2842 action_name: "ListPackageRepositories",
2843 mechanism: ActionMechanism::FileScan {
2844 path: dir.path().to_string_lossy().into_owned(),
2845 },
2846 risk_level: RiskLevel::Low,
2847 reboot_required: false,
2848 rollback_available: false,
2849 };
2850 let out = execute_spec(&spec).await.unwrap();
2851 assert!(
2852 out.stdout.contains("a.repo"),
2853 "expected a.repo in: {}",
2854 out.stdout
2855 );
2856 assert!(
2857 out.stdout.contains("b.repo"),
2858 "expected b.repo in: {}",
2859 out.stdout
2860 );
2861 assert_eq!(out.exit_code, 0);
2862 }
2863
2864 #[test]
2867 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
2868 let spec = rollback_spec_for("UpdateSystem").unwrap();
2869 assert_eq!(spec.action_name, "RollbackDeployment");
2870 assert!(
2871 matches!(
2872 &spec.mechanism,
2873 ActionMechanism::Command { program: "rpm-ostree", args }
2874 if args == &["rollback".to_string()]
2875 ),
2876 "expected rpm-ostree rollback, got: {:?}",
2877 spec.mechanism
2878 );
2879 assert!(!spec.rollback_available, "rollback spec must not recurse");
2880 }
2881
2882 #[test]
2883 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
2884 assert!(rollback_spec_for("InstallPackages").is_some());
2885 }
2886
2887 #[test]
2888 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
2889 assert!(rollback_spec_for("RemovePackages").is_some());
2890 }
2891
2892 #[test]
2893 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
2894 assert!(rollback_spec_for("RebaseSystem").is_some());
2895 }
2896
2897 #[test]
2898 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
2899 assert!(rollback_spec_for("SetKernelArguments").is_some());
2900 }
2901
2902 #[test]
2903 fn rollback_spec_for_read_only_action_returns_none() {
2904 assert!(rollback_spec_for("GetSystemState").is_none());
2905 assert!(rollback_spec_for("ListUsers").is_none());
2906 assert!(rollback_spec_for("GetFirewallState").is_none());
2907 }
2908
2909 #[test]
2910 fn rollback_spec_for_non_rollbackable_actions_return_none() {
2911 assert!(rollback_spec_for("AddUserToGroup").is_none());
2912 assert!(rollback_spec_for("DeleteUser").is_none());
2913 assert!(rollback_spec_for("CleanupDeployments").is_none());
2914 assert!(rollback_spec_for("RollbackDeployment").is_none());
2916 }
2917
2918 #[test]
2921 fn public_key_accepts_valid_ed25519() {
2922 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
2923 assert!(validated_public_key(key).is_ok());
2924 }
2925
2926 #[test]
2927 fn public_key_accepts_valid_rsa() {
2928 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
2929 assert!(validated_public_key(key).is_ok());
2930 }
2931
2932 #[test]
2933 fn public_key_rejects_empty() {
2934 assert!(matches!(
2935 validated_public_key(""),
2936 Err(ExecutorError::InvalidParam("public_key"))
2937 ));
2938 }
2939
2940 #[test]
2941 fn public_key_rejects_unknown_prefix() {
2942 assert!(matches!(
2943 validated_public_key("sk-rsa AAAA... user@host"),
2944 Err(ExecutorError::InvalidParam("public_key"))
2945 ));
2946 assert!(matches!(
2947 validated_public_key("AAAAB3Nz... user@host"),
2948 Err(ExecutorError::InvalidParam("public_key"))
2949 ));
2950 }
2951
2952 #[test]
2953 fn public_key_rejects_single_quote() {
2954 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
2955 assert!(matches!(
2956 validated_public_key(key),
2957 Err(ExecutorError::InvalidParam("public_key"))
2958 ));
2959 }
2960
2961 #[test]
2969 fn package_actions_reject_a_local_file_target() {
2970 let reject: &[(&str, serde_json::Value)] = &[
2971 ("AptInstall", json!({"package": "/tmp/evil.deb"})),
2972 ("AptRemove", json!({"package": "/tmp/evil.deb"})),
2973 ("AptPurge", json!({"package": "./evil.deb"})),
2974 ("AptHold", json!({"package": "/tmp/evil.deb"})),
2975 ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
2976 ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
2977 ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
2978 ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
2979 (
2980 "ReplaceLayeredPackage",
2981 json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
2982 ),
2983 (
2984 "ReplaceLayeredPackage",
2985 json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
2986 ),
2987 ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
2988 ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
2989 ];
2990 for (action, params) in reject {
2991 assert!(
2992 matches!(
2993 build_action_spec(action, params),
2994 Err(ExecutorError::InvalidParam(_))
2995 ),
2996 "{action} must refuse a local-file package target: {params}"
2997 );
2998 }
2999 let accept: &[(&str, serde_json::Value)] = &[
3002 ("AptInstall", json!({"package": "nginx"})),
3003 ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3004 (
3005 "InstallPackages",
3006 json!({"packages": ["nginx", "python3-pip"]}),
3007 ),
3008 ];
3009 for (action, params) in accept {
3010 assert!(
3011 build_action_spec(action, params).is_ok(),
3012 "{action} must accept a real package spec: {params}"
3013 );
3014 }
3015 }
3016
3017 #[test]
3022 fn activating_actions_refuse_a_root_shell_unit() {
3023 for action in ["StartService", "RestartService", "UnmaskService"] {
3024 for unit in [
3025 "debug-shell.service",
3026 "emergency.target",
3027 "rescue.service",
3028 "runlevel1.target",
3029 ] {
3030 assert!(
3031 matches!(
3032 build_action_spec(action, &json!({"unit": unit})),
3033 Err(ExecutorError::InvalidParam("unit"))
3034 ),
3035 "{action} must not activate {unit}"
3036 );
3037 }
3038 assert!(
3039 build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3040 "{action} must still activate an ordinary unit"
3041 );
3042 }
3043 assert!(
3044 build_action_spec(
3045 "SetServiceEnabled",
3046 &json!({"unit": "debug-shell.service", "enabled": true})
3047 )
3048 .is_err(),
3049 "enabling a root-shell unit brings it up at boot and must be refused"
3050 );
3051 assert!(
3052 build_action_spec(
3053 "SetServiceEnabled",
3054 &json!({"unit": "debug-shell.service", "enabled": false})
3055 )
3056 .is_ok(),
3057 "disabling a root-shell unit is a mitigation and must stay reachable"
3058 );
3059 for action in ["StopService", "MaskService"] {
3062 assert!(
3063 build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3064 "{action} on debug-shell is a mitigation and must stay allowed"
3065 );
3066 }
3067 }
3068
3069 #[test]
3070 fn authorized_keys_actions_reject_a_traversal_username() {
3071 for action in [
3078 "GetAuthorizedKeys",
3079 "AddAuthorizedKey",
3080 "RemoveAuthorizedKey",
3081 ] {
3082 for bad in ["../../etc", "..", "root/../../etc"] {
3083 let err = build_action_spec(
3084 action,
3085 &json!({
3086 "username": bad,
3087 "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h",
3088 }),
3089 )
3090 .unwrap_err();
3091 assert!(
3092 matches!(err, ExecutorError::InvalidParam("username")),
3093 "{action} must reject username {bad:?}, got {err:?}"
3094 );
3095 }
3096 }
3097 }
3098
3099 #[test]
3100 fn ip_taking_actions_reject_a_malformed_address() {
3101 let cases: [(&str, serde_json::Value); 3] = [
3104 (
3105 "ResolvectlSetDns",
3106 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
3107 ),
3108 (
3109 "Fail2banBanIp",
3110 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
3111 ),
3112 (
3113 "Fail2banUnbanIp",
3114 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
3115 ),
3116 ];
3117 for (action, params) in cases {
3118 let err = build_action_spec(action, ¶ms).unwrap_err();
3119 assert!(
3120 matches!(err, ExecutorError::InvalidIpAddress { .. }),
3121 "{action} must reject a malformed IP, got {err:?}"
3122 );
3123 }
3124 }
3125
3126 #[test]
3127 fn public_key_rejects_pipe_metacharacter() {
3128 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
3132 assert!(matches!(
3133 validated_public_key(key),
3134 Err(ExecutorError::InvalidParam("public_key"))
3135 ));
3136 }
3137
3138 #[test]
3139 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
3140 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
3147 assert!(validated_public_key(key).is_ok());
3148 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
3151 }
3152
3153 #[test]
3154 fn public_key_rejects_shell_metacharacters() {
3155 for (metachar, desc) in [
3156 (';', "semicolon"),
3157 ('`', "backtick"),
3158 ('$', "dollar"),
3159 ('\\', "backslash"),
3160 ('&', "ampersand"),
3161 ] {
3162 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
3163 assert!(
3164 matches!(
3165 validated_public_key(&key),
3166 Err(ExecutorError::InvalidParam("public_key"))
3167 ),
3168 "{desc} should be rejected"
3169 );
3170 }
3171 }
3172
3173 #[test]
3174 fn public_key_rejects_newline() {
3175 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
3176 assert!(matches!(
3177 validated_public_key(key),
3178 Err(ExecutorError::InvalidParam("public_key"))
3179 ));
3180 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
3181 assert!(matches!(
3182 validated_public_key(key_cr),
3183 Err(ExecutorError::InvalidParam("public_key"))
3184 ));
3185 }
3186
3187 #[test]
3188 fn public_key_rejects_too_long() {
3189 let long_payload = "A".repeat(8192);
3191 let key = format!("ssh-ed25519 {long_payload} user@host");
3192 assert!(matches!(
3193 validated_public_key(&key),
3194 Err(ExecutorError::InvalidParam("public_key"))
3195 ));
3196 }
3197
3198 #[test]
3201 fn str_array_or_empty_rejects_non_string_element() {
3202 let params = json!({ "packages": ["vim", 42, "curl"] });
3203 assert!(matches!(
3204 str_array_or_empty(¶ms, "packages"),
3205 Err(ExecutorError::InvalidParam("packages"))
3206 ));
3207 }
3208
3209 #[test]
3210 fn str_array_or_empty_accepts_string_array() {
3211 let params = json!({ "packages": ["vim", "curl"] });
3212 assert_eq!(
3213 str_array_or_empty(¶ms, "packages").unwrap(),
3214 vec!["vim".to_string(), "curl".to_string()]
3215 );
3216 }
3217
3218 #[test]
3219 fn str_array_or_empty_returns_empty_when_key_absent() {
3220 let params = json!({});
3221 assert_eq!(
3222 str_array_or_empty(¶ms, "packages").unwrap(),
3223 Vec::<String>::new()
3224 );
3225 }
3226
3227 #[test]
3230 fn kernel_arg_allows_safe_args() {
3231 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3232 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3233 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3234 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3235 }
3236
3237 #[test]
3238 fn kernel_arg_blocks_init_override() {
3239 assert!(matches!(
3240 validated_safe_kernel_arg("init=/bin/sh", "add"),
3241 Err(ExecutorError::InvalidParam("add"))
3242 ));
3243 assert!(matches!(
3244 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3245 Err(ExecutorError::InvalidParam("add"))
3246 ));
3247 }
3248
3249 #[test]
3250 fn kernel_arg_blocks_selinux_disable() {
3251 assert!(matches!(
3252 validated_safe_kernel_arg("selinux=0", "add"),
3253 Err(ExecutorError::InvalidParam("add"))
3254 ));
3255 assert!(matches!(
3256 validated_safe_kernel_arg("enforcing=0", "add"),
3257 Err(ExecutorError::InvalidParam("add"))
3258 ));
3259 }
3260
3261 #[test]
3262 fn kernel_arg_blocks_security_override() {
3263 assert!(matches!(
3264 validated_safe_kernel_arg("security=none", "add"),
3265 Err(ExecutorError::InvalidParam("add"))
3266 ));
3267 }
3268
3269 #[test]
3270 fn kernel_arg_blocks_module_blacklist() {
3271 assert!(matches!(
3272 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3273 Err(ExecutorError::InvalidParam("add"))
3274 ));
3275 }
3276
3277 #[test]
3278 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
3279 assert!(matches!(
3280 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
3281 Err(ExecutorError::InvalidParam("add"))
3282 ));
3283 assert!(matches!(
3284 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
3285 Err(ExecutorError::InvalidParam("add"))
3286 ));
3287 assert!(matches!(
3288 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
3289 Err(ExecutorError::InvalidParam("add"))
3290 ));
3291 }
3292
3293 #[test]
3294 fn kernel_arg_blocks_apparmor_disable() {
3295 assert!(matches!(
3296 validated_safe_kernel_arg("apparmor=0", "add"),
3297 Err(ExecutorError::InvalidParam("add"))
3298 ));
3299 }
3300
3301 #[test]
3302 fn kernel_arg_blocks_mitigations_off() {
3303 assert!(matches!(
3304 validated_safe_kernel_arg("mitigations=off", "add"),
3305 Err(ExecutorError::InvalidParam("add"))
3306 ));
3307 }
3308
3309 #[test]
3310 fn kernel_arg_blocks_lockdown_override() {
3311 assert!(matches!(
3312 validated_safe_kernel_arg("lockdown=none", "add"),
3313 Err(ExecutorError::InvalidParam("add"))
3314 ));
3315 }
3316
3317 #[test]
3318 fn kernel_arg_blocks_pti_off() {
3319 assert!(matches!(
3320 validated_safe_kernel_arg("pti=off", "add"),
3321 Err(ExecutorError::InvalidParam("add"))
3322 ));
3323 }
3324
3325 #[test]
3326 fn kernel_arg_blocks_smap_smep_disable() {
3327 assert!(matches!(
3328 validated_safe_kernel_arg("nosmap", "add"),
3329 Err(ExecutorError::InvalidParam("add"))
3330 ));
3331 assert!(matches!(
3332 validated_safe_kernel_arg("nosmep", "add"),
3333 Err(ExecutorError::InvalidParam("add"))
3334 ));
3335 }
3336
3337 #[test]
3338 fn kernel_arg_blocks_single_user_shortcuts() {
3339 assert!(matches!(
3341 validated_safe_kernel_arg("single", "add"),
3342 Err(ExecutorError::InvalidParam("add"))
3343 ));
3344 assert!(matches!(
3345 validated_safe_kernel_arg("s", "add"),
3346 Err(ExecutorError::InvalidParam("add"))
3347 ));
3348 assert!(matches!(
3349 validated_safe_kernel_arg("1", "add"),
3350 Err(ExecutorError::InvalidParam("add"))
3351 ));
3352 }
3353
3354 #[test]
3355 fn signal_process_guardrails() {
3356 for bad_pid in [0, 1] {
3358 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
3359 assert!(
3360 matches!(err, ExecutorError::InvalidParam("pid")),
3361 "pid {bad_pid} must be rejected, got {err:?}"
3362 );
3363 }
3364 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
3366 .unwrap_err();
3367 assert!(
3368 matches!(err, ExecutorError::InvalidParam("signal")),
3369 "got {err:?}"
3370 );
3371 assert!(matches!(
3373 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
3374 ExecutorError::MissingParam("pid")
3375 ));
3376 let spec = build_action_spec(
3378 "SignalProcess",
3379 &json!({ "pid": "4242", "signal": "sigkill" }),
3380 )
3381 .unwrap();
3382 assert_eq!(spec.action_name, "SignalProcess");
3383 assert_eq!(
3384 spec.mechanism,
3385 ActionMechanism::Command {
3386 program: "sudo",
3387 args: vec![
3388 "kill".to_string(),
3389 "-s".to_string(),
3390 "KILL".to_string(),
3391 "4242".to_string()
3392 ],
3393 }
3394 );
3395 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
3397 if let ActionMechanism::Command { args, .. } = spec.mechanism {
3398 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
3399 } else {
3400 panic!("expected Command mechanism");
3401 }
3402 }
3403
3404 #[test]
3405 fn create_scheduled_job_validates_name_command_schedule() {
3406 assert!(matches!(
3408 build_action_spec(
3409 "CreateScheduledJob",
3410 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
3411 )
3412 .unwrap_err(),
3413 ExecutorError::InvalidParam("command")
3414 ));
3415 assert!(matches!(
3417 build_action_spec(
3418 "CreateScheduledJob",
3419 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
3420 )
3421 .unwrap_err(),
3422 ExecutorError::InvalidParam("name")
3423 ));
3424 assert!(matches!(
3426 build_action_spec(
3427 "CreateScheduledJob",
3428 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
3429 )
3430 .unwrap_err(),
3431 ExecutorError::InvalidParam("schedule")
3432 ));
3433 let spec = build_action_spec(
3435 "CreateScheduledJob",
3436 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
3437 )
3438 .unwrap();
3439 assert_eq!(
3440 spec.mechanism,
3441 ActionMechanism::Command {
3442 program: "sudo",
3443 args: vec![
3444 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
3445 "--name".to_string(),
3446 "nightly-backup".to_string(),
3447 "--command".to_string(),
3448 "/usr/bin/backup --full".to_string(),
3449 "--schedule".to_string(),
3450 "*-*-* 02:00:00".to_string(),
3451 ],
3452 }
3453 );
3454 }
3455
3456 #[test]
3457 fn set_sshd_option_allowlist_guardrails() {
3458 assert!(matches!(
3460 build_action_spec(
3461 "SetSshdOption",
3462 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
3463 )
3464 .unwrap_err(),
3465 ExecutorError::InvalidParam("option")
3466 ));
3467 assert!(matches!(
3469 build_action_spec(
3470 "SetSshdOption",
3471 &json!({ "option": "PermitRootLogin", "value": "maybe" })
3472 )
3473 .unwrap_err(),
3474 ExecutorError::InvalidParam("value")
3475 ));
3476 let spec = build_action_spec(
3478 "SetSshdOption",
3479 &json!({ "option": "PasswordAuthentication", "value": "no" }),
3480 )
3481 .unwrap();
3482 assert_eq!(
3483 spec.mechanism,
3484 ActionMechanism::Command {
3485 program: "sudo",
3486 args: vec![
3487 "/usr/lib/sysknife/sshd-option-edit".to_string(),
3488 "--option".to_string(),
3489 "PasswordAuthentication".to_string(),
3490 "--value".to_string(),
3491 "no".to_string(),
3492 ],
3493 }
3494 );
3495 }
3496
3497 #[test]
3498 fn configure_unattended_upgrades_toggles_helper_flag() {
3499 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
3500 let spec = build_action_spec(
3501 "ConfigureUnattendedUpgrades",
3502 &json!({ "enabled": enabled }),
3503 )
3504 .unwrap();
3505 assert_eq!(
3506 spec.mechanism,
3507 ActionMechanism::Command {
3508 program: "sudo",
3509 args: vec![
3510 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
3511 flag.to_string(),
3512 ],
3513 }
3514 );
3515 }
3516 assert!(matches!(
3517 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
3518 ExecutorError::MissingParam("enabled")
3519 ));
3520 }
3521
3522 #[test]
3523 fn grub_set_kargs_append_blocks_single_user_shortcut() {
3524 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
3532 let err = build_action_spec(
3533 "GrubSetKargs",
3534 &json!({ "append": [dangerous], "delete": [] }),
3535 )
3536 .unwrap_err();
3537 assert!(
3538 matches!(err, ExecutorError::InvalidParam("append")),
3539 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
3540 );
3541 }
3542 assert!(build_action_spec(
3544 "GrubSetKargs",
3545 &json!({ "append": ["quiet"], "delete": [] })
3546 )
3547 .is_ok());
3548 }
3549
3550 #[test]
3551 fn kernel_arg_build_spec_rejects_dangerous_arg() {
3552 let err = build_action_spec(
3558 "SetKernelArguments",
3559 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
3560 )
3561 .unwrap_err();
3562 assert!(
3563 matches!(err, ExecutorError::InvalidParam("add")),
3564 "expected InvalidParam(add), got {err:?}"
3565 );
3566 }
3567
3568 #[test]
3569 fn grub_set_kargs_cannot_delete_a_protective_karg() {
3570 for karg in [
3577 "apparmor=1",
3578 "lockdown=confidentiality",
3579 "mitigations=auto",
3580 "pti=on",
3581 "selinux=1",
3582 ] {
3583 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
3584 .unwrap_err();
3585 assert!(
3586 matches!(err, ExecutorError::InvalidParam("delete")),
3587 "deleting {karg:?} must be refused, got {err:?}"
3588 );
3589 }
3590 }
3591
3592 #[test]
3597 fn rollback_available_matches_rollback_spec_for_all_actions() {
3598 for spec in crate::actions::all_specs() {
3606 let has_rollback = rollback_spec_for(spec.action_name).is_some();
3607 assert_eq!(
3608 spec.rollback_available,
3609 has_rollback,
3610 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
3611 spec.action_name,
3612 spec.rollback_available,
3613 if has_rollback { "Some" } else { "None" },
3614 );
3615 }
3616 }
3617
3618 #[test]
3624 fn create_user_is_high_risk() {
3625 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
3626 assert_eq!(
3627 spec.risk_level,
3628 RiskLevel::High,
3629 "CreateUser must be High (T1136.001 Persistence)"
3630 );
3631 }
3632
3633 #[test]
3634 fn configure_firewall_is_high_risk() {
3635 let spec = build_action_spec(
3636 "ConfigureFirewall",
3637 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
3638 )
3639 .unwrap();
3640 assert_eq!(
3641 spec.risk_level,
3642 RiskLevel::High,
3643 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
3644 );
3645 }
3646
3647 #[test]
3648 fn mask_service_is_high_risk() {
3649 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
3650 assert_eq!(
3651 spec.risk_level,
3652 RiskLevel::High,
3653 "MaskService must be High (T1562.001 Impair Defenses)"
3654 );
3655 }
3656
3657 #[test]
3658 fn add_package_repository_is_high_risk() {
3659 let spec = build_action_spec(
3660 "AddPackageRepository",
3661 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
3662 )
3663 .unwrap();
3664 assert_eq!(
3665 spec.risk_level,
3666 RiskLevel::High,
3667 "AddPackageRepository must be High (supply-chain vector)"
3668 );
3669 }
3670
3671 #[test]
3672 fn set_dns_servers_is_high_risk() {
3673 let spec = build_action_spec(
3674 "SetDnsServers",
3675 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
3676 )
3677 .unwrap();
3678 assert_eq!(
3679 spec.risk_level,
3680 RiskLevel::High,
3681 "SetDnsServers must be High (DNS hijacking / T1557)"
3682 );
3683 }
3684}