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