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