Skip to main content

zad_cli/cli/
echo.rs

1//! Echo-mode for runtime verbs whose permissions file can't be trusted.
2//!
3//! When `permissions::signing::verify_raw` rejects a permissions file
4//! (no trust entry, tampered bytes, rotated key, broken trust store, or
5//! missing keychain), runtime verbs should NOT execute their network
6//! call — but they also shouldn't just print an opaque error and exit.
7//! The operator wants to see *what would have been issued*, plus the
8//! reason, so they can iterate on permission files without round-tripping
9//! through real API calls.
10//!
11//! ## Flow
12//!
13//! 1. The verb calls `<service>::permissions::load_effective_or_echo()`
14//!    instead of `load_effective()`.
15//! 2. If the load fails with a [signing error][zad::permissions::signing::is_signing_error],
16//!    the helper [`arm`]s this module with an [`EchoReason`] and returns
17//!    a permissive [`EffectivePermissions::default()`] so subsequent
18//!    `check_*` calls on the verb's hot path no-op.
19//! 3. The verb's transport selector (`discord_http_for` etc.) checks
20//!    [`echo_active`]; when it is on, it returns the existing
21//!    `DryRun*Transport` wired to [`dry_run_sink_for_echo`] (a buffer
22//!    instead of stderr/stdout).
23//! 4. After the verb's transport call returns, the verb invokes
24//!    [`render_and_clear`], which drains the captured [`DryRunOp`]s,
25//!    pairs them with the [`EchoReason`], prints either a human-readable
26//!    summary or a structured JSON envelope, and calls [`mark_echoed`].
27//! 5. `main.rs` reads [`was_echoed`] after the verb returns and exits
28//!    with code `3` instead of `0` so callers can distinguish "ran" from
29//!    "echoed" from "failed".
30//!
31//! Diagnostic verbs (`permissions show|check|path|init|...`) keep
32//! calling `load_effective()` directly so signing errors surface there
33//! — that's the surface the operator uses to *fix* a broken trust
34//! state, and silently echoing them would obscure the failure mode.
35
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::{Arc, Mutex, OnceLock};
38
39use serde::Serialize;
40use serde_json::json;
41
42use zad::error::{Result, ZadError};
43use zad::permissions::signing;
44use zad::service::{DryRunOp, DryRunSink};
45
46/// Why the echo path was taken. Mirrors the five signing-related
47/// `ZadError` variants. `kind` is a stable string tag callers may
48/// switch on; `reason` is the user-facing message; `path` is the
49/// permissions (or trust store) file the operator should fix.
50#[derive(Debug, Clone, Serialize)]
51pub struct EchoReason {
52    pub kind: &'static str,
53    pub reason: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub path: Option<String>,
56}
57
58/// Build an [`EchoReason`] from a [`ZadError`]. Returns `None` for
59/// errors that are *not* signing-related — those should keep their
60/// hard-fail shape.
61pub fn from_signing_error(err: &ZadError) -> Option<EchoReason> {
62    match err {
63        ZadError::NotTrusted { path, .. } => Some(EchoReason {
64            kind: "not_trusted",
65            reason: err.to_string(),
66            path: Some(path.display().to_string()),
67        }),
68        ZadError::SignatureInvalid { path, .. } => Some(EchoReason {
69            kind: "signature_invalid",
70            reason: err.to_string(),
71            path: Some(path.display().to_string()),
72        }),
73        ZadError::SignatureKeyMismatch { path, .. } => Some(EchoReason {
74            kind: "signature_key_mismatch",
75            reason: err.to_string(),
76            path: Some(path.display().to_string()),
77        }),
78        ZadError::TrustStoreTampered { path, .. } => Some(EchoReason {
79            kind: "trust_store_tampered",
80            reason: err.to_string(),
81            path: Some(path.display().to_string()),
82        }),
83        ZadError::SigningKeyMissing { .. } => Some(EchoReason {
84            kind: "signing_key_missing",
85            reason: err.to_string(),
86            path: None,
87        }),
88        _ => None,
89    }
90}
91
92static ECHO_STATE: OnceLock<Mutex<Option<EchoReason>>> = OnceLock::new();
93static ECHO_SINK: OnceLock<Arc<EchoSink>> = OnceLock::new();
94static ECHOED: AtomicBool = AtomicBool::new(false);
95
96fn state() -> &'static Mutex<Option<EchoReason>> {
97    ECHO_STATE.get_or_init(|| Mutex::new(None))
98}
99
100fn shared_sink() -> &'static Arc<EchoSink> {
101    ECHO_SINK.get_or_init(|| {
102        Arc::new(EchoSink {
103            buf: Mutex::new(Vec::new()),
104        })
105    })
106}
107
108/// Buffer the next captured [`DryRunOp`] from a transport instead of
109/// printing it. Replaces [`zad::service::default_dry_run_sink`] when
110/// echo mode is active so the verb-end can render op + reason together.
111pub struct EchoSink {
112    buf: Mutex<Vec<DryRunOp>>,
113}
114
115impl DryRunSink for EchoSink {
116    fn record(&self, op: DryRunOp) {
117        self.buf.lock().expect("echo sink poisoned").push(op);
118    }
119}
120
121/// Stash an [`EchoReason`] for the current invocation. Called by
122/// [`load_effective_or_echo`] when `verify_raw` rejects the file.
123pub fn arm(reason: EchoReason) {
124    *state().lock().expect("echo state poisoned") = Some(reason);
125}
126
127/// Wrap a per-service `load_effective` call with echo-mode arming.
128/// On a signing error (untrusted file, tampered bytes, rotated key,
129/// broken trust store, missing keychain), [`arm`] the reason and
130/// return a permissive [`Default`] permissions value so the verb's
131/// `check_*` calls no-op and the verb's transport selector can switch
132/// to the buffered dry-run path.
133///
134/// Non-signing errors propagate unchanged. Diagnostic verbs
135/// (`permissions show|check|...`) bypass this wrapper and call
136/// `load_effective` directly so signing errors surface there.
137pub fn load_effective_or_echo<P, F>(loader: F) -> Result<P>
138where
139    P: Default,
140    F: FnOnce() -> Result<P>,
141{
142    match loader() {
143        Ok(p) => Ok(p),
144        Err(e) if signing::is_signing_error(&e) => {
145            if let Some(reason) = from_signing_error(&e) {
146                arm(reason);
147            }
148            Ok(P::default())
149        }
150        Err(e) => Err(e),
151    }
152}
153
154/// `true` once an [`EchoReason`] has been armed and not yet rendered.
155/// Transport selectors switch to the dry-run + buffered sink path while
156/// this is on.
157pub fn echo_active() -> bool {
158    state().lock().expect("echo state poisoned").is_some()
159}
160
161/// Sink to hand to a `DryRun*Transport` when [`echo_active`] is true.
162/// All transports share one buffer so [`render_and_clear`] can drain it
163/// regardless of which verb captured the op.
164pub fn dry_run_sink_for_echo() -> Arc<dyn DryRunSink> {
165    shared_sink().clone()
166}
167
168/// Set the process-global "this run echoed" flag. `main.rs` reads it
169/// to pick exit code 3 over 0.
170pub fn mark_echoed() {
171    ECHOED.store(true, Ordering::SeqCst);
172}
173
174/// `true` if any verb called [`mark_echoed`] this invocation.
175pub fn was_echoed() -> bool {
176    ECHOED.load(Ordering::SeqCst)
177}
178
179/// Reset all echo state. Library tests that exercise multiple
180/// invocations in one process call this between runs; the binary never
181/// needs it (one CLI invocation = one process).
182#[doc(hidden)]
183pub fn reset_for_test() {
184    *state().lock().expect("echo state poisoned") = None;
185    shared_sink()
186        .buf
187        .lock()
188        .expect("echo sink poisoned")
189        .clear();
190    ECHOED.store(false, Ordering::SeqCst);
191}
192
193#[derive(Debug, Serialize)]
194struct EchoEnvelope<'a> {
195    /// Structured payload of the call that would have been issued.
196    /// `null` when the verb didn't call any mutating transport method
197    /// (read-only verbs return early without recording).
198    echoed: &'a serde_json::Value,
199    error: &'a EchoReason,
200}
201
202/// Drain the captured op + armed reason and render them to stdout in
203/// the requested format, then call [`mark_echoed`].
204///
205/// Verbs invoke this in place of their normal success print at the
206/// point where they would otherwise have exited with `Ok(())`. If no
207/// reason is armed (i.e. echo mode wasn't actually triggered), this is
208/// a no-op — the verb's caller still prints its real success output
209/// because [`mark_echoed`] is never called.
210pub fn render_and_clear(json: bool) {
211    let Some(reason) = state().lock().expect("echo state poisoned").take() else {
212        return;
213    };
214    let ops: Vec<DryRunOp> = {
215        let mut buf = shared_sink().buf.lock().expect("echo sink poisoned");
216        buf.drain(..).collect()
217    };
218
219    if json {
220        let payload = match ops.first() {
221            Some(op) => serde_json::to_value(EchoEnvelope {
222                echoed: &op.details,
223                error: &reason,
224            })
225            .unwrap_or_else(|_| json!({ "error": &reason })),
226            None => json!({
227                "echoed": null,
228                "error": &reason,
229            }),
230        };
231        match serde_json::to_string_pretty(&payload) {
232            Ok(rendered) => println!("{rendered}"),
233            Err(e) => eprintln!("echo: failed to render payload as JSON: {e}"),
234        }
235    } else {
236        if ops.is_empty() {
237            println!("would have run: (no transport call captured)");
238        } else {
239            for op in &ops {
240                println!("would have run: {}", op.summary);
241            }
242        }
243        println!("  reason: {}", reason.reason);
244    }
245    mark_echoed();
246}