majordomus_cli/logging.rs
1//! Where diagnostics go, and where they must never go.
2//!
3//! Every diagnostic this executable emits is written to **stderr**, structured, filtered by
4//! [`LOG_ENV`] and defaulting to `info`. stdout is never touched here, and that is the whole
5//! reason this module exists as a boundary rather than as two lines in `main`: stdout
6//! belongs to the protocol. An MCP client reads JSON-RPC framing from it, a shell reads the
7//! JSON a command printed, and one stray line of logging on that stream is a parse error in
8//! the caller rather than a message to a person.
9//!
10//! Nothing the log writes carries a credential the redactor knows (I2708). A server started
11//! in the background writes this stream to `server.log`, a file nobody reads before it is
12//! kept, and a field logged at a call site — a request path, an origin, an error's text —
13//! can hold whatever a caller sent. So every field of every event and span, the message
14//! among them, is formatted through one layer, [`subscriber`]'s field formatter, which
15//! passes the field's text through `redaction::redact_secrets` before it is written. There
16//! is no second rule set and no per-call-site opt-in: a field added tomorrow is redacted
17//! because it is a field. A string field is redacted before it is quoted, and a debugged
18//! value whose escaped quotes hide a shape is redacted as if they were plain, so an escape
19//! cannot hide a credential from the redactor.
20//!
21//! The subscriber is installed once per process. A second call is a no-op rather than an
22//! error, so a command that initialises defensively cannot fail because something already
23//! did it.
24//!
25//! ```
26//! use majordomus_cli::logging;
27//!
28//! // installing twice is safe, which is the property every caller relies on
29//! logging::init();
30//! logging::init();
31//! assert_eq!(logging::LOG_ENV, "MAJORDOMUS_LOG");
32//! ```
33
34use std::fmt;
35
36use tracing::field::{Field, Visit};
37use tracing::Subscriber;
38use tracing_subscriber::field::RecordFields;
39use tracing_subscriber::fmt::format::Writer;
40use tracing_subscriber::fmt::{FormatFields, MakeWriter};
41use tracing_subscriber::util::SubscriberInitExt;
42use tracing_subscriber::EnvFilter;
43
44use crate::redaction;
45
46/// The environment variable that sets the log filter, e.g. `debug` or `majordomus_cli=trace`.
47pub const LOG_ENV: &str = "MAJORDOMUS_LOG";
48
49/// Install the stderr subscriber once; a second call is a no-op.
50pub fn init() {
51 let filter = EnvFilter::try_from_env(LOG_ENV).unwrap_or_else(|_| EnvFilter::new("info"));
52 let _ = subscriber(filter, std::io::stderr).try_init();
53}
54
55/// The subscriber [`init`] installs, writing to `writer` under `filter`.
56///
57/// It is the one place the log's format is decided: no target, no colour, and every field
58/// redacted. [`init`] hands it stderr; a test hands it a file and reads back what was
59/// written.
60///
61/// ```
62/// use std::sync::{Arc, Mutex};
63/// use majordomus_cli::logging;
64/// use tracing_subscriber::EnvFilter;
65///
66/// let written = Arc::new(Mutex::new(Vec::new()));
67/// let sink = written.clone();
68/// let to_memory = move || Sink(sink.clone());
69/// // assembled at run time, so that no committed file carries a credential's shape
70/// let token = format!("{}{}", "ghp_", "a".repeat(24));
71/// let log = logging::subscriber(EnvFilter::new("info"), to_memory);
72/// tracing::subscriber::with_default(log, || {
73/// tracing::info!(token = token.as_str(), "pushing with {token}");
74/// });
75/// let text = String::from_utf8(written.lock().unwrap().clone()).unwrap();
76/// assert!(!text.contains(&token), "{text}");
77/// assert!(text.contains("pushing with [redacted:github-token]"), "{text}");
78/// assert!(text.contains(r#"token="[redacted:github-token]""#), "{text}");
79///
80/// struct Sink(Arc<Mutex<Vec<u8>>>);
81/// impl std::io::Write for Sink {
82/// fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
83/// self.0.lock().unwrap().extend_from_slice(buf);
84/// Ok(buf.len())
85/// }
86/// fn flush(&mut self) -> std::io::Result<()> {
87/// Ok(())
88/// }
89/// }
90/// ```
91pub fn subscriber<W>(filter: EnvFilter, writer: W) -> impl Subscriber + Send + Sync + 'static
92where
93 W: for<'w> MakeWriter<'w> + Send + Sync + 'static,
94{
95 tracing_subscriber::fmt()
96 .with_env_filter(filter)
97 .fmt_fields(RedactedFields)
98 .with_writer(writer)
99 .with_target(false)
100 .with_ansi(false)
101 .finish()
102}
103
104/// The field formatter of [`subscriber`]: the default `name=value` layout, with every
105/// value's text passed through [`redact`] first.
106struct RedactedFields;
107
108impl<'writer> FormatFields<'writer> for RedactedFields {
109 fn format_fields<R: RecordFields>(&self, writer: Writer<'writer>, fields: R) -> fmt::Result {
110 let mut visitor = Redacting {
111 writer,
112 empty: true,
113 result: Ok(()),
114 };
115 fields.record(&mut visitor);
116 visitor.result
117 }
118}
119
120/// Writes the fields of one event or span, each redacted, separated by a space.
121struct Redacting<'writer> {
122 writer: Writer<'writer>,
123 empty: bool,
124 result: fmt::Result,
125}
126
127impl Redacting<'_> {
128 /// Write one field whose value reads as `text`; `quoted` writes it as a string literal,
129 /// as the default formatter writes a string field, after it is redacted.
130 fn write(&mut self, field: &Field, text: &str, quoted: bool) {
131 if self.result.is_err() {
132 return;
133 }
134 let clean = redact(text);
135 let gap = if self.empty { "" } else { " " };
136 self.empty = false;
137 let name = field.name();
138 self.result = if name == "message" {
139 write!(self.writer, "{gap}{clean}")
140 } else {
141 let name = name.strip_prefix("r#").unwrap_or(name);
142 if quoted {
143 write!(self.writer, "{gap}{name}={clean:?}")
144 } else {
145 write!(self.writer, "{gap}{name}={clean}")
146 }
147 };
148 }
149}
150
151impl Visit for Redacting<'_> {
152 fn record_str(&mut self, field: &Field, value: &str) {
153 self.write(field, value, true);
154 }
155
156 fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
157 self.write(
158 field,
159 &unescaped_where_it_hides(&format!("{value:?}")),
160 false,
161 );
162 }
163}
164
165/// A debugged value's text, with its escaped quotes taken literally where that is what
166/// hides a credential.
167///
168/// `Debug` writes a string inside a value as an escaped literal, so `"password": "…"` reaches
169/// the redactor as `\"password\": \"…` and the assignment rule, which reads the quotes,
170/// never fires. The text is returned as it is unless reading `\"` as `"` lets a shape fire;
171/// then the unescaped text is returned, and `Redacting::write` redacts it.
172fn unescaped_where_it_hides(text: &str) -> String {
173 let loose = text.replace("\\\"", "\"");
174 let markers = |t: &str| redact(t).matches("[redacted:").count();
175 if loose != text && markers(&loose) > markers(text) {
176 loose
177 } else {
178 text.to_string()
179 }
180}
181
182/// The one place a log field's text meets the redactor.
183fn redact(text: &str) -> String {
184 redaction::redact_secrets(text).text
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn installing_the_subscriber_twice_is_not_an_error() {
193 // `main` initialises, and so does anything that runs a command in-process; the
194 // second caller must not fail because the first one won
195 init();
196 init();
197 }
198
199 #[test]
200 fn the_filter_variable_is_the_one_the_documentation_names() {
201 // read by a person from the documentation and by this module from the environment;
202 // if they were two strings they could differ, so they are one
203 assert_eq!(LOG_ENV, "MAJORDOMUS_LOG");
204 }
205
206 #[test]
207 fn an_escaped_quote_does_not_hide_an_assignment() {
208 let value = format!("{}{}", "\"password\": \"", "s".repeat(16));
209 let debugged = format!("{value:?}");
210 let loose = unescaped_where_it_hides(&debugged);
211 assert!(redact(&loose).contains("[redacted:assignment]"), "{loose}");
212 // text the escape hides nothing in is written as it was debugged
213 assert_eq!(
214 unescaped_where_it_hides(r#""a \"quoted\" word""#),
215 r#""a \"quoted\" word""#
216 );
217 }
218
219 #[test]
220 fn a_field_s_text_is_redacted_and_other_text_is_kept() {
221 let key = format!("{}{}", "sk-ant-", "q".repeat(24));
222 assert_eq!(
223 redact(&format!("with {key}")),
224 "with [redacted:anthropic-key]"
225 );
226 assert_eq!(redact("GET /api/v1/health"), "GET /api/v1/health");
227 }
228
229 /// A writer that refuses every write, counting how often it was asked.
230 struct Refusing(usize);
231
232 impl fmt::Write for Refusing {
233 fn write_str(&mut self, _: &str) -> fmt::Result {
234 self.0 += 1;
235 Err(fmt::Error)
236 }
237 }
238
239 /// A subscriber that formats each event's fields into a [`Refusing`] writer and keeps
240 /// the result and how many writes were attempted. A scoped default, so it never meets
241 /// the process-wide subscriber another test installs.
242 struct FormatsIntoARefusal(std::sync::Mutex<Option<(fmt::Result, usize)>>);
243
244 impl Subscriber for FormatsIntoARefusal {
245 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
246 true
247 }
248 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
249 tracing::span::Id::from_u64(1)
250 }
251 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
252 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
253 fn event(&self, event: &tracing::Event<'_>) {
254 let mut refusing = Refusing(0);
255 let result = RedactedFields.format_fields(Writer::new(&mut refusing), event);
256 *self.0.lock().unwrap() = Some((result, refusing.0));
257 }
258 fn enter(&self, _: &tracing::span::Id) {}
259 fn exit(&self, _: &tracing::span::Id) {}
260 }
261
262 /// Once a write has failed, the fields after it are not written at all: the failure is
263 /// what the formatter returns, and the writer is asked nothing more.
264 #[test]
265 fn a_failed_write_ends_the_fields_that_follow_it() {
266 let seen = std::sync::Arc::new(FormatsIntoARefusal(std::sync::Mutex::new(None)));
267 tracing::subscriber::with_default(std::sync::Arc::clone(&seen), || {
268 tracing::error!(first = "a", second = "b", third = 3, "a message");
269 });
270 let (result, attempts) = seen
271 .0
272 .lock()
273 .unwrap()
274 .take()
275 .expect("the event was formatted");
276 assert!(result.is_err());
277 assert_eq!(attempts, 1, "only the first field was written");
278 }
279}