Skip to main content

sysknife_daemon/
preview.rs

1//! Preview-time presentation profile for every action the daemon recognises.
2//!
3//! The dispatcher calls [`preview_action`] *before* execution to produce a
4//! [`PreviewEnvelope`] the shell shows the operator.
5//!
6//! ## Risk is a single source of truth
7//!
8//! `risk_level` is **not** declared here — it is owned by each action's
9//! `ActionSpec` (`crate::actions`) and derived via [`crate::actions::spec_meta`],
10//! so the approval gate can never disagree with the documented risk in
11//! `docs/action-reference.md`. This module supplies the preview-specific fields:
12//! expected side effects, warnings, and the reboot/rollback display flags.
13//! (`reboot_required` / `rollback_available` are still declared here pending a
14//! separate consolidation onto the spec — see the follow-up note in
15//! `tests/action_consistency.rs`.)
16//!
17//! ## Invariant: spec ↔ preview ↔ policy consistency
18//!
19//! `policy::min_role_for_action` mirrors the spec risk via
20//! `policy::role_for_risk_level`, except a documented, *monotonic* exception
21//! list (an exception may only raise the role above the risk floor, never lower
22//! it). The cross-module tests in `tests/action_consistency.rs` pin
23//! `preview.risk == spec.risk` and `role == role_for_risk_level(risk)` (± the
24//! exception list) for every action, so these tables cannot silently drift.
25//!
26//! [`CallerRole`]: sysknife_types::CallerRole
27
28use 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    reboot_required: bool,
35    rollback_available: bool,
36    warnings: Vec<String>,
37}
38
39pub fn preview_action(
40    request: &RequestEnvelope,
41    current_state: Value,
42    proposed_change: Value,
43) -> PreviewEnvelope {
44    let profile = preview_profile(&request.action_name);
45    // `risk_level` is OWNED by the action's `ActionSpec` (single source of truth,
46    // `crate::actions`); derive it here so the approval gate can never disagree
47    // with the documented risk. Only the dispatcher-internal `ListJobHistory`
48    // reaches preview without a spec (see `fallback_risk`).
49    let risk_level = crate::actions::spec_meta(&request.action_name)
50        .map(|m| m.risk_level)
51        .unwrap_or_else(|| fallback_risk(&request.action_name));
52
53    PreviewEnvelope {
54        summary: preview_summary(&request.action_name, &risk_level),
55        risk_level,
56        current_state,
57        proposed_change,
58        expected_side_effects: profile.expected_side_effects,
59        reboot_required: profile.reboot_required,
60        rollback_available: profile.rollback_available,
61        warnings: profile.warnings,
62        request_hash: request.request_hash.clone(),
63    }
64}
65
66/// Conservative risk for the rare action that reaches preview without an
67/// `ActionSpec`. Only `ListJobHistory` (dispatcher-internal, read-only) does so;
68/// anything else unknown is High, so a missing spec can never silently downgrade
69/// the approval gate.
70fn fallback_risk(action_name: &str) -> RiskLevel {
71    match action_name {
72        "ListJobHistory" => RiskLevel::Low,
73        _ => RiskLevel::High,
74    }
75}
76
77fn preview_profile(action_name: &str) -> PreviewProfile {
78    match action_name {
79        "GetSystemState"
80        | "CollectDiagnostics"
81        | "GetDeploymentHistory"
82        | "ListDeployments"
83        | "GetKernelArguments"
84        | "GetPendingUpdates"
85        | "SearchFlatpakApps"
86        | "ListFlatpakRemotes"
87        | "ListInstalledFlatpaks"
88        | "GetFlatpakAppInfo"
89        | "ListToolboxes"
90        | "ListServices"
91        | "GetServiceLogs"
92        | "GetServiceStatus"
93        | "GetServiceResourceLimits"
94        | "ListTimers"
95        | "GetFirewallState"
96        | "ListUsers"
97        | "ListGroups"
98        | "ListPackageRepositories"
99        | "ListContainers"
100        | "GetContainerInfo"
101        | "GetLayeredPackages"
102        | "GetDiskUsage"
103        | "ListProcesses"
104        | "GetMemoryInfo"
105        | "GetNetworkStatus"
106        | "GetListeningPorts"
107        | "GetJournalLog"
108        | "GetLvmReport"
109        | "GetSysctl"
110        | "GetMounts"
111        | "GetSudoGrants"
112        | "GetLogrotateStatus"
113        | "GetPasswordAging"
114        | "GetAuditRules"
115        | "GetCertificates"
116        | "GetAuthorizedKeys"
117        | "GetDateTime"
118        | "ListJobHistory"
119        // Ubuntu apt read-only
120        | "AptSearch"
121        | "AptListInstalled"
122        | "AptShow"
123        | "GetAptPins"
124        // Ubuntu snap read-only
125        | "SnapList"
126        | "SnapInfo"
127        // Ubuntu ufw read-only
128        | "UfwStatus"
129        // Ubuntu distrobox read-only
130        | "DistroboxList"
131        // Ubuntu netplan read-only
132        | "NetplanGetConfig"
133        // Ubuntu Pro / Livepatch / Multipass read-only (Tier 3)
134        | "ProStatus"
135        | "LivepatchStatus"
136        | "MultipassList" => PreviewProfile {
137            expected_side_effects: Vec::new(),
138            reboot_required: false,
139            rollback_available: false,
140            warnings: Vec::new(),
141        },
142        // ── Ubuntu apt: package-state changes ─────────────────────────────
143        //
144        // These ops change installed packages — reversible but not atomic, and
145        // may trigger service restarts via needrestart. (Risk is owned by each
146        // action's ActionSpec; this arm only supplies preview side effects.)
147        "AptInstall" | "AptRemove" | "AptPurge" | "AptHold" | "AptUnhold" => PreviewProfile {
148            expected_side_effects: vec![
149                "package state will change".to_string(),
150                "services may be restarted by needrestart".to_string(),
151            ],
152            reboot_required: false,
153            rollback_available: false,
154            warnings: vec!["approval required".to_string()],
155        },
156
157        // ── Ubuntu snap medium-risk ───────────────────────────────────────
158        "SnapInstall" | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" => {
159            PreviewProfile {
160                expected_side_effects: vec!["snap state will change".to_string()],
161                reboot_required: false,
162                rollback_available: false,
163                warnings: vec![
164                    "approval required".to_string(),
165                    "snap auto-refresh: install is paired with --hold by default".to_string(),
166                ],
167            }
168        }
169
170        // ── Ubuntu distrobox medium-risk ──────────────────────────────────
171        "DistroboxCreate" | "DistroboxRemove" => PreviewProfile {
172            expected_side_effects: vec!["container lifecycle will change".to_string()],
173            reboot_required: false,
174            rollback_available: false,
175            warnings: vec!["approval required".to_string()],
176        },
177
178        // ── Ubuntu apt high-risk ──────────────────────────────────────────
179        //
180        // AptUpgrade uses dist-upgrade which can remove packages to resolve
181        // dependency conflicts, and triggers needrestart service restarts.
182        "AptUpgrade" => PreviewProfile {
183            expected_side_effects: vec![
184                "all packages will be upgraded".to_string(),
185                "packages may be removed to resolve dependency conflicts".to_string(),
186                "services may be restarted by needrestart".to_string(),
187            ],
188            reboot_required: false,
189            rollback_available: false,
190            warnings: vec![
191                "dist-upgrade may remove packages".to_string(),
192                "exact approval required".to_string(),
193            ],
194        },
195
196        // ── Ubuntu ufw high-risk ──────────────────────────────────────────
197        "UfwEnable" | "UfwDisable" | "UfwAllow" | "UfwDeny" | "UfwReset" => PreviewProfile {
198            expected_side_effects: vec![
199                "firewall rules will change".to_string(),
200                "network access may be immediately affected".to_string(),
201            ],
202            reboot_required: false,
203            rollback_available: false,
204            warnings: vec![
205                "misconfigured rules can lock out SSH access".to_string(),
206                "exact approval required".to_string(),
207            ],
208        },
209
210        // ── Ubuntu netplan high-risk ──────────────────────────────────────
211        "NetplanApply" => PreviewProfile {
212            expected_side_effects: vec![
213                "network interfaces will be reconfigured immediately".to_string(),
214                "SSH session may be disconnected".to_string(),
215            ],
216            reboot_required: false,
217            rollback_available: false,
218            warnings: vec![
219                "can disconnect the current SSH session if config is wrong".to_string(),
220                "exact approval required".to_string(),
221            ],
222        },
223
224        // ── Ubuntu netplan medium-risk (Tier 3) ───────────────────────────
225        "NetplanGenerate" => PreviewProfile {
226            expected_side_effects: vec![
227                "backend network config files will be regenerated".to_string(),
228            ],
229            reboot_required: false,
230            rollback_available: false,
231            warnings: vec!["approval required".to_string()],
232        },
233
234        // ── Ubuntu netplan high-risk (Tier 3) ─────────────────────────────
235        "NetplanSet" => PreviewProfile {
236            expected_side_effects: vec![
237                "netplan configuration will be modified".to_string(),
238                "network may be affected when NetplanApply is run".to_string(),
239            ],
240            reboot_required: false,
241            rollback_available: true,
242            warnings: vec![
243                "run NetplanApply to activate the change".to_string(),
244                "exact approval required".to_string(),
245            ],
246        },
247
248        // ── Ubuntu ufw Tier 3 high-risk ───────────────────────────────────
249        "UfwDeleteRule" | "UfwLimit" => PreviewProfile {
250            expected_side_effects: vec![
251                "firewall rules will change".to_string(),
252                "network access may be immediately affected".to_string(),
253            ],
254            reboot_required: false,
255            rollback_available: false,
256            warnings: vec![
257                "misconfigured rules can lock out SSH access".to_string(),
258                "exact approval required".to_string(),
259            ],
260        },
261
262        // ── Ubuntu Pro attach (Tier 3 high-risk; carries token) ──────────
263        "ProAttach" => PreviewProfile {
264            expected_side_effects: vec![
265                "Ubuntu Pro subscription will be attached".to_string(),
266                "Pro services (ESM, Livepatch, FIPS) may be enabled".to_string(),
267            ],
268            reboot_required: false,
269            rollback_available: true,
270            warnings: vec![
271                "exact approval required".to_string(),
272                "token is redacted from the preview, audit log, and diagnostic output".to_string(),
273            ],
274        },
275
276        // ── Ubuntu Pro detach (Tier 3 high-risk; no credential param) ───
277        "ProDetach" => PreviewProfile {
278            expected_side_effects: vec![
279                "Ubuntu Pro subscription will be released".to_string(),
280                "ESM, Livepatch, and FIPS services will be disabled".to_string(),
281            ],
282            reboot_required: false,
283            rollback_available: true,
284            warnings: vec![
285                "exact approval required".to_string(),
286                "after detach, this machine no longer receives Pro security patches".to_string(),
287            ],
288        },
289
290        // ── auditd file-watch rules ─────────────────────────────────────
291        "AddAuditRule" | "RemoveAuditRule" => PreviewProfile {
292            expected_side_effects: vec![
293                "a persistent audit rule will be written/removed and reloaded".to_string(),
294            ],
295            reboot_required: false,
296            rollback_available: false,
297            warnings: vec![
298                "requires the auditd package installed to take effect".to_string(),
299                "exact approval required".to_string(),
300            ],
301        },
302
303        // ── certbot / ACME ──────────────────────────────────────────────
304        "ObtainCertificate" => PreviewProfile {
305            expected_side_effects: vec![
306                "certbot will obtain a TLS certificate (ACME challenge)".to_string(),
307                "TLS material will be written under /etc/letsencrypt".to_string(),
308            ],
309            reboot_required: false,
310            rollback_available: false,
311            warnings: vec![
312                "contacts a public ACME CA over the network".to_string(),
313                "requires certbot installed + a reachable DNS/HTTP challenge".to_string(),
314                "exact approval required".to_string(),
315            ],
316        },
317        "RenewCertificates" => PreviewProfile {
318            expected_side_effects: vec!["certbot will renew due certificates".to_string()],
319            reboot_required: false,
320            rollback_available: false,
321            warnings: vec![
322                "contacts a public ACME CA over the network".to_string(),
323                "exact approval required".to_string(),
324            ],
325        },
326
327        // ── fail2ban jail config ────────────────────────────────────────
328        "ConfigureFail2banJail" => PreviewProfile {
329            expected_side_effects: vec![
330                "a fail2ban jail override will be written and the daemon reloaded".to_string(),
331            ],
332            reboot_required: false,
333            rollback_available: false,
334            warnings: vec![
335                "changes who gets banned (too strict can lock out real users)".to_string(),
336                "requires the fail2ban package installed to take effect".to_string(),
337                "exact approval required".to_string(),
338            ],
339        },
340
341        // ── Ubuntu Pro service toggles ──────────────────────────────────
342        "EnableProService" | "DisableProService" => PreviewProfile {
343            expected_side_effects: vec![
344                "a single Ubuntu Pro service will be enabled/disabled".to_string(),
345            ],
346            reboot_required: false,
347            rollback_available: false,
348            warnings: vec![
349                "exact approval required".to_string(),
350                "requires an attached Pro subscription + network to take effect".to_string(),
351            ],
352        },
353
354        // ── Ubuntu release upgrade Tier 3 ────────────────────────────────
355        "UbuntuReleaseUpgrade" => PreviewProfile {
356            expected_side_effects: vec![
357                "entire OS will be upgraded to the next Ubuntu release".to_string(),
358                "takes 20–45 minutes; system will be rebooted to complete".to_string(),
359                "third-party PPAs may be disabled or break during upgrade".to_string(),
360            ],
361            reboot_required: true,
362            rollback_available: false,
363            warnings: vec![
364                "reboot required to complete the upgrade".to_string(),
365                "long-running operation — configure timeout >= 3600 seconds".to_string(),
366                "exact approval required".to_string(),
367            ],
368        },
369
370        "ReloadService" => PreviewProfile {
371            expected_side_effects: vec!["service config will be reloaded".to_string()],
372            reboot_required: false,
373            rollback_available: false,
374            warnings: vec![
375                "approval required".to_string(),
376                "requires ExecReload= to be defined in the unit file; \
377                 if not defined, use RestartService instead"
378                    .to_string(),
379            ],
380        },
381        "RestartService"
382        | "ReloadDaemon"
383        | "SetServiceEnabled"
384        | "StartService"
385        | "StopService"
386        | "ConfigureWifi"
387        | "SetDnsServers"
388        | "ConfigureFirewall"
389        | "CreateToolbox"
390        | "RemoveToolbox"
391        | "InstallFlatpak"
392        | "RemoveFlatpak"
393        | "UpdateFlatpak"
394        | "AddFlatpakRemote"
395        | "RemoveFlatpakRemote"
396        | "MaskService"
397        | "UnmaskService"
398        | "SetHostname"
399        | "SetTimezone"
400        | "SetLocale"
401        | "SetNtp"
402        | "CreateUser" => PreviewProfile {
403            expected_side_effects: vec!["service interruption".to_string()],
404            reboot_required: false,
405            rollback_available: false,
406            warnings: vec!["approval required".to_string()],
407        },
408        // ── Observability / journald maintenance ─────────────────────────
409        "VacuumJournal" => PreviewProfile {
410            expected_side_effects: vec![
411                "old journal entries will be permanently deleted".to_string(),
412                "disk space will be reclaimed".to_string(),
413            ],
414            reboot_required: false,
415            rollback_available: false,
416            warnings: vec!["deleted log history cannot be recovered".to_string()],
417        },
418
419        // ── Storage / LVM mutations ───────────────────────────────────────
420        "ExtendLogicalVolume" => PreviewProfile {
421            expected_side_effects: vec![
422                "the logical volume and its filesystem will be grown".to_string(),
423            ],
424            reboot_required: false,
425            rollback_available: false,
426            warnings: vec![
427                "resizes a live filesystem; a wrong volume target risks data".to_string(),
428                "exact approval required".to_string(),
429            ],
430        },
431        "CreateLogicalVolume" | "CreateLvSnapshot" => PreviewProfile {
432            expected_side_effects: vec![
433                "volume-group free space will be consumed".to_string(),
434            ],
435            reboot_required: false,
436            rollback_available: false,
437            warnings: vec![
438                "consumes VG capacity; snapshots fill as the origin changes".to_string(),
439                "exact approval required".to_string(),
440            ],
441        },
442
443        // ── systemd resource limits ───────────────────────────────────────
444        "SetServiceResourceLimits" => PreviewProfile {
445            expected_side_effects: vec![
446                "the service's memory/CPU/task limits will change immediately".to_string(),
447                "a persistent drop-in will be written (undo: systemctl revert)".to_string(),
448            ],
449            reboot_required: false,
450            rollback_available: false,
451            warnings: vec![
452                "too tight a MemoryMax can OOM-kill the service".to_string(),
453                "exact approval required".to_string(),
454            ],
455        },
456
457        // ── Log management ────────────────────────────────────────────────
458        "ConfigureLogRotation" | "RemoveLogRotation" => PreviewProfile {
459            expected_side_effects: vec![
460                "a logrotate drop-in will be written/removed".to_string(),
461            ],
462            reboot_required: false,
463            rollback_available: false,
464            warnings: vec!["approval required".to_string()],
465        },
466        "ConfigureRemoteSyslog" | "RemoveRemoteSyslog" => PreviewProfile {
467            expected_side_effects: vec![
468                "rsyslog will start/stop forwarding all logs to a remote host".to_string(),
469                "rsyslog will be restarted".to_string(),
470            ],
471            reboot_required: false,
472            rollback_available: false,
473            warnings: vec![
474                "forwarding sends log data off-host (exfiltration surface)".to_string(),
475                "exact approval required".to_string(),
476            ],
477        },
478
479        // ── PAM password policy ───────────────────────────────────────────
480        "SetPasswordAging" => PreviewProfile {
481            expected_side_effects: vec![
482                "the target account's password-aging limits will change".to_string(),
483            ],
484            reboot_required: false,
485            rollback_available: false,
486            warnings: vec!["exact approval required".to_string()],
487        },
488        "SetPasswordPolicy" | "SetAccountLockout" => PreviewProfile {
489            expected_side_effects: vec![
490                "a system-wide PAM policy file will be written".to_string(),
491            ],
492            reboot_required: false,
493            rollback_available: false,
494            warnings: vec![
495                "affects password/lockout rules for all accounts".to_string(),
496                "takes effect only if the PAM module is enabled in the auth stack".to_string(),
497                "exact approval required".to_string(),
498            ],
499        },
500
501        // ── Ubuntu apt pinning ────────────────────────────────────────────
502        "SetAptPin" | "RemoveAptPin" => PreviewProfile {
503            expected_side_effects: vec![
504                "apt version/origin preferences will change".to_string(),
505                "a /etc/apt/preferences.d drop-in will be written/removed".to_string(),
506            ],
507            reboot_required: false,
508            rollback_available: false,
509            warnings: vec!["approval required".to_string()],
510        },
511
512        // ── Scoped sudoers.d ──────────────────────────────────────────────
513        "GrantSudoAccess" | "RevokeSudoAccess" => PreviewProfile {
514            expected_side_effects: vec![
515                "a sudoers.d drop-in will be created/removed".to_string(),
516                "the target user's sudo privileges will change".to_string(),
517            ],
518            reboot_required: false,
519            rollback_available: false,
520            warnings: vec![
521                "this configures privilege escalation — review the rule carefully".to_string(),
522                "exact approval required".to_string(),
523            ],
524        },
525
526        // ── Filesystem mounts / swap ──────────────────────────────────────
527        "AddMount" | "RemoveMount" => PreviewProfile {
528            expected_side_effects: vec![
529                "a filesystem will be (un)mounted".to_string(),
530                "/etc/fstab will be updated (managed entries carry nofail)".to_string(),
531            ],
532            reboot_required: false,
533            rollback_available: false,
534            warnings: vec![
535                "a wrong device or mountpoint risks data or availability".to_string(),
536                "exact approval required".to_string(),
537            ],
538        },
539        "AddSwap" | "RemoveSwap" => PreviewProfile {
540            expected_side_effects: vec![
541                "swap will be enabled/disabled".to_string(),
542                "a swap file will be created/removed and /etc/fstab updated".to_string(),
543            ],
544            reboot_required: false,
545            rollback_available: false,
546            warnings: vec!["exact approval required".to_string()],
547        },
548
549        // ── Kernel / sysctl mutation ──────────────────────────────────────
550        "SetSysctl" => PreviewProfile {
551            expected_side_effects: vec![
552                "a kernel parameter will change immediately".to_string(),
553                "the value will persist across reboots (/etc/sysctl.d)".to_string(),
554            ],
555            reboot_required: false,
556            rollback_available: false,
557            warnings: vec![
558                "a wrong net.*/vm.*/kernel.* value can degrade or lock the host".to_string(),
559                "exact approval required".to_string(),
560            ],
561        },
562
563        "SignalProcess" => PreviewProfile {
564            expected_side_effects: vec!["the target process will be terminated".to_string()],
565            reboot_required: false,
566            rollback_available: false,
567            warnings: vec![
568                "the process and its in-flight work will stop".to_string(),
569                "exact approval required".to_string(),
570            ],
571        },
572        "ConfigureUnattendedUpgrades" => PreviewProfile {
573            expected_side_effects: vec![
574                "the automatic security-update policy will change".to_string()
575            ],
576            reboot_required: false,
577            rollback_available: false,
578            warnings: vec!["approval required".to_string()],
579        },
580        "CreateScheduledJob" => PreviewProfile {
581            expected_side_effects: vec![
582                "a systemd timer will run the command on a recurring schedule".to_string(),
583            ],
584            reboot_required: false,
585            rollback_available: false,
586            warnings: vec![
587                "persistent root-scheduled execution".to_string(),
588                "exact approval required".to_string(),
589            ],
590        },
591        "AddUserToGroup"
592        | "RemoveUserFromGroup"
593        | "CreateGroup"
594        | "DeleteGroup"
595        | "LockUserAccount"
596        | "UnlockUserAccount"
597        | "SetSshdOption"
598        | "DeleteUser"
599        | "AddAuthorizedKey"
600        | "RemoveAuthorizedKey" => PreviewProfile {
601            // High risk: access-control changes — group membership, account
602            // deletion, and SSH key modifications require Admin authorization.
603            expected_side_effects: vec!["access control will change".to_string()],
604            reboot_required: false,
605            rollback_available: false,
606            warnings: vec![
607                "privilege change".to_string(),
608                "exact approval required".to_string(),
609            ],
610        },
611        "AddPackageRepository"
612        | "RemovePackageRepository"
613        | "EnablePackageRepository"
614        | "DisablePackageRepository" => PreviewProfile {
615            expected_side_effects: vec!["package repository configuration will change".to_string()],
616            reboot_required: false,
617            rollback_available: false,
618            warnings: vec!["approval required".to_string()],
619        },
620        "CreateContainer" | "StartContainer" | "StopContainer" | "RemoveContainer" => {
621            PreviewProfile {
622                expected_side_effects: vec!["container lifecycle will change".to_string()],
623                reboot_required: false,
624                rollback_available: false,
625                warnings: vec!["approval required".to_string()],
626            }
627        }
628        "UpdateSystem"
629        | "InstallPackages"
630        | "RemovePackages"
631        | "RebaseSystem"
632        | "RollbackDeployment"
633        | "AddLayeredPackage"
634        | "RemoveLayeredPackage"
635        | "ReplaceLayeredPackage"
636        | "ResetLayeredPackageOverride"
637        | "RemoveBasePackage" => PreviewProfile {
638            expected_side_effects: vec![
639                "system deployment will change".to_string(),
640                "reboot may be required".to_string(),
641            ],
642            reboot_required: true,
643            rollback_available: true,
644            warnings: vec![
645                "reboot required".to_string(),
646                "exact approval required".to_string(),
647            ],
648        },
649        "SetKernelArguments" => PreviewProfile {
650            expected_side_effects: vec!["boot arguments will change".to_string()],
651            reboot_required: true,
652            rollback_available: true,
653            warnings: vec![
654                "reboot required".to_string(),
655                "exact approval required".to_string(),
656            ],
657        },
658        "RebootSystem" => PreviewProfile {
659            expected_side_effects: vec!["system reboot will interrupt running work".to_string()],
660            reboot_required: true,
661            rollback_available: false,
662            warnings: vec![
663                "reboot required".to_string(),
664                "exact approval required".to_string(),
665            ],
666        },
667        "CleanupDeployments" => PreviewProfile {
668            expected_side_effects: vec!["old deployments may be removed".to_string()],
669            reboot_required: false,
670            rollback_available: false,
671            warnings: vec!["exact approval required".to_string()],
672        },
673        "PinDeployment" | "UnpinDeployment" => PreviewProfile {
674            expected_side_effects: vec!["deployment pin state will change".to_string()],
675            reboot_required: false,
676            rollback_available: false,
677            warnings: vec!["exact approval required".to_string()],
678        },
679        _ => PreviewProfile {
680            expected_side_effects: vec!["unclassified action".to_string()],
681            reboot_required: false,
682            rollback_available: false,
683            warnings: vec!["action profile not recognized".to_string()],
684        },
685    }
686}
687
688fn preview_summary(action_name: &str, risk_level: &RiskLevel) -> String {
689    let risk = match risk_level {
690        RiskLevel::Low => "low-risk",
691        RiskLevel::Medium => "medium-risk",
692        RiskLevel::High => "high-risk",
693    };
694
695    format!("{action_name} preview ({risk})")
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use sysknife_types::{CallerRole, RiskLevel};
702
703    fn req(action: &str) -> RequestEnvelope {
704        RequestEnvelope {
705            action_name: action.to_string(),
706            request_id: "test-req".to_string(),
707            params: serde_json::Value::Null,
708            caller_role: CallerRole::Dev,
709            request_hash: sysknife_types::RequestHash::new("hash".to_string()),
710        }
711    }
712
713    #[test]
714    fn all_low_risk_actions() {
715        let actions = [
716            "GetSystemState",
717            "CollectDiagnostics",
718            "GetDeploymentHistory",
719            "ListDeployments",
720            "GetKernelArguments",
721            "GetPendingUpdates",
722            "SearchFlatpakApps",
723            "ListFlatpakRemotes",
724            "ListInstalledFlatpaks",
725            "GetFlatpakAppInfo",
726            "ListToolboxes",
727            "ListServices",
728            "GetServiceLogs",
729            "GetServiceStatus",
730            "ListTimers",
731            "GetFirewallState",
732            "ListUsers",
733            "ListGroups",
734            "ListPackageRepositories",
735            "ListContainers",
736            "GetContainerInfo",
737            "GetLayeredPackages",
738            "GetDiskUsage",
739            "ListProcesses",
740            "GetMemoryInfo",
741            "GetNetworkStatus",
742            "GetAuthorizedKeys",
743            "GetDateTime",
744            "ListJobHistory",
745        ];
746
747        for action in &actions {
748            let envelope = preview_action(
749                &req(action),
750                serde_json::Value::Null,
751                serde_json::Value::Null,
752            );
753            assert_eq!(
754                envelope.risk_level,
755                RiskLevel::Low,
756                "{action} should be Low risk"
757            );
758            assert!(
759                !envelope.reboot_required,
760                "{action} should not require reboot"
761            );
762            assert!(
763                !envelope.rollback_available,
764                "{action} should not have rollback available"
765            );
766        }
767    }
768
769    #[test]
770    fn reload_service_is_medium_risk() {
771        let envelope = preview_action(
772            &req("ReloadService"),
773            serde_json::Value::Null,
774            serde_json::Value::Null,
775        );
776        assert_eq!(envelope.risk_level, RiskLevel::Medium);
777        assert_eq!(envelope.warnings.len(), 2);
778        assert!(!envelope.reboot_required);
779        assert!(!envelope.rollback_available);
780    }
781
782    #[test]
783    fn medium_risk_actions() {
784        let actions = [
785            "RestartService",
786            "StopService",
787            "StartService",
788            "SetHostname",
789            "SetTimezone",
790            "SetLocale",
791            "SetNtp",
792            "InstallFlatpak",
793            "RemoveFlatpak",
794            "CreateToolbox",
795            "RemoveToolbox",
796            "CreateContainer",
797            "StartContainer",
798            "StopContainer",
799            "RemoveContainer",
800            "RemovePackageRepository",
801        ];
802
803        for action in &actions {
804            let envelope = preview_action(
805                &req(action),
806                serde_json::Value::Null,
807                serde_json::Value::Null,
808            );
809            assert_eq!(
810                envelope.risk_level,
811                RiskLevel::Medium,
812                "{action} should be Medium risk"
813            );
814        }
815    }
816
817    #[test]
818    fn high_risk_access_control_actions() {
819        let actions = [
820            "AddUserToGroup",
821            "RemoveUserFromGroup",
822            "DeleteUser",
823            "AddAuthorizedKey",
824            "RemoveAuthorizedKey",
825        ];
826
827        for action in &actions {
828            let envelope = preview_action(
829                &req(action),
830                serde_json::Value::Null,
831                serde_json::Value::Null,
832            );
833            assert_eq!(
834                envelope.risk_level,
835                RiskLevel::High,
836                "{action} should be High risk"
837            );
838            assert!(
839                envelope
840                    .expected_side_effects
841                    .iter()
842                    .any(|e| e.contains("access control will change")),
843                "{action} should have 'access control will change' in expected_side_effects"
844            );
845        }
846    }
847
848    #[test]
849    fn high_risk_system_reboot_actions() {
850        let actions = [
851            "UpdateSystem",
852            "InstallPackages",
853            "RemovePackages",
854            "RebaseSystem",
855            "RollbackDeployment",
856            "AddLayeredPackage",
857            "RemoveLayeredPackage",
858            "SetKernelArguments",
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::High,
870                "{action} should be High risk"
871            );
872            assert!(envelope.reboot_required, "{action} should require reboot");
873            assert!(
874                envelope.rollback_available,
875                "{action} should have rollback available"
876            );
877        }
878    }
879
880    #[test]
881    fn reboot_system_no_rollback() {
882        let envelope = preview_action(
883            &req("RebootSystem"),
884            serde_json::Value::Null,
885            serde_json::Value::Null,
886        );
887        assert_eq!(envelope.risk_level, RiskLevel::High);
888        assert!(envelope.reboot_required);
889        assert!(!envelope.rollback_available);
890    }
891
892    #[test]
893    fn cleanup_deployments_is_high_no_reboot() {
894        let envelope = preview_action(
895            &req("CleanupDeployments"),
896            serde_json::Value::Null,
897            serde_json::Value::Null,
898        );
899        assert_eq!(envelope.risk_level, RiskLevel::High);
900        assert!(!envelope.reboot_required);
901    }
902
903    #[test]
904    fn pin_unpin_deployment_are_high() {
905        for action in &["PinDeployment", "UnpinDeployment"] {
906            let envelope = preview_action(
907                &req(action),
908                serde_json::Value::Null,
909                serde_json::Value::Null,
910            );
911            assert_eq!(
912                envelope.risk_level,
913                RiskLevel::High,
914                "{action} should be High risk"
915            );
916        }
917    }
918
919    #[test]
920    fn unknown_action_defaults_to_high() {
921        let envelope = preview_action(
922            &req("DefinitelyNotRealAction"),
923            serde_json::Value::Null,
924            serde_json::Value::Null,
925        );
926        assert_eq!(envelope.risk_level, RiskLevel::High);
927        assert!(
928            envelope
929                .warnings
930                .iter()
931                .any(|w| w.contains("action profile not recognized")),
932            "warnings should contain 'action profile not recognized'"
933        );
934        assert!(
935            envelope
936                .expected_side_effects
937                .iter()
938                .any(|e| e.contains("unclassified action")),
939            "expected_side_effects should contain 'unclassified action'"
940        );
941    }
942
943    #[test]
944    fn preview_action_summary_format() {
945        let envelope = preview_action(
946            &req("GetDiskUsage"),
947            serde_json::Value::Null,
948            serde_json::Value::Null,
949        );
950        assert_eq!(envelope.summary, "GetDiskUsage preview (low-risk)");
951    }
952
953    #[test]
954    fn preview_action_passes_current_and_proposed_state() {
955        let current = serde_json::json!({"disk": "80%"});
956        let proposed = serde_json::json!({"action": "cleanup"});
957        let envelope = preview_action(
958            &req("CleanupDeployments"),
959            current.clone(),
960            proposed.clone(),
961        );
962        assert_eq!(envelope.current_state, current);
963        assert_eq!(envelope.proposed_change, proposed);
964    }
965
966    #[test]
967    fn preview_action_preserves_request_hash() {
968        let mut r = req("GetDiskUsage");
969        r.request_hash = sysknife_types::RequestHash::new("deadbeef".to_string());
970        let envelope = preview_action(&r, serde_json::Value::Null, serde_json::Value::Null);
971        assert_eq!(envelope.request_hash.as_str(), "deadbeef");
972    }
973}