Skip to main content

sysknife_daemon/
dispatcher.rs

1//! IPC connection handler for the daemon.
2//!
3//! Accepts both Unix socket and vsock connections. The caller role is resolved
4//! before entering the handler:
5//!
6//! - **Unix**: `SO_PEERCRED` → Linux group membership → `CallerRole`
7//! - **Vsock**: first frame must be `{"type":"auth","token":"<hex>"}` validated
8//!   against `~/.config/sysknife/token`; valid token → configured role.
9//!
10//! # Security model
11//!
12//! - Unix role is derived from the peer process's Linux group membership
13//!   via `SO_PEERCRED` + `/proc/{pid}/status` + `/etc/group`, with the peer
14//!   pinned by a pidfd (`SO_PEERPIDFD`, Linux 6.5+) to guard the supplementary
15//!   group read against PID reuse (see `resolve_caller`). The shell never
16//!   supplies its own role.
17//! - Vsock role is derived from a pre-shared token validated against a file;
18//!   the token is generated at setup time and distributed out-of-band.
19//! - Every `execute` request must carry a one-time receipt issued for the exact
20//!   persisted preview. Receipt verification and consumption are atomic.
21//! - Role is checked against the per-action allowlist (see `policy.rs`) before
22//!   preview and again before execute.
23
24use std::sync::Arc;
25
26#[cfg(target_os = "linux")]
27use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
28
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value};
31use sha2::{Digest, Sha256};
32use tokio::io::{AsyncRead, AsyncWrite};
33use tokio::net::UnixStream;
34use uuid::Uuid;
35
36use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RequestEnvelope};
37
38// ---------------------------------------------------------------------------
39// Credential redaction
40// ---------------------------------------------------------------------------
41//
42// Some actions take credential parameters (e.g. `ProAttach` takes a Pro token).
43// These values must NOT appear in:
44//   - the `command` field of a DescribeResponse (sent over the wire)
45//   - the `proposed_change` field of a PreviewEnvelope (persisted to the
46//     transactions table AND returned in PreviewResponse)
47//   - any tracing / debug output
48//
49// `credential_keys_for(action_name)` returns the per-action set of credential
50// param keys. `redact_params` replaces each credential value with the literal
51// string `<REDACTED>` while preserving structure. `redact_argv` walks the
52// rendered argv and replaces any element that matches a known credential value
53// (lookup by reading the params of the same action) with `<REDACTED>`.
54
55/// Per-action credential param keys. Add new actions here when they take secrets.
56fn credential_keys_for(action_name: &str) -> &'static [&'static str] {
57    match action_name {
58        "ProAttach" => &["token"],
59        "ConfigureWifi" => &["password"],
60        _ => &[],
61    }
62}
63
64/// Return a copy of `params` with every credential value replaced by
65/// the literal string `"<REDACTED>"`. Other keys are preserved unchanged.
66fn redact_params(action_name: &str, params: &Value) -> Value {
67    let keys = credential_keys_for(action_name);
68    if keys.is_empty() {
69        return params.clone();
70    }
71    if let Value::Object(map) = params {
72        let mut out = serde_json::Map::with_capacity(map.len());
73        for (k, v) in map {
74            if keys.iter().any(|ck| *ck == k) {
75                out.insert(k.clone(), Value::String("<REDACTED>".to_string()));
76            } else {
77                out.insert(k.clone(), v.clone());
78            }
79        }
80        Value::Object(out)
81    } else {
82        params.clone()
83    }
84}
85
86/// Per-action positional credential spec.
87///
88/// Returns the 0-based index of the argv element that holds a credential.
89/// `usize::MAX` is a sentinel meaning "last element" (credential is always
90/// the last positional argument). Returns `None` when the action carries no
91/// positional credential.
92///
93/// Positional redaction is applied FIRST to guarantee correctness even when
94/// the credential value coincidentally equals a structural argv element (e.g.
95/// `token == "attach"` — red-team finding ME2).
96fn credential_argv_spec(action_name: &str) -> Option<CredentialArgvSpec> {
97    match action_name {
98        // `sudo pro attach <token>` — the token is always the final element.
99        "ProAttach" => Some(CredentialArgvSpec::Last),
100        // `sudo nmcli device wifi connect <ssid> password <pw>` — the pair is
101        // omitted entirely for open networks, so the credential has no fixed
102        // index and is not reliably last either.
103        "ConfigureWifi" => Some(CredentialArgvSpec::AfterKeyword("password")),
104        _ => None,
105    }
106}
107
108/// Where an action's credential sits inside its rendered argv.
109///
110/// A spec is authoritative: when one exists the credential lives at exactly
111/// that slot, so redaction never has to match on the secret's text and can
112/// therefore not clobber a structural element that happens to share it.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114enum CredentialArgvSpec {
115    /// The credential is the final argv element.
116    Last,
117    /// The credential is the element immediately after `keyword`.
118    AfterKeyword(&'static str),
119}
120
121/// Replace the credential argv element(s) with `<REDACTED>`.
122///
123/// Strategy (ME2 fix):
124/// 1. **Positional redaction** — if `credential_argv_spec` returns a spec
125///    for this action, redact that exact index and return immediately. This
126///    prevents the value-match pass from also clobbering structural argv
127///    elements that happen to share the same text as the credential (e.g.
128///    `token == "attach"` — red-team finding ME2). When a positional spec
129///    exists it is authoritative: the credential is always at that slot and
130///    nowhere else in the argv.
131/// 2. **Value-match fallback** — for actions that have no positional spec,
132///    walk argv and replace any element whose text matches a credential value.
133///    This is the original behavior and remains as a safety net for future
134///    actions that haven't been assigned a positional spec yet.
135fn redact_argv(action_name: &str, params: &Value, args: &[String]) -> Vec<String> {
136    let keys = credential_keys_for(action_name);
137    if keys.is_empty() {
138        return args.to_vec();
139    }
140    let secrets: Vec<&str> = keys
141        .iter()
142        .filter_map(|k| params.get(k).and_then(|v| v.as_str()))
143        .collect();
144    if secrets.is_empty() {
145        return args.to_vec();
146    }
147
148    let mut out: Vec<String> = args.to_vec();
149
150    // Step 1 — positional redaction (authoritative when a spec exists).
151    if let Some(spec) = credential_argv_spec(action_name) {
152        let idx = match spec {
153            CredentialArgvSpec::Last => Some(out.len().saturating_sub(1)),
154            // Take the LAST keyword occurrence that still has a successor.
155            // "last" handles an SSID that is itself named `password`; the
156            // successor requirement handles a password whose value is the
157            // literal `password`, where the final occurrence IS the secret.
158            CredentialArgvSpec::AfterKeyword(keyword) => out
159                .iter()
160                .enumerate()
161                .rev()
162                .find(|(i, arg)| arg.as_str() == keyword && i + 1 < out.len())
163                .map(|(i, _)| i + 1),
164        };
165        if let Some(idx) = idx.filter(|i| *i < out.len()) {
166            out[idx] = "<REDACTED>".to_string();
167        }
168        // Positional spec is authoritative — skip value-match to avoid
169        // inadvertently redacting structural argv elements that share the
170        // same text as the credential (ME2 fix).
171        return out;
172    }
173
174    // Step 2 — value-match fallback for actions without a positional spec.
175    out.iter_mut().for_each(|a| {
176        if secrets.iter().any(|s| s == &a.as_str()) {
177            *a = "<REDACTED>".to_string();
178        }
179    });
180
181    out
182}
183
184use crate::{
185    audit_chain::RequestHashKey,
186    auth::{highest_role_from_groups, CallerAttribution},
187    executor::{build_action_spec, rollback_spec_for, ActionExecutor},
188    preview::preview_action,
189    state::DaemonState,
190    state_collector::{collect_state, CollectedState, CommandRunner},
191    transactions::{HistorySince, NewTransaction},
192    transport::framing::{FramedStream, FramingError},
193};
194
195// ---------------------------------------------------------------------------
196// Tunable constants
197// ---------------------------------------------------------------------------
198
199/// Maximum wait for the first auth frame on a vsock connection, in seconds.
200///
201/// A peer that opens the socket but never writes is either firewalled,
202/// confused, or hostile.  Ten seconds is comfortably longer than any sane
203/// auth round-trip (~ms) and short enough that a stuck guest does not
204/// sit on a connection slot indefinitely under [`MAX_CONNECTIONS`] backpressure.
205const VSOCK_AUTH_FRAME_TIMEOUT_SECS: u64 = 10;
206
207/// Default number of history rows returned by `query_history` when the
208/// caller does not specify `limit`.
209///
210/// 20 is a single-screenful of recent activity that mirrors the CLI's
211/// `sysknife history` default — large enough to be useful for triage,
212/// small enough to keep the response under the IPC frame budget.
213const DEFAULT_HISTORY_LIMIT: u32 = 20;
214
215/// How many leading characters of a transaction UUID to render in the
216/// human-readable history listing.  UUID v4 has 122 bits of entropy; even
217/// at 80,000 entries the chance of collision in the first 8 hex chars
218/// (32 bits) is < 1%, which is fine for a forensic-only display where the
219/// full ID is also recorded.
220const TRANSACTION_ID_DISPLAY_PREFIX_LEN: usize = 8;
221
222// ---------------------------------------------------------------------------
223// Wire types — Shell → Daemon
224// ---------------------------------------------------------------------------
225
226#[derive(Debug, Deserialize)]
227#[serde(transparent)]
228struct TransactionId(String);
229
230impl TransactionId {
231    fn as_str(&self) -> &str {
232        &self.0
233    }
234}
235
236#[derive(Debug, Deserialize)]
237#[serde(transparent)]
238struct ApprovalReceipt(String);
239
240impl ApprovalReceipt {
241    fn as_str(&self) -> &str {
242        &self.0
243    }
244}
245
246#[derive(Debug, Deserialize)]
247#[serde(tag = "type", rename_all = "snake_case")]
248enum DaemonRequest {
249    QueryState {
250        request_id: String,
251    },
252    Preview {
253        request_id: String,
254        action_name: String,
255        params: Value,
256        /// The client is running with its approval gate lifted, so no human
257        /// will confirm this step.
258        ///
259        /// `#[serde(default)]` keeps an older client working unchanged. The
260        /// daemon does not trust this to *permit* anything; it only records
261        /// it, by appending [`UNATTENDED_WARNING`] to the preview warnings,
262        /// which are signed into the transaction row. A client that lies by
263        /// omission gains nothing, because the field grants no authority.
264        #[serde(default)]
265        unattended: bool,
266    },
267    Approve {
268        request_id: String,
269        transaction_id: TransactionId,
270    },
271    ApprovalDetails {
272        request_id: String,
273        transaction_id: TransactionId,
274    },
275    Execute {
276        request_id: String,
277        transaction_id: TransactionId,
278        action_name: String,
279        params: Value,
280        approval_receipt: ApprovalReceipt,
281    },
282    /// Cancel a still-queued transaction (before it is claimed for execution).
283    /// Identified by transaction_id — a queued transaction has no job_id yet.
284    /// Option A: an in-flight (Running) action is never interrupted.
285    Cancel {
286        request_id: String,
287        transaction_id: TransactionId,
288    },
289    QueryAction {
290        request_id: String,
291        action_name: String,
292        params: Value,
293    },
294    /// Structured audit-log history for programmatic clients. Unlike the
295    /// `ListJobHistory` action (which returns human-formatted text), this
296    /// returns typed rows so the MCP `sysknife_history` tool gets `created_at`
297    /// and `risk_level` without re-parsing text.
298    QueryHistory {
299        request_id: String,
300        limit: Option<u32>,
301        status_filter: Option<String>,
302        action_filter: Option<String>,
303        since_hours: Option<u32>,
304        /// Exact RFC 3339 lower bound on `created_at` (the CLI's `--since`).
305        /// Mutually exclusive with `since_hours`.
306        since: Option<String>,
307    },
308    Describe {
309        request_id: String,
310        action_name: String,
311        params: Value,
312    },
313}
314
315/// Fuzzing entry point for the two-step request decode in the connection loop:
316/// `serde_json::from_slice` into a [`Value`], then `serde_json::from_value` into
317/// [`DaemonRequest`]. Returns whether the frame decoded to a request.
318///
319/// `DaemonRequest` is private to this module, so the `fuzz/` crate cannot name
320/// it; this function is the one door in. cargo-fuzz sets `--cfg fuzzing`, so the
321/// function does not exist in a normal or release build. It repeats the two
322/// calls rather than sharing a helper with the loop, to leave the loop's
323/// control flow untouched; if the loop's decode changes shape, change this to
324/// match.
325#[cfg(fuzzing)]
326pub fn fuzz_decode_request(raw: &[u8]) -> bool {
327    match serde_json::from_slice::<Value>(raw) {
328        Ok(msg) => serde_json::from_value::<DaemonRequest>(msg).is_ok(),
329        Err(_) => false,
330    }
331}
332
333// ---------------------------------------------------------------------------
334// Wire types — Daemon → Shell
335// ---------------------------------------------------------------------------
336
337#[derive(Debug, Serialize)]
338#[serde(tag = "type", rename_all = "snake_case")]
339enum DaemonResponse {
340    StateResponse {
341        request_id: String,
342        state: CollectedState,
343    },
344    PreviewResponse {
345        request_id: String,
346        preview: PreviewEnvelope,
347        transaction_id: String,
348    },
349    ApprovalResponse {
350        request_id: String,
351        transaction_id: String,
352        approval_receipt: String,
353    },
354    ApprovalDetailsResponse {
355        request_id: String,
356        transaction_id: String,
357        action_name: String,
358        preview: PreviewEnvelope,
359    },
360    JobStarted {
361        request_id: String,
362        job_id: String,
363        transaction_id: String,
364    },
365    JobProgress {
366        job_id: String,
367        line: String,
368    },
369    JobCompleted {
370        job_id: String,
371        result: JobResult,
372    },
373    QueryActionResponse {
374        request_id: String,
375        action_name: String,
376        output: String,
377    },
378    HistoryResponse {
379        request_id: String,
380        entries: Vec<crate::transactions::JobHistoryEntry>,
381    },
382    CancelResponse {
383        request_id: String,
384        transaction_id: String,
385    },
386    DescribeResponse {
387        request_id: String,
388        command: String,
389        risk_level: String,
390        reboot_required: bool,
391    },
392    ErrorResponse {
393        request_id: String,
394        category: String,
395        message: String,
396    },
397    /// Returned when a mutating action is submitted while a High-risk
398    /// reboot-required action (e.g. `UbuntuReleaseUpgrade`) is already
399    /// executing. The caller should retry after the running job completes.
400    ///
401    /// Wire shape (JSON):
402    /// ```json
403    /// {
404    ///   "type": "conflict_response",
405    ///   "request_id": "<id>",
406    ///   "message": "high-risk action in progress; retry after the current job completes",
407    ///   "retry_after_seconds": null
408    /// }
409    /// ```
410    ConflictResponse {
411        request_id: String,
412        message: String,
413        /// Hint to the client: how long to wait before retrying. `null` when
414        /// the daemon cannot estimate the remaining runtime.
415        retry_after_seconds: Option<u32>,
416    },
417}
418
419/// IPC payload included in `job_completed` and related response frames.
420///
421/// Although the type itself is private to the dispatcher module, every field
422/// is serialised on the wire and parsed by the shell, the CLI, and the MCP
423/// adapter — treat changes here as breaking IPC changes.
424#[derive(Debug, Serialize)]
425struct JobResult {
426    /// Terminal job status: `"succeeded"`, `"failed"`, `"canceled"`,
427    /// `"rolled_back"`, or `"needs_reboot"`. Mirrors `JobState`'s `Display`.
428    status: String,
429    /// One-line human-readable summary of the outcome.
430    summary: String,
431    /// Per-step warning strings preserved verbatim from the executor.
432    warnings: Vec<String>,
433    /// Daemon-assigned identifier for this job (UUID v4).
434    job_id: String,
435    /// `true` when the action requires a reboot to take effect.
436    needs_reboot: bool,
437    /// OSTree commit reference recorded for rollback, or `None` when the
438    /// action is not rollbackable.
439    rollback_ref: Option<String>,
440    /// Audit-log transaction ID for this job — also the join key for SIEM
441    /// correlation against forwarded events.
442    transaction_id: String,
443}
444
445// ---------------------------------------------------------------------------
446// Role resolution
447// ---------------------------------------------------------------------------
448
449/// `SO_PEERPIDFD` getsockopt option (Linux 6.5+). Value 77 is from the
450/// asm-generic UAPI `socket.h`, which x86_64 and aarch64 — SysKnife's only
451/// release targets — use. Defined locally so the code builds against libc
452/// versions that predate the constant.
453#[cfg(target_os = "linux")]
454const SO_PEERPIDFD: libc::c_int = 77;
455
456#[cfg(target_os = "linux")]
457enum PeerPin {
458    Pinned(OwnedFd),
459    Unsupported,
460    Unpinnable,
461}
462
463#[cfg(target_os = "linux")]
464impl PeerPin {
465    fn from_error(error: &std::io::Error) -> Self {
466        if error.raw_os_error() == Some(libc::ENOPROTOOPT) {
467            Self::Unsupported
468        } else {
469            Self::Unpinnable
470        }
471    }
472}
473
474/// Obtain a pidfd pinned to the process that opened this connection, via
475/// `SO_PEERPIDFD`. Unlike `pidfd_open(pid)`, this has no PID-based lookup and so
476/// no reuse race — the kernel pins the actual peer captured at `connect()`.
477///
478/// Only `ENOPROTOOPT` means the kernel does not support the option (e.g.
479/// Ubuntu 22.04's 5.15 kernel). Other failures, including `EINVAL` for a
480/// reaped peer and fd exhaustion, must not enable the best-effort `/proc` path.
481#[cfg(target_os = "linux")]
482fn peer_pidfd(stream: &UnixStream) -> PeerPin {
483    let mut fd: libc::c_int = -1;
484    let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
485    // SAFETY: getsockopt writes at most `len` bytes into `fd` (a c_int) and
486    // updates `len`; we pass the true buffer size and check the return code.
487    let rc = unsafe {
488        libc::getsockopt(
489            stream.as_raw_fd(),
490            libc::SOL_SOCKET,
491            SO_PEERPIDFD,
492            (&mut fd as *mut libc::c_int).cast::<libc::c_void>(),
493            &mut len,
494        )
495    };
496    if rc != 0 {
497        return PeerPin::from_error(&std::io::Error::last_os_error());
498    }
499    if fd < 0 {
500        return PeerPin::Unpinnable;
501    }
502    // SAFETY: getsockopt returned a fresh, owned fd; take sole ownership so it is
503    // closed on drop.
504    PeerPin::Pinned(unsafe { OwnedFd::from_raw_fd(fd) })
505}
506
507/// The pin is acquired before this function; the injected reader keeps the
508/// ordering and the unpinnable-peer case testable without forcing PID reuse.
509#[cfg(target_os = "linux")]
510fn groups_for_pinned_peer(
511    pid: u32,
512    pin: PeerPin,
513    read_groups: impl FnOnce() -> Vec<String>,
514) -> Vec<String> {
515    match pin {
516        PeerPin::Unsupported => read_groups(),
517        PeerPin::Unpinnable => {
518            eprintln!(
519                "[sysknife-daemon] WARNING: cannot pin peer PID {pid}; ignoring supplementary \
520                 groups and using the SO_PEERCRED primary GID only"
521            );
522            Vec::new()
523        }
524        PeerPin::Pinned(fd) => {
525            let groups = read_groups();
526            if pidfd_peer_still_live(&fd) {
527                groups
528            } else {
529                eprintln!(
530                    "[sysknife-daemon] WARNING: peer PID {pid} was reaped while resolving its \
531                     groups (possible PID reuse); ignoring supplementary groups and using the \
532                     SO_PEERCRED primary GID only"
533                );
534                Vec::new()
535            }
536        }
537    }
538}
539
540/// Returns `true` while the pinned peer process has not yet been reaped — i.e.
541/// its PID cannot have been recycled. Uses `pidfd_send_signal(pidfd, 0)`, whose
542/// signal `0` performs an existence/permission check with no delivery: success
543/// or any errno other than `ESRCH` means the process still exists; `ESRCH` means
544/// it has been reaped.
545#[cfg(target_os = "linux")]
546fn pidfd_peer_still_live(pidfd: &OwnedFd) -> bool {
547    // SAFETY: FFI call with a valid owned pidfd; signal 0 delivers nothing, and
548    // NULL siginfo + 0 flags are the documented "just check" form.
549    let rc = unsafe {
550        libc::syscall(
551            libc::SYS_pidfd_send_signal,
552            pidfd.as_raw_fd(),
553            0,
554            std::ptr::null::<libc::c_void>(),
555            0,
556        )
557    };
558    if rc == 0 {
559        return true;
560    }
561    // A reaped process reports ESRCH; anything else (e.g. EPERM) still means the
562    // process exists, so its PID cannot have been recycled.
563    std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
564}
565
566/// Resolve who is calling: the role that decides what they may do, and the
567/// principal that records which account did it.
568///
569/// Uses `SO_PEERCRED` (via `peer_cred()`) to obtain the peer PID and primary
570/// GID, reads `/proc/{pid}/status` for the supplementary GIDs, and resolves
571/// each GID to a group name via `/etc/group`. The primary GID is included
572/// explicitly because Linux's `Groups:` line in `/proc/{pid}/status` lists
573/// only supplementary groups — a process whose primary group is `wheel` would
574/// otherwise be misclassified as `Observer`. Falls back to `Observer` on any
575/// error.
576///
577/// # PID-reuse hardening
578///
579/// The uid/gid/pid from `SO_PEERCRED` are captured by the kernel at `connect()`
580/// and are race-free, but the *supplementary* group set is read from
581/// `/proc/{pid}/status` after the fact. If the peer exited and its PID were
582/// recycled before that read, we would read a different process's groups. On
583/// Linux 6.5+ (Ubuntu 24.04 / 26.04) we pin the peer with a pidfd via
584/// `peer_pidfd` and, after reading `/proc`, confirm the pinned process was not
585/// reaped during the read; if it was, the supplementary set is untrustworthy and
586/// is dropped, keeping only the race-free primary GID. Only `ENOPROTOOPT`
587/// disables the pin check and permits a best-effort read on older kernels
588/// (e.g. Ubuntu 22.04). Every other pin failure, including `EINVAL` for an
589/// already-reaped peer, skips the supplementary read and keeps the primary GID.
590pub fn resolve_caller(stream: &UnixStream) -> CallerAttribution {
591    // Pin before any /proc read. The kernel's peer identity does not depend on
592    // the PID lookup performed later, even if the peer has already exited.
593    resolve_caller_with_pin(
594        stream,
595        #[cfg(target_os = "linux")]
596        peer_pidfd(stream),
597    )
598}
599
600fn resolve_caller_with_pin(
601    stream: &UnixStream,
602    #[cfg(target_os = "linux")] pin: PeerPin,
603) -> CallerAttribution {
604    let (pid, primary_gid, uid) = match stream.peer_cred() {
605        Ok(cred) => {
606            let pid = match cred.pid() {
607                // Strictly positive: the kernel writes 0 when the peer's pid is
608                // not representable in this daemon's pid namespace, and 0 is
609                // never a real peer. Accepting it read as a successful
610                // attribution of a process that cannot be named.
611                Some(p) if p > 0 => p as u32,
612                _ => {
613                    eprintln!(
614                        "[sysknife-daemon] WARNING: peer_cred() returned no usable pid; \
615                         defaulting to Observer and recording the caller as unattributed"
616                    );
617                    return CallerAttribution::unattributed();
618                }
619            };
620            (pid, cred.gid(), cred.uid())
621        }
622        Err(e) => {
623            eprintln!(
624                "[sysknife-daemon] WARNING: peer_cred() failed: {e}; defaulting to Observer \
625                 and recording the caller as unattributed"
626            );
627            return CallerAttribution::unattributed();
628        }
629    };
630
631    // Read /etc/group once and build a lookup map — avoids N+1 file reads when
632    // a process has many supplementary groups (one read per GID in the old code).
633    let gid_map = read_gid_map();
634    #[cfg(target_os = "linux")]
635    let mut groups = groups_for_pinned_peer(pid, pin, || groups_for_pid(pid, &gid_map));
636    #[cfg(not(target_os = "linux"))]
637    let mut groups = groups_for_pid(pid, &gid_map);
638
639    // Include the primary GID from SO_PEERCRED. It is not listed in the
640    // supplementary Groups: line so must be resolved and added explicitly.
641    if let Some(name) = gid_map.get(&primary_gid) {
642        if !groups.contains(name) {
643            groups.push(name.clone());
644        }
645    }
646    if groups.is_empty() {
647        eprintln!(
648            "[sysknife-daemon] WARNING: could not resolve groups for PID {pid}; defaulting to Observer"
649        );
650    }
651    // The uid comes from the same SO_PEERCRED read as the gid, captured by the
652    // kernel at connect(), so it is race-free in a way the supplementary group
653    // set read from /proc is not.
654    //
655    // It is not always a real account, though. The kernel translates `struct
656    // ucred` into the *reader's* namespaces, and when the peer's uid is not
657    // mappable it substitutes the overflow uid (`/proc/sys/kernel/overflowuid`,
658    // 65534 / `nobody` by default) rather than failing. A socket bind-mounted
659    // into a container reaches this path. Signing `uid:65534` there would make a
660    // positive claim that the `nobody` account acted, when the truth is that the
661    // kernel could not name the peer — exactly the false attribution
662    // `CallerPrincipal::Unattributed` exists to avoid.
663    if uid == overflow_uid() {
664        eprintln!(
665            "[sysknife-daemon] WARNING: SO_PEERCRED reported the overflow uid ({uid}) for pid \
666             {pid}; the peer is not representable in this daemon's user namespace. Recording \
667             the caller as unattributed rather than as uid:{uid}."
668        );
669        return CallerAttribution::unattributed();
670    }
671
672    CallerAttribution::from_peer_uid(uid, highest_role_from_groups(groups))
673}
674
675/// The uid the kernel substitutes when a peer's uid cannot be mapped into this
676/// process's user namespace.
677///
678/// Read once from `/proc/sys/kernel/overflowuid`, falling back to the kernel's
679/// compile-time default. The fallback is not a "just in case" branch: on a host
680/// where procfs is not mounted the value is still 65534, and treating an
681/// unreadable sysctl as "no overflow uid exists" would re-open the false
682/// attribution this guards against.
683fn overflow_uid() -> u32 {
684    /// Kernel default from `include/linux/highuid.h`.
685    const DEFAULT_OVERFLOW_UID: u32 = 65534;
686    static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
687    *CACHED.get_or_init(|| {
688        std::fs::read_to_string("/proc/sys/kernel/overflowuid")
689            .ok()
690            .and_then(|raw| raw.trim().parse::<u32>().ok())
691            .unwrap_or(DEFAULT_OVERFLOW_UID)
692    })
693}
694
695/// Read `/etc/group` once and return a `HashMap<gid, group_name>`.
696/// Silently returns an empty map on I/O failure (falls back to Observer role).
697fn read_gid_map() -> std::collections::HashMap<u32, String> {
698    let content = match std::fs::read_to_string("/etc/group") {
699        Ok(c) => c,
700        Err(e) => {
701            // Without /etc/group all callers will be resolved to Observer.
702            // This is a misconfiguration or permission problem that must be visible.
703            eprintln!("[sysknife-daemon] WARNING: cannot read /etc/group: {e}; all callers will be demoted to Observer");
704            return std::collections::HashMap::new();
705        }
706    };
707    let mut map = std::collections::HashMap::new();
708    for (line_no, line) in content.lines().enumerate() {
709        let mut parts = line.splitn(4, ':');
710        let name = match parts.next() {
711            Some(n) => n,
712            None => {
713                eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no}: missing name field");
714                continue;
715            }
716        };
717        let _ = parts.next(); // password field
718        let gid_str = match parts.next() {
719            Some(g) => g,
720            None => {
721                eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): missing GID field");
722                continue;
723            }
724        };
725        match gid_str.parse::<u32>() {
726            Ok(gid) => {
727                map.insert(gid, name.to_string());
728            }
729            Err(_) => {
730                eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): GID {gid_str:?} is not a number");
731            }
732        }
733    }
734    map
735}
736
737fn groups_for_pid(pid: u32, gid_map: &std::collections::HashMap<u32, String>) -> Vec<String> {
738    let status = match std::fs::read_to_string(format!("/proc/{pid}/status")) {
739        Ok(s) => s,
740        Err(e) => {
741            eprintln!(
742                "[sysknife-daemon] could not read /proc/{pid}/status: {e}; treating as no groups"
743            );
744            return vec![];
745        }
746    };
747    for line in status.lines() {
748        if line.starts_with("Groups:") {
749            return line
750                .trim_start_matches("Groups:")
751                .split_whitespace()
752                .filter_map(|s| s.parse::<u32>().ok())
753                .filter_map(|gid| gid_map.get(&gid).cloned())
754                .collect();
755        }
756    }
757    // If the Groups: line is absent (unusual kernel config or namespacing),
758    // the caller will be resolved to Observer via its primary GID only.
759    // Log so operators can diagnose unexpected authorization failures.
760    eprintln!(
761        "[sysknife-daemon] WARNING: no Groups: line in /proc/{pid}/status; \
762         supplementary groups unavailable — caller may be demoted to Observer"
763    );
764    vec![]
765}
766
767// ---------------------------------------------------------------------------
768// Authorization helpers
769// ---------------------------------------------------------------------------
770
771fn authorize_action(
772    policy: &crate::policy::PolicyTable,
773    caller: &CallerRole,
774    action_name: &str,
775) -> bool {
776    policy.action_allowed(caller, action_name)
777}
778
779/// The message for a refused action.
780///
781/// Refusals used to end at "is not allowed for Observer role", which is true and
782/// unactionable. When the required role is known, name the group that grants it
783/// and the command to join. The role ceiling can be raised per action via
784/// `[policy.risk_overrides]`, so the effective minimum is read from the live
785/// policy rather than the compile-time baseline.
786fn authorization_failure_message(
787    policy: &crate::policy::PolicyTable,
788    caller: &CallerRole,
789    action_name: &str,
790) -> String {
791    match policy.min_role_for_action(action_name) {
792        Some(required) => crate::auth::denial_message(action_name, *caller, required),
793        // No baseline entry means the action is not in the catalogue at all;
794        // there is no group to recommend.
795        None => format!("action '{action_name}' is not allowed for {caller:?} role"),
796    }
797}
798
799/// Authorize a caller to act on an existing transaction (approve / cancel /
800/// view details), keyed on the transaction's own action. Transaction IDs are
801/// enumerable by any Observer-tier caller via history, so without this a
802/// low-privilege caller could mint, consume, or cancel the one-time approval
803/// slot of an action they are not allowed to run — defeating the human-approval
804/// boundary. Only a caller authorized for the action may touch its approval.
805///
806/// Returns `Ok(true)` when authorized (proceed). Returns `Ok(false)` when a
807/// response has already been sent (transaction missing, load error, or denied)
808/// and the caller must return early.
809/// Whether `caller` is the account that created `transaction_id`.
810///
811/// The approval receipt is documented as proof that a human confirmed one
812/// specific preview. Checking only the caller's role made that proof
813/// unattributed: any account permitted to reach the socket could mint a
814/// receipt for a transaction it had never previewed, and the signed row would
815/// still name the account that *previewed* it, so the trail blamed the wrong
816/// person. `caller_principal` was already captured, stored and signed; nothing
817/// read it back.
818///
819/// The rule is principal equality, not connection equality. `sysknife approve`
820/// is deliberately a separate process from the client that previewed, and every
821/// CLI call opens its own connection, so requiring one connection would break
822/// the product. Requiring one account does not: both halves run as the same
823/// uid, and a vsock client previews and approves over the same channel.
824///
825/// Two cases refuse rather than compare:
826///
827/// - `Unattributed`, on either side. The kernel could not name the peer, so
828///   two such callers are not known to be the same account. Treating them as
829///   equal would put a claim in the signed record that the daemon has no
830///   evidence for, which is the thing [`CallerPrincipal::Unattributed`] exists
831///   to avoid.
832/// - A row with no stored principal. Only a chain imported from before the
833///   `caller_principal` migration can be in that state, and such a row cannot
834///   legitimately be queued for approval on a daemon running this code.
835async fn caller_owns_transaction(
836    state: &DaemonState,
837    caller: &CallerAttribution,
838    transaction_id: &str,
839) -> Ownership {
840    if matches!(
841        caller.principal(),
842        crate::auth::CallerPrincipal::Unattributed
843    ) {
844        return Ownership::CannotEstablish(
845            "this connection could not be attributed to an account, so it cannot approve, \
846             cancel, inspect or execute a transaction. The daemon reports the peer as \
847             unattributed when SO_PEERCRED fails or when the peer is not representable in \
848             this daemon's user namespace."
849                .to_string(),
850        );
851    }
852    let row = match state.audit.fetch_chain_row(transaction_id).await {
853        Ok(row) => row,
854        Err(e) => {
855            return Ownership::CannotEstablish(format!(
856                "failed to load transaction ownership: {e}"
857            ));
858        }
859    };
860    // A transaction that does not exist is not an ownership question. Saying
861    // "that belongs to someone else" about an id nobody ever created would be
862    // both wrong and a way to probe which ids exist, so this hands the case
863    // back to the caller's own missing-transaction path.
864    let Some(row) = row else {
865        return Ownership::NoSuchTransaction;
866    };
867    let Some(owner) = row.caller_principal.as_deref() else {
868        return Ownership::CannotEstablish(format!(
869            "transaction {transaction_id} records no owning account, so ownership cannot be \
870             established"
871        ));
872    };
873    if owner == caller.principal().as_signed_str() {
874        Ownership::Owned
875    } else {
876        Ownership::NotOwned
877    }
878}
879
880/// The four answers [`caller_owns_transaction`] can give.
881///
882/// An enum rather than a `Result<bool, _>` because "no such transaction" and
883/// "someone else's transaction" need different answers to the caller, and a
884/// boolean forced them together. The first version of this check reported a
885/// nonexistent id as belonging to another account, which is both untrue and a
886/// way to probe the id space.
887enum Ownership {
888    Owned,
889    NotOwned,
890    NoSuchTransaction,
891    CannotEstablish(String),
892}
893
894/// The refusal an operator sees when they act on someone else's transaction.
895///
896/// Deliberately does not name the owning account. Telling one caller which
897/// other account queued a transaction would hand out exactly the cross-account
898/// information this check exists to withhold.
899fn not_your_transaction_message(transaction_id: &str) -> String {
900    format!(
901        "transaction {transaction_id} was created by a different account. An approval receipt \
902         is proof that a specific account confirmed a specific preview, so only the account \
903         that previewed an action can approve, cancel, inspect or execute it."
904    )
905}
906
907async fn authorize_for_transaction(
908    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
909    state: &DaemonState,
910    caller: &CallerAttribution,
911    request_id: &str,
912    transaction_id: &str,
913    missing_category: &str,
914    missing_message: &str,
915) -> Result<bool, HandlerError> {
916    match state.audit.get(transaction_id).await {
917        Ok(Some(transaction)) => {
918            match caller_owns_transaction(state, caller, transaction_id).await {
919                Ownership::Owned | Ownership::NoSuchTransaction => {}
920                Ownership::NotOwned => {
921                    send_error(
922                        framed,
923                        request_id,
924                        "authorization_failure",
925                        not_your_transaction_message(transaction_id),
926                    )
927                    .await?;
928                    return Ok(false);
929                }
930                Ownership::CannotEstablish(message) => {
931                    send_error(framed, request_id, "authorization_failure", message).await?;
932                    return Ok(false);
933                }
934            }
935            if authorize_action(&state.policy, &caller.role(), &transaction.action_name) {
936                Ok(true)
937            } else {
938                send_error(
939                    framed,
940                    request_id,
941                    "authorization_failure",
942                    authorization_failure_message(
943                        &state.policy,
944                        &caller.role(),
945                        &transaction.action_name,
946                    ),
947                )
948                .await?;
949                Ok(false)
950            }
951        }
952        Ok(None) => {
953            send_error(framed, request_id, missing_category, missing_message).await?;
954            Ok(false)
955        }
956        Err(e) => {
957            send_error(
958                framed,
959                request_id,
960                "transient_infrastructure_failure",
961                format!("failed to load transaction: {e}"),
962            )
963            .await?;
964            Ok(false)
965        }
966    }
967}
968
969// Family-specific action lists come from the single source of truth in
970// `sysknife-core::action_family`, shared with the CLI routing guard and the
971// brain prompt so the execution fence can never drift out of parity.
972use sysknife_core::action_family::{action_matches_distro, action_requires_distro};
973
974fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<(), String> {
975    // Deliberately the compile-time baseline, not `state.policy`. Whether an
976    // action mutates the system is a property of the action; whether a given
977    // caller may run it is what `[policy.risk_overrides]` decides. Reading it
978    // from the override-adjusted role let an operator who tightened RBAC on a
979    // read-only action also make that read fail when the host distro could not
980    // be detected — a distro requirement appearing as a side effect of an
981    // access-control change.
982    let is_mutating = crate::policy::min_role_for_action(action_name)
983        .is_some_and(|role| role > CallerRole::Observer);
984    if !action_requires_distro(action_name) && !is_mutating {
985        return Ok(());
986    }
987
988    // Both gates below apply to hard-fenced actions whether or not they read,
989    // and to all baseline non-Observer actions. Portable Observer reads are
990    // exempt above, even without distro detection; planner preferences do not
991    // turn into daemon mechanism fences.
992    //
993    // An earlier revision exempted read-only ones, on the reasoning that
994    // docs/distro-support.md promises to refuse only *mutating* actions on an
995    // ineligible host. The exemption was keyed on `is_mutating`, which is derived
996    // from the RBAC role, which is derived from `risk_level` — and that proxy is
997    // wrong for at least one catalogued action: `AptUpdate` is `RiskLevel::Low`,
998    // so `Observer`, so "read-only" by that measure, while it runs
999    // `sudo apt-get update` as root, writes under /var/lib/apt/lists and takes
1000    // the dpkg lock. Exempting it widened the set of hosts that can run a
1001    // privileged mutation.
1002    //
1003    // Fail closed until the bit lives on its owner. "Does this action change the
1004    // host" is a property of the ActionSpec, not of who may call it, and the fix
1005    // is to record it there — see the linked issue. Until then the strict gate
1006    // costs read-only inspection on ineligible hosts (`AptShow` on Debian), which
1007    // is the pre-existing behaviour and the safe direction to be wrong in.
1008    let distro = state.host_distro.as_ref().ok_or_else(|| {
1009        format!("cannot safely run {action_name}: host distribution could not be detected")
1010    })?;
1011    if !distro.is_supported() {
1012        return Err(format!(
1013            "cannot run {action_name} on unsupported host {distro}; see docs/distro-support.md"
1014        ));
1015    }
1016    if !action_matches_distro(action_name, distro) {
1017        return Err(format!(
1018            "action {action_name} is incompatible with supported host {distro}"
1019        ));
1020    }
1021    Ok(())
1022}
1023
1024// ---------------------------------------------------------------------------
1025// Request hash
1026// ---------------------------------------------------------------------------
1027
1028/// Compute `HMAC-SHA256(key, action_name || "\x00" || canonical_json(params))`,
1029/// hex-encoded, where `key` is derived from the audit key (#268).
1030///
1031/// Canonical JSON serialises object keys in sorted order (recursively),
1032/// ensuring identical logical params always produce the same hash regardless
1033/// of insertion order.
1034///
1035/// This was a bare SHA-256 until `chain_version = 4`. The column is signed
1036/// into every audit row and exported verbatim, and the preimage is the
1037/// *unredacted* request, so an unsalted hash let any holder of an export
1038/// brute-force low-entropy parameters offline. Keying it with material that
1039/// never leaves the host removes that attack while keeping the
1040/// preview/execute binding this function exists for.
1041pub fn compute_request_hash(key: &RequestHashKey, action_name: &str, params: &Value) -> String {
1042    let canonical = canonical_json(params);
1043    key.request_hash(action_name, &canonical)
1044}
1045
1046/// Key under which a previewed `AptAutoremove` records the exact set of packages
1047/// `apt-get -s autoremove` would remove, so execute can bind to it (#151).
1048const AUTOREMOVE_REMOVALS_KEY: &str = "autoremove_removals";
1049
1050/// The deployment a Pin/Unpin preview was approved against.
1051const DEPLOYMENT_IDENTITY_KEY: &str = "deployment_identity";
1052
1053/// Pull the ostree checksum of the deployment sitting at `index` out of
1054/// `rpm-ostree status --json`.
1055///
1056/// The checksum is the stable identity; the index is a position in a list that
1057/// UpdateSystem, CleanupDeployments and RollbackDeployment all reorder. An
1058/// index past the end, unparseable output, or a deployment carrying no checksum
1059/// are all errors rather than a default: binding execute to an empty string
1060/// would make every later comparison succeed.
1061fn parse_deployment_identity(status_json: &str, index: u32) -> Result<String, String> {
1062    let doc: Value = serde_json::from_str(status_json)
1063        .map_err(|e| format!("rpm-ostree status --json did not parse: {e}"))?;
1064    let deployments = doc
1065        .get("deployments")
1066        .and_then(Value::as_array)
1067        .ok_or_else(|| "rpm-ostree status --json has no deployments array".to_string())?;
1068    let entry = deployments.get(index as usize).ok_or_else(|| {
1069        format!(
1070            "there is no deployment at index {index}; rpm-ostree lists {}",
1071            deployments.len()
1072        )
1073    })?;
1074    let checksum = entry
1075        .get("checksum")
1076        .and_then(Value::as_str)
1077        .filter(|c| !c.is_empty())
1078        .ok_or_else(|| format!("the deployment at index {index} carries no checksum"))?;
1079    Ok(checksum.to_string())
1080}
1081
1082/// Read the live identity of the deployment at `index`.
1083async fn resolve_deployment_identity(
1084    runner: &Arc<dyn CommandRunner + Send + Sync>,
1085    index: u32,
1086) -> Result<String, String> {
1087    let r = Arc::clone(runner);
1088    match tokio::task::spawn_blocking(move || r.run("rpm-ostree", &["status", "--json"])).await {
1089        Ok(Ok(out)) => parse_deployment_identity(&out, index),
1090        Ok(Err(e)) => Err(format!("rpm-ostree status --json failed: {e}")),
1091        Err(e) => Err(format!("deployment status task panicked: {e}")),
1092    }
1093}
1094
1095/// Confirm the deployment now at the approved index is still the one the
1096/// operator approved.
1097///
1098/// `PinDeployment` and `UnpinDeployment` take an ordinal, so the effect that
1099/// executes need not be the effect that was previewed. `compute_request_hash`
1100/// covers `{"index": N}` and nothing else, which is byte-identical however much
1101/// has moved into slot N since, so the hash binding and the signed audit row
1102/// both report a clean match to a validly approved preview while the wrong
1103/// deployment gets pinned. Fails closed the way `verify_autoremove_binding`
1104/// does: a preview that captured no identity cannot be executed.
1105fn verify_deployment_binding(
1106    approved_proposed_change: &Value,
1107    live_identity: &str,
1108) -> Result<(), String> {
1109    let Some(captured) = approved_proposed_change
1110        .get(DEPLOYMENT_IDENTITY_KEY)
1111        .and_then(Value::as_str)
1112    else {
1113        return Err(
1114            "the approved preview did not capture which deployment sat at that index;              preview again"
1115                .to_string(),
1116        );
1117    };
1118    if captured == live_identity {
1119        return Ok(());
1120    }
1121    Err(format!(
1122        "the deployment at that index changed since you approved it (approved {captured},          now {live_identity}); preview again"
1123    ))
1124}
1125
1126/// Run `apt-get -s autoremove` and parse the set of packages it would remove.
1127/// The simulate is read-only (no `sudo`), so it runs through the same
1128/// `CommandRunner` the preview uses to collect state.
1129async fn simulate_autoremove_set(
1130    runner: &Arc<dyn CommandRunner + Send + Sync>,
1131) -> Result<std::collections::BTreeSet<String>, String> {
1132    let r = Arc::clone(runner);
1133    match tokio::task::spawn_blocking(move || r.run("apt-get", &["-s", "autoremove"])).await {
1134        Ok(Ok(out)) => Ok(crate::actions::apt::parse_autoremove_removals(&out)),
1135        Ok(Err(e)) => Err(format!("apt-get -s autoremove failed: {e}")),
1136        Err(e) => Err(format!("autoremove simulate task panicked: {e}")),
1137    }
1138}
1139
1140/// A warning if the autoremove set includes kernel, driver, or bootloader
1141/// packages — the classes that make an unattended autoremove dangerous, so the
1142/// operator sees them explicitly in the preview.
1143fn autoremove_kernel_warning(set: &std::collections::BTreeSet<String>) -> Option<String> {
1144    let flagged: Vec<&str> = set
1145        .iter()
1146        .map(String::as_str)
1147        .filter(|n| {
1148            let base = n.split(':').next().unwrap_or(n);
1149            base.starts_with("linux-image")
1150                || base.starts_with("linux-modules")
1151                || base.starts_with("linux-headers")
1152                || base.starts_with("grub")
1153                || base.contains("nvidia")
1154                || base.ends_with("-dkms")
1155        })
1156        .collect();
1157    if flagged.is_empty() {
1158        None
1159    } else {
1160        Some(format!(
1161            "autoremove would delete kernel/driver/bootloader packages: {}. Review carefully.",
1162            flagged.join(", ")
1163        ))
1164    }
1165}
1166
1167/// Confirm the live autoremove set still matches the set captured in the
1168/// approved preview (#151). `apt-get autoremove -y` resolves its deletion set at
1169/// run time, so without this the executed effect is unbound by the approval.
1170/// Fails closed: a preview that never captured a set (the simulate failed then)
1171/// cannot be executed.
1172fn verify_autoremove_binding(
1173    approved_proposed_change: &Value,
1174    live: &std::collections::BTreeSet<String>,
1175) -> Result<(), String> {
1176    let Some(captured) = approved_proposed_change.get(AUTOREMOVE_REMOVALS_KEY) else {
1177        return Err(
1178            "the approved preview did not capture the autoremove deletion set; preview again"
1179                .to_string(),
1180        );
1181    };
1182    let approved: std::collections::BTreeSet<String> = serde_json::from_value(captured.clone())
1183        .map_err(|_| {
1184            "the approved preview's autoremove set is malformed; preview again".to_string()
1185        })?;
1186    if &approved == live {
1187        return Ok(());
1188    }
1189    let added: Vec<&str> = live.difference(&approved).map(String::as_str).collect();
1190    let removed: Vec<&str> = approved.difference(live).map(String::as_str).collect();
1191    Err(format!(
1192        "the set of packages autoremove would delete changed since you approved it \
1193         (now also removes: [{}]; no longer removes: [{}]); preview again",
1194        added.join(", "),
1195        removed.join(", ")
1196    ))
1197}
1198
1199fn canonical_json(v: &Value) -> String {
1200    match v {
1201        Value::Object(map) => {
1202            let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1203            keys.sort_unstable();
1204            let pairs = keys
1205                .iter()
1206                .map(|k| {
1207                    // Use Value::String's Display impl to JSON-encode the key —
1208                    // infallible because Rust strings are always valid UTF-8.
1209                    format!(
1210                        "{}:{}",
1211                        Value::String((*k).to_string()),
1212                        canonical_json(&map[*k])
1213                    )
1214                })
1215                .collect::<Vec<_>>()
1216                .join(",");
1217            format!("{{{pairs}}}")
1218        }
1219        // Arrays preserve element order (ordering is meaningful) but recurse
1220        // into each element so nested objects get their keys sorted.
1221        Value::Array(arr) => {
1222            let items = arr.iter().map(canonical_json).collect::<Vec<_>>().join(",");
1223            format!("[{items}]")
1224        }
1225        // For scalars (null, bool, number, string) use Value's Display impl,
1226        // which renders valid JSON and is infallible.
1227        _ => format!("{v}"),
1228    }
1229}
1230
1231// ---------------------------------------------------------------------------
1232// Connection handler
1233// ---------------------------------------------------------------------------
1234
1235/// Handle a single Unix-socket (or test duplex) connection until the peer
1236/// closes it.
1237///
1238/// # Security — Unix/test streams only
1239///
1240/// **Do not call with a vsock stream.** The name `unix_connection_handler`
1241/// is the type-system gate: this function is explicitly for Unix-domain
1242/// sockets (where `caller_role` is resolved from `SO_PEERCRED` out-of-band)
1243/// and for in-process duplex streams used by integration tests (where the
1244/// test supplies the role directly).
1245///
1246/// Vsock connections carry an untrusted remote peer. Calling this function
1247/// with a vsock stream would hand the peer whatever `caller_role` was passed
1248/// in — bypassing token authentication entirely. Vsock connections MUST go
1249/// through [`vsock_connection_handler`], which validates the auth token in
1250/// the first frame before dispatching to the inner handler.
1251///
1252/// **Threat model note:** lying about the stream type here (e.g. passing a
1253/// vsock stream while pretending it is a Unix stream) constitutes a
1254/// privilege escalation: the caller receives the role passed in without any
1255/// cryptographic proof.
1256///
1257/// `caller_role` MUST be resolved before this function is called.
1258pub async fn unix_connection_handler<S>(
1259    stream: S,
1260    state: DaemonState,
1261    runner: Arc<dyn CommandRunner + Send + Sync>,
1262    caller: CallerAttribution,
1263) where
1264    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1265{
1266    let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
1267    connection_handler_with_executor(stream, state, runner, executor, caller).await;
1268}
1269
1270/// Handle a vsock connection: validate token from first frame, then dispatch.
1271///
1272/// The first frame must be `{"type":"auth","token":"<hex>"}`. On failure the
1273/// connection is closed silently to prevent oracle attacks.
1274#[cfg(target_os = "linux")]
1275pub async fn vsock_connection_handler<S>(
1276    stream: S,
1277    state: DaemonState,
1278    runner: Arc<dyn CommandRunner + Send + Sync>,
1279) where
1280    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1281{
1282    let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
1283    vsock_connection_handler_with_executor(stream, state, runner, executor).await;
1284}
1285
1286/// Vsock handler with explicit executor (used by integration tests).
1287#[cfg(target_os = "linux")]
1288pub async fn vsock_connection_handler_with_executor<S>(
1289    stream: S,
1290    state: DaemonState,
1291    runner: Arc<dyn CommandRunner + Send + Sync>,
1292    executor: Arc<dyn ActionExecutor>,
1293) where
1294    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1295{
1296    let mut framed = FramedStream::new(stream);
1297
1298    let token_path = crate::auth::default_token_path();
1299    let caller = match authenticate_vsock_token(&mut framed, &token_path).await {
1300        // A pre-shared token proves possession of a file, not the identity of a
1301        // person. Recording it as such keeps the chain honest: any holder of
1302        // that token could have made this call.
1303        Some(role) => CallerAttribution::from_vsock_token(role),
1304        None => {
1305            eprintln!("[sysknife-daemon] vsock auth failed; closing connection");
1306            return;
1307        }
1308    };
1309
1310    dispatch_loop(&mut framed, state, runner, executor, caller).await;
1311}
1312
1313/// Inner handler that accepts an explicit [`ActionExecutor`].
1314///
1315/// Production code enters via [`unix_connection_handler`] or
1316/// [`vsock_connection_handler`]; tests call this directly with a mock executor.
1317pub async fn connection_handler_with_executor<S>(
1318    stream: S,
1319    state: DaemonState,
1320    runner: Arc<dyn CommandRunner + Send + Sync>,
1321    executor: Arc<dyn ActionExecutor>,
1322    caller: CallerAttribution,
1323) where
1324    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1325{
1326    let mut framed = FramedStream::new(stream);
1327    dispatch_loop(&mut framed, state, runner, executor, caller).await;
1328}
1329
1330// ---------------------------------------------------------------------------
1331// Vsock token authentication
1332// ---------------------------------------------------------------------------
1333
1334/// Receive the first frame, parse the auth message, and validate the token.
1335///
1336/// Returns the granted `CallerRole` on success, or `None` if the frame is
1337/// malformed or the token does not match the stored value. Every rejection
1338/// path logs a distinct, operator-facing `eprintln!` naming the specific
1339/// cause (timeout, framing error, malformed JSON, wrong frame type, or a bad
1340/// token) so operators can diagnose vsock auth failures from server logs.
1341/// The response sent back to the *client* stays a uniform generic rejection
1342/// regardless of cause — these logs are server-side only and must never be
1343/// used to build a client-observable auth oracle.
1344///
1345/// RESIDUAL RISK (#152): the token is a pre-shared **bearer** credential sent in
1346/// the clear as this first frame. Anyone who can observe one legitimate vsock
1347/// connection can read it and authenticate as the configured role, then mint a
1348/// fresh approval receipt — the approval/claim layers stop replay of a captured
1349/// request, not an attacker who holds the token. This is inherent to a bearer
1350/// token over an unencrypted channel and cannot be closed here; the threat
1351/// model, operator mitigations (isolate the vsock namespace, prefer forwarding
1352/// the Unix socket over `ssh -L`), and the paths to fully close it (a
1353/// nonce/HMAC challenge, or TLS/WireGuard under vsock) are documented in
1354/// SECURITY.md under "vsock transport authentication".
1355async fn authenticate_vsock_token(
1356    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1357    token_path: &std::path::Path,
1358) -> Option<CallerRole> {
1359    #[derive(serde::Deserialize)]
1360    struct AuthFrame {
1361        #[serde(rename = "type")]
1362        msg_type: String,
1363        token: String,
1364    }
1365
1366    let recv_result = tokio::time::timeout(
1367        std::time::Duration::from_secs(VSOCK_AUTH_FRAME_TIMEOUT_SECS),
1368        framed.recv(),
1369    )
1370    .await;
1371    let Ok(frame_result) = recv_result else {
1372        eprintln!(
1373            "[sysknife-daemon] vsock auth: timed out waiting for auth frame after {VSOCK_AUTH_FRAME_TIMEOUT_SECS}s"
1374        );
1375        return None;
1376    };
1377    let raw = match frame_result {
1378        Ok(raw) => raw,
1379        Err(e) => {
1380            eprintln!("[sysknife-daemon] vsock auth: framing error reading auth frame: {e}");
1381            return None;
1382        }
1383    };
1384    let auth: AuthFrame = match serde_json::from_slice(&raw) {
1385        Ok(auth) => auth,
1386        Err(e) => {
1387            eprintln!("[sysknife-daemon] vsock auth: malformed auth frame JSON: {e}");
1388            return None;
1389        }
1390    };
1391    if auth.msg_type != "auth" {
1392        eprintln!(
1393            "[sysknife-daemon] vsock auth: unexpected frame type {:?} (expected \"auth\")",
1394            auth.msg_type
1395        );
1396        return None;
1397    }
1398    let role = crate::auth::validate_token_against_file(&auth.token, token_path);
1399    if role.is_none() {
1400        eprintln!("[sysknife-daemon] vsock auth: token did not validate against token file");
1401    }
1402    role
1403}
1404
1405// ---------------------------------------------------------------------------
1406// Main dispatch loop (shared by Unix and vsock paths)
1407// ---------------------------------------------------------------------------
1408
1409/// How long a connection may wait for its next request before the daemon
1410/// closes it and frees the connection slot. Generous enough for an
1411/// interactive session (preview → read the plan → approve in another
1412/// terminal → execute), short enough that idle sockets cannot pin the
1413/// `MAX_CONNECTIONS` pool indefinitely.
1414const IDLE_CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
1415
1416/// How long a freshly accepted connection may stay silent before its first
1417/// request.
1418///
1419/// The idle bound above exists so a parked connection cannot squat a
1420/// `MAX_CONNECTIONS` permit forever, but 15 minutes is a generous allowance to
1421/// hand a peer that has not yet proved it wants anything. Every real client
1422/// writes its request immediately after `connect()`, so the pre-request window
1423/// can be short — and it is the cheapest window to abuse, because occupying it
1424/// needs only socket-group membership, not any mutating role.
1425const FIRST_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
1426
1427/// Read deadline for the next frame, given how many requests this connection
1428/// has already been served.
1429///
1430/// Split out as a pure function so the policy is unit-testable without driving
1431/// a socket through a paused clock.
1432fn read_deadline(requests_served: usize) -> std::time::Duration {
1433    if requests_served == 0 {
1434        FIRST_REQUEST_TIMEOUT
1435    } else {
1436        IDLE_CONNECTION_TIMEOUT
1437    }
1438}
1439
1440async fn dispatch_loop<S>(
1441    framed: &mut FramedStream<S>,
1442    state: DaemonState,
1443    runner: Arc<dyn CommandRunner + Send + Sync>,
1444    executor: Arc<dyn ActionExecutor>,
1445    caller: CallerAttribution,
1446) where
1447    S: AsyncRead + AsyncWrite + Unpin,
1448{
1449    let mut requests_served: usize = 0;
1450    loop {
1451        // Bound how long a connection may sit idle between requests.
1452        //
1453        // Without this, the lowest-privilege caller can open MAX_CONNECTIONS
1454        // sockets, never write a byte, and hold every semaphore permit
1455        // forever — the next connection is dropped at accept, including one
1456        // from an Admin trying to approve something. The pre-auth vsock path
1457        // already bounds its handshake for exactly this reason; the post-auth
1458        // loop had no equivalent on either transport.
1459        //
1460        // This is an *idle* bound: it only applies while waiting for the next
1461        // request. A long-running action is executing inside a handler, not
1462        // parked here, so a 45-minute upgrade is unaffected.
1463        let deadline = read_deadline(requests_served);
1464        let raw = match tokio::time::timeout(deadline, framed.recv()).await {
1465            Ok(Ok(bytes)) => bytes,
1466            Ok(Err(FramingError::Io(_))) => break, // peer closed
1467            Ok(Err(FramingError::MessageTooLarge(_))) => break, // framing violation
1468            Err(_) => {
1469                if requests_served == 0 {
1470                    eprintln!(
1471                        "[sysknife-daemon] closing connection that sent no request within {}s",
1472                        deadline.as_secs()
1473                    );
1474                } else {
1475                    eprintln!(
1476                        "[sysknife-daemon] closing connection idle for more than {}s",
1477                        deadline.as_secs()
1478                    );
1479                }
1480                break;
1481            }
1482        };
1483        requests_served += 1;
1484
1485        let msg: Value = match serde_json::from_slice(&raw) {
1486            Ok(v) => v,
1487            Err(e) => {
1488                eprintln!("[sysknife-daemon] malformed JSON from client, closing connection: {e}");
1489                break;
1490            }
1491        };
1492
1493        let request: DaemonRequest = match serde_json::from_value(msg) {
1494            Ok(r) => r,
1495            Err(e) => {
1496                if let Err(send_err) = send_error(
1497                    framed,
1498                    "",
1499                    "validation_failure",
1500                    format!("unknown message type: {e}"),
1501                )
1502                .await
1503                {
1504                    eprintln!(
1505                        "[sysknife-daemon] failed to send validation error response: {send_err}"
1506                    );
1507                }
1508                continue;
1509            }
1510        };
1511
1512        let result = match &request {
1513            DaemonRequest::QueryState { request_id } => {
1514                handle_query_state(
1515                    framed,
1516                    Arc::clone(&runner),
1517                    state.host_distro.clone(),
1518                    request_id,
1519                )
1520                .await
1521            }
1522            DaemonRequest::Preview {
1523                request_id,
1524                action_name,
1525                params,
1526                unattended,
1527            } => {
1528                handle_preview(
1529                    framed,
1530                    &state,
1531                    Arc::clone(&runner),
1532                    &caller,
1533                    request_id,
1534                    action_name,
1535                    params,
1536                    *unattended,
1537                )
1538                .await
1539            }
1540            DaemonRequest::Execute {
1541                request_id,
1542                transaction_id,
1543                action_name,
1544                params,
1545                approval_receipt,
1546            } => {
1547                handle_execute(
1548                    framed,
1549                    &state,
1550                    Arc::clone(&executor),
1551                    Arc::clone(&runner),
1552                    &caller,
1553                    request_id,
1554                    transaction_id,
1555                    action_name,
1556                    params,
1557                    approval_receipt,
1558                )
1559                .await
1560            }
1561            DaemonRequest::Approve {
1562                request_id,
1563                transaction_id,
1564            } => handle_approve(framed, &state, &caller, request_id, transaction_id.as_str()).await,
1565            DaemonRequest::ApprovalDetails {
1566                request_id,
1567                transaction_id,
1568            } => {
1569                handle_approval_details(
1570                    framed,
1571                    &state,
1572                    &caller,
1573                    request_id,
1574                    transaction_id.as_str(),
1575                )
1576                .await
1577            }
1578            DaemonRequest::QueryAction {
1579                request_id,
1580                action_name,
1581                params,
1582            } => {
1583                handle_query_action(
1584                    framed,
1585                    Arc::clone(&executor),
1586                    &state,
1587                    action_name,
1588                    params,
1589                    request_id,
1590                )
1591                .await
1592            }
1593            DaemonRequest::QueryHistory {
1594                request_id,
1595                limit,
1596                status_filter,
1597                action_filter,
1598                since_hours,
1599                since,
1600            } => {
1601                handle_query_history(
1602                    framed,
1603                    &state,
1604                    request_id,
1605                    *limit,
1606                    status_filter.clone(),
1607                    action_filter.clone(),
1608                    *since_hours,
1609                    since.as_deref(),
1610                )
1611                .await
1612            }
1613            DaemonRequest::Describe {
1614                request_id,
1615                action_name,
1616                params,
1617            } => handle_describe(framed, &state, &caller, action_name, params, request_id).await,
1618            DaemonRequest::Cancel {
1619                request_id,
1620                transaction_id,
1621            } => handle_cancel(framed, &state, &caller, request_id, transaction_id.as_str()).await,
1622        };
1623
1624        if let Err(e) = result {
1625            // A framing error occurred while sending a response. Log it and
1626            // continue; the next recv() will return Err if the peer is gone.
1627            eprintln!("[sysknife-daemon] connection handler send error: {e}");
1628        }
1629    }
1630}
1631
1632fn receipt_digest(receipt: &str) -> String {
1633    hex::encode(Sha256::digest(receipt.as_bytes()))
1634}
1635
1636async fn handle_approve(
1637    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1638    state: &DaemonState,
1639    caller: &CallerAttribution,
1640    request_id: &str,
1641    transaction_id: &str,
1642) -> Result<(), HandlerError> {
1643    if !authorize_for_transaction(
1644        framed,
1645        state,
1646        caller,
1647        request_id,
1648        transaction_id,
1649        "stale_approval",
1650        "transaction is missing, expired, already approved, or no longer queued",
1651    )
1652    .await?
1653    {
1654        return Ok(());
1655    }
1656    let receipt = match state
1657        .audit
1658        .approve_transaction(transaction_id, caller.principal())
1659        .await
1660    {
1661        Ok(receipt) => receipt,
1662        // A `DatabaseInvariant` here means the stored approval commitment does
1663        // not match the signed preview (tamper / key mismatch) — a fail-closed
1664        // integrity signal, NOT a transient blip. Reporting it as transient
1665        // would falsely tell the user a retry will help; it will fail
1666        // identically. Surface it as a distinct, non-retryable category.
1667        Err(e @ crate::transactions::TransactionStoreError::DatabaseInvariant(_)) => {
1668            return send_error(
1669                framed,
1670                request_id,
1671                "integrity_failure",
1672                format!("approval rejected by an integrity check: {e}"),
1673            )
1674            .await;
1675        }
1676        Err(e) => {
1677            return send_error(
1678                framed,
1679                request_id,
1680                "transient_infrastructure_failure",
1681                format!("failed to persist approval: {e}"),
1682            )
1683            .await;
1684        }
1685    };
1686    let Some(receipt) = receipt else {
1687        return send_error(
1688            framed,
1689            request_id,
1690            "stale_approval",
1691            "transaction is missing, expired, already approved, or no longer queued",
1692        )
1693        .await;
1694    };
1695    let response = send_response(
1696        framed,
1697        &DaemonResponse::ApprovalResponse {
1698            request_id: request_id.to_string(),
1699            transaction_id: transaction_id.to_string(),
1700            approval_receipt: receipt,
1701        },
1702    )
1703    .await;
1704    if response.is_err() {
1705        if let Err(e) = state
1706            .audit
1707            .revoke_unconsumed_approval(transaction_id, caller.principal())
1708            .await
1709        {
1710            eprintln!(
1711                "[sysknife-daemon] failed to revoke undelivered approval for \
1712                 {transaction_id}: {e}"
1713            );
1714        }
1715    }
1716    response
1717}
1718
1719async fn handle_approval_details(
1720    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1721    state: &DaemonState,
1722    caller: &CallerAttribution,
1723    request_id: &str,
1724    transaction_id: &str,
1725) -> Result<(), HandlerError> {
1726    match caller_owns_transaction(state, caller, transaction_id).await {
1727        Ownership::Owned | Ownership::NoSuchTransaction => {}
1728        Ownership::NotOwned => {
1729            return send_error(
1730                framed,
1731                request_id,
1732                "authorization_failure",
1733                not_your_transaction_message(transaction_id),
1734            )
1735            .await;
1736        }
1737        Ownership::CannotEstablish(message) => {
1738            return send_error(framed, request_id, "authorization_failure", message).await;
1739        }
1740    }
1741    let transaction = match state.audit.get(transaction_id).await {
1742        Ok(Some(transaction)) if transaction.status == JobState::Queued => transaction,
1743        Ok(_) => {
1744            return send_error(
1745                framed,
1746                request_id,
1747                "stale_approval",
1748                "transaction is missing or no longer queued",
1749            )
1750            .await;
1751        }
1752        Err(e) => {
1753            return send_error(
1754                framed,
1755                request_id,
1756                "transient_infrastructure_failure",
1757                format!("failed to load transaction: {e}"),
1758            )
1759            .await;
1760        }
1761    };
1762    if !authorize_action(&state.policy, &caller.role(), &transaction.action_name) {
1763        return send_error(
1764            framed,
1765            request_id,
1766            "authorization_failure",
1767            authorization_failure_message(&state.policy, &caller.role(), &transaction.action_name),
1768        )
1769        .await;
1770    }
1771    let preview = match state.audit.get_preview(transaction_id).await {
1772        Ok(Some(preview)) => preview,
1773        Ok(None) => {
1774            return send_error(
1775                framed,
1776                request_id,
1777                "stale_approval",
1778                "transaction has no persisted preview",
1779            )
1780            .await;
1781        }
1782        Err(e) => {
1783            return send_error(
1784                framed,
1785                request_id,
1786                "transient_infrastructure_failure",
1787                format!("failed to load preview: {e}"),
1788            )
1789            .await;
1790        }
1791    };
1792    send_response(
1793        framed,
1794        &DaemonResponse::ApprovalDetailsResponse {
1795            request_id: request_id.to_string(),
1796            transaction_id: transaction_id.to_string(),
1797            action_name: transaction.action_name,
1798            preview,
1799        },
1800    )
1801    .await
1802}
1803
1804// ---------------------------------------------------------------------------
1805// Per-request handlers
1806// ---------------------------------------------------------------------------
1807
1808async fn handle_query_state(
1809    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1810    runner: Arc<dyn CommandRunner + Send + Sync>,
1811    host_distro: Option<sysknife_core::distro::DistroId>,
1812    request_id: &str,
1813) -> Result<(), HandlerError> {
1814    // collect_state uses std::process::Command (blocking). Offload to the
1815    // blocking thread pool so the async executor is not stalled.
1816    let join_result = tokio::task::spawn_blocking(move || collect_state(&*runner))
1817        .await
1818        .map_err(|e| HandlerError::Internal(format!("collect_state task failed: {e}")))?;
1819    let mut collected = match join_result {
1820        Ok(s) => s,
1821        Err(e) => {
1822            return send_error(framed, request_id, "state_collection_failed", e.to_string()).await;
1823        }
1824    };
1825    collected.host_distro = host_distro;
1826    send_response(
1827        framed,
1828        &DaemonResponse::StateResponse {
1829            request_id: request_id.to_string(),
1830            state: collected,
1831        },
1832    )
1833    .await
1834}
1835
1836async fn handle_cancel(
1837    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1838    state: &DaemonState,
1839    caller: &CallerAttribution,
1840    request_id: &str,
1841    transaction_id: &str,
1842) -> Result<(), HandlerError> {
1843    if !authorize_for_transaction(
1844        framed,
1845        state,
1846        caller,
1847        request_id,
1848        transaction_id,
1849        "not_cancelable",
1850        "transaction cannot be canceled: it is missing, already executing, or finished",
1851    )
1852    .await?
1853    {
1854        return Ok(());
1855    }
1856    match state
1857        .audit
1858        .cancel_queued(transaction_id, caller.principal())
1859        .await
1860    {
1861        Ok(true) => {
1862            send_response(
1863                framed,
1864                &DaemonResponse::CancelResponse {
1865                    request_id: request_id.to_string(),
1866                    transaction_id: transaction_id.to_string(),
1867                },
1868            )
1869            .await
1870        }
1871        // Fail closed and informative: the only cancelable state is Queued.
1872        // A Running action is deliberately never interrupted (Option A).
1873        Ok(false) => {
1874            send_error(
1875                framed,
1876                request_id,
1877                "not_cancelable",
1878                "transaction cannot be canceled: it is missing, already executing, or finished",
1879            )
1880            .await
1881        }
1882        Err(e) => {
1883            send_error(
1884                framed,
1885                request_id,
1886                "transient_infrastructure_failure",
1887                format!("failed to cancel transaction: {e}"),
1888            )
1889            .await
1890        }
1891    }
1892}
1893
1894/// Turn the two wire spellings of a history lower bound into the store's
1895/// [`HistorySince`]. `since_hours` is the planner's whole-hour window; `since`
1896/// is an exact RFC 3339 instant (normalised to UTC) so a sub-hour cutoff is not
1897/// rounded away. Supplying both is ambiguous and rejected.
1898fn resolve_history_since(
1899    since_hours: Option<u32>,
1900    since: Option<&str>,
1901) -> Result<Option<HistorySince>, String> {
1902    match (since_hours, since) {
1903        (Some(_), Some(_)) => Err("'since' and 'since_hours' are mutually exclusive".to_string()),
1904        (Some(hours), None) => Ok(Some(HistorySince::Hours(hours))),
1905        (None, Some(raw)) => chrono::DateTime::parse_from_rfc3339(raw)
1906            .map(|at| Some(HistorySince::Cutoff(at.with_timezone(&chrono::Utc))))
1907            .map_err(|e| format!("'since' must be an RFC 3339 datetime, got {raw:?}: {e}")),
1908        (None, None) => Ok(None),
1909    }
1910}
1911
1912#[allow(clippy::too_many_arguments)]
1913async fn handle_query_history(
1914    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1915    state: &DaemonState,
1916    request_id: &str,
1917    limit: Option<u32>,
1918    status_filter: Option<String>,
1919    action_filter: Option<String>,
1920    since_hours: Option<u32>,
1921    since: Option<&str>,
1922) -> Result<(), HandlerError> {
1923    // Honour the SAME `[policy.risk_overrides]` gate as the `ListJobHistory`
1924    // action (see handle_query_action): if an operator raised history above
1925    // Observer, this structured path must refuse it too, otherwise it would be
1926    // an authorization bypass around that policy. History is keyed on the
1927    // `ListJobHistory` action so a single override governs both paths.
1928    match state.policy.min_role_for_action("ListJobHistory") {
1929        Some(CallerRole::Observer) => {}
1930        Some(_) => {
1931            return send_error(
1932                framed,
1933                request_id,
1934                "authorization_failure",
1935                "history has been restricted above read-only by policy; \
1936                 it is not available over the structured query path",
1937            )
1938            .await;
1939        }
1940        None => {
1941            return send_error(
1942                framed,
1943                request_id,
1944                "validation_failure",
1945                "ListJobHistory action is not registered",
1946            )
1947            .await;
1948        }
1949    }
1950
1951    let since = match resolve_history_since(since_hours, since) {
1952        Ok(since) => since,
1953        Err(message) => {
1954            return send_error(framed, request_id, "validation_failure", message).await;
1955        }
1956    };
1957
1958    let entries = match state
1959        .audit
1960        .list_history(
1961            limit.unwrap_or(DEFAULT_HISTORY_LIMIT),
1962            status_filter.as_deref(),
1963            action_filter.as_deref(),
1964            since,
1965        )
1966        .await
1967    {
1968        Ok(entries) => entries,
1969        Err(e) => {
1970            return send_error(
1971                framed,
1972                request_id,
1973                "execution_failure",
1974                format!("failed to query transaction history: {e}"),
1975            )
1976            .await;
1977        }
1978    };
1979    send_response(
1980        framed,
1981        &DaemonResponse::HistoryResponse {
1982            request_id: request_id.to_string(),
1983            entries,
1984        },
1985    )
1986    .await
1987}
1988
1989async fn handle_query_action(
1990    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1991    executor: Arc<dyn ActionExecutor>,
1992    state: &DaemonState,
1993    action_name: &str,
1994    params: &Value,
1995    request_id: &str,
1996) -> Result<(), HandlerError> {
1997    let audit = &state.audit;
1998
1999    // Honour `[policy.risk_overrides]`: an operator can RAISE a previously
2000    // Low-risk action above Observer, in which case the unprivileged query
2001    // path must reject it (matching the preview+execute path).
2002    let min_role = match state.policy.min_role_for_action(action_name) {
2003        Some(role) => role,
2004        None => {
2005            return send_error(
2006                framed,
2007                request_id,
2008                "validation_failure",
2009                format!("unknown action: {action_name}"),
2010            )
2011            .await;
2012        }
2013    };
2014
2015    if min_role != CallerRole::Observer {
2016        return send_error(
2017            framed,
2018            request_id,
2019            "authorization_failure",
2020            format!("{action_name} is not a read-only action; use preview+execute instead"),
2021        )
2022        .await;
2023    }
2024
2025    // Risk answers how much authorization an action needs; it does not prove
2026    // that an action is read-only. `AptUpdate`, for example, is Low/Observer but
2027    // runs `sudo apt-get update`, writes the root apt index and takes the dpkg
2028    // lock. The shared catalogue exception keeps this raw IPC path aligned with
2029    // the generated MCP surface instead of letting direct clients bypass the
2030    // preview, receipt and exclusion gates.
2031    if crate::actions::OBSERVER_MUTATING_ACTIONS.contains(&action_name) {
2032        return send_error(
2033            framed,
2034            request_id,
2035            "authorization_failure",
2036            format!(
2037                "{action_name} mutates the host and is not available through query_action; \
2038                 use preview+execute instead"
2039            ),
2040        )
2041        .await;
2042    }
2043
2044    // Same fence preview and execute apply. Without it a Fedora-only read-only
2045    // action reached the executor on a Debian host and failed as "rpm-ostree:
2046    // No such file or directory" — an execution error where the other paths
2047    // give a clean refusal. A read-only action with no family requirement shorts
2048    // out inside `validate_action_platform`; one *with* a family requirement is
2049    // checked against the family but exempt from the eligibility and
2050    // detection gates, which are mutation gates by contract.
2051    if let Err(message) = validate_action_platform(state, action_name) {
2052        return send_error(framed, request_id, "unsupported_platform", message).await;
2053    }
2054
2055    // Special case: ListJobHistory queries the daemon's own transaction
2056    // store rather than executing a system command. Handle it here to
2057    // avoid routing through the ActionSpec/executor path.
2058    if action_name == "ListJobHistory" {
2059        if let Err(e) = crate::executor::validate_action_params(action_name, params) {
2060            return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2061        }
2062        let limit = match params.get("limit") {
2063            Some(v) => match v.as_u64() {
2064                Some(n) => n as u32,
2065                None => {
2066                    return send_error(
2067                        framed,
2068                        request_id,
2069                        "validation_failure",
2070                        format!("'limit' must be an integer, got: {v}"),
2071                    )
2072                    .await;
2073                }
2074            },
2075            None => DEFAULT_HISTORY_LIMIT,
2076        };
2077        let status_filter = params
2078            .get("status_filter")
2079            .and_then(|v| v.as_str())
2080            .map(|s| s.to_string());
2081        let action_filter = params
2082            .get("action_filter")
2083            .and_then(|v| v.as_str())
2084            .map(|s| s.to_string());
2085        let since_hours = match params.get("since_hours") {
2086            Some(v) => match v.as_u64() {
2087                Some(n) => Some(n as u32),
2088                None => {
2089                    return send_error(
2090                        framed,
2091                        request_id,
2092                        "validation_failure",
2093                        format!("'since_hours' must be an integer, got: {v}"),
2094                    )
2095                    .await;
2096                }
2097            },
2098            None => None,
2099        };
2100        let since_cutoff = match params.get("since") {
2101            Some(v) => match v.as_str() {
2102                Some(s) => Some(s),
2103                None => {
2104                    return send_error(
2105                        framed,
2106                        request_id,
2107                        "validation_failure",
2108                        format!("'since' must be an RFC 3339 datetime string, got: {v}"),
2109                    )
2110                    .await;
2111                }
2112            },
2113            None => None,
2114        };
2115        let since = match resolve_history_since(since_hours, since_cutoff) {
2116            Ok(since) => since,
2117            Err(message) => {
2118                return send_error(framed, request_id, "validation_failure", message).await;
2119            }
2120        };
2121
2122        let records = match audit
2123            .list_transactions(
2124                limit,
2125                status_filter.as_deref(),
2126                action_filter.as_deref(),
2127                since,
2128            )
2129            .await
2130        {
2131            Ok(r) => r,
2132            Err(e) => {
2133                return send_error(
2134                    framed,
2135                    request_id,
2136                    "execution_failure",
2137                    format!("failed to query transaction log: {e}"),
2138                )
2139                .await;
2140            }
2141        };
2142
2143        let output = if records.is_empty() {
2144            let mut msg = "No transactions found".to_string();
2145            let mut filters = Vec::new();
2146            if let Some(s) = &status_filter {
2147                filters.push(format!("status={s}"));
2148            }
2149            if let Some(a) = &action_filter {
2150                filters.push(format!("action={a}"));
2151            }
2152            if let Some(h) = since_hours {
2153                filters.push(format!("since_hours={h}"));
2154            }
2155            if let Some(c) = since_cutoff {
2156                filters.push(format!("since={c}"));
2157            }
2158            if !filters.is_empty() {
2159                msg.push_str(&format!(" (filters: {})", filters.join(", ")));
2160            }
2161            msg.push('.');
2162            msg
2163        } else {
2164            format_job_history(&records)
2165        };
2166        return send_response(
2167            framed,
2168            &DaemonResponse::QueryActionResponse {
2169                request_id: request_id.to_string(),
2170                action_name: action_name.to_string(),
2171                output,
2172            },
2173        )
2174        .await;
2175    }
2176
2177    let spec = match build_action_spec(action_name, params) {
2178        Ok(s) => s,
2179        Err(e) => {
2180            return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2181        }
2182    };
2183
2184    let output = match executor.execute(&spec).await {
2185        Ok(out) => out,
2186        Err(e) => {
2187            return send_error(framed, request_id, "execution_failure", e.to_string()).await;
2188        }
2189    };
2190
2191    // Non-zero exit codes from read-only actions must not be silently presented
2192    // as successful output. The LLM would receive error text as if it were valid
2193    // system state, leading to incorrect planning decisions.
2194    //
2195    // Some commands use non-zero exit codes as semantic signals rather than
2196    // error indicators.  These are whitelisted here so the informative stdout
2197    // is passed through to the caller instead of being discarded.
2198    //
2199    // - systemctl status <unit>: exits 1 when inactive, 3 when dead/failed, 4 when not
2200    //   found.  All produce informative output the planner needs for diagnosis.
2201    let is_informational_exit =
2202        matches!((action_name, output.exit_code), ("GetServiceStatus", 1..=4));
2203
2204    if output.is_nonzero() && !is_informational_exit {
2205        return send_error(
2206            framed,
2207            request_id,
2208            "execution_failure",
2209            format!(
2210                "{action_name} failed with exit code {}{}",
2211                output.exit_code,
2212                if output.stderr.trim().is_empty() {
2213                    String::new()
2214                } else {
2215                    format!(": {}", output.stderr.trim())
2216                }
2217            ),
2218        )
2219        .await;
2220    }
2221
2222    let output_text = if output.stderr.is_empty() {
2223        output.stdout
2224    } else {
2225        format!("{}\n[stderr]\n{}", output.stdout, output.stderr)
2226    };
2227
2228    send_response(
2229        framed,
2230        &DaemonResponse::QueryActionResponse {
2231            request_id: request_id.to_string(),
2232            action_name: action_name.to_string(),
2233            output: output_text,
2234        },
2235    )
2236    .await
2237}
2238
2239/// Return a human-readable command string for an action without executing it.
2240///
2241/// Resolves `build_action_spec(action_name, params)` and formats the
2242/// `ActionMechanism` as a shell-style string so callers can show the user
2243/// exactly what will run.
2244async fn handle_describe(
2245    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2246    state: &DaemonState,
2247    caller: &CallerAttribution,
2248    action_name: &str,
2249    params: &Value,
2250    request_id: &str,
2251) -> Result<(), HandlerError> {
2252    use crate::actions::ActionMechanism;
2253    use crate::executor::build_action_spec;
2254
2255    // Describe renders the exact command an action would run. It used to apply
2256    // neither the authorization gate nor the platform fence, so any caller
2257    // could enumerate the argv of every privileged action on the host and get
2258    // a rendered command for actions this distro cannot run. Both gates match
2259    // `handle_preview` so the three read paths agree on who may ask what.
2260    //
2261    // Unknown actions are rejected first: an unrecognised name is a client
2262    // error, and reporting it as an authorization failure would both mislead
2263    // the caller and make "does this action exist" indistinguishable from
2264    // "am I allowed to see it" — which is the wrong trade for a name the
2265    // catalogue is public about anyway.
2266    if state.policy.min_role_for_action(action_name).is_none() {
2267        return send_error(
2268            framed,
2269            request_id,
2270            "validation_failure",
2271            format!("unknown action: {action_name}"),
2272        )
2273        .await;
2274    }
2275    if !authorize_action(&state.policy, &caller.role(), action_name) {
2276        return send_error(
2277            framed,
2278            request_id,
2279            "authorization_failure",
2280            authorization_failure_message(&state.policy, &caller.role(), action_name),
2281        )
2282        .await;
2283    }
2284    if let Err(message) = validate_action_platform(state, action_name) {
2285        return send_error(framed, request_id, "unsupported_platform", message).await;
2286    }
2287
2288    // ListJobHistory is handled directly in the dispatcher (SQLite query) and
2289    // has no ActionSpec.  Return a synthetic describe response so callers get a
2290    // meaningful `command` string instead of a validation_failure error.
2291    if action_name == "ListJobHistory" {
2292        return send_response(
2293            framed,
2294            &DaemonResponse::DescribeResponse {
2295                request_id: request_id.to_string(),
2296                command: "query daemon job history (SQLite)".to_string(),
2297                risk_level: "low".to_string(),
2298                reboot_required: false,
2299            },
2300        )
2301        .await;
2302    }
2303
2304    let spec = match build_action_spec(action_name, params) {
2305        Ok(s) => s,
2306        Err(e) => {
2307            return send_error(
2308                framed,
2309                request_id,
2310                "validation_failure",
2311                format!("unknown action: {action_name} ({e})"),
2312            )
2313            .await;
2314        }
2315    };
2316
2317    let command = match &spec.mechanism {
2318        ActionMechanism::Command { program, args } => {
2319            // Redact any argv element that matches a credential param value
2320            // before rendering — `ProAttach` carries the token in argv and
2321            // this is the place a describe response would otherwise leak it.
2322            let display_args = redact_argv(action_name, params, args);
2323            if display_args.is_empty() {
2324                program.to_string()
2325            } else {
2326                format!("{} {}", program, display_args.join(" "))
2327            }
2328        }
2329        ActionMechanism::FileScan { path } => format!("read {path}"),
2330        ActionMechanism::FileWrite { path, .. } => format!("write {path}"),
2331        ActionMechanism::FilePatch { path, .. } => format!("patch {path}"),
2332        ActionMechanism::FileDelete { path } => format!("rm {path}"),
2333    };
2334
2335    let risk_level = format!("{:?}", spec.risk_level).to_lowercase();
2336
2337    send_response(
2338        framed,
2339        &DaemonResponse::DescribeResponse {
2340            request_id: request_id.to_string(),
2341            command,
2342            risk_level,
2343            reboot_required: spec.reboot_required,
2344        },
2345    )
2346    .await
2347}
2348
2349/// Recorded in the signed transaction row when a client declares that its
2350/// approval gate was lifted for the run.
2351///
2352/// The exact string is part of the wire contract: the CLI looks for it in the
2353/// returned warnings and refuses to execute if it is absent, which is how a
2354/// new client detects that it is talking to a daemon too old to record the
2355/// fact. Changing the text breaks that check, so change it deliberately.
2356pub const UNATTENDED_WARNING: &str =
2357    "Approved without a human: this step was previewed by a client running with \
2358     --dangerously-skip-approval, so no operator confirmed it.";
2359
2360// Eight arguments, matching `handle_execute` and `run_action_job` below. The
2361// alternative is a struct that exists only to carry the request body one frame
2362// deeper, and the frames it would cross are the ones a reader of this file
2363// most wants to see spelled out.
2364#[allow(clippy::too_many_arguments)]
2365async fn handle_preview(
2366    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2367    state: &DaemonState,
2368    runner: Arc<dyn CommandRunner + Send + Sync>,
2369    caller: &CallerAttribution,
2370    request_id: &str,
2371    action_name: &str,
2372    params: &Value,
2373    unattended: bool,
2374) -> Result<(), HandlerError> {
2375    let spec = match build_action_spec(action_name, params) {
2376        Ok(s) => s,
2377        Err(e) => {
2378            return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2379        }
2380    };
2381
2382    if !authorize_action(&state.policy, &caller.role(), action_name) {
2383        return send_error(
2384            framed,
2385            request_id,
2386            "authorization_failure",
2387            authorization_failure_message(&state.policy, &caller.role(), action_name),
2388        )
2389        .await;
2390    }
2391    if let Err(message) = validate_action_platform(state, action_name) {
2392        return send_error(framed, request_id, "unsupported_platform", message).await;
2393    }
2394
2395    let request_hash = compute_request_hash(&state.request_hash_key, action_name, params);
2396
2397    // Snapshot current state for the preview. collect_state uses
2398    // std::process::Command (blocking), so offload to the blocking thread pool.
2399    // State is best-effort: if collection fails, the preview is generated with
2400    // an empty state and a warning is logged rather than aborting the preview.
2401    let runner_for_preview = Arc::clone(&runner);
2402    let current_state =
2403        match tokio::task::spawn_blocking(move || collect_state(&*runner_for_preview)).await {
2404            Err(e) => {
2405                eprintln!(
2406                    "[sysknife-daemon] handle_preview: collect_state task failed ({e}); \
2407                 generating preview with empty state"
2408                );
2409                Value::Null
2410            }
2411            Ok(Err(e)) => {
2412                eprintln!(
2413                    "[sysknife-daemon] handle_preview: state collection failed ({e}); \
2414                 generating preview with empty state"
2415                );
2416                Value::Null
2417            }
2418            Ok(Ok(s)) => match serde_json::to_value(&s) {
2419                Ok(v) => v,
2420                Err(e) => {
2421                    eprintln!(
2422                    "[sysknife-daemon] handle_preview: failed to serialize collected state ({e}); \
2423                     using empty state"
2424                );
2425                    Value::Null
2426                }
2427            },
2428        };
2429    // Redact credentials before assembling the payload that gets persisted
2430    // to the transactions table AND returned in PreviewResponse. The
2431    // RequestEnvelope keeps the original params because the daemon needs the
2432    // real values to actually run the action — the envelope never leaves
2433    // this process. proposed_change DOES leave the process and must be
2434    // scrubbed.
2435    let redacted_params = redact_params(action_name, params);
2436    let mut proposed_change = json!({ "action": action_name, "params": redacted_params });
2437
2438    // AptAutoremove resolves its deletion set at run time, so capture the exact
2439    // set the operator is about to approve and bind execute to it (#151). The
2440    // set is shown in the preview and re-checked before the real autoremove runs.
2441    let mut autoremove_warning: Option<String> = None;
2442    if action_name == "AptAutoremove" {
2443        match simulate_autoremove_set(&runner).await {
2444            Ok(set) => {
2445                autoremove_warning = autoremove_kernel_warning(&set);
2446                proposed_change[AUTOREMOVE_REMOVALS_KEY] =
2447                    json!(set.iter().collect::<Vec<&String>>());
2448            }
2449            Err(e) => {
2450                // Do NOT record a set: execute fails closed (verify_autoremove_binding
2451                // refuses a preview with no captured set), so a simulate failure at
2452                // preview cannot become an unbound autoremove at execute.
2453                eprintln!("[sysknife-daemon] handle_preview: autoremove simulate failed: {e}");
2454                autoremove_warning = Some(
2455                    "Could not compute which packages autoremove would delete; approving this \
2456                     preview will not let it run until a preview captures the set."
2457                        .to_string(),
2458                );
2459            }
2460        }
2461    }
2462
2463    // Pin/UnpinDeployment name an ordinal slot, so capture which deployment is
2464    // in it and bind execute to that rather than to the number. Same shape as
2465    // the autoremove capture above, and it fails closed the same way: a preview
2466    // that recorded no identity cannot execute.
2467    let mut deployment_warning: Option<String> = None;
2468    if matches!(action_name, "PinDeployment" | "UnpinDeployment") {
2469        match params.get("index").and_then(Value::as_u64) {
2470            Some(index) => match resolve_deployment_identity(&runner, index as u32).await {
2471                Ok(identity) => {
2472                    proposed_change[DEPLOYMENT_IDENTITY_KEY] = json!(identity);
2473                }
2474                Err(e) => {
2475                    eprintln!(
2476                        "[sysknife-daemon] handle_preview: deployment identity lookup failed: {e}"
2477                    );
2478                    deployment_warning = Some(
2479                        "Could not read which deployment sits at that index; approving this \
2480                         preview will not let it run until a preview captures one."
2481                            .to_string(),
2482                    );
2483                }
2484            },
2485            None => {
2486                deployment_warning =
2487                    Some("No deployment index was supplied, so nothing could be bound.".to_string())
2488            }
2489        }
2490    }
2491
2492    let envelope = RequestEnvelope {
2493        action_name: action_name.to_string(),
2494        request_id: request_id.to_string(),
2495        params: params.clone(),
2496        caller_role: caller.role(),
2497        request_hash: sysknife_types::RequestHash::new(request_hash.to_string()),
2498    };
2499
2500    // If state collection failed above, current_state is Null (collect_state
2501    // never yields null on success). Surface that in the client-visible
2502    // warnings — not just daemon stderr — so a human approving a high-risk
2503    // action knows the "current state" backing this preview is missing.
2504    let state_unavailable = current_state.is_null();
2505    let mut preview = preview_action(&envelope, current_state, proposed_change);
2506    if let Some(w) = autoremove_warning {
2507        preview.warnings.push(w);
2508    }
2509    // The operator has to see that the deployment binding was not captured,
2510    // because approving this preview then buys them nothing: execute refuses.
2511    if let Some(w) = deployment_warning {
2512        preview.warnings.push(w);
2513    }
2514    if state_unavailable {
2515        preview.warnings.push(
2516            "System state could not be collected; this preview was generated without it. \
2517             Review the action carefully before approving."
2518                .to_string(),
2519        );
2520    }
2521    // Recorded, not trusted. `warnings` is copied into `NewTransaction` below
2522    // and signed as `warnings_json`, so this sentence is inside the Ed25519
2523    // commitment for the row: an unattended run cannot later be presented as
2524    // one a human approved without the signature failing to verify.
2525    if unattended {
2526        preview.warnings.push(UNATTENDED_WARNING.to_string());
2527    }
2528
2529    // Persist a pending transaction so execute can verify a prior preview.
2530    let new_tx = NewTransaction {
2531        request_id: request_id.to_string(),
2532        request_hash,
2533        action_name: action_name.to_string(),
2534        risk_level: spec.risk_level,
2535        summary: preview.summary.clone(),
2536        warnings: preview.warnings.clone(),
2537        // Both resolved by the daemon from the connection, never read from the
2538        // request body. The role says what was permitted; the principal says
2539        // which account asked, which is the question an audit starts with.
2540        caller_role: caller.role(),
2541        caller_principal: caller.principal(),
2542    };
2543
2544    let recorded = match state.audit.record_previewed(new_tx, preview.clone()).await {
2545        Ok(r) => r,
2546        Err(e) => {
2547            return send_error(
2548                framed,
2549                request_id,
2550                "transient_infrastructure_failure",
2551                format!("failed to record preview transaction: {e}"),
2552            )
2553            .await;
2554        }
2555    };
2556
2557    // External SIEM forwarding. Best-effort, never blocks the preview
2558    // response — if the forwarder queue is full or its task is gone, the
2559    // event is dropped (with a counter + WARN). The local hash-chained log
2560    // is always written first; the SIEM receives a copy.
2561    forward_audit_event(state, &recorded.transaction.transaction_id, &caller.role());
2562
2563    send_response(
2564        framed,
2565        &DaemonResponse::PreviewResponse {
2566            request_id: request_id.to_string(),
2567            preview,
2568            transaction_id: recorded.transaction.transaction_id,
2569        },
2570    )
2571    .await
2572}
2573
2574/// Look up the just-inserted row's chain metadata and submit an
2575/// `AuditEvent` to the configured forwarder, if any.
2576///
2577/// Failures here are non-fatal — the local audit log is the durable record.
2578/// Spawned in the background so the preview response is not delayed by the
2579/// audit-store fetch (true fire-and-forget across both SQLite and Postgres
2580/// backends).
2581fn forward_audit_event(state: &DaemonState, transaction_id: &str, caller: &CallerRole) {
2582    let Some(forwarder) = state.forwarder.clone() else {
2583        return;
2584    };
2585    let audit = std::sync::Arc::clone(&state.audit);
2586    let transaction_id = transaction_id.to_string();
2587    let caller_label = format!("{caller:?}");
2588    tokio::spawn(async move {
2589        match audit.fetch_chain_row(&transaction_id).await {
2590            Ok(Some(row)) => {
2591                forwarder.submit(crate::audit_forward::AuditEvent {
2592                    seq: row.seq,
2593                    transaction_id: row.transaction_id,
2594                    action_name: row.action_name,
2595                    risk_level: row.risk_level,
2596                    summary: row.summary,
2597                    approval_id: row.approval_id,
2598                    created_at: row.created_at,
2599                    chain_hash: row.chain_hash,
2600                    key_id: row.key_id,
2601                    caller_role: Some(caller_label),
2602                    // From the row, not the connection: the SIEM should see the
2603                    // account the chain committed to.
2604                    caller_principal: row.caller_principal,
2605                    final_status: None,
2606                });
2607            }
2608            Ok(None) => {
2609                eprintln!(
2610                    "[sysknife-daemon] audit-forward: transaction {transaction_id} \
2611                     not found just after insert (race?)"
2612                );
2613            }
2614            Err(e) => {
2615                eprintln!(
2616                    "[sysknife-daemon] audit-forward: chain row fetch failed for \
2617                     {transaction_id}: {e}"
2618                );
2619            }
2620        }
2621    });
2622}
2623
2624/// Submit a status-change `AuditEvent` to the configured forwarder after
2625/// `update_status` records the terminal `JobState`.
2626///
2627/// Without this, SOC analysts watching the SIEM see the preview event but
2628/// never see whether the action ran, succeeded, failed, or was rolled back —
2629/// the local hash-chained log carries the terminal status, but the SIEM does
2630/// not. Mirrors [`forward_audit_event`]: bails when no forwarder is wired,
2631/// looks up the chain row in a background task, and sets `final_status` to
2632/// the lowercase Debug rendering of the terminal `JobState` so the
2633/// `terminal_status` SD-PARAM appears in the emitted RFC 5424 frame.
2634fn forward_status_change_event(
2635    state: &DaemonState,
2636    transaction_id: &str,
2637    caller: &CallerRole,
2638    final_status: JobState,
2639) {
2640    if state.forwarder.is_none() {
2641        return;
2642    }
2643    let audit = std::sync::Arc::clone(&state.audit);
2644    let forwarder = state.forwarder.clone().expect("checked above");
2645    let transaction_id = transaction_id.to_string();
2646    let caller_label = format!("{caller:?}");
2647    let final_status_label = format!("{final_status:?}").to_lowercase();
2648    tokio::spawn(async move {
2649        match audit.fetch_chain_row(&transaction_id).await {
2650            Ok(Some(row)) => {
2651                forwarder.submit(crate::audit_forward::AuditEvent {
2652                    seq: row.seq,
2653                    transaction_id: row.transaction_id,
2654                    action_name: row.action_name,
2655                    risk_level: row.risk_level,
2656                    summary: row.summary,
2657                    approval_id: row.approval_id,
2658                    created_at: row.created_at,
2659                    chain_hash: row.chain_hash,
2660                    key_id: row.key_id,
2661                    caller_role: Some(caller_label),
2662                    caller_principal: row.caller_principal,
2663                    final_status: Some(final_status_label),
2664                });
2665            }
2666            Ok(None) => {
2667                eprintln!(
2668                    "[sysknife-daemon] audit-forward: transaction {transaction_id} \
2669                     not found for status-change forward (race?)"
2670                );
2671            }
2672            Err(e) => {
2673                eprintln!(
2674                    "[sysknife-daemon] audit-forward: chain row fetch failed for \
2675                     {transaction_id}: {e}"
2676                );
2677            }
2678        }
2679    });
2680}
2681
2682/// Release every exclusion lock this request claimed.
2683///
2684/// Each entry is removed only when it still records *this* request's hash, so
2685/// a late release can never evict a lock a different job has since taken.
2686async fn release_exclusive_slots(
2687    state: &DaemonState,
2688    claimed: &[crate::actions::ExclusiveResource],
2689    request_hash: &str,
2690) {
2691    if claimed.is_empty() {
2692        return;
2693    }
2694
2695    let mut slots = state.running_exclusive.lock().await;
2696    for resource in claimed {
2697        if slots.get(resource).map(String::as_str) == Some(request_hash) {
2698            slots.remove(resource);
2699        }
2700    }
2701}
2702
2703/// Total attempts to persist a terminal transaction status before giving up.
2704/// Terminal writes are an audit-trail obligation, so a transient store error
2705/// is worth a few short retries; three keeps the worst-case latency bounded
2706/// (see [`TERMINAL_STATUS_RETRY_BACKOFF_MS`]) while surviving a brief blip.
2707const TERMINAL_STATUS_RETRY_ATTEMPTS: u32 = 3;
2708/// Linear backoff base between terminal-status retries. Attempt `n` waits
2709/// `BACKOFF_MS * (n + 1)`, i.e. 25 ms then 50 ms — long enough to let a
2710/// momentarily busy SQLite writer drain, short enough not to stall the client.
2711const TERMINAL_STATUS_RETRY_BACKOFF_MS: u64 = 25;
2712
2713async fn update_terminal_status(
2714    state: &DaemonState,
2715    transaction_id: &str,
2716    target: JobState,
2717) -> Result<(), crate::transactions::TransactionStoreError> {
2718    for attempt in 0..TERMINAL_STATUS_RETRY_ATTEMPTS {
2719        match state.audit.update_status(transaction_id, target).await {
2720            Ok(()) => return Ok(()),
2721            Err(error) => {
2722                // A `get` failure here collapses to `None` and is deliberately
2723                // swallowed: it cannot manufacture a false success, it only
2724                // costs one more retry before the original `error` propagates.
2725                if state
2726                    .audit
2727                    .get(transaction_id)
2728                    .await
2729                    .ok()
2730                    .flatten()
2731                    .is_some_and(|record| record.status == target)
2732                {
2733                    return Ok(());
2734                }
2735                if attempt == TERMINAL_STATUS_RETRY_ATTEMPTS - 1 {
2736                    return Err(error);
2737                }
2738                tokio::time::sleep(std::time::Duration::from_millis(
2739                    TERMINAL_STATUS_RETRY_BACKOFF_MS * u64::from(attempt + 1),
2740                ))
2741                .await;
2742            }
2743        }
2744    }
2745    unreachable!("bounded retry loop always returns")
2746}
2747
2748#[allow(clippy::too_many_arguments)]
2749async fn handle_execute(
2750    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2751    state: &DaemonState,
2752    executor: Arc<dyn ActionExecutor>,
2753    runner: Arc<dyn CommandRunner + Send + Sync>,
2754    caller: &CallerAttribution,
2755    request_id: &str,
2756    transaction_id: &TransactionId,
2757    action_name: &str,
2758    params: &Value,
2759    approval_receipt: &ApprovalReceipt,
2760) -> Result<(), HandlerError> {
2761    let transaction_id = transaction_id.as_str();
2762    let approval_receipt = approval_receipt.as_str();
2763    let spec = match build_action_spec(action_name, params) {
2764        Ok(s) => s,
2765        Err(e) => {
2766            return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2767        }
2768    };
2769
2770    // Checked here as well as at approve, not instead of it. A receipt is a
2771    // bearer token for one transaction, so an account that obtained one it was
2772    // never issued must still be refused at the point of execution.
2773    match caller_owns_transaction(state, caller, transaction_id).await {
2774        Ownership::Owned | Ownership::NoSuchTransaction => {}
2775        Ownership::NotOwned => {
2776            return send_error(
2777                framed,
2778                request_id,
2779                "authorization_failure",
2780                not_your_transaction_message(transaction_id),
2781            )
2782            .await;
2783        }
2784        Ownership::CannotEstablish(message) => {
2785            return send_error(framed, request_id, "authorization_failure", message).await;
2786        }
2787    }
2788
2789    if !authorize_action(&state.policy, &caller.role(), action_name) {
2790        return send_error(
2791            framed,
2792            request_id,
2793            "authorization_failure",
2794            authorization_failure_message(&state.policy, &caller.role(), action_name),
2795        )
2796        .await;
2797    }
2798    if let Err(message) = validate_action_platform(state, action_name) {
2799        return send_error(framed, request_id, "unsupported_platform", message).await;
2800    }
2801
2802    let submitted_hash = compute_request_hash(&state.request_hash_key, action_name, params);
2803    let prior_tx = match state.audit.get(transaction_id).await {
2804        Ok(tx) => tx,
2805        Err(e) => {
2806            return send_error(
2807                framed,
2808                request_id,
2809                "transient_infrastructure_failure",
2810                format!("transaction lookup failed: {e}"),
2811            )
2812            .await;
2813        }
2814    };
2815
2816    let prior_tx = match prior_tx {
2817        Some(tx) => tx,
2818        None => {
2819            return send_error(
2820                framed,
2821                request_id,
2822                "stale_approval",
2823                "no prior preview found for this transaction; preview before executing",
2824            )
2825            .await;
2826        }
2827    };
2828
2829    if prior_tx.action_name != action_name || prior_tx.request_hash != submitted_hash {
2830        return send_error(
2831            framed,
2832            request_id,
2833            "stale_approval",
2834            "action or parameters differ from the approved preview",
2835        )
2836        .await;
2837    }
2838    let stored_hash = prior_tx.request_hash;
2839
2840    // ── Concurrency gate (ME4) ─────────────────────────────────────────────
2841    //
2842    // A High-risk reboot-required action (e.g. `UbuntuReleaseUpgrade`,
2843    // `AddLayeredPackage`, `RebaseSystem`) can run for 20-45 minutes and holds
2844    // exclusive system-wide locks (dpkg, rpm-ostree). Allowing a second
2845    // mutating action to interleave causes lock contention, partial-upgrade
2846    // corruption, or worse. Read-only (`Observer`-level) actions are never
2847    // mutating and skip this check entirely — they are safe to run concurrently.
2848    //
2849    // "Mutating" is defined as: the action's minimum required role is Dev or
2850    // higher (i.e. `min_role_for_action` returns `Dev` or `Admin`). This reuses
2851    // the existing policy table rather than a separate hand-maintained list.
2852    //
2853    // The slot is held for the duration of the action only when the new action
2854    // is itself High-risk + reboot-required; other mutating actions are blocked
2855    // while a high-risk action is in-flight but do not themselves set the slot.
2856    let is_high_risk_reboot =
2857        spec.risk_level == sysknife_types::RiskLevel::High && spec.reboot_required;
2858
2859    let policy_says_mutating = match state.policy.min_role_for_action(action_name) {
2860        Some(r) => r > CallerRole::Observer,
2861        None => {
2862            // Unreachable today: `authorize_action` above rejects any action
2863            // the policy table does not know. Treat the miss as mutating
2864            // anyway — the failure direction of a lookup miss must be "gate
2865            // it", never "let it through ungated".
2866            eprintln!(
2867                "[sysknife-daemon] WARNING: no policy entry for {action_name:?} at the \
2868                 concurrency gate; treating it as mutating"
2869            );
2870            true
2871        }
2872    };
2873
2874    // A High-risk reboot-required action is mutating by definition. Deriving
2875    // this from the spec as well as the policy table means a drift between the
2876    // two tables cannot silently downgrade an upgrade-class action to
2877    // "ungated". The previous code asserted this relationship with
2878    // `debug_assert!`, which is compiled out of release builds — precisely the
2879    // binary that ships.
2880    let is_mutating = policy_says_mutating || is_high_risk_reboot;
2881    if is_high_risk_reboot && !policy_says_mutating {
2882        eprintln!(
2883            "[sysknife-daemon] WARNING: {action_name:?} is High-risk + reboot-required but the \
2884             policy table does not require Dev/Admin for it; gating it as mutating anyway. \
2885             The risk and role tables have drifted."
2886        );
2887    }
2888
2889    // Which system locks this request will hold while it runs.
2890    let mut to_claim: Vec<crate::actions::ExclusiveResource> = Vec::new();
2891    if is_high_risk_reboot {
2892        to_claim.push(crate::actions::ExclusiveResource::System);
2893    }
2894    let own_resource = crate::actions::exclusive_resource(&spec);
2895    if let Some(r) = own_resource {
2896        if !to_claim.contains(&r) {
2897            to_claim.push(r);
2898        }
2899    }
2900    if !is_mutating {
2901        to_claim.clear();
2902    }
2903
2904    // Check the gate for any mutating action, and claim every lock it needs.
2905    if is_mutating {
2906        let mut slots = state.running_exclusive.lock().await;
2907        // Conflict on a system-wide job first (it excludes everything), then
2908        // on this action's own resource.
2909        let blocking = slots
2910            .get(&crate::actions::ExclusiveResource::System)
2911            .map(|h| (crate::actions::ExclusiveResource::System, h.clone()))
2912            .or_else(|| own_resource.and_then(|r| slots.get(&r).map(|h| (r, h.clone()))));
2913        if let Some((blocking_resource, running_hash)) = blocking {
2914            // Another mutating action already holds a lock this one needs.
2915            // Return a typed Conflict response so the shell can surface a
2916            // "wait, an upgrade is running" message rather than a generic error.
2917            //
2918            // The approval is NOT consumed by a conflict (the claim happens
2919            // after this gate), so it remains valid for retry — but only until
2920            // its TTL elapses. A high-risk reboot job can outrun that window, so
2921            // we tell the user up front what a long wait means, instead of
2922            // letting the retry fail later with a bare stale_approval.
2923            //
2924            // Re-approving the SAME transaction can never produce a "fresh"
2925            // receipt: the TTL check in `approve_transaction` is anchored to
2926            // the transaction's immutable `created_at`, not to when `approve`
2927            // is called, so running `sysknife approve <transaction-id>` again
2928            // after the window has elapsed fails identically every time. The
2929            // only way to get a new TTL window is a new transaction, which
2930            // means re-running the preview.
2931            let ttl = crate::transactions::APPROVAL_RECEIPT_TTL_MINUTES;
2932            let held = blocking_resource.label();
2933            let msg = format!(
2934                "another mutating action is already executing and holds {held} (request_hash \
2935                 {running_hash}); retry after the current job completes. This approval \
2936                 expires {ttl} minutes after it was issued, and that window is anchored to \
2937                 when the preview was created — re-running `sysknife approve` on this same \
2938                 transaction will not renew it. If the running job is still going once the \
2939                 window has passed, re-run the preview (which mints a fresh transaction) and \
2940                 approve that new transaction instead"
2941            );
2942            drop(slots); // release before I/O
2943            return send_response(
2944                framed,
2945                &DaemonResponse::ConflictResponse {
2946                    request_id: request_id.to_string(),
2947                    message: msg,
2948                    retry_after_seconds: None,
2949                },
2950            )
2951            .await;
2952        }
2953        // Claim every lock before releasing the mutex so no other connection
2954        // can race between the check and the set.
2955        for resource in &to_claim {
2956            slots.insert(*resource, stored_hash.clone());
2957        }
2958        // Lock drops here via RAII, releasing for other read-only actions.
2959    }
2960
2961    let claimed = match state
2962        .audit
2963        .claim_approved_for_execution(
2964            transaction_id,
2965            &receipt_digest(approval_receipt),
2966            caller.principal(),
2967        )
2968        .await
2969    {
2970        Ok(c) => c,
2971        Err(e) => {
2972            release_exclusive_slots(state, &to_claim, &stored_hash).await;
2973            return send_error(
2974                framed,
2975                request_id,
2976                "transient_infrastructure_failure",
2977                format!("failed to claim transaction: {e}"),
2978            )
2979            .await;
2980        }
2981    };
2982    if !claimed {
2983        release_exclusive_slots(state, &to_claim, &stored_hash).await;
2984        return send_error(
2985            framed,
2986            request_id,
2987            "stale_approval",
2988            "transaction is not approved for this receipt, expired, or already consumed",
2989        )
2990        .await;
2991    }
2992
2993    // Bind AptAutoremove to the deletion set the operator approved (#151). The
2994    // request hash covers only the (empty) params, so re-simulate now and refuse
2995    // if the set apt would remove has drifted from what the approved preview
2996    // captured. Placed AFTER the concurrency gate and claim (both of which can
2997    // block — the gate can wait minutes on a long high-risk action) and just
2998    // BEFORE `JobStarted`, so the verified answer is as close to execution as it
2999    // can cleanly be reported (a drift here is still a plain error_response) and
3000    // the gate wait is not part of the verified-to-executed window. This shrinks
3001    // that window to request handling; it cannot eliminate it, because
3002    // `apt-get autoremove -y` re-resolves the set itself at run time and cannot
3003    // be pinned to a package list — see the residual note in the CHANGELOG.
3004    if action_name == "AptAutoremove" {
3005        let approved_preview = match state.audit.get_preview(transaction_id).await {
3006            Ok(Some(p)) => p,
3007            Ok(None) => {
3008                release_exclusive_slots(state, &to_claim, &stored_hash).await;
3009                return send_error(
3010                    framed,
3011                    request_id,
3012                    "stale_approval",
3013                    "no persisted preview for this transaction; preview before executing",
3014                )
3015                .await;
3016            }
3017            Err(e) => {
3018                release_exclusive_slots(state, &to_claim, &stored_hash).await;
3019                return send_error(
3020                    framed,
3021                    request_id,
3022                    "transient_infrastructure_failure",
3023                    format!("preview lookup failed: {e}"),
3024                )
3025                .await;
3026            }
3027        };
3028        let live = match simulate_autoremove_set(&runner).await {
3029            Ok(s) => s,
3030            Err(e) => {
3031                release_exclusive_slots(state, &to_claim, &stored_hash).await;
3032                return send_error(
3033                    framed,
3034                    request_id,
3035                    "execution_failure",
3036                    format!("could not confirm the autoremove set before executing: {e}"),
3037                )
3038                .await;
3039            }
3040        };
3041        if let Err(reason) = verify_autoremove_binding(&approved_preview.proposed_change, &live) {
3042            release_exclusive_slots(state, &to_claim, &stored_hash).await;
3043            return send_error(framed, request_id, "stale_approval", reason).await;
3044        }
3045    }
3046
3047    // The same re-check for the deployment actions, fetching its own preview
3048    // the way the autoremove block above does. Without it the approval binds a
3049    // slot number and the slot's occupant can change underneath it.
3050    if matches!(action_name, "PinDeployment" | "UnpinDeployment") {
3051        let approved_preview = match state.audit.get_preview(transaction_id).await {
3052            Ok(Some(p)) => p,
3053            Ok(None) => {
3054                release_exclusive_slots(state, &to_claim, &stored_hash).await;
3055                return send_error(
3056                    framed,
3057                    request_id,
3058                    "stale_approval",
3059                    "no persisted preview for this transaction; preview before executing",
3060                )
3061                .await;
3062            }
3063            Err(e) => {
3064                release_exclusive_slots(state, &to_claim, &stored_hash).await;
3065                return send_error(
3066                    framed,
3067                    request_id,
3068                    "transient_infrastructure_failure",
3069                    format!("preview lookup failed: {e}"),
3070                )
3071                .await;
3072            }
3073        };
3074        let Some(index) = params.get("index").and_then(Value::as_u64) else {
3075            release_exclusive_slots(state, &to_claim, &stored_hash).await;
3076            return send_error(
3077                framed,
3078                request_id,
3079                "validation_failure",
3080                "no deployment index was supplied, so the approval cannot be bound to one",
3081            )
3082            .await;
3083        };
3084        let live = match resolve_deployment_identity(&runner, index as u32).await {
3085            Ok(identity) => identity,
3086            Err(e) => {
3087                release_exclusive_slots(state, &to_claim, &stored_hash).await;
3088                return send_error(
3089                    framed,
3090                    request_id,
3091                    "execution_failure",
3092                    format!("could not confirm which deployment is at that index: {e}"),
3093                )
3094                .await;
3095            }
3096        };
3097        if let Err(reason) = verify_deployment_binding(&approved_preview.proposed_change, &live) {
3098            release_exclusive_slots(state, &to_claim, &stored_hash).await;
3099            return send_error(framed, request_id, "stale_approval", reason).await;
3100        }
3101    }
3102
3103    let job_id = Uuid::new_v4().to_string();
3104
3105    if let Err(send_error) = send_response(
3106        framed,
3107        &DaemonResponse::JobStarted {
3108            request_id: request_id.to_string(),
3109            job_id: job_id.clone(),
3110            transaction_id: transaction_id.to_string(),
3111        },
3112    )
3113    .await
3114    {
3115        release_exclusive_slots(state, &to_claim, &stored_hash).await;
3116        if let Err(status_error) =
3117            update_terminal_status(state, transaction_id, JobState::Failed).await
3118        {
3119            eprintln!(
3120                "[sysknife-daemon] failed to mark disconnected transaction {transaction_id} \
3121                 as Failed: {status_error}"
3122            );
3123        }
3124        return Err(send_error);
3125    }
3126
3127    let _ = send_response(
3128        framed,
3129        &DaemonResponse::JobProgress {
3130            job_id: job_id.clone(),
3131            line: format!("Authorization passed for {action_name}"),
3132        },
3133    )
3134    .await;
3135
3136    let _ = send_response(
3137        framed,
3138        &DaemonResponse::JobProgress {
3139            job_id: job_id.clone(),
3140            line: format!("Executing {action_name}..."),
3141        },
3142    )
3143    .await;
3144
3145    // All process execution goes through ActionExecutor. This is a security
3146    // and testability boundary: injected executors must never fall through to
3147    // real privileged commands merely because an action streams output.
3148    let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
3149    let execution = executor.execute_with_progress(&spec, progress_tx);
3150    tokio::pin!(execution);
3151    let mut progress_open = true;
3152    let output = loop {
3153        tokio::select! {
3154            result = &mut execution => break result,
3155            line = progress_rx.recv(), if progress_open => {
3156                let Some(line) = line else {
3157                    progress_open = false;
3158                    continue;
3159                };
3160                if let Err(e) = send_response(
3161                    framed,
3162                    &DaemonResponse::JobProgress {
3163                        job_id: job_id.clone(),
3164                        line,
3165                    },
3166                ).await {
3167                    eprintln!(
3168                        "[sysknife-daemon] progress send failed (client disconnected?): {e}"
3169                    );
3170                }
3171            }
3172        }
3173    };
3174
3175    // The executor may enqueue its final stdout lines and complete in the same
3176    // scheduler turn. Drain the closed channel so those lines are not lost when
3177    // `select!` observes completion first.
3178    while let Ok(line) = progress_rx.try_recv() {
3179        if let Err(e) = send_response(
3180            framed,
3181            &DaemonResponse::JobProgress {
3182                job_id: job_id.clone(),
3183                line,
3184            },
3185        )
3186        .await
3187        {
3188            eprintln!("[sysknife-daemon] final progress send failed: {e}");
3189        }
3190    }
3191
3192    let (initial_status, initial_summary) = match &output {
3193        Ok(out) if out.is_success() => {
3194            if spec.reboot_required {
3195                (
3196                    JobState::NeedsReboot,
3197                    format!("{action_name} completed; reboot required"),
3198                )
3199            } else {
3200                (
3201                    JobState::Succeeded,
3202                    format!("{action_name} completed successfully"),
3203                )
3204            }
3205        }
3206        Ok(out) => (
3207            JobState::Failed,
3208            format!("{action_name} failed with exit code {}", out.exit_code),
3209        ),
3210        Err(e) => (JobState::Failed, format!("{action_name} failed: {e}")),
3211    };
3212
3213    let completion_line = match &output {
3214        Ok(out) => format!("{action_name} completed with exit code {}", out.exit_code),
3215        Err(e) => format!("{action_name} completed with error: {e}"),
3216    };
3217    let _ = send_response(
3218        framed,
3219        &DaemonResponse::JobProgress {
3220            job_id: job_id.clone(),
3221            line: completion_line,
3222        },
3223    )
3224    .await;
3225
3226    // Attempt automatic rollback if the action failed and rollback is available.
3227    // A deadline that could not stop the work is the one failure that must not
3228    // trigger one: the original action may still be running.
3229    let action_may_still_be_running = matches!(
3230        &output,
3231        Err(crate::executor::ExecutorError::ActionNotStopped { .. })
3232    );
3233    let (final_status, summary, rollback_ref) = attempt_rollback_if_needed(
3234        framed,
3235        &executor,
3236        &job_id,
3237        action_name,
3238        &spec,
3239        initial_status,
3240        initial_summary,
3241        action_may_still_be_running,
3242    )
3243    .await;
3244
3245    // Clear the exclusion slot now that the action has finished, EXCEPT when it
3246    // could not be confirmed stopped. If a privileged transaction may still be
3247    // running, its resource is still in use, and releasing the slot would admit
3248    // a second mutating action to race it — the exact concurrent-transaction
3249    // state the rollback veto above refuses to create itself. Holding the slot
3250    // makes the veto binding against the operator too, not just the daemon. The
3251    // slot lives in memory, so a daemon restart clears it, which is the same
3252    // "inspect the host before retrying" recovery the error already names.
3253    if action_may_still_be_running {
3254        if !to_claim.is_empty() {
3255            eprintln!(
3256                "[sysknife-daemon] holding the exclusion slot for {action_name}: the action \
3257                 could not be confirmed stopped, so no other mutating action may start until \
3258                 the daemon is restarted"
3259            );
3260        }
3261    } else {
3262        release_exclusive_slots(state, &to_claim, &stored_hash).await;
3263    }
3264
3265    // Update the transaction record. A failure here is an audit-trail loss —
3266    // log it and surface it as a warning in the job result so the client is
3267    // aware of the gap.
3268    let mut warnings = Vec::new();
3269    match update_terminal_status(state, transaction_id, final_status).await {
3270        Ok(()) => {
3271            // Forward a status-change event to the SIEM so analysts see the
3272            // terminal outcome of the action, not just the preview. Best-effort
3273            // and fire-and-forget — the local hash-chained log is the durable
3274            // record.
3275            forward_status_change_event(state, transaction_id, &caller.role(), final_status);
3276        }
3277        Err(e) => {
3278            eprintln!(
3279                "[sysknife-daemon] failed to update transaction {transaction_id} to \
3280                 {final_status:?}: {e}"
3281            );
3282            warnings.push(format!("audit trail update failed: {e}"));
3283        }
3284    }
3285
3286    send_response(
3287        framed,
3288        &DaemonResponse::JobCompleted {
3289            job_id: job_id.clone(),
3290            result: JobResult {
3291                status: job_state_str(&final_status).to_string(),
3292                summary,
3293                warnings,
3294                job_id: job_id.clone(),
3295                needs_reboot: matches!(final_status, JobState::NeedsReboot),
3296                rollback_ref,
3297                transaction_id: transaction_id.to_string(),
3298            },
3299        },
3300    )
3301    .await
3302}
3303
3304/// If `status` is `Failed` and `spec.rollback_available`, attempt an
3305/// automatic rollback. Returns the updated `(JobState, summary, rollback_ref)`.
3306///
3307/// Sends `JobProgress` frames announcing the attempt and its outcome.
3308/// Send failures are logged but do not abort the rollback.
3309///
3310/// `action_may_still_be_running` vetoes the rollback. A failed action normally
3311/// means the host is where it was, so reverting is the safe move; but a deadline
3312/// that could not stop the work means the original transaction may still be
3313/// live, and `rpm-ostree rollback` on top of a running upgrade puts two package
3314/// transactions on one host. Doing nothing is recoverable, doing both is not.
3315/// See issue #140.
3316#[allow(clippy::too_many_arguments)]
3317async fn attempt_rollback_if_needed(
3318    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
3319    executor: &Arc<dyn ActionExecutor>,
3320    job_id: &str,
3321    action_name: &str,
3322    spec: &crate::actions::ActionSpec,
3323    status: JobState,
3324    summary: String,
3325    action_may_still_be_running: bool,
3326) -> (JobState, String, Option<String>) {
3327    if !matches!(status, JobState::Failed) || !spec.rollback_available {
3328        return (status, summary, None);
3329    }
3330    if action_may_still_be_running {
3331        eprintln!(
3332            "[sysknife-daemon] {action_name} could not be confirmed stopped; \
3333             skipping automatic rollback"
3334        );
3335        let line = format!(
3336            "{action_name} could not be confirmed stopped, so automatic rollback was \
3337             skipped: rolling back over a running transaction would be worse than \
3338             leaving it. Inspect the host before retrying."
3339        );
3340        let _ = send_response(
3341            framed,
3342            &DaemonResponse::JobProgress {
3343                job_id: job_id.to_string(),
3344                line: line.clone(),
3345            },
3346        )
3347        .await;
3348        return (
3349            status,
3350            format!("{summary} (automatic rollback skipped)"),
3351            None,
3352        );
3353    }
3354    let Some(rb_spec) = rollback_spec_for(action_name) else {
3355        return (status, summary, None);
3356    };
3357
3358    eprintln!("[sysknife-daemon] {action_name} failed; attempting automatic rollback");
3359
3360    let _ = send_response(
3361        framed,
3362        &DaemonResponse::JobProgress {
3363            job_id: job_id.to_string(),
3364            line: format!(
3365                "{action_name} failed — attempting automatic rollback via rpm-ostree rollback"
3366            ),
3367        },
3368    )
3369    .await;
3370
3371    match executor.execute(&rb_spec).await {
3372        Ok(rb_out) if rb_out.is_success() => {
3373            let _ = send_response(
3374                framed,
3375                &DaemonResponse::JobProgress {
3376                    job_id: job_id.to_string(),
3377                    line: "Rollback succeeded — previous deployment restored".to_string(),
3378                },
3379            )
3380            .await;
3381            (
3382                JobState::RolledBack,
3383                format!(
3384                    "{action_name} failed and was automatically rolled back to the previous deployment"
3385                ),
3386                Some("rpm-ostree rollback".to_string()),
3387            )
3388        }
3389        other => {
3390            let detail = match &other {
3391                Ok(o) => format!("exit code {}", o.exit_code),
3392                Err(e) => e.to_string(),
3393            };
3394            eprintln!("[sysknife-daemon] rollback also failed: {detail}");
3395            let _ = send_response(
3396                framed,
3397                &DaemonResponse::JobProgress {
3398                    job_id: job_id.to_string(),
3399                    line: format!(
3400                        "Rollback also failed ({detail}) — system may need manual intervention"
3401                    ),
3402                },
3403            )
3404            .await;
3405            (status, summary, None)
3406        }
3407    }
3408}
3409
3410fn job_state_str(state: &JobState) -> &'static str {
3411    match state {
3412        JobState::Queued => "queued",
3413        JobState::Running => "running",
3414        JobState::Succeeded => "succeeded",
3415        JobState::Failed => "failed",
3416        JobState::Canceled => "canceled",
3417        JobState::RolledBack => "rolled_back",
3418        JobState::NeedsReboot => "needs_reboot",
3419    }
3420}
3421
3422// ---------------------------------------------------------------------------
3423// Framing helpers
3424// ---------------------------------------------------------------------------
3425
3426#[derive(Debug, thiserror::Error)]
3427enum HandlerError {
3428    #[error("framing error: {0}")]
3429    Framing(#[from] FramingError),
3430    #[error("serialization error: {0}")]
3431    Serialization(#[from] serde_json::Error),
3432    #[error("internal error: {0}")]
3433    Internal(String),
3434}
3435
3436fn format_job_history(records: &[sysknife_types::TransactionRecord]) -> String {
3437    if records.is_empty() {
3438        return "No transactions found.".to_string();
3439    }
3440
3441    let mut output = format!("Transaction history ({} entries):\n\n", records.len());
3442    for r in records {
3443        output.push_str(&format!(
3444            "  {}  {:30}  {:12}  {}\n",
3445            r.transaction_id
3446                .chars()
3447                .take(TRANSACTION_ID_DISPLAY_PREFIX_LEN)
3448                .collect::<String>(),
3449            r.action_name,
3450            format!("{:?}", r.status).to_lowercase(),
3451            r.summary,
3452        ));
3453    }
3454    output
3455}
3456
3457async fn send_response(
3458    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
3459    response: &DaemonResponse,
3460) -> Result<(), HandlerError> {
3461    let json = serde_json::to_vec(response)?;
3462    framed.send(&json).await.map_err(HandlerError::Framing)
3463}
3464
3465async fn send_error(
3466    framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
3467    request_id: &str,
3468    category: &str,
3469    message: impl Into<String>,
3470) -> Result<(), HandlerError> {
3471    send_response(
3472        framed,
3473        &DaemonResponse::ErrorResponse {
3474            request_id: request_id.to_string(),
3475            category: category.to_string(),
3476            message: message.into(),
3477        },
3478    )
3479    .await
3480}
3481
3482// ---------------------------------------------------------------------------
3483// Tests
3484// ---------------------------------------------------------------------------
3485
3486#[cfg(test)]
3487mod tests {
3488    use super::*;
3489    use crate::auth::CallerPrincipal;
3490
3491    /// A uid-attributed caller, the shape a Unix-socket connection produces.
3492    fn uid_caller(role: CallerRole) -> CallerAttribution {
3493        uid_caller_with(1000, role)
3494    }
3495
3496    /// A uid-attributed caller with an explicit uid, for tests that need to tell
3497    /// one account from another.
3498    fn uid_caller_with(uid: u32, role: CallerRole) -> CallerAttribution {
3499        CallerAttribution::from_peer_uid(uid, role)
3500    }
3501    use crate::{
3502        state::{DaemonConfig, DaemonState},
3503        transport::listen::ListenTarget,
3504    };
3505    use std::io;
3506    use tempfile::tempdir;
3507
3508    // ------------------------------------------------------------------
3509    // Automatic rollback is vetoed when the action may still be running
3510    // ------------------------------------------------------------------
3511
3512    /// Records whether a rollback was ever attempted, so a test can assert on
3513    /// the absence of one. Counting the calls is the only way to tell "rollback
3514    /// ran and failed" from "rollback never ran".
3515    #[derive(Default)]
3516    struct CountingRollbackExecutor {
3517        calls: std::sync::atomic::AtomicUsize,
3518    }
3519
3520    #[async_trait::async_trait]
3521    impl ActionExecutor for CountingRollbackExecutor {
3522        async fn execute(
3523            &self,
3524            _spec: &crate::actions::ActionSpec,
3525        ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
3526            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3527            Ok(crate::executor::ExecutionOutput {
3528                stdout: "rolled back\n".to_string(),
3529                stderr: String::new(),
3530                exit_code: 0,
3531            })
3532        }
3533    }
3534
3535    /// `UpdateSystem`'s spec: rollback-eligible and High risk, the exact shape
3536    /// this veto exists to protect.
3537    fn rollback_eligible_spec() -> crate::actions::ActionSpec {
3538        let spec = crate::executor::build_action_spec("UpdateSystem", &serde_json::json!({}))
3539            .expect("UpdateSystem builds without params");
3540        assert!(
3541            spec.rollback_available,
3542            "this test is meaningless unless the action is rollback-eligible"
3543        );
3544        spec
3545    }
3546
3547    async fn rollback_outcome(
3548        may_still_be_running: bool,
3549    ) -> (JobState, String, Option<String>, usize) {
3550        let (a, _b) = UnixStream::pair().unwrap();
3551        let mut framed = FramedStream::new(a);
3552        let counting = Arc::new(CountingRollbackExecutor::default());
3553        let executor: Arc<dyn ActionExecutor> = counting.clone();
3554        let (status, summary, rollback_ref) = attempt_rollback_if_needed(
3555            &mut framed,
3556            &executor,
3557            "job-1",
3558            "UpdateSystem",
3559            &rollback_eligible_spec(),
3560            JobState::Failed,
3561            "UpdateSystem failed".to_string(),
3562            may_still_be_running,
3563        )
3564        .await;
3565        let calls = counting.calls.load(std::sync::atomic::Ordering::SeqCst);
3566        (status, summary, rollback_ref, calls)
3567    }
3568
3569    /// The control: an ordinary failure still rolls back, so the veto below
3570    /// cannot pass by disabling rollback entirely.
3571    #[tokio::test]
3572    async fn an_ordinary_failure_still_rolls_back() {
3573        let (status, _summary, rollback_ref, calls) = rollback_outcome(false).await;
3574        assert_eq!(calls, 1, "the rollback action must have been executed");
3575        assert!(matches!(status, JobState::RolledBack));
3576        assert!(rollback_ref.is_some());
3577    }
3578
3579    /// A deadline that could not stop the work must not start a second
3580    /// privileged transaction on top of the first. Rolling back over a live
3581    /// `rpm-ostree upgrade` is worse than leaving it alone: doing nothing is
3582    /// recoverable, doing both is not. See issue #140.
3583    #[tokio::test]
3584    async fn an_action_that_could_not_be_stopped_is_never_rolled_back_over() {
3585        let (status, summary, rollback_ref, calls) = rollback_outcome(true).await;
3586        assert_eq!(
3587            calls, 0,
3588            "no rollback may be executed while the original action may still be running"
3589        );
3590        assert!(
3591            matches!(status, JobState::Failed),
3592            "the job stays failed rather than claiming it was rolled back"
3593        );
3594        assert!(rollback_ref.is_none());
3595        assert!(
3596            summary.contains("rollback skipped"),
3597            "the operator must be told why nothing was reverted, got: {summary}"
3598        );
3599    }
3600
3601    // ------------------------------------------------------------------
3602    // PID-reuse hardening (SO_PEERPIDFD)
3603    // ------------------------------------------------------------------
3604
3605    #[cfg(target_os = "linux")]
3606    #[tokio::test]
3607    async fn peer_pidfd_reports_live_self() {
3608        let (a, _b) = UnixStream::pair().unwrap();
3609        // On kernels with SO_PEERPIDFD the pinned peer is this very test process,
3610        // which is obviously still alive, so the liveness check must return true.
3611        // Only an unsupported option may take the compatibility path.
3612        match peer_pidfd(&a) {
3613            PeerPin::Pinned(fd) => assert!(
3614                pidfd_peer_still_live(&fd),
3615                "the connecting (self) process must read as live"
3616            ),
3617            PeerPin::Unsupported => {}
3618            PeerPin::Unpinnable => panic!("a live peer must not silently lose its pin"),
3619        }
3620    }
3621
3622    #[cfg(target_os = "linux")]
3623    #[test]
3624    fn peer_pin_only_unsupported_option_allows_best_effort_groups() {
3625        assert!(matches!(
3626            PeerPin::from_error(&std::io::Error::from_raw_os_error(libc::ENOPROTOOPT)),
3627            PeerPin::Unsupported
3628        ));
3629        for errno in [
3630            libc::EINVAL,
3631            libc::ESRCH,
3632            libc::EMFILE,
3633            libc::ENFILE,
3634            libc::EPERM,
3635        ] {
3636            let pin = PeerPin::from_error(&std::io::Error::from_raw_os_error(errno));
3637            assert!(matches!(pin, PeerPin::Unpinnable), "errno {errno}");
3638            let groups = groups_for_pinned_peer(123, pin, || {
3639                panic!("an unpinnable peer must not read a potentially recycled PID")
3640            });
3641            assert_eq!(highest_role_from_groups(groups), CallerRole::Observer);
3642        }
3643        let groups = groups_for_pinned_peer(123, PeerPin::Unsupported, || {
3644            vec!["sysknife-admin".to_string()]
3645        });
3646        assert_eq!(highest_role_from_groups(groups), CallerRole::Admin);
3647    }
3648
3649    #[cfg(target_os = "linux")]
3650    #[tokio::test]
3651    async fn unpinnable_peer_keeps_only_its_primary_group() {
3652        let (a, _b) = UnixStream::pair().unwrap();
3653        let cred = a.peer_cred().unwrap();
3654        let gid_map = read_gid_map();
3655        let expected = highest_role_from_groups(gid_map.get(&cred.gid()));
3656        let caller = resolve_caller_with_pin(&a, PeerPin::Unpinnable);
3657        assert_eq!(caller.role(), expected);
3658        assert_eq!(caller.principal(), CallerPrincipal::Uid(cred.uid()));
3659    }
3660
3661    /// The uid must come from the kernel, not from anything the peer says. A
3662    /// socketpair peer is this test process, so the expected value is knowable
3663    /// and the assertion pins the wiring rather than merely the absence of a
3664    /// panic.
3665    #[tokio::test]
3666    async fn resolve_caller_attributes_a_unix_peer_to_its_kernel_reported_uid() {
3667        let (a, _b) = UnixStream::pair().unwrap();
3668        let caller = resolve_caller(&a);
3669
3670        assert_eq!(
3671            caller.principal(),
3672            CallerPrincipal::Uid(unsafe { libc::geteuid() }),
3673            "a Unix-socket caller must be attributed to the peer uid SO_PEERCRED reports"
3674        );
3675        assert_eq!(
3676            caller.principal().as_signed_str(),
3677            format!("uid:{}", unsafe { libc::geteuid() }),
3678            "and the signed form must carry the uid scheme"
3679        );
3680    }
3681
3682    /// A vsock caller presents a shared secret, so the chain must not imply an
3683    /// account. `token:vsock` says exactly what was proven: possession of a file
3684    /// that any holder could have read.
3685    #[test]
3686    fn a_vsock_principal_does_not_claim_an_account() {
3687        assert_eq!(CallerPrincipal::VsockToken.as_signed_str(), "token:vsock");
3688        assert!(
3689            !CallerPrincipal::VsockToken
3690                .as_signed_str()
3691                .starts_with("uid:"),
3692            "a token must never render as a uid; that would overstate the evidence"
3693        );
3694    }
3695
3696    #[tokio::test]
3697    async fn resolve_caller_matches_the_peers_actual_groups() {
3698        // An earlier version discarded the resolved value and asserted only that nothing panicked,
3699        // asserting only "no panic" — it would have passed just as happily if
3700        // the function returned Boot for everyone.
3701        //
3702        // The role is environment-dependent, so rather than hardcoding one we
3703        // derive the expectation from the same inputs the daemon uses and
3704        // compare. That pins the whole wiring — SO_PEERCRED → pid →
3705        // /proc/<pid>/status → /etc/group → role — instead of only its absence
3706        // of panics.
3707        let (a, _b) = UnixStream::pair().unwrap();
3708        let role = resolve_caller(&a).role();
3709
3710        let gid_map = read_gid_map();
3711        let expected =
3712            crate::auth::highest_role_from_groups(groups_for_pid(std::process::id(), &gid_map));
3713
3714        assert_eq!(
3715            role, expected,
3716            "the resolved role must be exactly what this process's groups justify"
3717        );
3718    }
3719
3720    #[tokio::test]
3721    async fn resolve_caller_never_exceeds_observer_without_a_privileged_group() {
3722        // The direction that actually matters for safety: privilege must come
3723        // from group membership, never from the mere act of connecting.
3724        let (a, _b) = UnixStream::pair().unwrap();
3725        let role = resolve_caller(&a).role();
3726
3727        let gid_map = read_gid_map();
3728        let groups = groups_for_pid(std::process::id(), &gid_map);
3729        let privileged = groups.iter().any(|g| {
3730            matches!(
3731                g.as_str(),
3732                crate::auth::DEV_GROUP
3733                    | crate::auth::ADMIN_GROUP
3734                    | crate::auth::BOOT_GROUP
3735                    | crate::auth::WHEEL_GROUP
3736            )
3737        });
3738
3739        if !privileged {
3740            assert_eq!(
3741                role,
3742                CallerRole::Observer,
3743                "a process in no privileged group must resolve to Observer"
3744            );
3745        }
3746    }
3747
3748    // ------------------------------------------------------------------
3749    // Connection read deadlines
3750    // ------------------------------------------------------------------
3751
3752    /// A caller that connects and never sends anything must be cut loose
3753    /// quickly. It holds one of MAX_CONNECTIONS semaphore permits while it
3754    /// waits, so the pre-request window is the cheap-denial window: a member of
3755    /// the socket group needs no role at all to occupy every slot.
3756    #[test]
3757    fn first_request_deadline_is_much_tighter_than_the_idle_deadline() {
3758        let first = read_deadline(0);
3759        let idle = read_deadline(1);
3760        assert!(
3761            first < idle,
3762            "pre-request deadline {first:?} must be tighter than the between-request \
3763             deadline {idle:?}; otherwise a silent connection squats a permit for the \
3764             full idle window"
3765        );
3766        assert_eq!(idle, IDLE_CONNECTION_TIMEOUT);
3767    }
3768
3769    /// Once a caller has been served, idling is legitimate: an MCP server holds
3770    /// its connection open between tool calls. The tighter bound must apply to
3771    /// the first request only.
3772    #[test]
3773    fn served_connections_keep_the_full_idle_allowance() {
3774        for served in 1..5 {
3775            assert_eq!(
3776                read_deadline(served),
3777                IDLE_CONNECTION_TIMEOUT,
3778                "request {served} must not be held to the pre-request deadline"
3779            );
3780        }
3781    }
3782
3783    // Credential redaction
3784    // ------------------------------------------------------------------
3785
3786    #[test]
3787    fn redact_params_replaces_pro_attach_token() {
3788        let params = json!({"token": "super-secret-test-only", "extra": "kept"});
3789        let r = redact_params("ProAttach", &params);
3790        // Token replaced with sentinel.
3791        assert_eq!(r["token"].as_str(), Some("<REDACTED>"));
3792        // Other keys preserved verbatim.
3793        assert_eq!(r["extra"].as_str(), Some("kept"));
3794        // Sanity: the literal token value is nowhere in the rendered JSON.
3795        let s = serde_json::to_string(&r).unwrap();
3796        assert!(
3797            !s.contains("super-secret-test-only"),
3798            "token leaked into proposed_change JSON: {s}"
3799        );
3800    }
3801
3802    #[test]
3803    fn redact_params_replaces_configure_wifi_password() {
3804        let params = json!({"ssid": "HomeNet", "password": "correct-horse-test-only"});
3805        let r = redact_params("ConfigureWifi", &params);
3806        assert_eq!(r["password"].as_str(), Some("<REDACTED>"));
3807        // The SSID is not a secret and must survive for the preview to be useful.
3808        assert_eq!(r["ssid"].as_str(), Some("HomeNet"));
3809        let s = serde_json::to_string(&r).unwrap();
3810        assert!(
3811            !s.contains("correct-horse-test-only"),
3812            "wifi password leaked into proposed_change JSON: {s}"
3813        );
3814    }
3815
3816    /// `nmcli device wifi connect <ssid> password <pw>` — the secret is the
3817    /// element after the `password` keyword, not a fixed index, because the
3818    /// keyword pair is absent for open networks.
3819    #[test]
3820    fn redact_argv_replaces_wifi_password_after_keyword() {
3821        let params = json!({"ssid": "HomeNet", "password": "correct-horse-test-only"});
3822        let argv = wifi_argv(&["HomeNet", "password", "correct-horse-test-only"]);
3823        let r = redact_argv("ConfigureWifi", &params, &argv);
3824        assert_eq!(r.last().map(String::as_str), Some("<REDACTED>"));
3825        assert!(
3826            !r.iter().any(|a| a == "correct-horse-test-only"),
3827            "wifi password survived argv redaction: {r:?}"
3828        );
3829    }
3830
3831    /// Open network: there is no password pair, so nothing may be redacted —
3832    /// in particular the SSID must not be mistaken for the credential.
3833    #[test]
3834    fn redact_argv_leaves_open_network_argv_intact() {
3835        let params = json!({"ssid": "CafeWifi"});
3836        let argv = wifi_argv(&["CafeWifi"]);
3837        let r = redact_argv("ConfigureWifi", &params, &argv);
3838        assert_eq!(r, argv, "open-network argv must be unchanged");
3839    }
3840
3841    /// An SSID that is literally `password` must not shift redaction onto the
3842    /// keyword and leave the secret in place.
3843    #[test]
3844    fn redact_argv_handles_ssid_named_password() {
3845        let params = json!({"ssid": "password", "password": "s3cret-test-only"});
3846        let argv = wifi_argv(&["password", "password", "s3cret-test-only"]);
3847        let r = redact_argv("ConfigureWifi", &params, &argv);
3848        assert!(
3849            !r.iter().any(|a| a == "s3cret-test-only"),
3850            "secret leaked when the SSID is named 'password': {r:?}"
3851        );
3852        // The SSID itself is not secret and is still shown.
3853        assert_eq!(r.iter().filter(|a| *a == "password").count(), 2);
3854    }
3855
3856    /// A password whose value is the literal keyword `password` is the mirror
3857    /// case: the last keyword occurrence IS the secret, so anchoring must use
3858    /// the last occurrence that still has a successor.
3859    #[test]
3860    fn redact_argv_handles_password_valued_password() {
3861        let params = json!({"ssid": "HomeNet", "password": "password"});
3862        let argv = wifi_argv(&["HomeNet", "password", "password"]);
3863        let r = redact_argv("ConfigureWifi", &params, &argv);
3864        assert_eq!(r.last().map(String::as_str), Some("<REDACTED>"));
3865    }
3866
3867    /// Build the argv `network::configure_wifi` produces, so these tests stay
3868    /// honest about the real command shape.
3869    fn wifi_argv(tail: &[&str]) -> Vec<String> {
3870        let mut argv: Vec<String> = ["nmcli", "device", "wifi", "connect"]
3871            .iter()
3872            .map(|s| (*s).to_string())
3873            .collect();
3874        argv.extend(tail.iter().map(|s| (*s).to_string()));
3875        argv
3876    }
3877
3878    #[test]
3879    fn redact_params_passes_through_for_actions_with_no_credentials() {
3880        let params = json!({"package": "vim"});
3881        let r = redact_params("AptInstall", &params);
3882        assert_eq!(r["package"].as_str(), Some("vim"));
3883    }
3884
3885    #[test]
3886    fn redact_argv_replaces_argv_element_matching_token_value() {
3887        // ProAttach's argv is `pro attach <token>`. The redactor finds the
3888        // element by value-match and replaces it without positional knowledge.
3889        let params = json!({"token": "super-secret-test-only"});
3890        let argv = vec![
3891            "pro".to_string(),
3892            "attach".to_string(),
3893            "super-secret-test-only".to_string(),
3894        ];
3895        let r = redact_argv("ProAttach", &params, &argv);
3896        assert_eq!(r, vec!["pro", "attach", "<REDACTED>"]);
3897    }
3898
3899    #[test]
3900    fn redact_argv_passes_through_when_no_credentials() {
3901        let params = json!({"package": "vim"});
3902        let argv = vec![
3903            "apt-get".to_string(),
3904            "install".to_string(),
3905            "vim".to_string(),
3906        ];
3907        let r = redact_argv("AptInstall", &params, &argv);
3908        assert_eq!(r, argv);
3909    }
3910
3911    // ME2 regression — token equal to structural element "attach".
3912    //
3913    // If token == "attach", value-match-only redaction would replace BOTH
3914    // the structural "attach" and the token position, producing:
3915    //   `sudo pro <REDACTED> <REDACTED>`
3916    // instead of the correct:
3917    //   `sudo pro attach <REDACTED>`
3918    //
3919    // Positional redaction (last element) must be applied first so that
3920    // "attach" is preserved as a structural element.
3921    #[test]
3922    fn redact_argv_token_equal_to_attach_preserves_structural_attach() {
3923        let params = json!({"token": "attach"});
3924        // Typical ProAttach argv shape: sudo pro attach <token>
3925        let argv = vec![
3926            "sudo".to_string(),
3927            "pro".to_string(),
3928            "attach".to_string(),
3929            "attach".to_string(), // token value == "attach"
3930        ];
3931        let r = redact_argv("ProAttach", &params, &argv);
3932        // Positional pass redacts last element.
3933        // Value-match fallback then redacts any remaining "attach" — but the
3934        // only remaining "attach" at index 2 is structural and should be kept.
3935        // The net result: last element is redacted; "attach" at index 2 is NOT.
3936        assert_eq!(
3937            r,
3938            vec!["sudo", "pro", "attach", "<REDACTED>"],
3939            "structural 'attach' must survive; only the positional token gets redacted"
3940        );
3941    }
3942
3943    // ------------------------------------------------------------------
3944    // Test helpers
3945    // ------------------------------------------------------------------
3946
3947    struct MockRunner;
3948    impl CommandRunner for MockRunner {
3949        fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
3950            match program {
3951                "hostname" => Ok("testhost\n".to_string()),
3952                _ => Ok(String::new()),
3953            }
3954        }
3955    }
3956
3957    fn test_state(dir: &tempfile::TempDir) -> DaemonState {
3958        let db_path = dir.path().join("sysknife-test.db");
3959        let sock_path = dir.path().join("sysknife-test.sock");
3960        let config = DaemonConfig::new(ListenTarget::Unix(sock_path), db_path);
3961        DaemonState::open(config).unwrap()
3962    }
3963
3964    fn runner() -> Arc<dyn CommandRunner + Send + Sync> {
3965        Arc::new(MockRunner)
3966    }
3967
3968    fn str_set<const N: usize>(items: [&str; N]) -> std::collections::BTreeSet<String> {
3969        items.iter().map(|s| s.to_string()).collect()
3970    }
3971
3972    #[test]
3973    fn autoremove_binding_accepts_the_same_set() {
3974        let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a", "b"] });
3975        assert!(verify_autoremove_binding(&pc, &str_set(["a", "b"])).is_ok());
3976    }
3977
3978    #[test]
3979    fn autoremove_binding_rejects_a_newly_added_package() {
3980        let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a"] });
3981        let err = verify_autoremove_binding(&pc, &str_set(["a", "linux-image-6.1"]))
3982            .expect_err("a package apt would now remove but the operator did not approve");
3983        assert!(err.contains("linux-image-6.1"), "{err}");
3984    }
3985
3986    #[test]
3987    fn autoremove_binding_rejects_a_no_longer_removed_package() {
3988        let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a", "b"] });
3989        let err = verify_autoremove_binding(&pc, &str_set(["a"]))
3990            .expect_err("the approved set no longer matches");
3991        assert!(err.contains('b'), "{err}");
3992    }
3993
3994    #[test]
3995    fn autoremove_binding_refuses_a_preview_that_captured_no_set() {
3996        // A preview whose simulate failed records no set; execute must fail closed.
3997        let pc = json!({ "action": "AptAutoremove" });
3998        assert!(verify_autoremove_binding(&pc, &str_set([])).is_err());
3999    }
4000
4001    // `ostree admin pin <index>` names an ordinal position in the live
4002    // deployment list, not a stable id. The operator approves on the basis of
4003    // what sat at that position during preview, and `compute_request_hash`
4004    // covers only `{"index": N}`, which is byte-identical however much has
4005    // moved into slot N since. Any concurrent UpdateSystem, CleanupDeployments
4006    // or RollbackDeployment inside the approval TTL reorders the list, and the
4007    // request-hash binding and the signed audit row both still show a clean
4008    // match to a validly approved preview.
4009    //
4010    // Same fix as #151 took for AptAutoremove: capture the identity at preview,
4011    // re-check it at execute, fail closed when the preview captured nothing.
4012    #[test]
4013    fn a_deployment_preview_binds_execute_to_the_deployment_not_the_slot() {
4014        // Nothing captured: execute must refuse rather than run against
4015        // whatever now occupies the slot.
4016        let empty = json!({ "action": "PinDeployment", "params": { "index": 1 } });
4017        let err = verify_deployment_binding(&empty, "abc123")
4018            .expect_err("a preview with no captured identity must not execute");
4019        assert!(
4020            err.contains("preview again"),
4021            "the refusal must tell the operator what to do: {err}"
4022        );
4023
4024        // Captured and unchanged: execute proceeds.
4025        let mut pinned = empty.clone();
4026        pinned[DEPLOYMENT_IDENTITY_KEY] = json!("abc123");
4027        assert!(verify_deployment_binding(&pinned, "abc123").is_ok());
4028
4029        // Captured and something else moved into that slot: refuse, and name
4030        // both so the operator can see what changed under them.
4031        let err = verify_deployment_binding(&pinned, "def456")
4032            .expect_err("a reordered list must not execute against the old approval");
4033        assert!(
4034            err.contains("abc123") && err.contains("def456"),
4035            "the refusal must name the approved and the live deployment: {err}"
4036        );
4037    }
4038
4039    #[test]
4040    fn deployment_identity_is_read_from_rpm_ostree_status() {
4041        let json_out = r#"{"deployments":[
4042            {"checksum":"aaa","origin":"fedora/41/x86_64/silverblue"},
4043            {"checksum":"bbb","origin":"fedora/41/x86_64/silverblue"}
4044        ]}"#;
4045        assert_eq!(parse_deployment_identity(json_out, 0).unwrap(), "aaa");
4046        assert_eq!(parse_deployment_identity(json_out, 1).unwrap(), "bbb");
4047        // An index past the end is not "no deployment"; it is a question this
4048        // cannot answer, and answering it with a default would bind execute to
4049        // nothing.
4050        assert!(parse_deployment_identity(json_out, 2).is_err());
4051        assert!(parse_deployment_identity("not json", 0).is_err());
4052        // A deployment with no checksum cannot be bound to. Returning an empty
4053        // string here would make every later comparison succeed.
4054        assert!(parse_deployment_identity(r#"{"deployments":[{}]}"#, 0).is_err());
4055    }
4056
4057    #[test]
4058    fn autoremove_kernel_warning_flags_kernel_and_driver_packages() {
4059        let set = str_set([
4060            "linux-image-6.1",
4061            "nvidia-driver-535",
4062            "libfoo:amd64",
4063            "vim",
4064        ]);
4065        let w = autoremove_kernel_warning(&set).expect("kernel/driver packages must warn");
4066        assert!(
4067            w.contains("linux-image-6.1") && w.contains("nvidia-driver-535"),
4068            "{w}"
4069        );
4070        assert!(!w.contains("vim") && !w.contains("libfoo"), "{w}");
4071    }
4072
4073    #[test]
4074    fn autoremove_kernel_warning_is_silent_for_ordinary_packages() {
4075        assert!(autoremove_kernel_warning(&str_set(["libfoo", "oldlib"])).is_none());
4076    }
4077
4078    /// Returns a scripted `apt-get -s autoremove` result per call (preview first,
4079    /// then execute), and "testhost" for hostname so state collection succeeds.
4080    /// Panics on an unscripted extra call so a duplicate simulate cannot be
4081    /// silently papered over, and shares its call counter so tests can assert the
4082    /// exactly-once-per-phase invariant.
4083    struct ScriptedAutoremoveRunner {
4084        outputs: Vec<Result<String, String>>,
4085        calls: Arc<std::sync::atomic::AtomicUsize>,
4086    }
4087    impl ScriptedAutoremoveRunner {
4088        fn with_outputs(
4089            outputs: Vec<Result<String, String>>,
4090        ) -> (
4091            Arc<dyn CommandRunner + Send + Sync>,
4092            Arc<std::sync::atomic::AtomicUsize>,
4093        ) {
4094            let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4095            let runner = Arc::new(Self {
4096                outputs,
4097                calls: Arc::clone(&calls),
4098            });
4099            (runner, calls)
4100        }
4101        /// Convenience: every call returns the same stdout.
4102        fn ok(
4103            outputs: &[&str],
4104        ) -> (
4105            Arc<dyn CommandRunner + Send + Sync>,
4106            Arc<std::sync::atomic::AtomicUsize>,
4107        ) {
4108            Self::with_outputs(outputs.iter().map(|s| Ok(s.to_string())).collect())
4109        }
4110    }
4111    impl CommandRunner for ScriptedAutoremoveRunner {
4112        fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error> {
4113            if program == "hostname" {
4114                return Ok("testhost\n".to_string());
4115            }
4116            if program == "apt-get" && args == ["-s", "autoremove"] {
4117                let i = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4118                let out = self.outputs.get(i).unwrap_or_else(|| {
4119                    panic!(
4120                        "ScriptedAutoremoveRunner: unexpected `apt-get -s autoremove` call #{i} \
4121                         (only {} scripted)",
4122                        self.outputs.len()
4123                    )
4124                });
4125                return out.clone().map_err(io::Error::other);
4126            }
4127            Ok(String::new())
4128        }
4129    }
4130
4131    #[tokio::test]
4132    async fn preview_captures_the_autoremove_set_and_warns_on_kernel_packages() {
4133        let dir = tempdir().unwrap();
4134        let state = test_state(&dir);
4135        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4136        let (runner, _calls) =
4137            ScriptedAutoremoveRunner::ok(&["Remv linux-image-6.1 [6.1]\nRemv libfoo [1]\n"]);
4138        tokio::spawn(async move {
4139            unix_connection_handler(
4140                server,
4141                state,
4142                runner,
4143                uid_caller_with(1000, CallerRole::Admin),
4144            )
4145            .await;
4146        });
4147
4148        let mut framed = FramedStream::new(client);
4149        framed
4150            .send(
4151                &serde_json::to_vec(&json!({
4152                    "type": "preview",
4153                    "request_id": "r1",
4154                    "action_name": "AptAutoremove",
4155                    "params": {}
4156                }))
4157                .unwrap(),
4158            )
4159            .await
4160            .unwrap();
4161        let resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4162        assert_eq!(resp["type"], "preview_response", "{resp}");
4163
4164        let removals = resp["preview"]["proposed_change"][AUTOREMOVE_REMOVALS_KEY]
4165            .as_array()
4166            .expect("the preview must capture the autoremove deletion set");
4167        let names: Vec<&str> = removals.iter().map(|v| v.as_str().unwrap()).collect();
4168        assert!(
4169            names.contains(&"linux-image-6.1") && names.contains(&"libfoo"),
4170            "captured set wrong: {names:?}"
4171        );
4172        let warnings = resp["preview"]["warnings"].as_array().unwrap();
4173        assert!(
4174            warnings
4175                .iter()
4176                .any(|w| w.as_str().unwrap().contains("linux-image-6.1")),
4177            "the kernel-package warning must be present: {warnings:?}"
4178        );
4179    }
4180
4181    #[tokio::test]
4182    async fn execute_refuses_autoremove_when_the_deletion_set_drifted() {
4183        let dir = tempdir().unwrap();
4184        let state = test_state(&dir);
4185        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4186        // Preview sees {pkga, pkgb}; by execute pkgb is no longer removable -> drift.
4187        let (runner, calls) =
4188            ScriptedAutoremoveRunner::ok(&["Remv pkga [1]\nRemv pkgb [1]\n", "Remv pkga [1]\n"]);
4189        let calls_probe = Arc::clone(&calls);
4190        tokio::spawn(async move {
4191            unix_connection_handler(
4192                server,
4193                state,
4194                runner,
4195                uid_caller_with(1000, CallerRole::Admin),
4196            )
4197            .await;
4198        });
4199
4200        let mut framed = FramedStream::new(client);
4201        framed
4202            .send(
4203                &serde_json::to_vec(&json!({
4204                    "type": "preview",
4205                    "request_id": "r1",
4206                    "action_name": "AptAutoremove",
4207                    "params": {}
4208                }))
4209                .unwrap(),
4210            )
4211            .await
4212            .unwrap();
4213        let resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4214        assert_eq!(resp["type"], "preview_response", "{resp}");
4215        let txid = resp["transaction_id"].as_str().unwrap().to_string();
4216        let receipt = approve_preview(&mut framed, &txid).await;
4217
4218        framed
4219            .send(
4220                &serde_json::to_vec(&json!({
4221                    "type": "execute",
4222                    "request_id": "r2",
4223                    "transaction_id": txid,
4224                    "action_name": "AptAutoremove",
4225                    "params": {},
4226                    "approval_receipt": receipt
4227                }))
4228                .unwrap(),
4229            )
4230            .await
4231            .unwrap();
4232        let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4233        // Refused before the executor runs — nothing is autoremoved.
4234        assert_eq!(
4235            exec["type"], "error_response",
4236            "drift must be refused: {exec}"
4237        );
4238        assert_eq!(exec["category"], "stale_approval", "{exec}");
4239        assert!(
4240            exec["message"].as_str().unwrap().contains("changed since"),
4241            "message should explain the drift: {exec}"
4242        );
4243        // Exactly one simulate at preview and one at execute — not more.
4244        assert_eq!(
4245            calls_probe.load(std::sync::atomic::Ordering::SeqCst),
4246            2,
4247            "apt-get -s autoremove must run once per phase"
4248        );
4249    }
4250
4251    /// Records whether the real action executor was invoked. Lets the
4252    /// unchanged-set test prove the Ok branch reaches execution without running
4253    /// any real command, independent of the sandbox's sudo behavior.
4254    #[derive(Default)]
4255    struct RecordingExecutor {
4256        called: Arc<std::sync::atomic::AtomicUsize>,
4257    }
4258    #[async_trait::async_trait]
4259    impl ActionExecutor for RecordingExecutor {
4260        async fn execute(
4261            &self,
4262            _spec: &crate::actions::ActionSpec,
4263        ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
4264            unreachable!("dispatcher uses execute_with_progress")
4265        }
4266        async fn execute_with_progress(
4267            &self,
4268            _spec: &crate::actions::ActionSpec,
4269            progress: tokio::sync::mpsc::UnboundedSender<String>,
4270        ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
4271            self.called
4272                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4273            let _ = progress.send("executed".to_string());
4274            Ok(crate::executor::ExecutionOutput {
4275                stdout: String::new(),
4276                stderr: String::new(),
4277                exit_code: 0,
4278            })
4279        }
4280    }
4281
4282    async fn preview_autoremove(framed: &mut FramedStream<tokio::net::UnixStream>) -> Value {
4283        framed
4284            .send(
4285                &serde_json::to_vec(&json!({
4286                    "type": "preview",
4287                    "request_id": "r1",
4288                    "action_name": "AptAutoremove",
4289                    "params": {}
4290                }))
4291                .unwrap(),
4292            )
4293            .await
4294            .unwrap();
4295        serde_json::from_slice(&framed.recv().await.unwrap()).unwrap()
4296    }
4297
4298    async fn send_execute_autoremove(
4299        framed: &mut FramedStream<tokio::net::UnixStream>,
4300        txid: &str,
4301        receipt: &str,
4302    ) {
4303        framed
4304            .send(
4305                &serde_json::to_vec(&json!({
4306                    "type": "execute",
4307                    "request_id": "r2",
4308                    "transaction_id": txid,
4309                    "action_name": "AptAutoremove",
4310                    "params": {},
4311                    "approval_receipt": receipt
4312                }))
4313                .unwrap(),
4314            )
4315            .await
4316            .unwrap();
4317    }
4318
4319    #[tokio::test]
4320    async fn execute_proceeds_when_the_autoremove_set_is_unchanged() {
4321        let dir = tempdir().unwrap();
4322        let state = test_state(&dir);
4323        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4324        // Same set at preview and execute -> no drift -> proceeds to the executor.
4325        let (runner, _calls) =
4326            ScriptedAutoremoveRunner::ok(&["Remv pkga [1]\n", "Remv pkga [1]\n"]);
4327        let called = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4328        let executor = Arc::new(RecordingExecutor {
4329            called: Arc::clone(&called),
4330        });
4331        tokio::spawn(async move {
4332            connection_handler_with_executor(
4333                server,
4334                state,
4335                runner,
4336                executor,
4337                uid_caller_with(1000, CallerRole::Admin),
4338            )
4339            .await;
4340        });
4341
4342        let mut framed = FramedStream::new(client);
4343        let resp = preview_autoremove(&mut framed).await;
4344        assert_eq!(resp["type"], "preview_response", "{resp}");
4345        let txid = resp["transaction_id"].as_str().unwrap().to_string();
4346        let receipt = approve_preview(&mut framed, &txid).await;
4347        send_execute_autoremove(&mut framed, &txid, &receipt).await;
4348
4349        loop {
4350            let r: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4351            match r["type"].as_str().unwrap() {
4352                "job_started" | "job_progress" => {}
4353                "job_completed" => break,
4354                other => panic!("an unchanged set must proceed, got {other}: {r}"),
4355            }
4356        }
4357        assert_eq!(
4358            called.load(std::sync::atomic::Ordering::SeqCst),
4359            1,
4360            "executor must run exactly once when the set is unchanged"
4361        );
4362    }
4363
4364    #[tokio::test]
4365    async fn preview_that_cannot_simulate_captures_no_set_and_execute_refuses() {
4366        let dir = tempdir().unwrap();
4367        let state = test_state(&dir);
4368        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4369        // Preview simulate fails -> no set captured. Execute's simulate succeeds
4370        // but the missing captured set makes verify refuse (fail-closed).
4371        let (runner, _calls) = ScriptedAutoremoveRunner::with_outputs(vec![
4372            Err("apt database lock held".to_string()),
4373            Ok("Remv pkga [1]\n".to_string()),
4374        ]);
4375        tokio::spawn(async move {
4376            unix_connection_handler(
4377                server,
4378                state,
4379                runner,
4380                uid_caller_with(1000, CallerRole::Admin),
4381            )
4382            .await;
4383        });
4384
4385        let mut framed = FramedStream::new(client);
4386        let resp = preview_autoremove(&mut framed).await;
4387        assert_eq!(resp["type"], "preview_response", "{resp}");
4388        // No set captured, and the operator is warned.
4389        assert!(
4390            resp["preview"]["proposed_change"]
4391                .get(AUTOREMOVE_REMOVALS_KEY)
4392                .is_none(),
4393            "a failed simulate must not capture a set: {resp}"
4394        );
4395        assert!(
4396            resp["preview"]["warnings"]
4397                .as_array()
4398                .unwrap()
4399                .iter()
4400                .any(|w| w.as_str().unwrap().contains("Could not compute")),
4401            "the operator must be warned: {resp}"
4402        );
4403        let txid = resp["transaction_id"].as_str().unwrap().to_string();
4404        let receipt = approve_preview(&mut framed, &txid).await;
4405        send_execute_autoremove(&mut framed, &txid, &receipt).await;
4406
4407        let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4408        assert_eq!(exec["type"], "error_response", "{exec}");
4409        assert_eq!(exec["category"], "stale_approval", "{exec}");
4410        assert!(
4411            exec["message"]
4412                .as_str()
4413                .unwrap()
4414                .contains("did not capture"),
4415            "must refuse an uncaptured set: {exec}"
4416        );
4417    }
4418
4419    #[tokio::test]
4420    async fn execute_refuses_when_the_autoremove_re_simulate_fails() {
4421        let dir = tempdir().unwrap();
4422        let state = test_state(&dir);
4423        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4424        // Preview captures a set; the execute-time re-simulate fails -> refuse
4425        // (execution_failure), never fall through to an unbound autoremove.
4426        let (runner, _calls) = ScriptedAutoremoveRunner::with_outputs(vec![
4427            Ok("Remv pkga [1]\n".to_string()),
4428            Err("apt database lock held".to_string()),
4429        ]);
4430        tokio::spawn(async move {
4431            unix_connection_handler(
4432                server,
4433                state,
4434                runner,
4435                uid_caller_with(1000, CallerRole::Admin),
4436            )
4437            .await;
4438        });
4439
4440        let mut framed = FramedStream::new(client);
4441        let resp = preview_autoremove(&mut framed).await;
4442        assert_eq!(resp["type"], "preview_response", "{resp}");
4443        let txid = resp["transaction_id"].as_str().unwrap().to_string();
4444        let receipt = approve_preview(&mut framed, &txid).await;
4445        send_execute_autoremove(&mut framed, &txid, &receipt).await;
4446
4447        let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4448        assert_eq!(exec["type"], "error_response", "{exec}");
4449        assert_eq!(exec["category"], "execution_failure", "{exec}");
4450        assert!(
4451            exec["message"]
4452                .as_str()
4453                .unwrap()
4454                .contains("confirm the autoremove set"),
4455            "{exec}"
4456        );
4457    }
4458
4459    #[test]
4460    fn autoremove_binding_refuses_a_malformed_captured_set() {
4461        // A present-but-wrong-shaped captured set must refuse, not default to allow.
4462        let pc = json!({ AUTOREMOVE_REMOVALS_KEY: "not-an-array" });
4463        let err = verify_autoremove_binding(&pc, &str_set(["a"]))
4464            .expect_err("a malformed captured set must be refused");
4465        assert!(err.contains("malformed"), "{err}");
4466    }
4467
4468    async fn approve_preview(
4469        framed: &mut FramedStream<tokio::net::UnixStream>,
4470        transaction_id: &str,
4471    ) -> String {
4472        framed
4473            .send(
4474                &serde_json::to_vec(&json!({
4475                    "type": "approve",
4476                    "request_id": format!("approve-{transaction_id}"),
4477                    "transaction_id": transaction_id,
4478                }))
4479                .unwrap(),
4480            )
4481            .await
4482            .unwrap();
4483        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4484        assert_eq!(response["type"], "approval_response");
4485        response["approval_receipt"]
4486            .as_str()
4487            .expect("approval receipt")
4488            .to_string()
4489    }
4490
4491    async fn preview_and_approve(
4492        framed: &mut FramedStream<tokio::net::UnixStream>,
4493        action_name: &str,
4494        params: Value,
4495    ) -> (String, String) {
4496        framed
4497            .send(
4498                &serde_json::to_vec(&json!({
4499                    "type": "preview",
4500                    "request_id": format!("preview-{action_name}"),
4501                    "action_name": action_name,
4502                    "params": params,
4503                }))
4504                .unwrap(),
4505            )
4506            .await
4507            .unwrap();
4508        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4509        assert_eq!(response["type"], "preview_response");
4510        let transaction_id = response["transaction_id"].as_str().unwrap().to_string();
4511        let receipt = approve_preview(framed, &transaction_id).await;
4512        (transaction_id, receipt)
4513    }
4514
4515    #[tokio::test]
4516    async fn approval_details_returns_persisted_preview_before_approval() {
4517        let dir = tempdir().unwrap();
4518        let state = test_state(&dir);
4519        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4520        tokio::spawn(async move {
4521            unix_connection_handler(
4522                server,
4523                state,
4524                runner(),
4525                uid_caller_with(1000, CallerRole::Observer),
4526            )
4527            .await;
4528        });
4529        let mut framed = FramedStream::new(client);
4530        framed
4531            .send(
4532                &serde_json::to_vec(&json!({
4533                    "type": "preview",
4534                    "request_id": "details-preview",
4535                    "action_name": "GetMemoryInfo",
4536                    "params": {}
4537                }))
4538                .unwrap(),
4539            )
4540            .await
4541            .unwrap();
4542        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4543        let transaction_id = preview["transaction_id"].as_str().unwrap();
4544
4545        framed
4546            .send(
4547                &serde_json::to_vec(&json!({
4548                    "type": "approval_details",
4549                    "request_id": "details-read",
4550                    "transaction_id": transaction_id
4551                }))
4552                .unwrap(),
4553            )
4554            .await
4555            .unwrap();
4556        let details: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4557        assert_eq!(details["type"], "approval_details_response");
4558        assert_eq!(details["transaction_id"], transaction_id);
4559        assert_eq!(details["action_name"], "GetMemoryInfo");
4560        assert_eq!(details["preview"]["risk_level"], "low");
4561        assert!(!details["preview"]["summary"].as_str().unwrap().is_empty());
4562    }
4563
4564    #[tokio::test]
4565    async fn approve_missing_transaction_maps_to_stale_approval() {
4566        let dir = tempdir().unwrap();
4567        let responses = exchange(
4568            test_state(&dir),
4569            CallerRole::Observer,
4570            vec![json!({
4571                "type": "approve",
4572                "request_id": "approve-missing",
4573                "transaction_id": "missing-transaction",
4574            })],
4575            1,
4576        )
4577        .await;
4578
4579        assert_eq!(responses[0]["type"], "error_response");
4580        assert_eq!(responses[0]["category"], "stale_approval");
4581    }
4582
4583    #[tokio::test]
4584    async fn approving_same_transaction_twice_maps_second_to_stale_approval() {
4585        let dir = tempdir().unwrap();
4586        let state = test_state(&dir);
4587        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4588        tokio::spawn(async move {
4589            unix_connection_handler(
4590                server,
4591                state,
4592                runner(),
4593                uid_caller_with(1000, CallerRole::Observer),
4594            )
4595            .await;
4596        });
4597        let mut framed = FramedStream::new(client);
4598        let (transaction_id, _) =
4599            preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
4600
4601        framed
4602            .send(
4603                &serde_json::to_vec(&json!({
4604                    "type": "approve",
4605                    "request_id": "approve-again",
4606                    "transaction_id": transaction_id,
4607                }))
4608                .unwrap(),
4609            )
4610            .await
4611            .unwrap();
4612        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4613
4614        assert_eq!(response["type"], "error_response");
4615        assert_eq!(response["category"], "stale_approval");
4616    }
4617
4618    #[tokio::test]
4619    async fn approve_with_forged_commitment_maps_to_integrity_failure() {
4620        // A tampered `approval_id` makes the store return `DatabaseInvariant`
4621        // (fail-closed). The dispatcher must surface that as a distinct,
4622        // non-retryable `integrity_failure` — NOT `transient_infrastructure_
4623        // failure`, which would falsely tell the user a retry can succeed.
4624        let dir = tempdir().unwrap();
4625        let db_path = dir.path().join("sysknife-test.db");
4626        let state = test_state(&dir);
4627        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4628        tokio::spawn(async move {
4629            unix_connection_handler(
4630                server,
4631                state,
4632                runner(),
4633                uid_caller_with(1000, CallerRole::Observer),
4634            )
4635            .await;
4636        });
4637        let mut framed = FramedStream::new(client);
4638
4639        framed
4640            .send(
4641                &serde_json::to_vec(&json!({
4642                    "type": "preview",
4643                    "request_id": "preview-forge",
4644                    "action_name": "GetMemoryInfo",
4645                    "params": {},
4646                }))
4647                .unwrap(),
4648            )
4649            .await
4650            .unwrap();
4651        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4652        let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
4653
4654        // Forge the stored commitment via an independent connection to the same
4655        // SQLite file so the daemon reads a tampered value on the next request.
4656        rusqlite::Connection::open(&db_path)
4657            .unwrap()
4658            .execute(
4659                "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
4660                rusqlite::params![transaction_id],
4661            )
4662            .unwrap();
4663
4664        framed
4665            .send(
4666                &serde_json::to_vec(&json!({
4667                    "type": "approve",
4668                    "request_id": "approve-forged",
4669                    "transaction_id": transaction_id,
4670                }))
4671                .unwrap(),
4672            )
4673            .await
4674            .unwrap();
4675        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4676
4677        assert_eq!(response["type"], "error_response");
4678        assert_eq!(response["category"], "integrity_failure");
4679    }
4680
4681    #[tokio::test]
4682    async fn query_history_returns_structured_rows_with_created_at_and_risk_level() {
4683        // Regression for the null created_at/risk_level history bug: the
4684        // structured query_history IPC must carry typed rows, not text.
4685        let dir = tempdir().unwrap();
4686        let state = test_state(&dir);
4687        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4688        tokio::spawn(async move {
4689            unix_connection_handler(
4690                server,
4691                state,
4692                runner(),
4693                uid_caller_with(1000, CallerRole::Observer),
4694            )
4695            .await;
4696        });
4697        let mut framed = FramedStream::new(client);
4698
4699        framed
4700            .send(
4701                &serde_json::to_vec(&json!({
4702                    "type": "preview",
4703                    "request_id": "preview-hist",
4704                    "action_name": "GetMemoryInfo",
4705                    "params": {},
4706                }))
4707                .unwrap(),
4708            )
4709            .await
4710            .unwrap();
4711        let _ = framed.recv().await.unwrap();
4712
4713        framed
4714            .send(
4715                &serde_json::to_vec(&json!({
4716                    "type": "query_history",
4717                    "request_id": "hist-1",
4718                    "limit": 10,
4719                }))
4720                .unwrap(),
4721            )
4722            .await
4723            .unwrap();
4724        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4725
4726        assert_eq!(response["type"], "history_response");
4727        let entries = response["entries"].as_array().expect("entries array");
4728        assert_eq!(entries.len(), 1);
4729        assert_eq!(entries[0]["action_name"], "GetMemoryInfo");
4730        assert!(
4731            !entries[0]["created_at"].as_str().unwrap_or("").is_empty(),
4732            "created_at must be populated over the structured IPC"
4733        );
4734        assert_eq!(
4735            entries[0]["risk_level"], "low",
4736            "GetSystemState is a low-risk action; risk_level must carry the exact typed value"
4737        );
4738    }
4739
4740    /// Seed one audit row created 75 minutes ago and one created 20 minutes
4741    /// ago, then return a framed connection to a daemon over that store.
4742    async fn history_daemon_with_20_and_75_minute_rows(
4743        dir: &tempfile::TempDir,
4744    ) -> FramedStream<tokio::net::UnixStream> {
4745        let state = test_state(dir);
4746        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4747        tokio::spawn(async move {
4748            unix_connection_handler(
4749                server,
4750                state,
4751                runner(),
4752                uid_caller_with(1000, CallerRole::Observer),
4753            )
4754            .await;
4755        });
4756        let mut framed = FramedStream::new(client);
4757        let db = rusqlite::Connection::open(dir.path().join("sysknife-test.db")).unwrap();
4758        for (i, minutes_ago) in [75, 20].into_iter().enumerate() {
4759            framed
4760                .send(
4761                    &serde_json::to_vec(&json!({
4762                        "type": "preview",
4763                        "request_id": format!("preview-since-{i}"),
4764                        "action_name": "GetMemoryInfo",
4765                        "params": {},
4766                    }))
4767                    .unwrap(),
4768                )
4769                .await
4770                .unwrap();
4771            let _ = framed.recv().await.unwrap();
4772            // Only the row just written is newer than five minutes.
4773            db.execute(
4774                "UPDATE transactions \
4775                 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?1) \
4776                 WHERE julianday(created_at) > julianday('now', '-5 minutes')",
4777                rusqlite::params![format!("-{minutes_ago} minutes")],
4778            )
4779            .unwrap();
4780        }
4781        framed
4782    }
4783
4784    fn minutes_ago_rfc3339(minutes: i64) -> String {
4785        (chrono::Utc::now() - chrono::Duration::minutes(minutes))
4786            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4787    }
4788
4789    #[test]
4790    fn resolve_history_since_parses_and_rejects_ambiguity() {
4791        assert_eq!(resolve_history_since(None, None), Ok(None));
4792        assert_eq!(
4793            resolve_history_since(Some(24), None),
4794            Ok(Some(HistorySince::Hours(24)))
4795        );
4796        let expected = chrono::DateTime::parse_from_rfc3339("2026-01-15T10:30:00Z")
4797            .unwrap()
4798            .with_timezone(&chrono::Utc);
4799        assert_eq!(
4800            resolve_history_since(None, Some("2026-01-15T12:30:00+02:00")),
4801            Ok(Some(HistorySince::Cutoff(expected)))
4802        );
4803        assert!(resolve_history_since(None, Some("yesterday")).is_err());
4804        assert!(resolve_history_since(Some(1), Some("2026-01-15T10:30:00Z")).is_err());
4805    }
4806
4807    #[tokio::test]
4808    async fn query_history_since_keeps_sub_hour_precision() {
4809        // Regression for #567: `--since` used to be rounded down to whole
4810        // hours, so a cutoff 30 minutes ago became `since_hours = 0` (nothing
4811        // returned) and 90 minutes ago became `since_hours = 1` (the 75-minute
4812        // row was lost). The exact cutoff must reach the store.
4813        let dir = tempdir().unwrap();
4814        let mut framed = history_daemon_with_20_and_75_minute_rows(&dir).await;
4815
4816        for (cutoff_minutes, expected) in [(30, 1), (90, 2), (10, 0)] {
4817            framed
4818                .send(
4819                    &serde_json::to_vec(&json!({
4820                        "type": "query_history",
4821                        "request_id": format!("hist-since-{cutoff_minutes}"),
4822                        "since": minutes_ago_rfc3339(cutoff_minutes),
4823                    }))
4824                    .unwrap(),
4825                )
4826                .await
4827                .unwrap();
4828            let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4829            assert_eq!(response["type"], "history_response", "{response}");
4830            let entries = response["entries"].as_array().expect("entries array");
4831            assert_eq!(
4832                entries.len(),
4833                expected,
4834                "cutoff {cutoff_minutes} minutes ago: {response}"
4835            );
4836        }
4837    }
4838
4839    #[tokio::test]
4840    async fn list_job_history_since_keeps_sub_hour_precision() {
4841        // Same regression through the text `ListJobHistory` query path.
4842        let dir = tempdir().unwrap();
4843        let mut framed = history_daemon_with_20_and_75_minute_rows(&dir).await;
4844
4845        for (cutoff_minutes, expect_rows) in [(30, true), (10, false)] {
4846            framed
4847                .send(
4848                    &serde_json::to_vec(&json!({
4849                        "type": "query_action",
4850                        "request_id": format!("ljh-since-{cutoff_minutes}"),
4851                        "action_name": "ListJobHistory",
4852                        "params": { "since": minutes_ago_rfc3339(cutoff_minutes) },
4853                    }))
4854                    .unwrap(),
4855                )
4856                .await
4857                .unwrap();
4858            let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4859            let output = response["output"].as_str().expect("output text");
4860            assert_eq!(
4861                !output.starts_with("No transactions found"),
4862                expect_rows,
4863                "cutoff {cutoff_minutes} minutes ago: {output}"
4864            );
4865        }
4866    }
4867
4868    #[tokio::test]
4869    async fn query_history_honours_policy_override_raising_history_above_observer() {
4870        // Authorization consistency: if an operator raises ListJobHistory above
4871        // Observer via [policy.risk_overrides], the structured query_history
4872        // path must refuse it too — it must not be a bypass around that policy.
4873        let dir = tempdir().unwrap();
4874        let real = test_state(&dir);
4875        // risk_overrides raise an action's RISK LEVEL; "high" pushes
4876        // ListJobHistory's derived min-role above Observer.
4877        let overrides =
4878            std::collections::HashMap::from([("ListJobHistory".to_string(), "high".to_string())]);
4879        let policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
4880        // Same key file `test_state` loaded: the derived HMAC key must match
4881        // the one behind `real.audit` so hashes compare equal.
4882        let audit_key = crate::audit_chain::AuditKey::load_or_generate(
4883            &crate::audit_chain::resolve_audit_key_path(&dir.path().join("sysknife-test.db")),
4884        )
4885        .unwrap();
4886        let state = crate::state::DaemonState::open_with_audit(
4887            real.config.clone(),
4888            policy,
4889            None,
4890            real.audit.clone(),
4891            &audit_key,
4892        );
4893
4894        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4895        tokio::spawn(async move {
4896            unix_connection_handler(
4897                server,
4898                state,
4899                runner(),
4900                uid_caller_with(1000, CallerRole::Observer),
4901            )
4902            .await;
4903        });
4904        let mut framed = FramedStream::new(client);
4905        framed
4906            .send(
4907                &serde_json::to_vec(&json!({
4908                    "type": "query_history",
4909                    "request_id": "hist-denied",
4910                    "limit": 10,
4911                }))
4912                .unwrap(),
4913            )
4914            .await
4915            .unwrap();
4916        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4917
4918        assert_eq!(response["type"], "error_response");
4919        assert_eq!(response["category"], "authorization_failure");
4920    }
4921
4922    #[tokio::test]
4923    async fn cancel_queued_transaction_succeeds_and_marks_it_canceled() {
4924        let dir = tempdir().unwrap();
4925        let state = test_state(&dir);
4926        let handler_state = state.clone();
4927        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4928        tokio::spawn(async move {
4929            unix_connection_handler(
4930                server,
4931                handler_state,
4932                runner(),
4933                uid_caller_with(1000, CallerRole::Observer),
4934            )
4935            .await;
4936        });
4937        let mut framed = FramedStream::new(client);
4938
4939        framed
4940            .send(
4941                &serde_json::to_vec(&json!({
4942                    "type": "preview",
4943                    "request_id": "preview-cancel",
4944                    "action_name": "GetMemoryInfo",
4945                    "params": {},
4946                }))
4947                .unwrap(),
4948            )
4949            .await
4950            .unwrap();
4951        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4952        let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
4953
4954        framed
4955            .send(
4956                &serde_json::to_vec(&json!({
4957                    "type": "cancel",
4958                    "request_id": "cancel-1",
4959                    "transaction_id": transaction_id,
4960                }))
4961                .unwrap(),
4962            )
4963            .await
4964            .unwrap();
4965        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4966
4967        assert_eq!(response["type"], "cancel_response");
4968        assert_eq!(
4969            state
4970                .audit
4971                .get(&transaction_id)
4972                .await
4973                .unwrap()
4974                .unwrap()
4975                .status,
4976            JobState::Canceled
4977        );
4978    }
4979
4980    #[tokio::test]
4981    async fn cancel_running_transaction_is_rejected_and_left_running() {
4982        let dir = tempdir().unwrap();
4983        let state = test_state(&dir);
4984        let handler_state = state.clone();
4985        let (client, server) = tokio::net::UnixStream::pair().unwrap();
4986        tokio::spawn(async move {
4987            unix_connection_handler(
4988                server,
4989                handler_state,
4990                runner(),
4991                uid_caller_with(1000, CallerRole::Observer),
4992            )
4993            .await;
4994        });
4995        let mut framed = FramedStream::new(client);
4996        let (transaction_id, receipt) =
4997            preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
4998        // Claim it (Queued -> Running) so it is in-flight from the store's view.
4999        assert!(state
5000            .audit
5001            .claim_approved_for_execution(
5002                &transaction_id,
5003                &receipt_digest(&receipt),
5004                CallerPrincipal::Uid(1000),
5005            )
5006            .await
5007            .unwrap());
5008
5009        framed
5010            .send(
5011                &serde_json::to_vec(&json!({
5012                    "type": "cancel",
5013                    "request_id": "cancel-running",
5014                    "transaction_id": transaction_id,
5015                }))
5016                .unwrap(),
5017            )
5018            .await
5019            .unwrap();
5020        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5021
5022        assert_eq!(response["type"], "error_response");
5023        assert_eq!(response["category"], "not_cancelable");
5024        assert_eq!(
5025            state
5026                .audit
5027                .get(&transaction_id)
5028                .await
5029                .unwrap()
5030                .unwrap()
5031                .status,
5032            JobState::Running,
5033            "an in-flight action must not be disturbed by cancel"
5034        );
5035    }
5036
5037    #[tokio::test]
5038    async fn cancel_missing_transaction_is_rejected() {
5039        let dir = tempdir().unwrap();
5040        let responses = exchange(
5041            test_state(&dir),
5042            CallerRole::Observer,
5043            vec![json!({
5044                "type": "cancel",
5045                "request_id": "cancel-missing",
5046                "transaction_id": "no-such-transaction",
5047            })],
5048            1,
5049        )
5050        .await;
5051        assert_eq!(responses[0]["type"], "error_response");
5052        assert_eq!(responses[0]["category"], "not_cancelable");
5053    }
5054
5055    #[tokio::test]
5056    async fn undelivered_approval_is_revoked_so_the_user_can_retry() {
5057        let dir = tempdir().unwrap();
5058        let state = test_state(&dir);
5059        let (client, server) = tokio::net::UnixStream::pair().unwrap();
5060        let handler_state = state.clone();
5061        tokio::spawn(async move {
5062            unix_connection_handler(
5063                server,
5064                handler_state,
5065                runner(),
5066                uid_caller_with(1000, CallerRole::Observer),
5067            )
5068            .await;
5069        });
5070        let mut framed = FramedStream::new(client);
5071        framed
5072            .send(
5073                &serde_json::to_vec(&json!({
5074                    "type": "preview",
5075                    "request_id": "preview-undelivered",
5076                    "action_name": "GetMemoryInfo",
5077                    "params": {},
5078                }))
5079                .unwrap(),
5080            )
5081            .await
5082            .unwrap();
5083        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5084        let transaction_id = preview["transaction_id"].as_str().unwrap();
5085
5086        let (peer, broken_stream) = tokio::io::duplex(64);
5087        drop(peer);
5088        let mut broken_framed = FramedStream::new(broken_stream);
5089        assert!(handle_approve(
5090            &mut broken_framed,
5091            &state,
5092            &uid_caller(CallerRole::Admin),
5093            "approve-undelivered",
5094            transaction_id,
5095        )
5096        .await
5097        .is_err());
5098
5099        let (retry_stream, receiver) = tokio::io::duplex(4096);
5100        let mut retry_framed = FramedStream::new(retry_stream);
5101        handle_approve(
5102            &mut retry_framed,
5103            &state,
5104            &uid_caller(CallerRole::Admin),
5105            "approve-retry",
5106            transaction_id,
5107        )
5108        .await
5109        .unwrap();
5110        let mut receiver = FramedStream::new(receiver);
5111        let response: Value = serde_json::from_slice(&receiver.recv().await.unwrap()).unwrap();
5112        assert_eq!(response["type"], "approval_response");
5113    }
5114
5115    #[tokio::test]
5116    async fn approval_details_for_running_transaction_maps_to_stale_approval() {
5117        let dir = tempdir().unwrap();
5118        let state = test_state(&dir);
5119        let (client, server) = tokio::net::UnixStream::pair().unwrap();
5120        let handler_state = state.clone();
5121        tokio::spawn(async move {
5122            unix_connection_handler(
5123                server,
5124                handler_state,
5125                runner(),
5126                uid_caller_with(1000, CallerRole::Observer),
5127            )
5128            .await;
5129        });
5130        let mut framed = FramedStream::new(client);
5131        let (transaction_id, receipt) =
5132            preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
5133        assert!(state
5134            .audit
5135            .claim_approved_for_execution(
5136                &transaction_id,
5137                &receipt_digest(&receipt),
5138                CallerPrincipal::Uid(1000),
5139            )
5140            .await
5141            .unwrap());
5142
5143        framed
5144            .send(
5145                &serde_json::to_vec(&json!({
5146                    "type": "approval_details",
5147                    "request_id": "details-running",
5148                    "transaction_id": transaction_id,
5149                }))
5150                .unwrap(),
5151            )
5152            .await
5153            .unwrap();
5154        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5155
5156        assert_eq!(response["type"], "error_response");
5157        assert_eq!(response["category"], "stale_approval");
5158    }
5159
5160    /// Send `requests` to a spawned handler, collect exactly `want_responses`
5161    /// response frames, then drop the client to signal EOF.
5162    async fn exchange(
5163        state: DaemonState,
5164        role: CallerRole,
5165        requests: Vec<Value>,
5166        want_responses: usize,
5167    ) -> Vec<Value> {
5168        exchange_as(state, uid_caller_with(1000, role), requests, want_responses).await
5169    }
5170
5171    /// `exchange` with the caller spelled out, for tests that assert on which
5172    /// account the daemon recorded.
5173    async fn exchange_as(
5174        state: DaemonState,
5175        caller: CallerAttribution,
5176        requests: Vec<Value>,
5177        want_responses: usize,
5178    ) -> Vec<Value> {
5179        let (client, server) = tokio::net::UnixStream::pair().unwrap();
5180        tokio::spawn(async move {
5181            unix_connection_handler(server, state, runner(), caller).await;
5182        });
5183
5184        let mut framed = FramedStream::new(client);
5185        for req in &requests {
5186            let bytes = serde_json::to_vec(req).unwrap();
5187            framed.send(&bytes).await.unwrap();
5188        }
5189
5190        let mut responses = Vec::new();
5191        for _ in 0..want_responses {
5192            let raw = framed.recv().await.unwrap();
5193            responses.push(serde_json::from_slice::<Value>(&raw).unwrap());
5194        }
5195        responses
5196    }
5197
5198    // ------------------------------------------------------------------
5199    // query_state
5200    // ------------------------------------------------------------------
5201
5202    #[tokio::test]
5203    async fn query_state_returns_state_response() {
5204        let dir = tempdir().unwrap();
5205        let mut state = test_state(&dir);
5206        let distro = sysknife_core::distro::DistroId::Ubuntu {
5207            major: 24,
5208            minor: 4,
5209        };
5210        state.host_distro = Some(distro.clone());
5211
5212        let resps = exchange(
5213            state,
5214            CallerRole::Observer,
5215            vec![json!({"type": "query_state", "request_id": "r1"})],
5216            1,
5217        )
5218        .await;
5219
5220        assert_eq!(resps[0]["type"], "state_response");
5221        assert_eq!(resps[0]["request_id"], "r1");
5222        assert_eq!(resps[0]["state"]["host_name"], "testhost");
5223        assert_eq!(
5224            resps[0]["state"]["host_distro"],
5225            json!({"Ubuntu": {"major": 24, "minor": 4}})
5226        );
5227    }
5228
5229    // ------------------------------------------------------------------
5230    // preview
5231    // ------------------------------------------------------------------
5232
5233    /// The account the connection was attributed to must reach the stored row.
5234    ///
5235    /// Everything else about the principal is tested in isolation: the resolver,
5236    /// the renderings, the encoder, the store. None of that notices if
5237    /// `handle_preview` passes a default instead of the connection's own caller,
5238    /// because the value would still be a well-formed principal that verifies.
5239    /// This is the test that fails for a hardcoded one.
5240    #[tokio::test]
5241    async fn the_recorded_principal_is_the_one_the_connection_was_attributed_to() {
5242        let dir = tempdir().unwrap();
5243        let state = test_state(&dir);
5244        let audit = std::sync::Arc::clone(&state.audit);
5245
5246        let resps = exchange_as(
5247            state,
5248            uid_caller_with(4242, CallerRole::Observer),
5249            vec![json!({
5250                "type": "preview",
5251                "request_id": "r-principal",
5252                "action_name": "GetMemoryInfo",
5253                "params": {}
5254            })],
5255            1,
5256        )
5257        .await;
5258
5259        let transaction_id = resps[0]["transaction_id"].as_str().unwrap();
5260        let row = audit
5261            .fetch_chain_row(transaction_id)
5262            .await
5263            .expect("fetch the row just written")
5264            .expect("preview records a transaction");
5265
5266        assert_eq!(
5267            row.caller_principal.as_deref(),
5268            Some("uid:4242"),
5269            "the row must name the account resolved for this connection, not a default"
5270        );
5271        assert_eq!(row.chain_version, crate::audit_chain::CHAIN_VERSION_CURRENT);
5272    }
5273
5274    /// The handlers must pass the connection's own account into the event
5275    /// chain, not merely accept one at the store boundary.
5276    ///
5277    /// The store-level tests choose the principal they hand down, so a
5278    /// dispatcher that signed `Unattributed` for every grant and every consume
5279    /// would pass all of them. This test drives a real connection as uid 4242
5280    /// through preview -> approve -> execute and reads the signed rows back,
5281    /// the same shape as `the_recorded_principal_is_the_one_the_connection_
5282    /// was_attributed_to` on the transaction side.
5283    #[tokio::test]
5284    async fn handlers_sign_the_connection_account_into_approval_events() {
5285        let dir = tempdir().unwrap();
5286        let state = test_state(&dir);
5287        let audit = std::sync::Arc::clone(&state.audit);
5288        let (client, server) = tokio::net::UnixStream::pair().unwrap();
5289        tokio::spawn(async move {
5290            unix_connection_handler(
5291                server,
5292                state,
5293                runner(),
5294                uid_caller_with(4242, CallerRole::Admin),
5295            )
5296            .await;
5297        });
5298        let mut framed = FramedStream::new(client);
5299        let (txid, receipt) = preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
5300        framed
5301            .send(
5302                &serde_json::to_vec(&json!({
5303                    "type": "execute",
5304                    "request_id": "r-exec",
5305                    "transaction_id": txid,
5306                    "action_name": "GetMemoryInfo",
5307                    "params": {},
5308                    "approval_receipt": receipt
5309                }))
5310                .unwrap(),
5311            )
5312            .await
5313            .unwrap();
5314        let _exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5315        let events = audit.fetch_event_rows().await.unwrap();
5316        let event = |kind: &str| {
5317            events
5318                .iter()
5319                .find(|e| e.kind == kind)
5320                .unwrap_or_else(|| panic!("no {kind} event in {events:?}"))
5321        };
5322        assert_eq!(
5323            event("approval_granted").caller_principal.as_deref(),
5324            Some("uid:4242"),
5325            "handle_approve must sign the account the connection was attributed to"
5326        );
5327        assert_eq!(
5328            event("approval_consumed").caller_principal.as_deref(),
5329            Some("uid:4242"),
5330            "handle_execute must sign the account the connection was attributed to"
5331        );
5332        assert_eq!(
5333            event("approval_granted").chain_version,
5334            crate::audit_chain::EVENT_VERSION_V2
5335        );
5336        assert_eq!(
5337            event("approval_consumed").chain_version,
5338            crate::audit_chain::EVENT_VERSION_V2
5339        );
5340    }
5341
5342    /// Same guarantee on the cancel path: `handle_cancel` holds the caller, so
5343    /// the `approval_revoked` row must name the cancelling account rather than
5344    /// discard the identity the daemon already resolved.
5345    #[tokio::test]
5346    async fn cancelling_an_approved_transaction_signs_the_cancelling_account() {
5347        let dir = tempdir().unwrap();
5348        let state = test_state(&dir);
5349        let audit = std::sync::Arc::clone(&state.audit);
5350        let (client, server) = tokio::net::UnixStream::pair().unwrap();
5351        tokio::spawn(async move {
5352            unix_connection_handler(
5353                server,
5354                state,
5355                runner(),
5356                uid_caller_with(4242, CallerRole::Observer),
5357            )
5358            .await;
5359        });
5360        let mut framed = FramedStream::new(client);
5361        let (txid, _receipt) = preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
5362        framed
5363            .send(
5364                &serde_json::to_vec(&json!({
5365                    "type": "cancel",
5366                    "request_id": "r-cancel",
5367                    "transaction_id": txid,
5368                }))
5369                .unwrap(),
5370            )
5371            .await
5372            .unwrap();
5373        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5374        assert_eq!(response["type"], "cancel_response", "{response}");
5375        let events = audit.fetch_event_rows().await.unwrap();
5376        let revoked = events
5377            .iter()
5378            .find(|e| e.kind == "approval_revoked")
5379            .unwrap_or_else(|| panic!("no approval_revoked event in {events:?}"));
5380        assert_eq!(
5381            revoked.caller_principal.as_deref(),
5382            Some("uid:4242"),
5383            "handle_cancel must sign the account that cancelled"
5384        );
5385        assert_eq!(revoked.chain_version, crate::audit_chain::EVENT_VERSION_V2);
5386    }
5387
5388    #[tokio::test]
5389    async fn preview_returns_hash_and_transaction_id() {
5390        let dir = tempdir().unwrap();
5391        let state = test_state(&dir);
5392
5393        let resps = exchange(
5394            state,
5395            CallerRole::Observer,
5396            vec![json!({
5397                "type": "preview",
5398                "request_id": "r1",
5399                "action_name": "GetMemoryInfo",
5400                "params": {}
5401            })],
5402            1,
5403        )
5404        .await;
5405
5406        assert_eq!(resps[0]["type"], "preview_response");
5407        assert_eq!(resps[0]["request_id"], "r1");
5408        let hash = resps[0]["preview"]["request_hash"].as_str().unwrap();
5409        assert_eq!(
5410            hash.len(),
5411            64,
5412            "request_hash should be a 64-char hex SHA-256"
5413        );
5414        assert!(
5415            !resps[0]["transaction_id"].as_str().unwrap().is_empty(),
5416            "transaction_id must be set"
5417        );
5418    }
5419
5420    /// The live path must be *keyed*, not merely deterministic. The tests
5421    /// above check that the same request hashes the same way and that object
5422    /// keys sort; a bare `sha256(action || \0 || canonical)` satisfies both,
5423    /// so a refactor that drops the key would reopen #268 with this suite
5424    /// green. This drives a real preview over the socket and pins the
5425    /// response hash to the HMAC under `state.request_hash_key` — revert
5426    /// `compute_request_hash` to the old body and it fails.
5427    #[tokio::test]
5428    async fn preview_request_hash_is_keyed_not_a_bare_sha256() {
5429        let dir = tempdir().unwrap();
5430        let state = test_state(&dir);
5431        let key = state.request_hash_key.clone();
5432
5433        let resps = exchange(
5434            state,
5435            CallerRole::Observer,
5436            vec![json!({
5437                "type": "preview",
5438                "request_id": "r1",
5439                "action_name": "GetMemoryInfo",
5440                "params": {}
5441            })],
5442            1,
5443        )
5444        .await;
5445
5446        assert_eq!(resps[0]["type"], "preview_response", "{:?}", resps[0]);
5447        let hash = resps[0]["preview"]["request_hash"].as_str().unwrap();
5448
5449        // Exactly what the dispatcher must have computed: HMAC under the
5450        // state's derived key over the canonical framing.
5451        let expected = key.request_hash("GetMemoryInfo", &canonical_json(&json!({})));
5452        assert_eq!(
5453            hash, expected,
5454            "the live preview hash must be the HMAC under the daemon's \
5455             request_hash_key"
5456        );
5457
5458        // And not what it was before chain_version 4.
5459        let mut hasher = Sha256::new();
5460        hasher.update(b"GetMemoryInfo");
5461        hasher.update(b"\x00");
5462        hasher.update(canonical_json(&json!({})).as_bytes());
5463        let unsalted = hex::encode(hasher.finalize());
5464        assert_ne!(
5465            hash, unsalted,
5466            "the live path returned the unsalted SHA-256; #268 is open again"
5467        );
5468    }
5469
5470    #[tokio::test]
5471    async fn preview_hash_is_deterministic() {
5472        // The same action + params must always produce the same hash.
5473        let key = crate::audit_chain::AuditKey::from_bytes(vec![0x42; 32]).request_hash_key();
5474        let hash1 = compute_request_hash(
5475            &key,
5476            "InstallFlatpak",
5477            &json!({"app_id": "org.gnome.Builder", "remote": "flathub"}),
5478        );
5479        let hash2 = compute_request_hash(
5480            &key,
5481            "InstallFlatpak",
5482            &json!({"remote": "flathub", "app_id": "org.gnome.Builder"}),
5483        );
5484        assert_eq!(hash1, hash2, "canonical JSON must sort keys");
5485    }
5486
5487    #[test]
5488    fn canonical_json_recurses_into_arrays() {
5489        // Objects nested inside arrays must also have sorted keys so that
5490        // {"packages": [{"b": 1, "a": 2}]} and {"packages": [{"a": 2, "b": 1}]}
5491        // produce the same hash.
5492        let key = crate::audit_chain::AuditKey::from_bytes(vec![0x42; 32]).request_hash_key();
5493        let hash1 = compute_request_hash(
5494            &key,
5495            "InstallPackages",
5496            &json!({"packages": [{"b": 1, "a": 2}]}),
5497        );
5498        let hash2 = compute_request_hash(
5499            &key,
5500            "InstallPackages",
5501            &json!({"packages": [{"a": 2, "b": 1}]}),
5502        );
5503        assert_eq!(hash1, hash2, "nested object keys in arrays must be sorted");
5504    }
5505
5506    #[test]
5507    fn canonical_json_preserves_array_element_order() {
5508        // Array element order is semantically significant ("install a then b"
5509        // is different from "install b then a"), so it must be preserved.
5510        let key = crate::audit_chain::AuditKey::from_bytes(vec![0x42; 32]).request_hash_key();
5511        let hash1 = compute_request_hash(&key, "Op", &json!({"items": ["a", "b"]}));
5512        let hash2 = compute_request_hash(&key, "Op", &json!({"items": ["b", "a"]}));
5513        assert_ne!(hash1, hash2, "array element order must be preserved");
5514    }
5515
5516    // ------------------------------------------------------------------
5517    // authorization
5518    // ------------------------------------------------------------------
5519
5520    #[tokio::test]
5521    async fn high_risk_action_rejected_for_observer() {
5522        let dir = tempdir().unwrap();
5523        let state = test_state(&dir);
5524
5525        let resps = exchange(
5526            state,
5527            CallerRole::Observer, // UpdateSystem requires Admin
5528            vec![json!({
5529                "type": "preview",
5530                "request_id": "r1",
5531                "action_name": "UpdateSystem",
5532                "params": {}
5533            })],
5534            1,
5535        )
5536        .await;
5537
5538        assert_eq!(resps[0]["type"], "error_response");
5539        assert_eq!(resps[0]["category"], "authorization_failure");
5540    }
5541
5542    #[tokio::test]
5543    async fn under_privileged_caller_cannot_act_on_admin_transaction() {
5544        // Regression: authorization must be enforced on approve / cancel /
5545        // approval-details, not only at preview time. An Admin-tier transaction
5546        // that was previewed by an Admin caller must be untouchable by an
5547        // under-privileged caller on a *different* connection, yet remain
5548        // actionable by an authorized caller.
5549        let dir = tempdir().unwrap();
5550        // UpdateSystem is the Admin-tier vehicle here, and it is rpm-ostree
5551        // shaped, so the host has to be a Fedora-family one. The subject of this
5552        // test is authorization, not platform.
5553        let mut state = test_state(&dir);
5554        state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 });
5555
5556        // An Admin caller previews an Admin-tier action, persisting a queued tx.
5557        let (client, server) = tokio::net::UnixStream::pair().unwrap();
5558        let handler_state = state.clone();
5559        tokio::spawn(async move {
5560            unix_connection_handler(
5561                server,
5562                handler_state,
5563                runner(),
5564                uid_caller_with(1000, CallerRole::Admin),
5565            )
5566            .await;
5567        });
5568        let mut framed = FramedStream::new(client);
5569        framed
5570            .send(
5571                &serde_json::to_vec(&json!({
5572                    "type": "preview",
5573                    "request_id": "preview-admin",
5574                    "action_name": "UpdateSystem",
5575                    "params": {},
5576                }))
5577                .unwrap(),
5578            )
5579            .await
5580            .unwrap();
5581        let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5582        assert_eq!(preview["type"], "preview_response");
5583        let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
5584
5585        // Every mutating/reading verb an Observer might try must be refused with
5586        // authorization_failure while leaving the transaction untouched.
5587        for (request_id, verb) in [
5588            ("obs-approve", "approve"),
5589            ("obs-cancel", "cancel"),
5590            ("obs-details", "details"),
5591        ] {
5592            let (stream, rx) = tokio::io::duplex(4096);
5593            let mut caller = FramedStream::new(stream);
5594            match verb {
5595                "approve" => handle_approve(
5596                    &mut caller,
5597                    &state,
5598                    &uid_caller(CallerRole::Observer),
5599                    request_id,
5600                    &transaction_id,
5601                )
5602                .await
5603                .unwrap(),
5604                "cancel" => handle_cancel(
5605                    &mut caller,
5606                    &state,
5607                    &uid_caller(CallerRole::Observer),
5608                    request_id,
5609                    &transaction_id,
5610                )
5611                .await
5612                .unwrap(),
5613                _ => handle_approval_details(
5614                    &mut caller,
5615                    &state,
5616                    &uid_caller(CallerRole::Observer),
5617                    request_id,
5618                    &transaction_id,
5619                )
5620                .await
5621                .unwrap(),
5622            }
5623            let mut rx = FramedStream::new(rx);
5624            let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
5625            assert_eq!(resp["type"], "error_response", "{verb} must be refused");
5626            assert_eq!(
5627                resp["category"], "authorization_failure",
5628                "{verb} refusal must be an authorization failure"
5629            );
5630        }
5631
5632        // The transaction survived the refusals: an authorized caller can still
5633        // approve it, proving we blocked only the unauthorized path.
5634        let (stream, rx) = tokio::io::duplex(4096);
5635        let mut admin = FramedStream::new(stream);
5636        handle_approve(
5637            &mut admin,
5638            &state,
5639            &uid_caller(CallerRole::Admin),
5640            "admin-approve",
5641            &transaction_id,
5642        )
5643        .await
5644        .unwrap();
5645        let mut rx = FramedStream::new(rx);
5646        let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
5647        assert_eq!(resp["type"], "approval_response");
5648    }
5649
5650    #[tokio::test]
5651    async fn medium_risk_action_rejected_for_observer() {
5652        let dir = tempdir().unwrap();
5653        let state = test_state(&dir);
5654
5655        let resps = exchange(
5656            state,
5657            CallerRole::Observer, // InstallFlatpak requires Dev
5658            vec![json!({
5659                "type": "preview",
5660                "request_id": "r1",
5661                "action_name": "InstallFlatpak",
5662                "params": {"username": "alice", "app_id": "org.gnome.Builder", "remote": "flathub"}
5663            })],
5664            1,
5665        )
5666        .await;
5667
5668        assert_eq!(resps[0]["type"], "error_response");
5669        assert_eq!(resps[0]["category"], "authorization_failure");
5670    }
5671
5672    #[tokio::test]
5673    async fn low_risk_action_allowed_for_observer() {
5674        let dir = tempdir().unwrap();
5675        let state = test_state(&dir);
5676
5677        let resps = exchange(
5678            state,
5679            CallerRole::Observer,
5680            vec![json!({
5681                "type": "preview",
5682                "request_id": "r1",
5683                "action_name": "GetMemoryInfo",
5684                "params": {}
5685            })],
5686            1,
5687        )
5688        .await;
5689
5690        assert_eq!(resps[0]["type"], "preview_response");
5691    }
5692
5693    #[test]
5694    fn unsupported_distros_cannot_reach_mutation_paths() {
5695        let dir = tempdir().unwrap();
5696        let mut state = test_state(&dir);
5697        state.host_distro = Some(sysknife_core::distro::DistroId::UbuntuCore {
5698            major: 24,
5699            minor: 4,
5700        });
5701        assert!(validate_action_platform(&state, "AptInstall").is_err());
5702
5703        // Below the support floor: no longer receiving Ubuntu security updates.
5704        // The vehicle has to be an action Ubuntu can run at all — `UpdateSystem`
5705        // is `rpm-ostree upgrade` and is fenced to the Fedora family, so using it
5706        // here would assert the support floor while actually measuring the family
5707        // fence.
5708        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5709            major: 18,
5710            minor: 4,
5711        });
5712        assert!(validate_action_platform(&state, "AptUpgrade").is_err());
5713
5714        // The counterpart assertion, so this test keeps its teeth: releases at
5715        // and above the floor DO reach the mutation path, interim ones included.
5716        for (major, minor) in [(20, 4), (25, 10), (26, 4)] {
5717            state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu { major, minor });
5718            assert!(
5719                validate_action_platform(&state, "AptUpgrade").is_ok(),
5720                "Ubuntu {major}.{minor:02} must be able to run mutating actions"
5721            );
5722        }
5723    }
5724
5725    #[test]
5726    fn a_low_risk_action_that_mutates_is_not_treated_as_a_read() {
5727        // `AptUpdate` is RiskLevel::Low, so min_role_for_action puts it at
5728        // Observer — yet it runs `sudo apt-get update`. Any exemption keyed on
5729        // the RBAC role therefore lets a privileged mutation through, which is
5730        // why the platform gate does not exempt hard-fenced reads. Portable
5731        // Observer reads still return early without requiring distro detection.
5732        let dir = tempdir().unwrap();
5733        let mut state = test_state(&dir);
5734        state.host_distro = Some(sysknife_core::distro::DistroId::Debian { version: Some(11) });
5735        assert!(
5736            !state.host_distro.as_ref().unwrap().is_supported(),
5737            "this test needs an ineligible host"
5738        );
5739        assert_eq!(
5740            crate::policy::min_role_for_action("AptUpdate"),
5741            Some(CallerRole::Observer),
5742            "AptUpdate is the action that makes the role a bad proxy for mutation"
5743        );
5744        assert!(
5745            validate_action_platform(&state, "AptUpdate").is_err(),
5746            "a privileged mutation must stay refused on an ineligible host"
5747        );
5748        for version in [None, Some(11), Some(12), Some(13)] {
5749            state.host_distro = Some(sysknife_core::distro::DistroId::Debian { version });
5750            assert_eq!(
5751                validate_action_platform(&state, "AptUpdate").is_ok(),
5752                matches!(version, Some(12 | 13))
5753            );
5754            assert!(validate_action_platform(&state, "AddPpa").is_err());
5755        }
5756    }
5757    #[test]
5758    fn raising_a_read_only_action_via_risk_overrides_does_not_arm_the_platform_fence() {
5759        // `[policy.risk_overrides]` answers "who may run this", not "is this a
5760        // mutation". Deriving `is_mutating` from the override-adjusted role let
5761        // an operator who tightened RBAC on a harmless read also make that read
5762        // fail whenever the host distro could not be detected — a restriction
5763        // they never asked for, appearing in an unrelated subsystem.
5764        let dir = tempdir().unwrap();
5765        let mut state = test_state(&dir);
5766        state.host_distro = None;
5767        let overrides =
5768            std::collections::HashMap::from([("GetDiskUsage".to_string(), "high".to_string())]);
5769        state.policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
5770
5771        assert_eq!(
5772            state.policy.min_role_for_action("GetDiskUsage"),
5773            Some(CallerRole::Admin),
5774            "the override still governs authorization"
5775        );
5776        assert!(
5777            validate_action_platform(&state, "GetDiskUsage").is_ok(),
5778            "a read-only action stays exempt from the distro fence"
5779        );
5780    }
5781
5782    #[test]
5783    fn supported_hosts_still_enforce_action_family() {
5784        let dir = tempdir().unwrap();
5785        let mut state = test_state(&dir);
5786        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5787            major: 24,
5788            minor: 4,
5789        });
5790        assert!(validate_action_platform(&state, "AptInstall").is_ok());
5791        assert!(validate_action_platform(&state, "AddLayeredPackage").is_err());
5792
5793        for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS {
5794            assert!(validate_action_platform(&state, action).is_ok(), "{action}");
5795        }
5796        state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 });
5797        for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS {
5798            assert!(
5799                validate_action_platform(&state, action).is_err(),
5800                "{action}"
5801            );
5802        }
5803        for action in sysknife_core::action_family::NON_CANONICAL_ON_FEDORA {
5804            assert!(
5805                validate_action_platform(&state, action).is_ok(),
5806                "portable {action}"
5807            );
5808        }
5809        state.host_distro = None;
5810        for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS {
5811            assert!(
5812                validate_action_platform(&state, action).is_err(),
5813                "undetected {action}"
5814            );
5815        }
5816    }
5817
5818    // ------------------------------------------------------------------
5819    // query_action / describe — the same fences as preview and execute
5820    // ------------------------------------------------------------------
5821
5822    #[tokio::test]
5823    async fn query_action_refuses_an_action_the_host_family_cannot_run() {
5824        // `preview` and `execute` both call `validate_action_platform`;
5825        // `query_action` reached `build_action_spec` and ran the command. On a
5826        // Debian host that meant shelling out to `rpm-ostree`, so the caller
5827        // got a "No such file or directory" execution error instead of the
5828        // clean refusal the other two paths give.
5829        let dir = tempdir().unwrap();
5830        let mut state = test_state(&dir);
5831        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5832            major: 24,
5833            minor: 4,
5834        });
5835
5836        let resps = exchange(
5837            state,
5838            CallerRole::Observer,
5839            vec![json!({
5840                "type": "query_action",
5841                "request_id": "r1",
5842                "action_name": "GetLayeredPackages",
5843                "params": {}
5844            })],
5845            1,
5846        )
5847        .await;
5848
5849        assert_eq!(resps[0]["type"], "error_response");
5850        assert_eq!(resps[0]["category"], "unsupported_platform");
5851    }
5852
5853    #[tokio::test]
5854    async fn describe_refuses_an_action_the_caller_is_not_allowed_to_run() {
5855        // Describe renders the exact command an action would run. It had no
5856        // authorization check at all, so an Observer could enumerate the argv
5857        // of every privileged action on the box.
5858        let dir = tempdir().unwrap();
5859        let state = test_state(&dir);
5860
5861        let resps = exchange(
5862            state,
5863            CallerRole::Observer,
5864            vec![json!({
5865                "type": "describe",
5866                "request_id": "r1",
5867                "action_name": "GrantSudoAccess",
5868                "params": {"username": "alice", "commands": "/usr/bin/ls", "nopasswd": false}
5869            })],
5870            1,
5871        )
5872        .await;
5873
5874        assert_eq!(resps[0]["type"], "error_response");
5875        assert_eq!(resps[0]["category"], "authorization_failure");
5876    }
5877
5878    #[tokio::test]
5879    async fn describe_refuses_an_action_the_host_family_cannot_run() {
5880        let dir = tempdir().unwrap();
5881        let mut state = test_state(&dir);
5882        state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5883            major: 24,
5884            minor: 4,
5885        });
5886
5887        let resps = exchange(
5888            state,
5889            CallerRole::Admin,
5890            vec![json!({
5891                "type": "describe",
5892                "request_id": "r1",
5893                "action_name": "AddLayeredPackage",
5894                "params": {"package": "vim"}
5895            })],
5896            1,
5897        )
5898        .await;
5899
5900        assert_eq!(resps[0]["type"], "error_response");
5901        assert_eq!(resps[0]["category"], "unsupported_platform");
5902    }
5903
5904    #[tokio::test]
5905    async fn describe_still_answers_for_an_allowed_action() {
5906        // The fences must not turn describe into a privileged-only call: a
5907        // read-only action stays describable by an Observer.
5908        let dir = tempdir().unwrap();
5909        let state = test_state(&dir);
5910
5911        let resps = exchange(
5912            state,
5913            CallerRole::Observer,
5914            vec![json!({
5915                "type": "describe",
5916                "request_id": "r1",
5917                "action_name": "GetDiskUsage",
5918                "params": {}
5919            })],
5920            1,
5921        )
5922        .await;
5923
5924        assert_eq!(resps[0]["type"], "describe_response");
5925    }
5926
5927    // ------------------------------------------------------------------
5928    // execute — stale approval
5929    // ------------------------------------------------------------------
5930
5931    #[tokio::test]
5932    async fn execute_without_prior_preview_returns_stale_approval() {
5933        let dir = tempdir().unwrap();
5934        let state = test_state(&dir);
5935
5936        let resps = exchange(
5937            state,
5938            CallerRole::Observer,
5939            vec![json!({
5940                "type": "execute",
5941                "request_id": "r1",
5942                "transaction_id": "missing-transaction",
5943                "action_name": "GetMemoryInfo",
5944                "params": {},
5945                "approval_receipt": "unissued-receipt"
5946            })],
5947            1,
5948        )
5949        .await;
5950
5951        assert_eq!(resps[0]["type"], "error_response");
5952        assert_eq!(resps[0]["category"], "stale_approval");
5953    }
5954
5955    #[tokio::test]
5956    async fn execute_with_changed_params_rejects_without_consuming_approval() {
5957        let dir = tempdir().unwrap();
5958        let state = test_state(&dir);
5959        let (client, server) = tokio::net::UnixStream::pair().unwrap();
5960        let handler_state = state.clone();
5961        tokio::spawn(async move {
5962            unix_connection_handler(
5963                server,
5964                handler_state,
5965                runner(),
5966                uid_caller_with(1000, CallerRole::Observer),
5967            )
5968            .await;
5969        });
5970        let mut framed = FramedStream::new(client);
5971        let (transaction_id, receipt) = preview_and_approve(
5972            &mut framed,
5973            "GetServiceStatus",
5974            json!({"unit": "sshd.service"}),
5975        )
5976        .await;
5977
5978        framed
5979            .send(
5980                &serde_json::to_vec(&json!({
5981                    "type": "execute",
5982                    "request_id": "changed-params",
5983                    "transaction_id": transaction_id,
5984                    "action_name": "GetServiceStatus",
5985                    "params": {"unit": "cron.service"},
5986                    "approval_receipt": receipt,
5987                }))
5988                .unwrap(),
5989            )
5990            .await
5991            .unwrap();
5992        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5993
5994        assert_eq!(response["type"], "error_response");
5995        assert_eq!(response["category"], "stale_approval");
5996        assert_eq!(
5997            state
5998                .audit
5999                .get(&transaction_id)
6000                .await
6001                .unwrap()
6002                .unwrap()
6003                .status,
6004            JobState::Queued
6005        );
6006        assert!(state
6007            .audit
6008            .claim_approved_for_execution(
6009                &transaction_id,
6010                &receipt_digest(&receipt),
6011                CallerPrincipal::Uid(1000),
6012            )
6013            .await
6014            .unwrap());
6015    }
6016
6017    #[tokio::test]
6018    async fn execute_with_changed_action_rejects_without_consuming_approval() {
6019        let dir = tempdir().unwrap();
6020        let state = test_state(&dir);
6021        let (client, server) = tokio::net::UnixStream::pair().unwrap();
6022        let handler_state = state.clone();
6023        tokio::spawn(async move {
6024            unix_connection_handler(
6025                server,
6026                handler_state,
6027                runner(),
6028                uid_caller_with(1000, CallerRole::Observer),
6029            )
6030            .await;
6031        });
6032        let mut framed = FramedStream::new(client);
6033        let (transaction_id, receipt) =
6034            preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
6035
6036        framed
6037            .send(
6038                &serde_json::to_vec(&json!({
6039                    "type": "execute",
6040                    "request_id": "changed-action",
6041                    "transaction_id": transaction_id,
6042                    "action_name": "GetDiskUsage",
6043                    "params": {},
6044                    "approval_receipt": receipt,
6045                }))
6046                .unwrap(),
6047            )
6048            .await
6049            .unwrap();
6050        let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
6051
6052        assert_eq!(response["type"], "error_response");
6053        assert_eq!(response["category"], "stale_approval");
6054        assert_eq!(
6055            state
6056                .audit
6057                .get(&transaction_id)
6058                .await
6059                .unwrap()
6060                .unwrap()
6061                .status,
6062            JobState::Queued
6063        );
6064        assert!(state
6065            .audit
6066            .claim_approved_for_execution(
6067                &transaction_id,
6068                &receipt_digest(&receipt),
6069                CallerPrincipal::Uid(1000),
6070            )
6071            .await
6072            .unwrap());
6073    }
6074
6075    #[tokio::test]
6076    async fn job_started_send_failure_releases_slot_and_marks_transaction_failed() {
6077        let dir = tempdir().unwrap();
6078        let mut state = test_state(&dir);
6079        state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 44 });
6080        let (client, server) = tokio::net::UnixStream::pair().unwrap();
6081        let handler_state = state.clone();
6082        tokio::spawn(async move {
6083            unix_connection_handler(
6084                server,
6085                handler_state,
6086                runner(),
6087                uid_caller_with(1000, CallerRole::Admin),
6088            )
6089            .await;
6090        });
6091        let mut preview_stream = FramedStream::new(client);
6092        let params = json!({"package": "vim"});
6093        let (transaction_id, receipt) =
6094            preview_and_approve(&mut preview_stream, "AddLayeredPackage", params.clone()).await;
6095
6096        let (peer, broken_stream) = tokio::io::duplex(64);
6097        drop(peer);
6098        let mut broken_framed = FramedStream::new(broken_stream);
6099        let typed_transaction_id = TransactionId(transaction_id.clone());
6100        let typed_receipt = ApprovalReceipt(receipt);
6101        let result = handle_execute(
6102            &mut broken_framed,
6103            &state,
6104            Arc::new(crate::executor::RealActionExecutor),
6105            runner(),
6106            &uid_caller(CallerRole::Admin),
6107            "execute-disconnected",
6108            &typed_transaction_id,
6109            "AddLayeredPackage",
6110            &params,
6111            &typed_receipt,
6112        )
6113        .await;
6114
6115        assert!(result.is_err(), "the initial response write must fail");
6116        assert!(
6117            state.running_exclusive.lock().await.is_empty(),
6118            "a disconnected client must not retain any exclusion lock"
6119        );
6120        assert_eq!(
6121            state
6122                .audit
6123                .get(&transaction_id)
6124                .await
6125                .unwrap()
6126                .unwrap()
6127                .status,
6128            JobState::Failed,
6129            "a claimed transaction must not remain Running when execution never starts"
6130        );
6131    }
6132
6133    // ------------------------------------------------------------------
6134    // execute — full preview → execute flow
6135    // ------------------------------------------------------------------
6136
6137    #[tokio::test]
6138    async fn execute_after_preview_with_receipt_returns_job_completed() {
6139        let dir = tempdir().unwrap();
6140        let state = test_state(&dir);
6141        let (client, server) = tokio::net::UnixStream::pair().unwrap();
6142
6143        tokio::spawn(async move {
6144            unix_connection_handler(
6145                server,
6146                state,
6147                runner(),
6148                uid_caller_with(1000, CallerRole::Observer),
6149            )
6150            .await;
6151        });
6152
6153        let mut framed = FramedStream::new(client);
6154
6155        // Step 1: preview.
6156        let preview_req = json!({
6157            "type": "preview",
6158            "request_id": "r1",
6159            "action_name": "GetMemoryInfo",
6160            "params": {}
6161        });
6162        framed
6163            .send(&serde_json::to_vec(&preview_req).unwrap())
6164            .await
6165            .unwrap();
6166
6167        let raw = framed.recv().await.unwrap();
6168        let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
6169        assert_eq!(preview_resp["type"], "preview_response");
6170        let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
6171        let receipt = approve_preview(&mut framed, transaction_id).await;
6172
6173        // Step 2: execute with the one-time receipt the daemon returned.
6174        let exec_req = json!({
6175            "type": "execute",
6176            "request_id": "r2",
6177            "transaction_id": transaction_id,
6178            "action_name": "GetMemoryInfo",
6179            "params": {},
6180            "approval_receipt": receipt
6181        });
6182        framed
6183            .send(&serde_json::to_vec(&exec_req).unwrap())
6184            .await
6185            .unwrap();
6186
6187        // Expect job_started first.
6188        let raw = framed.recv().await.unwrap();
6189        let started: Value = serde_json::from_slice(&raw).unwrap();
6190        assert_eq!(started["type"], "job_started");
6191        let job_id = started["job_id"].as_str().unwrap().to_string();
6192
6193        // Drain frames until job_completed (there may be job_progress frames).
6194        loop {
6195            let raw = framed.recv().await.unwrap();
6196            let msg: Value = serde_json::from_slice(&raw).unwrap();
6197            let msg_type = msg["type"].as_str().unwrap();
6198            match msg_type {
6199                "job_progress" => {
6200                    assert_eq!(msg["job_id"], job_id);
6201                }
6202                "job_completed" => {
6203                    assert_eq!(msg["job_id"], job_id);
6204                    let status = msg["result"]["status"].as_str().unwrap();
6205                    // rpm-ostree may not be available in CI; both outcomes are valid.
6206                    assert!(
6207                        matches!(status, "succeeded" | "failed" | "needs_reboot"),
6208                        "unexpected status: {status}"
6209                    );
6210                    break;
6211                }
6212                other => panic!("unexpected message type: {other}"),
6213            }
6214        }
6215    }
6216
6217    // ------------------------------------------------------------------
6218    // T12 — `update_status` failure surfaces as a job-completed warning
6219    //
6220    // When the audit-log update fails after a job runs, the dispatcher
6221    // logs to stderr AND attaches a "audit trail update failed: …"
6222    // string to the job_completed response's `warnings` field so the
6223    // client can flag the audit gap. The warning emission was
6224    // unverified: a regression that swapped the warnings push for an
6225    // early return would silently drop the audit-loss signal.
6226    // ------------------------------------------------------------------
6227
6228    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6229    async fn update_status_failure_surfaces_as_audit_warning_in_job_completed() {
6230        use crate::audit_chain::{AuditKey, ChainRow, VerifyOutcome};
6231        use crate::store::AuditStore;
6232        use crate::transactions::{
6233            NewTransaction as NewTx, RecordedPreviewedTransaction, TransactionStoreError,
6234        };
6235        use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
6236
6237        // A tiny `AuditStore` wrapper that delegates to the real
6238        // SQLite-backed store for everything except `update_status`,
6239        // which returns the most plausible production failure mode
6240        // ("not found" — e.g. the row was rotated out before the dispatcher
6241        // could close the loop). The dispatcher must still emit
6242        // job_completed with the warning attached.
6243        #[derive(Debug)]
6244        struct UpdateStatusFails(std::sync::Arc<dyn AuditStore>);
6245
6246        #[async_trait::async_trait]
6247        impl AuditStore for UpdateStatusFails {
6248            async fn record(&self, t: NewTx) -> Result<TransactionRecord, TransactionStoreError> {
6249                self.0.record(t).await
6250            }
6251            async fn record_previewed(
6252                &self,
6253                t: NewTx,
6254                p: PreviewEnvelope,
6255            ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
6256                self.0.record_previewed(t, p).await
6257            }
6258            async fn get(
6259                &self,
6260                id: &str,
6261            ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
6262                self.0.get(id).await
6263            }
6264            async fn get_preview(
6265                &self,
6266                id: &str,
6267            ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
6268                self.0.get_preview(id).await
6269            }
6270            async fn update_status(
6271                &self,
6272                id: &str,
6273                _s: JobState,
6274            ) -> Result<(), TransactionStoreError> {
6275                Err(TransactionStoreError::NotFound(id.to_string()))
6276            }
6277            async fn approve_transaction(
6278                &self,
6279                id: &str,
6280                approver: CallerPrincipal,
6281            ) -> Result<Option<String>, TransactionStoreError> {
6282                self.0.approve_transaction(id, approver).await
6283            }
6284            async fn revoke_unconsumed_approval(
6285                &self,
6286                id: &str,
6287                revoker: CallerPrincipal,
6288            ) -> Result<bool, TransactionStoreError> {
6289                self.0.revoke_unconsumed_approval(id, revoker).await
6290            }
6291            async fn claim_approved_for_execution(
6292                &self,
6293                id: &str,
6294                digest: &str,
6295                executor: CallerPrincipal,
6296            ) -> Result<bool, TransactionStoreError> {
6297                self.0
6298                    .claim_approved_for_execution(id, digest, executor)
6299                    .await
6300            }
6301            async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
6302                self.0.cleanup_stale_queued().await
6303            }
6304            async fn cancel_queued(
6305                &self,
6306                id: &str,
6307                canceller: CallerPrincipal,
6308            ) -> Result<bool, TransactionStoreError> {
6309                self.0.cancel_queued(id, canceller).await
6310            }
6311            async fn list_transactions(
6312                &self,
6313                limit: u32,
6314                status: Option<&str>,
6315                action: Option<&str>,
6316                since: Option<HistorySince>,
6317            ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
6318                self.0.list_transactions(limit, status, action, since).await
6319            }
6320            async fn list_history(
6321                &self,
6322                limit: u32,
6323                status: Option<&str>,
6324                action: Option<&str>,
6325                since: Option<HistorySince>,
6326            ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>
6327            {
6328                self.0.list_history(limit, status, action, since).await
6329            }
6330            async fn fetch_chain_row(
6331                &self,
6332                id: &str,
6333            ) -> Result<Option<ChainRow>, TransactionStoreError> {
6334                self.0.fetch_chain_row(id).await
6335            }
6336            async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
6337                self.0.fetch_chain_rows().await
6338            }
6339            async fn fetch_event_rows(
6340                &self,
6341            ) -> Result<Vec<crate::audit_chain::EventRow>, TransactionStoreError> {
6342                self.0.fetch_event_rows().await
6343            }
6344            async fn verify_audit_chain(
6345                &self,
6346                k: &AuditKey,
6347            ) -> Result<VerifyOutcome, TransactionStoreError> {
6348                self.0.verify_audit_chain(k).await
6349            }
6350        }
6351
6352        let dir = tempdir().unwrap();
6353        let real = test_state(&dir);
6354        // Wrap the inner audit store with our update-status-failing decorator.
6355        let faulty: std::sync::Arc<dyn AuditStore> =
6356            std::sync::Arc::new(UpdateStatusFails(real.audit.clone()));
6357        let cfg = real.config.clone();
6358        let audit_key = crate::audit_chain::AuditKey::load_or_generate(
6359            &crate::audit_chain::resolve_audit_key_path(&dir.path().join("sysknife-test.db")),
6360        )
6361        .unwrap();
6362        let state = crate::state::DaemonState::open_with_audit(
6363            cfg,
6364            crate::policy::PolicyTable::empty(),
6365            None,
6366            faulty,
6367            &audit_key,
6368        );
6369
6370        let (client, server) = tokio::net::UnixStream::pair().unwrap();
6371        tokio::spawn(async move {
6372            unix_connection_handler(
6373                server,
6374                state,
6375                runner(),
6376                uid_caller_with(1000, CallerRole::Observer),
6377            )
6378            .await;
6379        });
6380        let mut framed = FramedStream::new(client);
6381
6382        // Preview + execute against a low-risk action so Observer can run it.
6383        framed
6384            .send(
6385                &serde_json::to_vec(&json!({
6386                    "type": "preview",
6387                    "request_id": "r1",
6388                    "action_name": "GetMemoryInfo",
6389                    "params": {}
6390                }))
6391                .unwrap(),
6392            )
6393            .await
6394            .unwrap();
6395        let preview_resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
6396        let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
6397        let receipt = approve_preview(&mut framed, transaction_id).await;
6398
6399        framed
6400            .send(
6401                &serde_json::to_vec(&json!({
6402                    "type": "execute",
6403                    "request_id": "r2",
6404                    "transaction_id": transaction_id,
6405                    "action_name": "GetMemoryInfo",
6406                    "params": {},
6407                    "approval_receipt": receipt
6408                }))
6409                .unwrap(),
6410            )
6411            .await
6412            .unwrap();
6413
6414        // Drain frames until we hit job_completed.
6415        let mut completed: Option<Value> = None;
6416        for _ in 0..30 {
6417            let raw = framed.recv().await.unwrap();
6418            let msg: Value = serde_json::from_slice(&raw).unwrap();
6419            if msg["type"] == "job_completed" {
6420                completed = Some(msg);
6421                break;
6422            }
6423        }
6424        let completed = completed.expect("dispatcher must emit job_completed");
6425        let warnings = completed["result"]["warnings"]
6426            .as_array()
6427            .expect("warnings array on job_completed");
6428
6429        assert!(
6430            warnings
6431                .iter()
6432                .any(|w| w.as_str().unwrap_or("").contains("audit trail update failed")),
6433            "expected an `audit trail update failed` warning when update_status returns Err; got: {warnings:#?}"
6434        );
6435    }
6436
6437    // ------------------------------------------------------------------
6438    // T4 — concurrent execute race at the dispatcher boundary
6439    //
6440    // The transactions store guarantees that receipt consumption and the
6441    // Queued-to-Running transition happen in one atomic claim; the second
6442    // caller observes that the receipt is consumed and bails. Drive
6443    // that contract through TWO concurrent unix_connection_handler instances
6444    // sharing one DaemonState: one preview produces a Queued row, then
6445    // two executes race for it.  Exactly one must reach job_started;
6446    // the other must surface `stale_approval`.
6447    //
6448    // The test runs against the multi-thread tokio runtime so the two
6449    // handlers are genuinely parallel — single-thread would serialise
6450    // them and never exercise the conditional UPDATE.
6451    // ------------------------------------------------------------------
6452
6453    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6454    async fn concurrent_executes_against_one_preview_serialise_to_one_winner() {
6455        let dir = tempdir().unwrap();
6456        let state = test_state(&dir);
6457
6458        // ── Producer: one preview to seed a Queued row ───────────────
6459        let (pc, ps) = tokio::net::UnixStream::pair().unwrap();
6460        {
6461            let state = state.clone();
6462            tokio::spawn(async move {
6463                unix_connection_handler(
6464                    ps,
6465                    state,
6466                    runner(),
6467                    uid_caller_with(1000, CallerRole::Observer),
6468                )
6469                .await;
6470            });
6471        }
6472        let mut framed_p = FramedStream::new(pc);
6473        framed_p
6474            .send(
6475                &serde_json::to_vec(&json!({
6476                    "type": "preview",
6477                    "request_id": "preview-once",
6478                    "action_name": "GetMemoryInfo",
6479                    "params": {}
6480                }))
6481                .unwrap(),
6482            )
6483            .await
6484            .unwrap();
6485        let raw = framed_p.recv().await.unwrap();
6486        let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
6487        let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
6488        let approval_receipt = approve_preview(&mut framed_p, &transaction_id).await;
6489
6490        // ── Two execute connections sharing the same DaemonState ─────
6491        async fn exec_once(
6492            state: crate::state::DaemonState,
6493            transaction_id: String,
6494            approval_receipt: String,
6495            request_id: &'static str,
6496        ) -> Vec<Value> {
6497            let (c, s) = tokio::net::UnixStream::pair().unwrap();
6498            tokio::spawn(async move {
6499                unix_connection_handler(
6500                    s,
6501                    state,
6502                    runner(),
6503                    uid_caller_with(1000, CallerRole::Observer),
6504                )
6505                .await;
6506            });
6507            let mut f = FramedStream::new(c);
6508            f.send(
6509                &serde_json::to_vec(&json!({
6510                    "type": "execute",
6511                    "request_id": request_id,
6512                    "transaction_id": transaction_id,
6513                    "action_name": "GetMemoryInfo",
6514                    "params": {},
6515                    "approval_receipt": approval_receipt
6516                }))
6517                .unwrap(),
6518            )
6519            .await
6520            .unwrap();
6521
6522            // Read frames until we hit either job_completed (winner path)
6523            // or error_response (loser path). Cap at 30 frames so a stuck
6524            // handler can't hang the test.
6525            let mut frames = Vec::new();
6526            for _ in 0..30 {
6527                let raw = match f.recv().await {
6528                    Ok(b) => b,
6529                    Err(_) => break,
6530                };
6531                let v: Value = serde_json::from_slice(&raw).unwrap();
6532                let kind = v["type"].as_str().unwrap_or("").to_string();
6533                frames.push(v);
6534                if kind == "job_completed" || kind == "error_response" {
6535                    break;
6536                }
6537            }
6538            frames
6539        }
6540
6541        let (a, b) = tokio::join!(
6542            exec_once(
6543                state.clone(),
6544                transaction_id.clone(),
6545                approval_receipt.clone(),
6546                "exec-A"
6547            ),
6548            exec_once(
6549                state.clone(),
6550                transaction_id.clone(),
6551                approval_receipt.clone(),
6552                "exec-B"
6553            ),
6554        );
6555
6556        // Each side must end in either job_completed or error_response.
6557        let terminal = |frames: &[Value]| -> &'static str {
6558            for f in frames {
6559                match f["type"].as_str().unwrap_or("") {
6560                    "job_completed" => return "win",
6561                    "error_response" => return "lose",
6562                    _ => {}
6563                }
6564            }
6565            "neither"
6566        };
6567        let outcomes = [terminal(&a), terminal(&b)];
6568
6569        // Exactly one winner, exactly one loser. Order is unspecified.
6570        let mut sorted = outcomes;
6571        sorted.sort();
6572        assert_eq!(
6573            sorted,
6574            ["lose", "win"],
6575            "exactly one execute must win and one must lose; got {outcomes:?}\n A={a:#?}\n B={b:#?}"
6576        );
6577
6578        // The loser must be flagged as stale_approval, not some other
6579        // category — that's the receipt claim's wire contract.
6580        let loser = if outcomes[0] == "lose" { &a } else { &b };
6581        let err_frame = loser
6582            .iter()
6583            .find(|f| f["type"] == "error_response")
6584            .expect("loser has an error_response");
6585        assert_eq!(
6586            err_frame["category"], "stale_approval",
6587            "loser must be classified stale_approval; got {err_frame:#?}"
6588        );
6589    }
6590
6591    // ------------------------------------------------------------------
6592    // T5 — replay attack at the dispatcher boundary
6593    //
6594    // The transaction store verifies and consumes the one-time receipt while
6595    // moving the row from Queued to Running. A captured receipt submitted a
6596    // second time must surface as `stale_approval`. Pin that contract through
6597    // the live dispatcher, not just the store.
6598    // ------------------------------------------------------------------
6599
6600    #[tokio::test]
6601    async fn re_executing_a_completed_approval_returns_stale_approval() {
6602        let dir = tempdir().unwrap();
6603        let state = test_state(&dir);
6604        let (client, server) = tokio::net::UnixStream::pair().unwrap();
6605
6606        tokio::spawn(async move {
6607            unix_connection_handler(
6608                server,
6609                state,
6610                runner(),
6611                uid_caller_with(1000, CallerRole::Observer),
6612            )
6613            .await;
6614        });
6615
6616        let mut framed = FramedStream::new(client);
6617
6618        // Step 1: preview a low-risk action so we can drive the full
6619        // execute path under Observer (which can run Get* actions).
6620        framed
6621            .send(
6622                &serde_json::to_vec(&json!({
6623                    "type": "preview",
6624                    "request_id": "r1",
6625                    "action_name": "GetMemoryInfo",
6626                    "params": {}
6627                }))
6628                .unwrap(),
6629            )
6630            .await
6631            .unwrap();
6632
6633        let raw = framed.recv().await.unwrap();
6634        let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
6635        assert_eq!(preview_resp["type"], "preview_response");
6636        let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
6637        let approval_receipt = approve_preview(&mut framed, &transaction_id).await;
6638
6639        // Step 2: first execute — must succeed and reach a terminal state.
6640        framed
6641            .send(
6642                &serde_json::to_vec(&json!({
6643                    "type": "execute",
6644                    "request_id": "r2",
6645                    "transaction_id": transaction_id,
6646                    "action_name": "GetMemoryInfo",
6647                    "params": {},
6648                    "approval_receipt": approval_receipt
6649                }))
6650                .unwrap(),
6651            )
6652            .await
6653            .unwrap();
6654
6655        // Drain everything until job_completed so the transaction row
6656        // has been moved out of Queued.
6657        loop {
6658            let raw = framed.recv().await.unwrap();
6659            let msg: Value = serde_json::from_slice(&raw).unwrap();
6660            match msg["type"].as_str().unwrap() {
6661                "job_started" | "job_progress" => continue,
6662                "job_completed" => break,
6663                other => panic!("unexpected message during first execute: {other}"),
6664            }
6665        }
6666
6667        // Step 3: replay the same one-time receipt. The receipt is consumed,
6668        // so the dispatcher must reject it rather than re-execute the action.
6669        framed
6670            .send(
6671                &serde_json::to_vec(&json!({
6672                    "type": "execute",
6673                    "request_id": "r3-replay",
6674                    "transaction_id": transaction_id,
6675                    "action_name": "GetMemoryInfo",
6676                    "params": {},
6677                    "approval_receipt": approval_receipt
6678                }))
6679                .unwrap(),
6680            )
6681            .await
6682            .unwrap();
6683
6684        let raw = framed.recv().await.unwrap();
6685        let resp: Value = serde_json::from_slice(&raw).unwrap();
6686        assert_eq!(
6687            resp["type"], "error_response",
6688            "replay must produce error_response, got: {resp}"
6689        );
6690        assert_eq!(
6691            resp["category"], "stale_approval",
6692            "replay must be classified as stale_approval, got: {}",
6693            resp["category"]
6694        );
6695        assert_eq!(resp["request_id"], "r3-replay");
6696    }
6697
6698    // ------------------------------------------------------------------
6699    // describe
6700    // ------------------------------------------------------------------
6701
6702    #[tokio::test]
6703    async fn describe_returns_command_and_risk_for_known_action() {
6704        let dir = tempdir().unwrap();
6705        let state = test_state(&dir);
6706
6707        let resps = exchange(
6708            state,
6709            CallerRole::Observer,
6710            vec![json!({
6711                "type": "describe",
6712                "request_id": "r1",
6713                "action_name": "GetDateTime",
6714                "params": {}
6715            })],
6716            1,
6717        )
6718        .await;
6719
6720        assert_eq!(resps[0]["type"], "describe_response");
6721        assert_eq!(resps[0]["request_id"], "r1");
6722        assert_eq!(resps[0]["command"], "timedatectl");
6723        assert_eq!(resps[0]["risk_level"], "low");
6724        assert_eq!(resps[0]["reboot_required"], false);
6725    }
6726
6727    #[tokio::test]
6728    async fn describe_returns_error_for_unknown_action() {
6729        let dir = tempdir().unwrap();
6730        let state = test_state(&dir);
6731
6732        let resps = exchange(
6733            state,
6734            CallerRole::Observer,
6735            vec![json!({
6736                "type": "describe",
6737                "request_id": "r1",
6738                "action_name": "NotARealAction",
6739                "params": {}
6740            })],
6741            1,
6742        )
6743        .await;
6744
6745        assert_eq!(resps[0]["type"], "error_response");
6746        assert_eq!(resps[0]["category"], "validation_failure");
6747    }
6748
6749    // ------------------------------------------------------------------
6750    // unknown message type
6751    // ------------------------------------------------------------------
6752
6753    #[tokio::test]
6754    async fn unknown_message_type_returns_validation_failure() {
6755        let dir = tempdir().unwrap();
6756        let state = test_state(&dir);
6757
6758        let resps = exchange(
6759            state,
6760            CallerRole::Observer,
6761            vec![json!({"type": "does_not_exist", "request_id": "r1"})],
6762            1,
6763        )
6764        .await;
6765
6766        assert_eq!(resps[0]["type"], "error_response");
6767        assert_eq!(resps[0]["category"], "validation_failure");
6768    }
6769
6770    // ------------------------------------------------------------------
6771    // Dispatcher progress relay
6772    // ------------------------------------------------------------------
6773
6774    #[tokio::test]
6775    async fn fast_executor_progress_is_drained_before_job_completed() {
6776        struct FastProgressExecutor;
6777
6778        #[async_trait::async_trait]
6779        impl ActionExecutor for FastProgressExecutor {
6780            async fn execute(
6781                &self,
6782                _spec: &crate::actions::ActionSpec,
6783            ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
6784            {
6785                unreachable!("dispatcher uses execute_with_progress")
6786            }
6787
6788            async fn execute_with_progress(
6789                &self,
6790                _spec: &crate::actions::ActionSpec,
6791                progress: tokio::sync::mpsc::UnboundedSender<String>,
6792            ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
6793            {
6794                progress.send("last line before exit".to_string()).unwrap();
6795                Ok(crate::executor::ExecutionOutput {
6796                    stdout: "last line before exit\n".to_string(),
6797                    stderr: String::new(),
6798                    exit_code: 0,
6799                })
6800            }
6801        }
6802
6803        let dir = tempdir().unwrap();
6804        let state = test_state(&dir);
6805        let (client, server) = tokio::net::UnixStream::pair().unwrap();
6806        tokio::spawn(async move {
6807            connection_handler_with_executor(
6808                server,
6809                state,
6810                runner(),
6811                Arc::new(FastProgressExecutor),
6812                uid_caller(CallerRole::Observer),
6813            )
6814            .await;
6815        });
6816        let mut framed = FramedStream::new(client);
6817        let (transaction_id, receipt) =
6818            preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
6819        framed
6820            .send(
6821                &serde_json::to_vec(&json!({
6822                    "type": "execute",
6823                    "request_id": "execute-fast-progress",
6824                    "transaction_id": transaction_id,
6825                    "action_name": "GetMemoryInfo",
6826                    "params": {},
6827                    "approval_receipt": receipt,
6828                }))
6829                .unwrap(),
6830            )
6831            .await
6832            .unwrap();
6833
6834        let mut progress_lines = Vec::new();
6835        loop {
6836            let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
6837            match response["type"].as_str().unwrap() {
6838                "job_progress" => {
6839                    progress_lines.push(response["line"].as_str().unwrap().to_string())
6840                }
6841                "job_started" => {}
6842                "job_completed" => break,
6843                other => panic!("unexpected response: {other}"),
6844            }
6845        }
6846        assert!(progress_lines
6847            .iter()
6848            .any(|line| line == "last line before exit"));
6849    }
6850
6851    // ------------------------------------------------------------------
6852    // vsock token auth gate
6853    // ------------------------------------------------------------------
6854
6855    #[cfg(target_os = "linux")]
6856    mod vsock_auth {
6857        use super::*;
6858        use std::sync::Arc;
6859
6860        fn executor() -> Arc<dyn ActionExecutor> {
6861            Arc::new(crate::executor::RealActionExecutor)
6862        }
6863
6864        /// The daemon refuses a token file other local users can read, so
6865        /// tests must provision one the way an operator is told to
6866        /// (`install -m 600`). `std::fs::write` alone lands at 0644 under the
6867        /// usual umask and would be rejected.
6868        fn write_token_mode(path: &std::path::Path, mode: u32) {
6869            use std::os::unix::fs::PermissionsExt;
6870            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
6871        }
6872
6873        /// A bearer token any local user can read is not a credential. The
6874        /// daemon must refuse it rather than grant `token_role()` to whoever
6875        /// presents it.
6876        #[tokio::test]
6877        async fn group_or_world_readable_token_file_is_rejected() {
6878            let dir = tempdir().unwrap();
6879            let token_path = dir.path().join("token");
6880            std::fs::write(&token_path, "test-secret").unwrap();
6881            write_token_mode(&token_path, 0o644);
6882
6883            assert!(
6884                crate::auth::validate_token_against_file("test-secret", &token_path).is_none(),
6885                "a 0644 token file must be rejected even when the token matches"
6886            );
6887
6888            // Same file, same token, tightened permissions — now accepted.
6889            write_token_mode(&token_path, 0o600);
6890            assert!(
6891                crate::auth::validate_token_against_file("test-secret", &token_path).is_some(),
6892                "a 0600 token file with a matching token must be accepted"
6893            );
6894        }
6895
6896        /// Send a valid auth frame then a `query_state` — connection must succeed.
6897        #[tokio::test]
6898        async fn valid_token_grants_access() {
6899            let dir = tempdir().unwrap();
6900            let token_path = dir.path().join("token");
6901            std::fs::write(&token_path, "test-secret").unwrap();
6902            write_token_mode(&token_path, 0o600);
6903
6904            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
6905            let state = test_state(&dir);
6906
6907            tokio::spawn(async move {
6908                let mut framed = FramedStream::new(server);
6909                let role = authenticate_vsock_token(&mut framed, &token_path).await;
6910                if let Some(r) = role {
6911                    dispatch_loop(
6912                        &mut framed,
6913                        state,
6914                        runner(),
6915                        executor(),
6916                        CallerAttribution::from_vsock_token(r),
6917                    )
6918                    .await;
6919                }
6920            });
6921
6922            let mut framed = FramedStream::new(client);
6923            let auth = serde_json::json!({"type": "auth", "token": "test-secret"});
6924            framed
6925                .send(&serde_json::to_vec(&auth).unwrap())
6926                .await
6927                .unwrap();
6928            let req = serde_json::json!({"type": "query_state", "request_id": "vsock-r1"});
6929            framed
6930                .send(&serde_json::to_vec(&req).unwrap())
6931                .await
6932                .unwrap();
6933            let raw = framed.recv().await.unwrap();
6934            let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
6935            assert_eq!(resp["type"], "state_response");
6936        }
6937
6938        /// Wrong token — connection must be closed immediately (EOF on client).
6939        #[tokio::test]
6940        async fn wrong_token_closes_connection() {
6941            let dir = tempdir().unwrap();
6942            let token_path = dir.path().join("token");
6943            std::fs::write(&token_path, "correct").unwrap();
6944            write_token_mode(&token_path, 0o600);
6945
6946            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
6947            let state = test_state(&dir);
6948
6949            tokio::spawn(async move {
6950                let mut framed = FramedStream::new(server);
6951                let _ = authenticate_vsock_token(&mut framed, &token_path).await;
6952                // None returned — handler closes connection (drops framed)
6953                drop(state);
6954            });
6955
6956            let mut framed = FramedStream::new(client);
6957            let auth = serde_json::json!({"type": "auth", "token": "wrong"});
6958            framed
6959                .send(&serde_json::to_vec(&auth).unwrap())
6960                .await
6961                .unwrap();
6962            // Server dropped the stream — recv must fail (EOF or reset)
6963            let result = framed.recv().await;
6964            assert!(
6965                result.is_err(),
6966                "expected EOF after wrong token, got {:?}",
6967                result
6968            );
6969        }
6970
6971        /// EOF before any auth frame — connection must be closed without panic.
6972        #[tokio::test]
6973        async fn eof_before_auth_frame_closes_connection() {
6974            let dir = tempdir().unwrap();
6975            let token_path = dir.path().join("token");
6976            std::fs::write(&token_path, "secret").unwrap();
6977            write_token_mode(&token_path, 0o600);
6978
6979            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
6980
6981            tokio::spawn(async move {
6982                let mut framed = FramedStream::new(server);
6983                let result = authenticate_vsock_token(&mut framed, &token_path).await;
6984                assert!(result.is_none());
6985            });
6986
6987            // Close write side immediately (sends EOF)
6988            drop(client);
6989        }
6990
6991        /// Malformed JSON as first frame — must return None without panic.
6992        #[tokio::test]
6993        async fn malformed_json_frame_closes_connection() {
6994            let dir = tempdir().unwrap();
6995            let token_path = dir.path().join("token");
6996            std::fs::write(&token_path, "secret").unwrap();
6997            write_token_mode(&token_path, 0o600);
6998
6999            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
7000
7001            let handle = tokio::spawn(async move {
7002                let mut framed = FramedStream::new(server);
7003                let result = authenticate_vsock_token(&mut framed, &token_path).await;
7004                assert!(result.is_none());
7005            });
7006
7007            let mut framed = FramedStream::new(client);
7008            framed.send(b"not json at all {{{{").await.unwrap();
7009            drop(framed);
7010            handle.await.unwrap();
7011        }
7012
7013        /// Wrong `type` field — must return None.
7014        #[tokio::test]
7015        async fn wrong_msg_type_closes_connection() {
7016            let dir = tempdir().unwrap();
7017            let token_path = dir.path().join("token");
7018            std::fs::write(&token_path, "secret").unwrap();
7019            write_token_mode(&token_path, 0o600);
7020
7021            let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
7022
7023            let handle = tokio::spawn(async move {
7024                let mut framed = FramedStream::new(server);
7025                let result = authenticate_vsock_token(&mut framed, &token_path).await;
7026                assert!(result.is_none());
7027            });
7028
7029            let mut framed = FramedStream::new(client);
7030            let bad = serde_json::json!({"type": "query_state", "token": "secret"});
7031            framed
7032                .send(&serde_json::to_vec(&bad).unwrap())
7033                .await
7034                .unwrap();
7035            drop(framed);
7036            handle.await.unwrap();
7037        }
7038    }
7039
7040    // ------------------------------------------------------------------
7041    // stream-type agnosticism (vsock / duplex)
7042    // ------------------------------------------------------------------
7043
7044    #[tokio::test]
7045    async fn connection_handler_accepts_duplex_stream() {
7046        // Proves the handler works with any AsyncRead+AsyncWrite stream,
7047        // not only UnixStream. This is the key invariant enabling vsock support.
7048        let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
7049        let dir = tempdir().unwrap();
7050        let state = test_state(&dir);
7051        tokio::spawn(unix_connection_handler(
7052            server,
7053            state,
7054            runner(),
7055            uid_caller(CallerRole::Observer),
7056        ));
7057
7058        let mut framed = FramedStream::new(client);
7059        let req = json!({"type": "query_state", "request_id": "duplex-r1"});
7060        framed
7061            .send(&serde_json::to_vec(&req).unwrap())
7062            .await
7063            .unwrap();
7064        let raw = framed.recv().await.unwrap();
7065        let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
7066        assert_eq!(resp["type"], "state_response");
7067        assert_eq!(resp["request_id"], "duplex-r1");
7068    }
7069}