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