1use serde_json::Value;
29use sysknife_types::{PreviewEnvelope, RequestEnvelope, RiskLevel};
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32struct PreviewProfile {
33 expected_side_effects: Vec<String>,
34 warnings: Vec<String>,
35}
36
37const APPROVAL_REQUIRED: &str = "approval required";
44const EXACT_APPROVAL_REQUIRED: &str = "exact approval required";
45
46pub fn preview_action(
47 request: &RequestEnvelope,
48 current_state: Value,
49 proposed_change: Value,
50) -> PreviewEnvelope {
51 let profile = preview_profile(&request.action_name);
52 let risk_level = gate_risk(&request.action_name);
59 let (reboot_required, rollback_available) = crate::actions::spec_meta(&request.action_name)
60 .map(|m| (m.reboot_required, m.rollback_available))
61 .unwrap_or_else(|| {
62 if request.action_name != "ListJobHistory" {
70 eprintln!(
71 "[sysknife-daemon] preview_action: no ActionSpec for {:?}; defaulting \
72 reboot_required=false, rollback_available=false (risk falls back to High)",
73 request.action_name
74 );
75 }
76 (false, false)
77 });
78
79 PreviewEnvelope {
80 summary: preview_summary(&request.action_name, &risk_level),
81 risk_level,
82 current_state,
83 proposed_change,
84 expected_side_effects: profile.expected_side_effects,
85 reboot_required,
86 rollback_available,
87 warnings: profile.warnings,
88 request_hash: request.request_hash.clone(),
89 }
90}
91
92fn fallback_risk(action_name: &str) -> RiskLevel {
97 match action_name {
98 "ListJobHistory" => RiskLevel::Low,
99 _ => RiskLevel::High,
100 }
101}
102
103pub fn gate_risk(action_name: &str) -> RiskLevel {
110 crate::actions::spec_meta(action_name)
111 .map(|m| m.risk_level)
112 .unwrap_or_else(|| fallback_risk(action_name))
113}
114
115fn preview_profile(action_name: &str) -> PreviewProfile {
116 match action_name {
117 "GetSystemState"
118 | "CollectDiagnostics"
119 | "GetDeploymentHistory"
120 | "ListDeployments"
121 | "GetKernelArguments"
122 | "GetPendingUpdates"
123 | "SearchFlatpakApps"
124 | "ListFlatpakRemotes"
125 | "ListInstalledFlatpaks"
126 | "GetFlatpakAppInfo"
127 | "ListToolboxes"
128 | "ListServices"
129 | "GetServiceLogs"
130 | "GetServiceStatus"
131 | "GetServiceResourceLimits"
132 | "ListTimers"
133 | "GetFirewallState"
134 | "ListUsers"
135 | "ListGroups"
136 | "ListPackageRepositories"
137 | "ListContainers"
138 | "GetContainerInfo"
139 | "GetLayeredPackages"
140 | "GetDiskUsage"
141 | "ListProcesses"
142 | "GetMemoryInfo"
143 | "GetNetworkStatus"
144 | "GetListeningPorts"
145 | "GetJournalLog"
146 | "GetLvmReport"
147 | "GetSysctl"
148 | "GetMounts"
149 | "GetSudoGrants"
150 | "GetLogrotateStatus"
151 | "GetPasswordAging"
152 | "GetAuditRules"
153 | "GetCertificates"
154 | "GetAuthorizedKeys"
155 | "GetDateTime"
156 | "ListJobHistory"
157 | "AptSearch"
159 | "AptListInstalled"
160 | "AptShow"
161 | "GetAptPins"
162 | "SnapList"
164 | "SnapInfo"
165 | "UfwStatus"
167 | "DistroboxList"
169 | "NetplanGetConfig"
171 | "ProStatus"
173 | "LivepatchStatus"
174 | "MultipassList"
175 | "AptListUpgradable"
177 | "AptHistoryList"
178 | "GrubGetKargs"
179 | "CheckPendingReboot"
180 | "ResolvectlStatus"
181 | "AppArmorStatus"
182 | "CloudInitStatus"
183 | "UbuntuListFlatpaks"
184 | "Fail2banStatus" => PreviewProfile {
185 expected_side_effects: Vec::new(),
186 warnings: Vec::new(),
187 },
188 "AptInstall" | "AptRemove" | "AptPurge" | "AptHold" | "AptUnhold" => PreviewProfile {
194 expected_side_effects: vec![
195 "package state will change".to_string(),
196 "services may be restarted by needrestart".to_string(),
197 ],
198 warnings: vec![APPROVAL_REQUIRED.to_string()],
199 },
200
201 "SnapInstall" | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" => {
203 PreviewProfile {
204 expected_side_effects: vec!["snap state will change".to_string()],
205 warnings: vec![
206 APPROVAL_REQUIRED.to_string(),
207 "snap auto-refresh: install is paired with --hold by default".to_string(),
208 ],
209 }
210 }
211
212 "DistroboxCreate" | "DistroboxRemove" => PreviewProfile {
214 expected_side_effects: vec!["container lifecycle will change".to_string()],
215 warnings: vec![APPROVAL_REQUIRED.to_string()],
216 },
217
218 "AptUpgrade" => PreviewProfile {
223 expected_side_effects: vec![
224 "all packages will be upgraded".to_string(),
225 "packages may be removed to resolve dependency conflicts".to_string(),
226 "services may be restarted by needrestart".to_string(),
227 ],
228 warnings: vec![
229 "dist-upgrade may remove packages".to_string(),
230 EXACT_APPROVAL_REQUIRED.to_string(),
231 ],
232 },
233
234 "UfwEnable" | "UfwDisable" | "UfwAllow" | "UfwDeny" | "UfwReset" => PreviewProfile {
236 expected_side_effects: vec![
237 "firewall rules will change".to_string(),
238 "network access may be immediately affected".to_string(),
239 ],
240 warnings: vec![
241 "misconfigured rules can lock out SSH access".to_string(),
242 EXACT_APPROVAL_REQUIRED.to_string(),
243 ],
244 },
245
246 "NetplanApply" => PreviewProfile {
248 expected_side_effects: vec![
249 "network interfaces will be reconfigured immediately".to_string(),
250 "SSH session may be disconnected".to_string(),
251 ],
252 warnings: vec![
253 "can disconnect the current SSH session if config is wrong".to_string(),
254 EXACT_APPROVAL_REQUIRED.to_string(),
255 ],
256 },
257
258 "NetplanGenerate" => PreviewProfile {
260 expected_side_effects: vec![
261 "backend network config files will be regenerated".to_string(),
262 ],
263 warnings: vec![APPROVAL_REQUIRED.to_string()],
264 },
265
266 "NetplanSet" => PreviewProfile {
268 expected_side_effects: vec![
269 "netplan configuration will be modified".to_string(),
270 "network may be affected when NetplanApply is run".to_string(),
271 ],
272 warnings: vec![
273 "run NetplanApply to activate the change".to_string(),
274 EXACT_APPROVAL_REQUIRED.to_string(),
275 ],
276 },
277
278 "UfwDeleteRule" | "UfwLimit" => PreviewProfile {
280 expected_side_effects: vec![
281 "firewall rules will change".to_string(),
282 "network access may be immediately affected".to_string(),
283 ],
284 warnings: vec![
285 "misconfigured rules can lock out SSH access".to_string(),
286 EXACT_APPROVAL_REQUIRED.to_string(),
287 ],
288 },
289
290 "ProAttach" => PreviewProfile {
292 expected_side_effects: vec![
293 "Ubuntu Pro subscription will be attached".to_string(),
294 "Pro services (ESM, Livepatch, FIPS) may be enabled".to_string(),
295 ],
296 warnings: vec![
297 EXACT_APPROVAL_REQUIRED.to_string(),
298 "token is redacted from the preview, audit log, and diagnostic output".to_string(),
299 ],
300 },
301
302 "ProDetach" => PreviewProfile {
304 expected_side_effects: vec![
305 "Ubuntu Pro subscription will be released".to_string(),
306 "ESM, Livepatch, and FIPS services will be disabled".to_string(),
307 ],
308 warnings: vec![
309 EXACT_APPROVAL_REQUIRED.to_string(),
310 "after detach, this machine no longer receives Pro security patches".to_string(),
311 ],
312 },
313
314 "AddAuditRule" | "RemoveAuditRule" => PreviewProfile {
316 expected_side_effects: vec![
317 "a persistent audit rule will be written/removed and reloaded".to_string(),
318 ],
319 warnings: vec![
320 "requires the auditd package installed to take effect".to_string(),
321 EXACT_APPROVAL_REQUIRED.to_string(),
322 ],
323 },
324
325 "ObtainCertificate" => PreviewProfile {
327 expected_side_effects: vec![
328 "certbot will obtain a TLS certificate (ACME challenge)".to_string(),
329 "TLS material will be written under /etc/letsencrypt".to_string(),
330 ],
331 warnings: vec![
332 "contacts a public ACME CA over the network".to_string(),
333 "requires certbot installed + a reachable DNS/HTTP challenge".to_string(),
334 EXACT_APPROVAL_REQUIRED.to_string(),
335 ],
336 },
337 "RenewCertificates" => PreviewProfile {
338 expected_side_effects: vec!["certbot will renew due certificates".to_string()],
339 warnings: vec![
340 "contacts a public ACME CA over the network".to_string(),
341 EXACT_APPROVAL_REQUIRED.to_string(),
342 ],
343 },
344
345 "ConfigureFail2banJail" => PreviewProfile {
347 expected_side_effects: vec![
348 "a fail2ban jail override will be written and the daemon reloaded".to_string(),
349 ],
350 warnings: vec![
351 "changes who gets banned (too strict can lock out real users)".to_string(),
352 "requires the fail2ban package installed to take effect".to_string(),
353 EXACT_APPROVAL_REQUIRED.to_string(),
354 ],
355 },
356
357 "EnableProService" | "DisableProService" => PreviewProfile {
359 expected_side_effects: vec![
360 "a single Ubuntu Pro service will be enabled/disabled".to_string(),
361 ],
362 warnings: vec![
363 EXACT_APPROVAL_REQUIRED.to_string(),
364 "requires an attached Pro subscription + network to take effect".to_string(),
365 ],
366 },
367
368 "UbuntuReleaseUpgrade" => PreviewProfile {
370 expected_side_effects: vec![
371 "entire OS will be upgraded to the next Ubuntu release".to_string(),
372 "takes 20–45 minutes; system will be rebooted to complete".to_string(),
373 "third-party PPAs may be disabled or break during upgrade".to_string(),
374 ],
375 warnings: vec![
376 "reboot required to complete the upgrade".to_string(),
377 "long-running operation — configure timeout >= 3600 seconds".to_string(),
378 EXACT_APPROVAL_REQUIRED.to_string(),
379 ],
380 },
381
382 "ReloadService" => PreviewProfile {
383 expected_side_effects: vec!["service config will be reloaded".to_string()],
384 warnings: vec![
385 APPROVAL_REQUIRED.to_string(),
386 "requires ExecReload= to be defined in the unit file; \
387 if not defined, use RestartService instead"
388 .to_string(),
389 ],
390 },
391 "RestartService"
392 | "ReloadDaemon"
393 | "SetServiceEnabled"
394 | "StartService"
395 | "StopService"
396 | "ConfigureWifi"
397 | "SetDnsServers"
398 | "ConfigureFirewall"
399 | "CreateToolbox"
400 | "RemoveToolbox"
401 | "InstallFlatpak"
402 | "RemoveFlatpak"
403 | "UpdateFlatpak"
404 | "AddFlatpakRemote"
405 | "RemoveFlatpakRemote"
406 | "MaskService"
407 | "UnmaskService"
408 | "SetHostname"
409 | "SetTimezone"
410 | "SetLocale"
411 | "SetNtp"
412 | "CreateUser" => PreviewProfile {
413 expected_side_effects: vec!["service interruption".to_string()],
414 warnings: vec![APPROVAL_REQUIRED.to_string()],
415 },
416 "VacuumJournal" => PreviewProfile {
418 expected_side_effects: vec![
419 "old journal entries will be permanently deleted".to_string(),
420 "disk space will be reclaimed".to_string(),
421 ],
422 warnings: vec!["deleted log history cannot be recovered".to_string()],
423 },
424
425 "ExtendLogicalVolume" => PreviewProfile {
427 expected_side_effects: vec![
428 "the logical volume and its filesystem will be grown".to_string(),
429 ],
430 warnings: vec![
431 "resizes a live filesystem; a wrong volume target risks data".to_string(),
432 EXACT_APPROVAL_REQUIRED.to_string(),
433 ],
434 },
435 "CreateLogicalVolume" | "CreateLvSnapshot" => PreviewProfile {
436 expected_side_effects: vec![
437 "volume-group free space will be consumed".to_string(),
438 ],
439 warnings: vec![
440 "consumes VG capacity; snapshots fill as the origin changes".to_string(),
441 EXACT_APPROVAL_REQUIRED.to_string(),
442 ],
443 },
444
445 "SetServiceResourceLimits" => PreviewProfile {
447 expected_side_effects: vec![
448 "the service's memory/CPU/task limits will change immediately".to_string(),
449 "a persistent drop-in will be written (undo: systemctl revert)".to_string(),
450 ],
451 warnings: vec![
452 "too tight a MemoryMax can OOM-kill the service".to_string(),
453 EXACT_APPROVAL_REQUIRED.to_string(),
454 ],
455 },
456
457 "ConfigureLogRotation" | "RemoveLogRotation" => PreviewProfile {
459 expected_side_effects: vec![
460 "a logrotate drop-in will be written/removed".to_string(),
461 ],
462 warnings: vec![APPROVAL_REQUIRED.to_string()],
463 },
464 "ConfigureRemoteSyslog" | "RemoveRemoteSyslog" => PreviewProfile {
465 expected_side_effects: vec![
466 "rsyslog will start/stop forwarding all logs to a remote host".to_string(),
467 "rsyslog will be restarted".to_string(),
468 ],
469 warnings: vec![
470 "forwarding sends log data off-host (exfiltration surface)".to_string(),
471 EXACT_APPROVAL_REQUIRED.to_string(),
472 ],
473 },
474
475 "SetPasswordAging" => PreviewProfile {
477 expected_side_effects: vec![
478 "the target account's password-aging limits will change".to_string(),
479 ],
480 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
481 },
482 "SetPasswordPolicy" | "SetAccountLockout" => PreviewProfile {
483 expected_side_effects: vec![
484 "a system-wide PAM policy file will be written".to_string(),
485 ],
486 warnings: vec![
487 "affects password/lockout rules for all accounts".to_string(),
488 "takes effect only if the PAM module is enabled in the auth stack".to_string(),
489 EXACT_APPROVAL_REQUIRED.to_string(),
490 ],
491 },
492
493 "SetAptPin" | "RemoveAptPin" => PreviewProfile {
495 expected_side_effects: vec![
496 "apt version/origin preferences will change".to_string(),
497 "a /etc/apt/preferences.d drop-in will be written/removed".to_string(),
498 ],
499 warnings: vec![APPROVAL_REQUIRED.to_string()],
500 },
501
502 "GrantSudoAccess" | "RevokeSudoAccess" => PreviewProfile {
504 expected_side_effects: vec![
505 "a sudoers.d drop-in will be created/removed".to_string(),
506 "the target user's sudo privileges will change".to_string(),
507 ],
508 warnings: vec![
509 "this configures privilege escalation — review the rule carefully".to_string(),
510 EXACT_APPROVAL_REQUIRED.to_string(),
511 ],
512 },
513
514 "AddMount" | "RemoveMount" => PreviewProfile {
516 expected_side_effects: vec![
517 "a filesystem will be (un)mounted".to_string(),
518 "/etc/fstab will be updated (managed entries carry nofail)".to_string(),
519 ],
520 warnings: vec![
521 "a wrong device or mountpoint risks data or availability".to_string(),
522 EXACT_APPROVAL_REQUIRED.to_string(),
523 ],
524 },
525 "AddSwap" | "RemoveSwap" => PreviewProfile {
526 expected_side_effects: vec![
527 "swap will be enabled/disabled".to_string(),
528 "a swap file will be created/removed and /etc/fstab updated".to_string(),
529 ],
530 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
531 },
532
533 "SetSysctl" => PreviewProfile {
535 expected_side_effects: vec![
536 "a kernel parameter will change immediately".to_string(),
537 "the value will persist across reboots (/etc/sysctl.d)".to_string(),
538 ],
539 warnings: vec![
540 "a wrong net.*/vm.*/kernel.* value can degrade or lock the host".to_string(),
541 EXACT_APPROVAL_REQUIRED.to_string(),
542 ],
543 },
544
545 "SignalProcess" => PreviewProfile {
546 expected_side_effects: vec!["the target process will be terminated".to_string()],
547 warnings: vec![
548 "the process and its in-flight work will stop".to_string(),
549 EXACT_APPROVAL_REQUIRED.to_string(),
550 ],
551 },
552 "ConfigureUnattendedUpgrades" => PreviewProfile {
553 expected_side_effects: vec![
554 "the automatic security-update policy will change".to_string()
555 ],
556 warnings: vec![APPROVAL_REQUIRED.to_string()],
557 },
558 "CreateScheduledJob" => PreviewProfile {
559 expected_side_effects: vec![
560 "a systemd timer will run the command on a recurring schedule".to_string(),
561 ],
562 warnings: vec![
563 "persistent root-scheduled execution".to_string(),
564 EXACT_APPROVAL_REQUIRED.to_string(),
565 ],
566 },
567 "AddUserToGroup"
568 | "RemoveUserFromGroup"
569 | "CreateGroup"
570 | "DeleteGroup"
571 | "LockUserAccount"
572 | "UnlockUserAccount"
573 | "SetSshdOption"
574 | "DeleteUser"
575 | "AddAuthorizedKey"
576 | "RemoveAuthorizedKey" => PreviewProfile {
577 expected_side_effects: vec!["access control will change".to_string()],
580 warnings: vec![
581 "privilege change".to_string(),
582 EXACT_APPROVAL_REQUIRED.to_string(),
583 ],
584 },
585 "AddPackageRepository"
586 | "RemovePackageRepository"
587 | "EnablePackageRepository"
588 | "DisablePackageRepository" => PreviewProfile {
589 expected_side_effects: vec!["package repository configuration will change".to_string()],
590 warnings: vec![APPROVAL_REQUIRED.to_string()],
591 },
592 "CreateContainer" | "StartContainer" | "StopContainer" | "RemoveContainer" => {
593 PreviewProfile {
594 expected_side_effects: vec!["container lifecycle will change".to_string()],
595 warnings: vec![APPROVAL_REQUIRED.to_string()],
596 }
597 }
598 "UpdateSystem"
599 | "InstallPackages"
600 | "RemovePackages"
601 | "RebaseSystem"
602 | "RollbackDeployment"
603 | "AddLayeredPackage"
604 | "RemoveLayeredPackage"
605 | "ReplaceLayeredPackage"
606 | "ResetLayeredPackageOverride"
607 | "RemoveBasePackage" => PreviewProfile {
608 expected_side_effects: vec![
609 "system deployment will change".to_string(),
610 "reboot may be required".to_string(),
611 ],
612 warnings: vec![
613 "reboot required".to_string(),
614 EXACT_APPROVAL_REQUIRED.to_string(),
615 ],
616 },
617 "SetKernelArguments" => PreviewProfile {
618 expected_side_effects: vec!["boot arguments will change".to_string()],
619 warnings: vec![
620 "reboot required".to_string(),
621 EXACT_APPROVAL_REQUIRED.to_string(),
622 ],
623 },
624 "RebootSystem" => PreviewProfile {
625 expected_side_effects: vec!["system reboot will interrupt running work".to_string()],
626 warnings: vec![
627 "reboot required".to_string(),
628 EXACT_APPROVAL_REQUIRED.to_string(),
629 ],
630 },
631 "CleanupDeployments" => PreviewProfile {
632 expected_side_effects: vec!["old deployments may be removed".to_string()],
633 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
634 },
635 "PinDeployment" | "UnpinDeployment" => PreviewProfile {
636 expected_side_effects: vec!["deployment pin state will change".to_string()],
637 warnings: vec![EXACT_APPROVAL_REQUIRED.to_string()],
638 },
639 "AptUpdate" => PreviewProfile {
641 expected_side_effects: vec!["the apt package index will be refreshed".to_string()],
642 warnings: Vec::new(),
643 },
644 "AptAutoremove" => PreviewProfile {
645 expected_side_effects: vec![
646 "automatically-installed packages no longer needed will be removed".to_string(),
647 ],
648 warnings: vec![APPROVAL_REQUIRED.to_string()],
649 },
650
651 "AddPpa" => PreviewProfile {
653 expected_side_effects: vec![
654 "a Launchpad PPA and its signing key will be added".to_string(),
655 "the trusted software supply chain will expand".to_string(),
656 ],
657 warnings: vec![
658 "a PPA is a third-party package source outside the distro archive".to_string(),
659 EXACT_APPROVAL_REQUIRED.to_string(),
660 ],
661 },
662 "RemovePpa" => PreviewProfile {
663 expected_side_effects: vec!["a Launchpad PPA will be removed".to_string()],
664 warnings: vec![APPROVAL_REQUIRED.to_string()],
665 },
666
667 "SnapRevert" => PreviewProfile {
669 expected_side_effects: vec![
670 "the snap will be reverted to its previous revision".to_string(),
671 ],
672 warnings: vec![APPROVAL_REQUIRED.to_string()],
673 },
674 "SnapClassicInstall" => PreviewProfile {
675 expected_side_effects: vec![
676 "a snap will be installed with classic confinement".to_string(),
677 ],
678 warnings: vec![
679 "classic confinement grants the snap full, unsandboxed system access".to_string(),
680 APPROVAL_REQUIRED.to_string(),
681 ],
682 },
683
684 "GrubSetKargs" => PreviewProfile {
686 expected_side_effects: vec![
687 "GRUB kernel arguments will change".to_string(),
688 "update-grub will regenerate the boot configuration".to_string(),
689 ],
690 warnings: vec![
691 "takes effect on the next reboot".to_string(),
692 "a bad kernel argument can prevent the system from booting".to_string(),
693 EXACT_APPROVAL_REQUIRED.to_string(),
694 ],
695 },
696
697 "ResolvectlSetDns" => PreviewProfile {
699 expected_side_effects: vec!["DNS servers for the interface will change".to_string()],
700 warnings: vec![
701 "redirecting DNS can enable interception of name resolution".to_string(),
702 EXACT_APPROVAL_REQUIRED.to_string(),
703 ],
704 },
705
706 "AppArmorEnforce" => PreviewProfile {
708 expected_side_effects: vec![
709 "an AppArmor profile will be switched to enforce mode".to_string(),
710 ],
711 warnings: vec![
712 "an over-tight profile can block a legitimate service".to_string(),
713 EXACT_APPROVAL_REQUIRED.to_string(),
714 ],
715 },
716 "AppArmorComplain" => PreviewProfile {
717 expected_side_effects: vec![
718 "an AppArmor profile will be switched to complain mode".to_string(),
719 ],
720 warnings: vec![
721 "complain mode disables MAC enforcement for the profile".to_string(),
722 EXACT_APPROVAL_REQUIRED.to_string(),
723 ],
724 },
725
726 "Fail2banBanIp" => PreviewProfile {
728 expected_side_effects: vec!["an IP address will be banned in a fail2ban jail".to_string()],
729 warnings: vec![
730 "banning can lock out legitimate access from that address".to_string(),
731 EXACT_APPROVAL_REQUIRED.to_string(),
732 ],
733 },
734 "Fail2banUnbanIp" => PreviewProfile {
735 expected_side_effects: vec![
736 "an IP address will be unbanned in a fail2ban jail".to_string(),
737 ],
738 warnings: vec![APPROVAL_REQUIRED.to_string()],
739 },
740
741 "UbuntuInstallFlatpak" | "UbuntuRemoveFlatpak" | "UbuntuUpdateFlatpak" => PreviewProfile {
743 expected_side_effects: vec!["a user's Flatpak apps will change".to_string()],
744 warnings: vec![APPROVAL_REQUIRED.to_string()],
745 },
746
747 _ => PreviewProfile {
748 expected_side_effects: vec!["unclassified action".to_string()],
749 warnings: vec!["action profile not recognized".to_string()],
750 },
751 }
752}
753
754fn preview_summary(action_name: &str, risk_level: &RiskLevel) -> String {
755 let risk = match risk_level {
756 RiskLevel::Low => "low-risk",
757 RiskLevel::Medium => "medium-risk",
758 RiskLevel::High => "high-risk",
759 };
760
761 format!("{action_name} preview ({risk})")
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use sysknife_types::{CallerRole, RiskLevel};
768
769 fn req(action: &str) -> RequestEnvelope {
770 RequestEnvelope {
771 action_name: action.to_string(),
772 request_id: "test-req".to_string(),
773 params: serde_json::Value::Null,
774 caller_role: CallerRole::Dev,
775 request_hash: sysknife_types::RequestHash::new("hash".to_string()),
776 }
777 }
778
779 #[test]
780 fn all_low_risk_actions() {
781 let actions = [
782 "GetSystemState",
783 "CollectDiagnostics",
784 "GetDeploymentHistory",
785 "ListDeployments",
786 "GetKernelArguments",
787 "GetPendingUpdates",
788 "SearchFlatpakApps",
789 "ListFlatpakRemotes",
790 "ListInstalledFlatpaks",
791 "GetFlatpakAppInfo",
792 "ListToolboxes",
793 "ListServices",
794 "GetServiceLogs",
795 "GetServiceStatus",
796 "ListTimers",
797 "GetFirewallState",
798 "ListUsers",
799 "ListGroups",
800 "ListPackageRepositories",
801 "ListContainers",
802 "GetContainerInfo",
803 "GetLayeredPackages",
804 "GetDiskUsage",
805 "ListProcesses",
806 "GetMemoryInfo",
807 "GetNetworkStatus",
808 "GetAuthorizedKeys",
809 "GetDateTime",
810 "ListJobHistory",
811 ];
812
813 for action in &actions {
814 let envelope = preview_action(
815 &req(action),
816 serde_json::Value::Null,
817 serde_json::Value::Null,
818 );
819 assert_eq!(
820 envelope.risk_level,
821 RiskLevel::Low,
822 "{action} should be Low risk"
823 );
824 assert!(
825 !envelope.reboot_required,
826 "{action} should not require reboot"
827 );
828 assert!(
829 !envelope.rollback_available,
830 "{action} should not have rollback available"
831 );
832 }
833 }
834
835 #[test]
836 fn reload_service_is_medium_risk() {
837 let envelope = preview_action(
838 &req("ReloadService"),
839 serde_json::Value::Null,
840 serde_json::Value::Null,
841 );
842 assert_eq!(envelope.risk_level, RiskLevel::Medium);
843 assert_eq!(envelope.warnings.len(), 2);
844 assert!(!envelope.reboot_required);
845 assert!(!envelope.rollback_available);
846 }
847
848 #[test]
849 fn medium_risk_actions() {
850 let actions = [
851 "RestartService",
852 "StopService",
853 "StartService",
854 "SetHostname",
855 "SetTimezone",
856 "SetLocale",
857 "SetNtp",
858 "InstallFlatpak",
859 "RemoveFlatpak",
860 "CreateToolbox",
861 "RemoveToolbox",
862 "CreateContainer",
863 "StartContainer",
864 "StopContainer",
865 "RemoveContainer",
866 "RemovePackageRepository",
867 ];
868
869 for action in &actions {
870 let envelope = preview_action(
871 &req(action),
872 serde_json::Value::Null,
873 serde_json::Value::Null,
874 );
875 assert_eq!(
876 envelope.risk_level,
877 RiskLevel::Medium,
878 "{action} should be Medium risk"
879 );
880 }
881 }
882
883 #[test]
884 fn high_risk_access_control_actions() {
885 let actions = [
886 "AddUserToGroup",
887 "RemoveUserFromGroup",
888 "DeleteUser",
889 "AddAuthorizedKey",
890 "RemoveAuthorizedKey",
891 ];
892
893 for action in &actions {
894 let envelope = preview_action(
895 &req(action),
896 serde_json::Value::Null,
897 serde_json::Value::Null,
898 );
899 assert_eq!(
900 envelope.risk_level,
901 RiskLevel::High,
902 "{action} should be High risk"
903 );
904 assert!(
905 envelope
906 .expected_side_effects
907 .iter()
908 .any(|e| e.contains("access control will change")),
909 "{action} should have 'access control will change' in expected_side_effects"
910 );
911 }
912 }
913
914 #[test]
915 fn high_risk_system_reboot_actions() {
916 let actions = [
921 "UpdateSystem",
922 "InstallPackages",
923 "RemovePackages",
924 "RebaseSystem",
925 "RollbackDeployment",
926 "AddLayeredPackage",
927 "RemoveLayeredPackage",
928 "SetKernelArguments",
929 ];
930
931 for action in &actions {
932 let envelope = preview_action(
933 &req(action),
934 serde_json::Value::Null,
935 serde_json::Value::Null,
936 );
937 assert_eq!(
938 envelope.risk_level,
939 RiskLevel::High,
940 "{action} should be High risk"
941 );
942 assert!(envelope.reboot_required, "{action} should require reboot");
943 let expect_rollback = *action != "RollbackDeployment";
944 assert_eq!(
945 envelope.rollback_available, expect_rollback,
946 "{action} rollback_available should be {expect_rollback}"
947 );
948 }
949 }
950
951 #[test]
952 fn reboot_system_no_rollback() {
953 let envelope = preview_action(
954 &req("RebootSystem"),
955 serde_json::Value::Null,
956 serde_json::Value::Null,
957 );
958 assert_eq!(envelope.risk_level, RiskLevel::High);
959 assert!(envelope.reboot_required);
960 assert!(!envelope.rollback_available);
961 }
962
963 #[test]
964 fn cleanup_deployments_is_high_no_reboot() {
965 let envelope = preview_action(
966 &req("CleanupDeployments"),
967 serde_json::Value::Null,
968 serde_json::Value::Null,
969 );
970 assert_eq!(envelope.risk_level, RiskLevel::High);
971 assert!(!envelope.reboot_required);
972 }
973
974 #[test]
975 fn pin_unpin_deployment_are_high() {
976 for action in &["PinDeployment", "UnpinDeployment"] {
977 let envelope = preview_action(
978 &req(action),
979 serde_json::Value::Null,
980 serde_json::Value::Null,
981 );
982 assert_eq!(
983 envelope.risk_level,
984 RiskLevel::High,
985 "{action} should be High risk"
986 );
987 }
988 }
989
990 #[test]
991 fn unknown_action_defaults_to_high() {
992 let envelope = preview_action(
993 &req("DefinitelyNotRealAction"),
994 serde_json::Value::Null,
995 serde_json::Value::Null,
996 );
997 assert_eq!(envelope.risk_level, RiskLevel::High);
998 assert!(
999 envelope
1000 .warnings
1001 .iter()
1002 .any(|w| w.contains("action profile not recognized")),
1003 "warnings should contain 'action profile not recognized'"
1004 );
1005 assert!(
1006 envelope
1007 .expected_side_effects
1008 .iter()
1009 .any(|e| e.contains("unclassified action")),
1010 "expected_side_effects should contain 'unclassified action'"
1011 );
1012 }
1013
1014 #[test]
1019 fn apparmor_enforce_and_complain_profiles_are_not_swapped() {
1020 let enforce = preview_action(
1021 &req("AppArmorEnforce"),
1022 serde_json::Value::Null,
1023 serde_json::Value::Null,
1024 );
1025 let complain = preview_action(
1026 &req("AppArmorComplain"),
1027 serde_json::Value::Null,
1028 serde_json::Value::Null,
1029 );
1030 let se = |e: &sysknife_types::PreviewEnvelope| e.expected_side_effects.join(" ");
1034 assert!(se(&enforce).contains("enforce mode") && !se(&enforce).contains("complain"));
1035 assert!(se(&complain).contains("complain mode") && !se(&complain).contains("enforce mode"));
1036 }
1037
1038 #[test]
1039 fn fail2ban_ban_and_unban_profiles_are_not_swapped() {
1040 let ban = preview_action(
1041 &req("Fail2banBanIp"),
1042 serde_json::Value::Null,
1043 serde_json::Value::Null,
1044 );
1045 let unban = preview_action(
1046 &req("Fail2banUnbanIp"),
1047 serde_json::Value::Null,
1048 serde_json::Value::Null,
1049 );
1050 assert!(ban
1051 .expected_side_effects
1052 .iter()
1053 .any(|e| e.contains("will be banned")));
1054 assert!(unban
1055 .expected_side_effects
1056 .iter()
1057 .any(|e| e.contains("will be unbanned")));
1058 }
1059
1060 #[test]
1061 fn preview_action_summary_format() {
1062 let envelope = preview_action(
1063 &req("GetDiskUsage"),
1064 serde_json::Value::Null,
1065 serde_json::Value::Null,
1066 );
1067 assert_eq!(envelope.summary, "GetDiskUsage preview (low-risk)");
1068 }
1069
1070 #[test]
1071 fn preview_action_passes_current_and_proposed_state() {
1072 let current = serde_json::json!({"disk": "80%"});
1073 let proposed = serde_json::json!({"action": "cleanup"});
1074 let envelope = preview_action(
1075 &req("CleanupDeployments"),
1076 current.clone(),
1077 proposed.clone(),
1078 );
1079 assert_eq!(envelope.current_state, current);
1080 assert_eq!(envelope.proposed_change, proposed);
1081 }
1082
1083 #[test]
1084 fn preview_action_preserves_request_hash() {
1085 let mut r = req("GetDiskUsage");
1086 r.request_hash = sysknife_types::RequestHash::new("deadbeef".to_string());
1087 let envelope = preview_action(&r, serde_json::Value::Null, serde_json::Value::Null);
1088 assert_eq!(envelope.request_hash.as_str(), "deadbeef");
1089 }
1090}