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