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