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