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