Skip to main content

cratefield_core/
logging.rs

1//! Log-field redaction shared by every runtime (architecture section
2//! 11, issue #13): field names matching `(?i)secret|token|key|
3//! authorization|password` never reach output, and email-ish values are
4//! logged only as a truncated SHA-256 `subject_hash` (12 hex chars).
5//!
6//! The tracing **formatter** lives in each runtime; the redaction rules
7//! live here so Workers and native logs cannot drift apart.
8
9use sha2::{Digest, Sha256};
10use std::collections::BTreeMap;
11use tracing::field::{Field, Visit};
12
13/// Whether a field name marks a secret: matches
14/// `(?i)secret|token|key|authorization|password`.
15#[must_use]
16pub fn is_secret_field(name: &str) -> bool {
17    let lowered = name.to_ascii_lowercase();
18    ["secret", "token", "key", "authorization", "password"]
19        .iter()
20        .any(|needle| lowered.contains(needle))
21}
22
23/// Whether a field is expected to carry an email address.
24#[must_use]
25pub fn is_email_field(name: &str) -> bool {
26    let lowered = name.to_ascii_lowercase();
27    lowered.contains("email") || lowered == "subject"
28}
29
30/// The redacted form of an email-ish value: its SHA-256 digest, 12 hex
31/// characters, no `@` ever reaches the logs.
32#[must_use]
33pub fn subject_hash(value: &str) -> String {
34    use std::fmt::Write as _;
35    let digest = Sha256::digest(value.as_bytes());
36    let mut hex = String::with_capacity(12);
37    for byte in digest.iter().take(6) {
38        let _ = write!(hex, "{byte:02x}");
39    }
40    hex
41}
42
43/// How one recorded field is stored: `[redacted]` for secrets, the hash
44/// for emails, the value otherwise.
45#[must_use]
46pub fn redacted_value(name: &str, value: &str) -> String {
47    if is_secret_field(name) {
48        "[redacted]".to_owned()
49    } else if is_email_field(name) && value.contains('@') {
50        format!("subject_hash:{}", subject_hash(value))
51    } else {
52        value.to_owned()
53    }
54}
55
56/// A `tracing` field visitor that records `(name, redacted value)` pairs
57/// into a map. Runtimes use it in their formatters; tests use it to
58/// prove the redaction rules.
59#[derive(Debug, Default)]
60pub struct RedactingVisitor {
61    pub fields: BTreeMap<String, String>,
62}
63
64impl RedactingVisitor {
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    fn record(&mut self, name: &str, value: &str) {
71        self.fields
72            .insert(name.to_owned(), redacted_value(name, value));
73    }
74}
75
76impl Visit for RedactingVisitor {
77    fn record_str(&mut self, field: &Field, value: &str) {
78        self.record(field.name(), value);
79    }
80
81    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
82        self.record(field.name(), &format!("{value:?}"));
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn secret_names_are_detected_case_insensitively() {
92        for name in [
93            "authorization",
94            "Authorization",
95            "api_token",
96            "captchaToken",
97            "HARNESS_SECRET",
98            "kid_key",
99            "password",
100        ] {
101            assert!(is_secret_field(name), "{name}");
102        }
103        assert!(!is_secret_field("outcome"));
104        // Deliberately over-broad: `idempotency_key` matches too, so the
105        // mailer-outcome logs name the field `idempotency` (issue #14).
106        assert!(is_secret_field("idempotency_key"));
107    }
108
109    #[test]
110    fn subject_hash_is_twelve_hex_without_the_address() {
111        let hash = subject_hash("nick@example.com");
112        assert_eq!(hash.len(), 12);
113        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
114        assert!(!hash.contains('@'));
115        assert_eq!(hash, subject_hash("nick@example.com"), "deterministic");
116        assert_ne!(hash, subject_hash("nick2@example.com"));
117    }
118
119    #[test]
120    fn visitor_rules_match_redacted_value() {
121        for (name, value) in [
122            ("authorization", "Bearer super-secret-token"),
123            ("token", "captcha-value"),
124            ("email", "nick@example.com"),
125            ("subject", "nick@example.com"),
126            ("outcome", "sent"),
127        ] {
128            let stored = redacted_value(name, value);
129            if is_secret_field(name) {
130                assert_eq!(stored, "[redacted]", "{name}");
131            } else if is_email_field(name) && value.contains('@') {
132                assert!(stored.starts_with("subject_hash:"), "{name}: {stored}");
133                assert!(!stored.contains('@'));
134            } else {
135                assert_eq!(stored, value, "{name}");
136            }
137        }
138    }
139
140    #[test]
141    fn non_email_values_in_email_fields_pass_through() {
142        assert_eq!(
143            redacted_value("email_domain", "factory0.ventures"),
144            "factory0.ventures"
145        );
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Internal-error forwarder (issue #107)
151
152use std::sync::OnceLock;
153
154/// A process-wide sink for internal-error diagnostics, installed by the
155/// runtime. See [`set_error_forwarder`].
156type ErrorForwarder = fn(&str);
157
158static ERROR_FORWARDER: OnceLock<ErrorForwarder> = OnceLock::new();
159
160/// Installs a process-wide forwarder for internal-error diagnostics
161/// (architecture section 11).
162///
163/// On `wasm32` a tracing dispatcher cannot be installed — it hangs the
164/// workerd/miniflare isolate — so every `tracing::error!` core emits when it
165/// maps an internal failure to a 500 is dropped, and a Workers 500 becomes a
166/// black box (issue #107). The Cloudflare runtime therefore points this
167/// forwarder at `worker::console_error!`, and core calls it alongside its
168/// `tracing::error!` so the same one-line diagnostic reaches Workers Logs.
169///
170/// Native runs leave it unset and rely on the tracing subscriber. This is
171/// boot-time infrastructure installed before the first response, not request
172/// state (ADR 0007); the first installation wins and later calls are ignored.
173pub fn set_error_forwarder(forwarder: ErrorForwarder) {
174    let _ = ERROR_FORWARDER.set(forwarder);
175}
176
177/// Forwards a one-line internal-error diagnostic to the installed sink, if
178/// any; a no-op when none is installed (native, tests). Callers pass a message
179/// already safe to log — no raw field values that could carry a secret or an
180/// email (the [`redacted_value`] rules apply to structured `tracing` fields,
181/// not to this pre-formatted line).
182pub(crate) fn forward_internal_error(line: &str) {
183    if let Some(forwarder) = ERROR_FORWARDER.get() {
184        forwarder(line);
185    }
186}
187
188#[cfg(test)]
189#[allow(clippy::disallowed_types)] // test-only capture of the forwarded line
190mod forwarder_tests {
191    use super::*;
192    use std::sync::Mutex;
193
194    static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
195
196    fn capture(line: &str) {
197        CAPTURED.lock().unwrap().push(line.to_owned());
198    }
199
200    #[test]
201    fn an_installed_forwarder_receives_the_line() {
202        // The forwarder is a process-wide `OnceLock`, so this is the only test
203        // that installs one; `set` after the first is a no-op by contract.
204        set_error_forwarder(capture);
205        forward_internal_error("database error mapped to internal problem: boom");
206        assert!(
207            CAPTURED
208                .lock()
209                .unwrap()
210                .iter()
211                .any(|line| line.contains("boom")),
212            "the installed forwarder should have received the diagnostic"
213        );
214    }
215
216    #[test]
217    fn forwarding_without_a_sink_is_a_noop() {
218        // No panic, no output when nothing is installed (native, tests that do
219        // not opt in). This asserts the call is safe regardless of ordering.
220        forward_internal_error("ignored when no sink or captured when set");
221    }
222}