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