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 12-hex `subject_hash` pseudonym — a keyed HMAC when
5//! a runtime installed [`set_log_pseudonym_key`], and a fixed placeholder
6//! when it has not: never a bare digest, even misconfigured (issue #135).
7//!
8//! Field **names** are only half the story (issue #135): a secret riding
9//! inside a generic `uri`, `message` or `error` value is invisible to a
10//! name rule. Every value that is not redacted by name therefore also
11//! passes [`scrub_text`], which rewrites emails, signed tokens, URL query
12//! strings, URL credentials and `Bearer` values wherever they appear.
13//!
14//! The tracing **formatter** lives in each runtime; the redaction rules
15//! live here so Workers and native logs cannot drift apart.
16
17use hmac::{Hmac, KeyInit, Mac};
18use sha2::Sha256;
19use std::collections::BTreeMap;
20use std::sync::OnceLock;
21use tracing::field::{Field, Visit};
22
23/// A process-wide key that turns a logged email pseudonym into a keyed HMAC
24/// rather than a bare hash. See [`set_log_pseudonym_key`].
25static LOG_PSEUDONYM_KEY: OnceLock<Vec<u8>> = OnceLock::new();
26
27/// Domain-separates the pseudonym HMAC from the signer's use of the same
28/// secret, and versions it so the scheme can change without silently
29/// colliding.
30const PSEUDONYM_DOMAIN: &[u8] = b"cratefield/log-pseudonym/v1\x00";
31
32/// The value [`subject_hash`] emits when no pseudonym key is installed: a
33/// fixed placeholder, never a function of the input (issue #135, fail-closed).
34/// The unkeyed path is reachable in production — a runtime that finds
35/// `HARNESS_SECRET` missing or too short only warns and keeps serving, so a
36/// misconfigured deployment must still not emit a truncated bare SHA-256
37/// (deterministic, unkeyed, dictionary-reversible for low-entropy emails).
38/// All-zero hex is obviously-not-a-pseudonym in a log grep, lies outside the
39/// range any real HMAC prefix occupies, and keeps the `subject_hash:<12 hex>`
40/// output shape that [`scrub_text`]'s idempotence and the runtime formatters
41/// depend on, so nothing downstream changes.
42const UNKEYED_PSEUDONYM: &str = "000000000000";
43
44/// Installs the key that [`subject_hash`] uses to pseudonymise email values in
45/// logs (issue #135). A bare SHA-256 of a low-entropy email is
46/// dictionary-reversible, so a runtime derives this from `HARNESS_SECRET` and
47/// installs it once at startup; `subject_hash` then emits `HMAC(key, email)`
48/// instead. Unset (tests, or a runtime whose `HARNESS_SECRET` failed
49/// validation) makes `subject_hash` emit a fixed all-zero `000000000000`
50/// placeholder — fail-closed; no digest of the address ever reaches a sink.
51/// Boot-time infrastructure, not request state (ADR 0007); first-install-wins.
52pub fn set_log_pseudonym_key(key: &[u8]) {
53    let _ = LOG_PSEUDONYM_KEY.set(key.to_vec());
54}
55
56/// Whether a field name marks a secret: matches
57/// `(?i)secret|token|key|authorization|password`.
58#[must_use]
59pub fn is_secret_field(name: &str) -> bool {
60    let lowered = name.to_ascii_lowercase();
61    ["secret", "token", "key", "authorization", "password"]
62        .iter()
63        .any(|needle| lowered.contains(needle))
64}
65
66/// Whether a field is expected to carry an email address.
67#[must_use]
68pub fn is_email_field(name: &str) -> bool {
69    let lowered = name.to_ascii_lowercase();
70    lowered.contains("email") || lowered == "subject"
71}
72
73/// The redacted form of an email-ish value: a 12-hex pseudonym, no `@` ever
74/// reaching the logs. When a [pseudonym key](set_log_pseudonym_key) is
75/// installed it is `HMAC-SHA256(key, domain ‖ value)` (a keyed pseudonym,
76/// resistant to dictionary reversal); when it is not, a fixed all-zero
77/// placeholder — correlation is lost, but a bare digest never leaks
78/// (issue #135, fail-closed).
79#[must_use]
80pub fn subject_hash(value: &str) -> String {
81    pseudonym_hex(LOG_PSEUDONYM_KEY.get().map(Vec::as_slice), value)
82}
83
84/// The pseudonym decision of [`subject_hash`], pure in `key` so both arms
85/// are testable without touching the process-wide `OnceLock` (which is
86/// first-install-wins and already set by `tests/log_pseudonym.rs`).
87fn pseudonym_hex(key: Option<&[u8]>, value: &str) -> String {
88    use std::fmt::Write as _;
89    // The redaction path never panics: HMAC accepts any key length, and
90    // the `.ok()` arm covers the impossible error without unwrapping it —
91    // a key that fails to install is treated as no key at all (fail-closed).
92    let digest = key.and_then(|secret| {
93        Hmac::<Sha256>::new_from_slice(secret).ok().map(|mut mac| {
94            mac.update(PSEUDONYM_DOMAIN);
95            mac.update(value.as_bytes());
96            mac.finalize().into_bytes()
97        })
98    });
99    let Some(digest) = digest else {
100        return UNKEYED_PSEUDONYM.to_owned();
101    };
102    let mut hex = String::with_capacity(12);
103    for byte in digest.iter().take(6) {
104        let _ = write!(hex, "{byte:02x}");
105    }
106    hex
107}
108
109/// How one recorded field is stored: `[redacted]` for secrets, the hash
110/// for emails, the value passed through [`scrub_text`] otherwise (issue
111/// #135: a secret inside a generic `uri`/`message`/`error` value is
112/// invisible to the field-name rules alone).
113#[must_use]
114pub fn redacted_value(name: &str, value: &str) -> String {
115    if is_secret_field(name) {
116        "[redacted]".to_owned()
117    } else if is_email_field(name) && value.contains('@') {
118        format!("subject_hash:{}", subject_hash(value))
119    } else {
120        scrub_text(value)
121    }
122}
123
124// ---------------------------------------------------------------------------
125// Value-level scrubbing (issue #135)
126
127/// The marker [`scrub_text`] leaves in place of redacted material.
128const REDACTED: &str = "[redacted]";
129
130/// Bytes that may appear inside a URL run in log text. Whitespace and the
131/// delimiters a URL is typically wrapped in (`"`, `'`, `(`, `<`, …) end
132/// the run, so a URL embedded in prose or `{:?}` output is matched whole.
133/// `[` and `]` stay inside the run so a re-scrub of an already-redacted
134/// `?[redacted]` query is a no-op: the scrubber is idempotent.
135fn url_body_byte(b: u8) -> bool {
136    !matches!(
137        b,
138        b' ' | b'\t'
139            | b'\n'
140            | b'\r'
141            | b'"'
142            | b'\''
143            | b'<'
144            | b'>'
145            | b'`'
146            | b','
147            | b';'
148            | b')'
149            | b'}'
150            | b'\\'
151            | b'|'
152    )
153}
154
155fn base64url_byte(b: u8) -> bool {
156    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
157}
158
159fn email_local_byte(b: u8) -> bool {
160    b.is_ascii_alphanumeric()
161        || matches!(
162            b,
163            b'.' | b'!'
164                | b'#'
165                | b'$'
166                | b'%'
167                | b'&'
168                | b'\''
169                | b'*'
170                | b'+'
171                | b'-'
172                | b'/'
173                | b'='
174                | b'?'
175                | b'^'
176                | b'_'
177                | b'`'
178                | b'{'
179                | b'|'
180                | b'}'
181                | b'~'
182        )
183}
184
185fn email_domain_byte(b: u8) -> bool {
186    b.is_ascii_alphanumeric() || b == b'.' || b == b'-'
187}
188
189/// Scrubs secrets out of a free-text **value** (issue #135).
190///
191/// Field-name redaction ([`redacted_value`]) cannot see a secret riding
192/// inside a generic field — an `error` carrying a driver's `DETAIL:` line
193/// that quotes a row, a `message` carrying a confirmation URL, a `uri`
194/// carrying `?token=…`. Every value that is not redacted by name goes
195/// through here:
196///
197/// - an email address becomes `[subject_hash:<pseudonym>]` — the same
198///   keyed pseudonym the email fields use, so correlation survives;
199/// - a signed token or JWT (`eyJ…`, dot-separated base64url runs) becomes
200///   `[redacted]` — the signer's payload is base64url JSON, which always
201///   starts with `eyJ`;
202/// - the query of a URL (`scheme://…?…`) or of a path-shaped value
203///   (`/v1/…?…`) becomes `?[redacted]` — confirmation, unsubscribe and
204///   status tokens ride in the query;
205/// - URL userinfo (`scheme://user:pass@host`) becomes
206///   `scheme://[redacted]@host` — a connect failure must not disclose
207///   database credentials;
208/// - `Bearer <credential>` becomes `Bearer [redacted]`.
209///
210/// The rules are heuristics tuned for log values and deliberately
211/// over-redact: losing a query string costs debugging convenience,
212/// leaking a token or an address costs users. Idempotent — scrubbing
213/// already-scrubbed text changes nothing. Never panics.
214#[must_use]
215pub fn scrub_text(value: &str) -> String {
216    let urls = scrub_urls(value);
217    let paths = scrub_path_queries(&urls);
218    let tokens = scrub_dotted_tokens(&paths);
219    let bearer = scrub_bearer(&tokens);
220    scrub_emails(&bearer)
221}
222
223/// Cuts one **known** request URL back to its origin everywhere it appears
224/// in `message`, and redacts that request's bare target as well (issues
225/// #228, #229).
226///
227/// [`scrub_text`] cannot express this, and no general rule could. It
228/// redacts what is recognisably a secret — a query string, userinfo, a
229/// signed token, an address — and a URL *path* is none of those. On this
230/// harness some paths are the entire credential:
231/// `https://api.push.apple.com/3/device/<device token>` addresses a device
232/// *by* its token, and a Web Push endpoint's path is a bearer capability —
233/// whoever holds it can push to that browser indefinitely. Only the caller
234/// knows which URL it just sent to, so only the caller can say "this path
235/// is a secret"; this function is that knowledge applied to a message.
236///
237/// What survives is the origin — scheme, host and port, never userinfo —
238/// which is what keeps a transport failure diagnosable at all ("could not
239/// reach api.push.apple.com" answers most of them) and is the line `fz push
240/// inspect-subscription` already draws when it prints the `aud` and
241/// withholds the path.
242///
243/// It is a pass, not a replacement: an error message is free to quote
244/// things this call knows nothing about, so callers run [`scrub_text`] over
245/// the result too — this pass knows one URL, that one knows every shape of
246/// secret.
247///
248/// A `url` that is not absolute (`scheme://host…`) leaves `message`
249/// untouched, so an empty or unparsed value can never become a degenerate
250/// replacement. Never panics.
251///
252/// ```
253/// # use cratefield_core::scrub_request_url;
254/// let url = "https://api.push.apple.com/3/device/DEVICETOKEN";
255/// let safe = scrub_request_url(&format!("Fetch API cannot load: {url}."), url);
256/// assert_eq!(safe, "Fetch API cannot load: https://api.push.apple.com.");
257/// ```
258#[must_use]
259pub fn scrub_request_url(message: &str, url: &str) -> String {
260    let Some((origin, target)) = origin_and_target(url) else {
261        return message.to_owned();
262    };
263    let reduced = message.replace(url, &origin);
264    // `/` alone carries nothing and is in half the prose there is.
265    if target.len() > 1 {
266        reduced.replace(target, REDACTED)
267    } else {
268        reduced
269    }
270}
271
272/// Splits an absolute URL into the origin that may be logged and the
273/// request target that may not. `None` when `url` is not absolute, which
274/// is what makes [`scrub_request_url`] a no-op rather than a hazard.
275fn origin_and_target(url: &str) -> Option<(String, &str)> {
276    let colon = url.find("://")?;
277    let scheme = &url[..colon];
278    if scheme.is_empty()
279        || !scheme
280            .bytes()
281            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
282    {
283        return None;
284    }
285    let rest = &url[colon + 3..];
286    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
287    let (authority, target) = rest.split_at(end);
288    // Userinfo is a credential in its own right, so it never reaches the
289    // origin: `scheme://user:pass@host` is logged as `scheme://host`.
290    let host = authority.rsplit('@').next().unwrap_or(authority);
291    if host.is_empty() {
292        return None;
293    }
294    Some((format!("{scheme}://{host}"), target))
295}
296
297/// The URL pass of [`scrub_text`]: query and userinfo redaction for every
298/// absolute URL (`scheme://…`) in the value.
299fn scrub_urls(value: &str) -> String {
300    let bytes = value.as_bytes();
301    let mut out = String::with_capacity(value.len());
302    let mut processed = 0;
303    let mut search = 0;
304    while let Some(relative) = value[search..].find("://") {
305        let colon = search + relative;
306        let mut scheme_start = colon;
307        while scheme_start > processed {
308            let b = bytes[scheme_start - 1];
309            if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.') {
310                scheme_start -= 1;
311            } else {
312                break;
313            }
314        }
315        if scheme_start == colon {
316            // "://" with no scheme in front of it is not a URL.
317            search = colon + 3;
318            continue;
319        }
320        let mut end = colon + 3;
321        while end < bytes.len() && url_body_byte(bytes[end]) {
322            end += 1;
323        }
324        out.push_str(&value[processed..scheme_start]);
325        out.push_str(&scrub_one_url(&value[scheme_start..end]));
326        processed = end;
327        search = end;
328    }
329    out.push_str(&value[processed..]);
330    out
331}
332
333fn scrub_one_url(url: &str) -> String {
334    let Some((scheme, rest)) = url.split_once("://") else {
335        return url.to_owned();
336    };
337    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
338    let (authority, tail) = rest.split_at(authority_end);
339    let mut out = String::with_capacity(url.len());
340    out.push_str(scheme);
341    out.push_str("://");
342    match authority.rfind('@') {
343        Some(at) => {
344            out.push_str(REDACTED);
345            out.push_str(&authority[at..]);
346        }
347        None => out.push_str(authority),
348    }
349    match tail.find('?') {
350        Some(query) => {
351            out.push_str(&tail[..query]);
352            out.push('?');
353            out.push_str(REDACTED);
354        }
355        None => out.push_str(tail),
356    }
357    out
358}
359
360/// The path pass of [`scrub_text`]: a whitespace-delimited word that is
361/// itself a path — a `uri` field, a redirect target inside a message —
362/// loses its query: "/v1/waitlist/status?token=…".
363fn scrub_path_queries(value: &str) -> String {
364    let mut out = String::with_capacity(value.len());
365    let mut rest = value;
366    while !rest.is_empty() {
367        let lead = rest
368            .find(|c: char| !c.is_ascii_whitespace())
369            .unwrap_or(rest.len());
370        out.push_str(&rest[..lead]);
371        let word_end = rest[lead..]
372            .find(|c: char| c.is_ascii_whitespace())
373            .map_or(rest.len(), |offset| lead + offset);
374        let word = &rest[lead..word_end];
375        match word.strip_prefix('/') {
376            Some(_) => match word.find('?') {
377                Some(query) => {
378                    out.push_str(&word[..query]);
379                    out.push('?');
380                    out.push_str(REDACTED);
381                }
382                None => out.push_str(word),
383            },
384            None => out.push_str(word),
385        }
386        rest = &rest[word_end..];
387    }
388    out
389}
390
391/// The signed-token pass of [`scrub_text`]: a run of dot-separated
392/// base64url segments, each at least 8 chars, starting with `eyJ`.
393fn scrub_dotted_tokens(value: &str) -> String {
394    let bytes = value.as_bytes();
395    let mut out = String::with_capacity(value.len());
396    let mut processed = 0;
397    let mut search = 0;
398    while let Some(relative) = value[search..].find("eyJ") {
399        let start = search + relative;
400        if start > processed && (base64url_byte(bytes[start - 1]) || bytes[start - 1] == b'.') {
401            // A mid-run hit inside a longer token or word, not its start.
402            search = start + 3;
403            continue;
404        }
405        let mut end = start;
406        let mut segments = 0;
407        let mut all_segments_long = true;
408        loop {
409            let segment_start = end;
410            while end < bytes.len() && base64url_byte(bytes[end]) {
411                end += 1;
412            }
413            segments += 1;
414            if end - segment_start < 8 {
415                all_segments_long = false;
416            }
417            if end + 1 < bytes.len() && bytes[end] == b'.' && base64url_byte(bytes[end + 1]) {
418                end += 1;
419                continue;
420            }
421            break;
422        }
423        if segments >= 2 && all_segments_long {
424            out.push_str(&value[processed..start]);
425            out.push_str(REDACTED);
426            processed = end;
427        }
428        search = end.max(start + 3);
429    }
430    out.push_str(&value[processed..]);
431    out
432}
433
434/// The `Bearer`-credential pass of [`scrub_text`].
435fn scrub_bearer(value: &str) -> String {
436    const LABEL: &str = "Bearer ";
437    let bytes = value.as_bytes();
438    let mut out = String::with_capacity(value.len());
439    let mut processed = 0;
440    let mut search = 0;
441    while let Some(relative) = value[search..].find(LABEL) {
442        let label_end = search + relative + LABEL.len();
443        let mut end = label_end;
444        while end < bytes.len()
445            && (base64url_byte(bytes[end])
446                || matches!(bytes[end], b'.' | b'+' | b'/' | b'=' | b'~'))
447        {
448            end += 1;
449        }
450        if end - label_end >= 8 {
451            out.push_str(&value[processed..label_end]);
452            out.push_str(REDACTED);
453            processed = end;
454        }
455        search = label_end.max(processed);
456    }
457    out.push_str(&value[processed..]);
458    out
459}
460
461/// The email pass of [`scrub_text`]: RFC-ish local part, dotted domain
462/// whose last label is alphabetic — conservative enough that dependency
463/// specs (`crate@1.0.0`) and versions survive, strict enough that every
464/// address the harness itself accepts ([`crate::email::is_valid`]) is
465/// caught.
466fn scrub_emails(value: &str) -> String {
467    let bytes = value.as_bytes();
468    let mut out = String::with_capacity(value.len());
469    let mut processed = 0;
470    let mut search = 0;
471    while let Some(relative) = value[search..].find('@') {
472        let at = search + relative;
473        let mut start = at;
474        while start > processed && email_local_byte(bytes[start - 1]) {
475            start -= 1;
476        }
477        let mut end = at + 1;
478        while end < bytes.len() && email_domain_byte(bytes[end]) {
479            end += 1;
480        }
481        let local = &value[start..at];
482        let domain = &value[at + 1..end];
483        if email_shape_ok(local, domain) {
484            out.push_str(&value[processed..start]);
485            out.push_str("[subject_hash:");
486            out.push_str(&subject_hash(&value[start..end]));
487            out.push(']');
488            processed = end;
489        }
490        search = (at + 1).max(processed);
491    }
492    out.push_str(&value[processed..]);
493    out
494}
495
496fn email_shape_ok(local: &str, domain: &str) -> bool {
497    if local.is_empty() || local.len() > crate::email::MAX_LOCAL_BYTES {
498        return false;
499    }
500    if local.starts_with('.') || local.ends_with('.') {
501        return false;
502    }
503    if domain.is_empty() || !domain.contains('.') {
504        return false;
505    }
506    if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
507        return false;
508    }
509    if domain.len() > crate::email::MAX_EMAIL_BYTES {
510        return false;
511    }
512    domain
513        .split('.')
514        .all(|label| !label.is_empty() && !label.starts_with('-') && !label.ends_with('-'))
515        && domain
516            .rsplit('.')
517            .next()
518            .is_some_and(|tld| tld.len() >= 2 && tld.bytes().all(|b| b.is_ascii_alphabetic()))
519}
520
521/// A `tracing` field visitor that records `(name, redacted value)` pairs
522/// into a map. Runtimes use it in their formatters; tests use it to
523/// prove the redaction rules.
524#[derive(Debug, Default)]
525pub struct RedactingVisitor {
526    pub fields: BTreeMap<String, String>,
527}
528
529impl RedactingVisitor {
530    #[must_use]
531    pub fn new() -> Self {
532        Self::default()
533    }
534
535    fn record(&mut self, name: &str, value: &str) {
536        self.fields
537            .insert(name.to_owned(), redacted_value(name, value));
538    }
539}
540
541impl Visit for RedactingVisitor {
542    fn record_str(&mut self, field: &Field, value: &str) {
543        self.record(field.name(), value);
544    }
545
546    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
547        self.record(field.name(), &format!("{value:?}"));
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    /// The 12-hex truncation of `bytes`, same as `pseudonym_hex` does.
556    fn hex_prefix(bytes: &[u8]) -> String {
557        use std::fmt::Write as _;
558        let mut hex = String::with_capacity(12);
559        for byte in bytes.iter().take(6) {
560            let _ = write!(hex, "{byte:02x}");
561        }
562        hex
563    }
564
565    #[test]
566    fn secret_names_are_detected_case_insensitively() {
567        for name in [
568            "authorization",
569            "Authorization",
570            "api_token",
571            "captchaToken",
572            "HARNESS_SECRET",
573            "kid_key",
574            "password",
575        ] {
576            assert!(is_secret_field(name), "{name}");
577        }
578        assert!(!is_secret_field("outcome"));
579        // Deliberately over-broad: `idempotency_key` matches too, so the
580        // mailer-outcome logs name the field `idempotency` (issue #14).
581        assert!(is_secret_field("idempotency_key"));
582    }
583
584    #[test]
585    fn subject_hash_without_a_key_is_the_fixed_placeholder() {
586        // This binary never installs the process-wide `LOG_PSEUDONYM_KEY`,
587        // so the public entry point exercises the fail-closed arm: the
588        // output shape survives, the input sensitivity does not.
589        let hash = subject_hash("nick@example.com");
590        assert_eq!(hash, UNKEYED_PSEUDONYM, "no key, no digest");
591        assert_eq!(hash.len(), 12);
592        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
593        assert!(!hash.contains('@'));
594        assert_eq!(
595            hash,
596            subject_hash("nick2@example.com"),
597            "unkeyed output must not vary with the address"
598        );
599    }
600
601    #[test]
602    fn pseudonym_hex_without_a_key_is_constant_and_not_a_bare_digest() {
603        // The fail-open regression (issue #135): with no key the old code
604        // emitted the first 12 hex of a bare SHA-256 — deterministic and
605        // dictionary-attackable. Every unkeyed pseudonym must be the fixed
606        // placeholder instead, and never a digest prefix of its input.
607        use sha2::Digest as _;
608        for value in ["a@x.com", "b@x.com", "nick@example.com", ""] {
609            assert_eq!(pseudonym_hex(None, value), UNKEYED_PSEUDONYM, "{value}");
610            let digest: [u8; 32] = Sha256::digest(value.as_bytes()).into();
611            assert_ne!(
612                hex_prefix(&digest),
613                pseudonym_hex(None, value),
614                "the placeholder must not be a SHA-256 prefix of {value}"
615            );
616        }
617        assert_eq!(
618            pseudonym_hex(None, "a@x.com"),
619            pseudonym_hex(None, "b@x.com"),
620            "input-independent"
621        );
622    }
623
624    #[test]
625    fn pseudonym_hex_with_a_key_is_the_input_sensitive_hmac_form() {
626        let key: &[u8] = b"harness-secret-long-enough-for-this-test-0123456789";
627        let a = pseudonym_hex(Some(key), "a@x.com");
628        assert_eq!(a.len(), 12, "keyed pseudonym keeps the shape: {a}");
629        assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
630        assert_ne!(a, UNKEYED_PSEUDONYM, "keyed output is not the placeholder");
631        assert_eq!(
632            a,
633            pseudonym_hex(Some(key), "a@x.com"),
634            "stable under one key"
635        );
636        assert_ne!(
637            a,
638            pseudonym_hex(Some(key), "b@x.com"),
639            "keyed pseudonym stays input-sensitive"
640        );
641        // Byte-for-byte the same scheme as before the fail-closed change:
642        // HMAC-SHA256(key, PSEUDONYM_DOMAIN ‖ value), first 6 bytes as hex.
643        let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
644        mac.update(PSEUDONYM_DOMAIN);
645        mac.update(b"a@x.com");
646        assert_eq!(a, hex_prefix(&mac.finalize().into_bytes()));
647    }
648
649    #[test]
650    fn visitor_rules_match_redacted_value() {
651        for (name, value) in [
652            ("authorization", "Bearer super-secret-token"),
653            ("token", "captcha-value"),
654            ("email", "nick@example.com"),
655            ("subject", "nick@example.com"),
656            ("outcome", "sent"),
657        ] {
658            let stored = redacted_value(name, value);
659            if is_secret_field(name) {
660                assert_eq!(stored, "[redacted]", "{name}");
661            } else if is_email_field(name) && value.contains('@') {
662                assert!(stored.starts_with("subject_hash:"), "{name}: {stored}");
663                assert!(!stored.contains('@'));
664            } else {
665                assert_eq!(stored, value, "{name}");
666            }
667        }
668    }
669
670    #[test]
671    fn non_email_values_in_email_fields_pass_through() {
672        assert_eq!(
673            redacted_value("email_domain", "factory0.ventures"),
674            "factory0.ventures"
675        );
676    }
677
678    /// The leak the field-name rules miss (issue #135): a Postgres unique
679    /// violation quotes the offending row — an email — in a generic
680    /// `error` field, and the raw driver text must never reach the logs.
681    #[test]
682    fn scrub_text_pseudonymises_emails_inside_generic_values() {
683        let driver = "duplicate key value violates unique constraint \
684             \"subscribers_email_normalized_key\"\n\
685             DETAIL:  Key (email_normalized)=(nick@example.com) already exists.";
686        let scrubbed = scrub_text(driver);
687        assert!(!scrubbed.contains('@'), "{scrubbed}");
688        assert!(!scrubbed.contains("nick"), "{scrubbed}");
689        assert!(scrubbed.contains("[subject_hash:"), "{scrubbed}");
690        assert!(
691            scrubbed.contains(&subject_hash("nick@example.com")),
692            "the pseudonym is the same one the email fields use: {scrubbed}"
693        );
694        assert!(
695            scrubbed.contains("duplicate key value violates unique constraint"),
696            "the diagnostic itself survives: {scrubbed}"
697        );
698
699        assert_eq!(
700            redacted_value("message", "mailer rejected bob@example.com"),
701            format!(
702                "mailer rejected [subject_hash:{}]",
703                subject_hash("bob@example.com")
704            )
705        );
706    }
707
708    #[test]
709    fn scrub_text_drops_query_strings_from_urls_and_paths() {
710        let message = "GET https://api.factory0.ventures/v1/email-signup/confirm?token=abc \
711             redirected to /v1/waitlist/status?token=def";
712        let scrubbed = scrub_text(message);
713        assert!(!scrubbed.contains("token=abc"), "{scrubbed}");
714        assert!(!scrubbed.contains("token=def"), "{scrubbed}");
715        assert!(
716            scrubbed.contains("https://api.factory0.ventures/v1/email-signup/confirm?[redacted]"),
717            "{scrubbed}"
718        );
719        assert!(
720            scrubbed.contains("redirected to /v1/waitlist/status?[redacted]"),
721            "{scrubbed}"
722        );
723
724        let uri = scrub_text("/ui/waitlist/status?token=01J.secret");
725        assert_eq!(uri, "/ui/waitlist/status?[redacted]");
726
727        assert_eq!(
728            scrub_text("/v1/email-signup/confirm"),
729            "/v1/email-signup/confirm",
730            "a path without a query is untouched"
731        );
732    }
733
734    #[test]
735    fn scrub_text_redacts_url_credentials() {
736        let connect = "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app";
737        let scrubbed = scrub_text(connect);
738        assert!(!scrubbed.contains("sup3r-s3cret"), "{scrubbed}");
739        assert!(!scrubbed.contains("venture:"), "{scrubbed}");
740        assert!(
741            scrubbed.contains("postgres://[redacted]@db.internal:5432/app"),
742            "{scrubbed}"
743        );
744    }
745
746    #[test]
747    fn scrub_text_redacts_signed_tokens_wherever_they_appear() {
748        use crate::ports::signer::{Kid, Payload, Signer};
749        use crate::signer::HmacSigner;
750
751        let signer = HmacSigner::new("0".repeat(crate::signer::MIN_SECRET_BYTES), None)
752            .expect("test secret is long enough");
753        let token = signer.sign(&Payload {
754            purpose: "email-signup.confirm".to_owned(),
755            subject: "01J0000000000000000000000A".to_owned(),
756            exp: None,
757            kid: Kid::Cur,
758        });
759        assert!(token.starts_with("eyJ"), "test premise: {token}");
760
761        let scrubbed = scrub_text(&format!("confirm token {token} was rejected"));
762        assert!(!scrubbed.contains(&token), "{scrubbed}");
763        assert!(!scrubbed.contains("eyJ"), "{scrubbed}");
764        assert!(scrubbed.contains(REDACTED), "{scrubbed}");
765        assert!(
766            scrubbed.contains("confirm token") && scrubbed.contains("was rejected"),
767            "surrounding words survive: {scrubbed}"
768        );
769
770        // Inside a URL the query rule already dropped it; no `eyJ` survives
771        // either way.
772        let in_url = scrub_text(&format!("https://x.example/confirm?token={token}"));
773        assert!(!in_url.contains("eyJ"), "{in_url}");
774
775        // A JWT-shaped three-segment token is caught too.
776        let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMUoifQ.c2lnbmF0dXJlLXJ1bg";
777        assert!(!scrub_text(jwt).contains("eyJ"));
778
779        // Short dotted runs that merely start with eyJ are left alone.
780        assert_eq!(scrub_text("eyJhYi.短"), "eyJhYi.短");
781    }
782
783    #[test]
784    fn scrub_text_redacts_bearer_credentials() {
785        let scrubbed = scrub_text("request carried Authorization: Bearer 01Jsupersecretvalue");
786        assert!(!scrubbed.contains("01Jsupersecretvalue"), "{scrubbed}");
787        assert!(scrubbed.contains("Bearer [redacted]"), "{scrubbed}");
788    }
789
790    #[test]
791    fn scrub_text_leaves_ordinary_log_values_alone() {
792        for value in [
793            "sent",
794            "not_configured",
795            "01J8Z4QWERTYUIOPASDFGH",
796            "/v1/email-signup/confirm",
797            "factory0.ventures",
798            "cratefield-core@0.1.0",
799            "database did not answer within 2 s",
800            "[redacted]",
801        ] {
802            assert_eq!(scrub_text(value), value, "{value}");
803        }
804    }
805
806    #[test]
807    fn scrub_text_is_idempotent() {
808        let nasty = "failed for nick@example.com with Bearer abcdefgh12345 at \
809             https://x.example/v1/confirm?token=eyJ and postgres://u:p@h/db";
810        let once = scrub_text(nasty);
811        assert_eq!(scrub_text(&once), once, "{once}");
812    }
813
814    /// The disclosure [`scrub_request_url`] exists for, and the one
815    /// [`scrub_text`] provably cannot stop on its own: APNs addresses a
816    /// device by *path*, and a path is not a shape any general rule can
817    /// call a secret.
818    #[test]
819    fn scrub_request_url_removes_a_credential_path_scrub_text_cannot_see() {
820        const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f9";
821        let url = &format!("https://api.push.apple.com/3/device/{TOKEN}");
822
823        assert!(
824            scrub_text(url).contains(TOKEN),
825            "if scrub_text ever learns to do this, this pass can go"
826        );
827
828        // Both shapes a layer below is free to quote: the whole URL, and
829        // the bare request target on its own.
830        let message = format!("Fetch API cannot load: {url}. sending POST /3/device/{TOKEN}");
831        let safe = scrub_request_url(&message, url);
832        assert!(!safe.contains(TOKEN), "{safe}");
833        assert!(!safe.contains("/3/device"), "{safe}");
834        assert!(safe.contains("https://api.push.apple.com"), "{safe}");
835        // Still an error somebody can act on.
836        assert!(safe.contains("Fetch API cannot load"), "{safe}");
837        assert!(safe.contains(REDACTED), "{safe}");
838    }
839
840    #[test]
841    fn scrub_request_url_keeps_the_port_and_never_the_userinfo() {
842        let url = "http://user:pw@127.0.0.1:8787/3/device/tok";
843        let safe = scrub_request_url(&format!("connect failed: {url}"), url);
844        assert_eq!(safe, "connect failed: http://127.0.0.1:8787");
845    }
846
847    #[test]
848    fn scrub_request_url_ignores_anything_that_is_not_an_absolute_url() {
849        // An empty needle would otherwise splice the marker between every
850        // character of the message, and a bare path has no origin to keep.
851        for url in ["", "/3/device/tok", "api.push.apple.com", "://no-scheme/x"] {
852            let message = "the message is not the place to find out";
853            assert_eq!(scrub_request_url(message, url), message, "{url:?}");
854        }
855    }
856
857    #[test]
858    fn scrub_request_url_leaves_a_root_target_alone() {
859        // "/" is in half the prose there is; redacting it would make every
860        // message worse and hide nothing.
861        let safe = scrub_request_url("GET / failed at https://x.example/", "https://x.example/");
862        assert_eq!(safe, "GET / failed at https://x.example");
863    }
864}
865
866// ---------------------------------------------------------------------------
867// Internal-error forwarder (issue #107)
868
869/// A process-wide sink for internal-error diagnostics, installed by the
870/// runtime. See [`set_error_forwarder`].
871type ErrorForwarder = fn(&str);
872
873static ERROR_FORWARDER: OnceLock<ErrorForwarder> = OnceLock::new();
874
875/// Installs a process-wide forwarder for internal-error diagnostics
876/// (architecture section 11).
877///
878/// On `wasm32` a tracing dispatcher cannot be installed — it hangs the
879/// workerd/miniflare isolate — so every `tracing::error!` core emits when it
880/// maps an internal failure to a 500 is dropped, and a Workers 500 becomes a
881/// black box (issue #107). The Cloudflare runtime therefore points this
882/// forwarder at `worker::console_error!`, and core calls it alongside its
883/// `tracing::error!` so the same one-line diagnostic reaches Workers Logs.
884///
885/// Native runs leave it unset and rely on the tracing subscriber. This is
886/// boot-time infrastructure installed before the first response, not request
887/// state (ADR 0007); the first installation wins and later calls are ignored.
888pub fn set_error_forwarder(forwarder: ErrorForwarder) {
889    let _ = ERROR_FORWARDER.set(forwarder);
890}
891
892/// Forwards a one-line internal-error diagnostic to the installed sink, if
893/// any; a no-op when none is installed (native, tests). The line is a
894/// pre-formatted string, so the structured-field rules ([`redacted_value`])
895/// never see it; it passes [`scrub_text`] here instead (issue #135), which
896/// is idempotent for callers that already scrubbed what they formatted in.
897pub(crate) fn forward_internal_error(line: &str) {
898    if let Some(forwarder) = ERROR_FORWARDER.get() {
899        forwarder(&scrub_text(line));
900    }
901}
902
903#[cfg(test)]
904#[allow(clippy::disallowed_types)] // test-only capture of the forwarded line
905mod forwarder_tests {
906    use super::*;
907    use std::sync::Mutex;
908
909    static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
910
911    fn capture(line: &str) {
912        CAPTURED.lock().unwrap().push(line.to_owned());
913    }
914
915    #[test]
916    fn an_installed_forwarder_receives_the_line() {
917        // The forwarder is a process-wide `OnceLock`, so this is the only test
918        // that installs one; `set` after the first is a no-op by contract.
919        set_error_forwarder(capture);
920        forward_internal_error("database error mapped to internal problem: boom");
921        assert!(
922            CAPTURED
923                .lock()
924                .unwrap()
925                .iter()
926                .any(|line| line.contains("boom")),
927            "the installed forwarder should have received the diagnostic"
928        );
929
930        // The forwarded line is a pre-formatted string the structured-field
931        // rules never see, so the forwarder itself scrubs it (issue #135):
932        // a driver DETAIL quoting a row, or a redirect target with a token,
933        // must not reach Workers Logs verbatim.
934        forward_internal_error(
935            "database error: DETAIL: Key (email)=(nick@example.com) already exists",
936        );
937        forward_internal_error("redirect to /v1/waitlist/status?token=eyJhYmNkZWZnaA.mac failed");
938        let captured = CAPTURED.lock().unwrap();
939        let joined = captured.join("\n");
940        assert!(!joined.contains('@'), "email reached the sink: {joined}");
941        assert!(
942            !joined.contains("token=eyJ"),
943            "token reached the sink: {joined}"
944        );
945        assert!(
946            joined.contains("[subject_hash:"),
947            "the pseudonym should survive for correlation: {joined}"
948        );
949    }
950
951    #[test]
952    fn forwarding_without_a_sink_is_a_noop() {
953        // No panic, no output when nothing is installed (native, tests that do
954        // not opt in). This asserts the call is safe regardless of ordering.
955        forward_internal_error("ignored when no sink or captured when set");
956    }
957}