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}