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/// The parameter names whose value is an `AWS SigV4` credential (issue #622):
131/// the signature, credential scope and STS session token of a presigned URL's
132/// query, or the bare `Credential=`/`Signature=` of an
133/// `Authorization: AWS4-HMAC-SHA256 …` header. `X-Amz-Signature=` contains
134/// `Signature=`; the earliest match wins, so the longer name is the one
135/// redacted.
136const SIGV4_SECRET_NAMES: [&str; 5] = [
137    "X-Amz-Signature=",
138    "X-Amz-Credential=",
139    "X-Amz-Security-Token=",
140    "Credential=",
141    "Signature=",
142];
143
144/// Where a `SigV4` parameter's value ends: at the next `&` or `,` (query and
145/// header separators) or any whitespace. `[` is deliberately not one, so
146/// re-scrubbing `X-Amz-Signature=[redacted]` redacts `[redacted]` to itself
147/// and stays idempotent.
148fn sigv4_value_end(c: char) -> bool {
149    c.is_ascii_whitespace() || c == '&' || c == ','
150}
151
152/// Bytes that may appear inside a URL run in log text. Whitespace and the
153/// delimiters a URL is typically wrapped in (`"`, `'`, `(`, `<`, …) end
154/// the run, so a URL embedded in prose or `{:?}` output is matched whole.
155/// `[` and `]` stay inside the run so a re-scrub of an already-redacted
156/// `?[redacted]` query is a no-op: the scrubber is idempotent.
157fn url_body_byte(b: u8) -> bool {
158    !matches!(
159        b,
160        b' ' | b'\t'
161            | b'\n'
162            | b'\r'
163            | b'"'
164            | b'\''
165            | b'<'
166            | b'>'
167            | b'`'
168            | b','
169            | b';'
170            | b')'
171            | b'}'
172            | b'\\'
173            | b'|'
174    )
175}
176
177fn base64url_byte(b: u8) -> bool {
178    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
179}
180
181fn email_local_byte(b: u8) -> bool {
182    b.is_ascii_alphanumeric()
183        || matches!(
184            b,
185            b'.' | b'!'
186                | b'#'
187                | b'$'
188                | b'%'
189                | b'&'
190                | b'\''
191                | b'*'
192                | b'+'
193                | b'-'
194                | b'/'
195                | b'='
196                | b'?'
197                | b'^'
198                | b'_'
199                | b'`'
200                | b'{'
201                | b'|'
202                | b'}'
203                | b'~'
204        )
205}
206
207fn email_domain_byte(b: u8) -> bool {
208    b.is_ascii_alphanumeric() || b == b'.' || b == b'-'
209}
210
211/// Scrubs secrets out of a free-text **value** (issue #135).
212///
213/// Field-name redaction ([`redacted_value`]) cannot see a secret riding
214/// inside a generic field — an `error` carrying a driver's `DETAIL:` line
215/// that quotes a row, a `message` carrying a confirmation URL, a `uri`
216/// carrying `?token=…`. Every value that is not redacted by name goes
217/// through here:
218///
219/// - an email address becomes `[subject_hash:<pseudonym>]` — the same
220///   keyed pseudonym the email fields use, so correlation survives;
221/// - a signed token or JWT (`eyJ…`, dot-separated base64url runs) becomes
222///   `[redacted]` — the signer's payload is base64url JSON, which always
223///   starts with `eyJ`;
224/// - a GitHub token (`ghs_…`, `ghu_…`, `gho_…`, `ghp_…`, `ghr_…` or a
225///   `github_pat_…` fine-grained PAT) becomes `[redacted]` — a leaked
226///   token grants API access for its whole lifetime;
227/// - a PEM private-key block (`-----BEGIN … PRIVATE KEY-----` through
228///   `-----END … PRIVATE KEY-----`) becomes `[redacted]` whole;
229/// - an `AWS SigV4` credential (`X-Amz-Signature=…`, `X-Amz-Credential=…`, or
230///   the `Authorization: AWS4-HMAC-SHA256 Credential=…, Signature=…` header
231///   form) becomes `…=[redacted]` (issue #622);
232/// - the query of a URL (`scheme://…?…`) or of a path-shaped value
233///   (`/v1/…?…`) becomes `?[redacted]` — confirmation, unsubscribe and
234///   status tokens ride in the query;
235/// - URL userinfo (`scheme://user:pass@host`) becomes
236///   `scheme://[redacted]@host` — a connect failure must not disclose
237///   database credentials;
238/// - `Bearer <credential>` becomes `Bearer [redacted]`.
239///
240/// The rules are heuristics tuned for log values and deliberately
241/// over-redact: losing a query string costs debugging convenience,
242/// leaking a token or an address costs users. Idempotent — scrubbing
243/// already-scrubbed text changes nothing. Never panics.
244#[must_use]
245pub fn scrub_text(value: &str) -> String {
246    let pem = scrub_pem_private_keys(value);
247    let urls = scrub_urls(&pem);
248    let paths = scrub_path_queries(&urls);
249    let tokens = scrub_dotted_tokens(&paths);
250    let github = scrub_github_tokens(&tokens);
251    let bearer = scrub_bearer(&github);
252    let sigv4 = scrub_sigv4(&bearer);
253    scrub_emails(&sigv4)
254}
255
256/// Cuts one **known** request URL back to its origin everywhere it appears
257/// in `message`, and redacts that request's bare target as well (issues
258/// #228, #229).
259///
260/// [`scrub_text`] cannot express this, and no general rule could. It
261/// redacts what is recognisably a secret — a query string, userinfo, a
262/// signed token, an address — and a URL *path* is none of those. On this
263/// harness some paths are the entire credential:
264/// `https://api.push.apple.com/3/device/<device token>` addresses a device
265/// *by* its token, and a Web Push endpoint's path is a bearer capability —
266/// whoever holds it can push to that browser indefinitely. Only the caller
267/// knows which URL it just sent to, so only the caller can say "this path
268/// is a secret"; this function is that knowledge applied to a message.
269///
270/// What survives is the origin — scheme, host and port, never userinfo —
271/// which is what keeps a transport failure diagnosable at all ("could not
272/// reach api.push.apple.com" answers most of them) and is the line `fz push
273/// inspect-subscription` already draws when it prints the `aud` and
274/// withholds the path.
275///
276/// It is a pass, not a replacement: an error message is free to quote
277/// things this call knows nothing about, so callers run [`scrub_text`] over
278/// the result too — this pass knows one URL, that one knows every shape of
279/// secret.
280///
281/// A `url` that is not absolute (`scheme://host…`) leaves `message`
282/// untouched, so an empty or unparsed value can never become a degenerate
283/// replacement. Never panics.
284///
285/// ```
286/// # use cratefield_core::scrub_request_url;
287/// let url = "https://api.push.apple.com/3/device/DEVICETOKEN";
288/// let safe = scrub_request_url(&format!("Fetch API cannot load: {url}."), url);
289/// assert_eq!(safe, "Fetch API cannot load: https://api.push.apple.com.");
290/// ```
291#[must_use]
292pub fn scrub_request_url(message: &str, url: &str) -> String {
293    let Some((origin, target)) = origin_and_target(url) else {
294        return message.to_owned();
295    };
296    let reduced = message.replace(url, &origin);
297    // `/` alone carries nothing and is in half the prose there is.
298    if target.len() > 1 {
299        reduced.replace(target, REDACTED)
300    } else {
301        reduced
302    }
303}
304
305/// Splits an absolute URL into the origin that may be logged and the
306/// request target that may not. `None` when `url` is not absolute, which
307/// is what makes [`scrub_request_url`] a no-op rather than a hazard.
308fn origin_and_target(url: &str) -> Option<(String, &str)> {
309    let colon = url.find("://")?;
310    let scheme = &url[..colon];
311    if scheme.is_empty()
312        || !scheme
313            .bytes()
314            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
315    {
316        return None;
317    }
318    let rest = &url[colon + 3..];
319    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
320    let (authority, target) = rest.split_at(end);
321    // Userinfo is a credential in its own right, so it never reaches the
322    // origin: `scheme://user:pass@host` is logged as `scheme://host`.
323    let host = authority.rsplit('@').next().unwrap_or(authority);
324    if host.is_empty() {
325        return None;
326    }
327    Some((format!("{scheme}://{host}"), target))
328}
329
330/// The URL pass of [`scrub_text`]: query and userinfo redaction for every
331/// absolute URL (`scheme://…`) in the value.
332fn scrub_urls(value: &str) -> String {
333    let bytes = value.as_bytes();
334    let mut out = String::with_capacity(value.len());
335    let mut processed = 0;
336    let mut search = 0;
337    while let Some(relative) = value[search..].find("://") {
338        let colon = search + relative;
339        let mut scheme_start = colon;
340        while scheme_start > processed {
341            let b = bytes[scheme_start - 1];
342            if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.') {
343                scheme_start -= 1;
344            } else {
345                break;
346            }
347        }
348        if scheme_start == colon {
349            // "://" with no scheme in front of it is not a URL.
350            search = colon + 3;
351            continue;
352        }
353        let mut end = colon + 3;
354        while end < bytes.len() && url_body_byte(bytes[end]) {
355            end += 1;
356        }
357        out.push_str(&value[processed..scheme_start]);
358        out.push_str(&scrub_one_url(&value[scheme_start..end]));
359        processed = end;
360        search = end;
361    }
362    out.push_str(&value[processed..]);
363    out
364}
365
366fn scrub_one_url(url: &str) -> String {
367    let Some((scheme, rest)) = url.split_once("://") else {
368        return url.to_owned();
369    };
370    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
371    let (authority, tail) = rest.split_at(authority_end);
372    let mut out = String::with_capacity(url.len());
373    out.push_str(scheme);
374    out.push_str("://");
375    match authority.rfind('@') {
376        Some(at) => {
377            out.push_str(REDACTED);
378            out.push_str(&authority[at..]);
379        }
380        None => out.push_str(authority),
381    }
382    match tail.find('?') {
383        Some(query) => {
384            out.push_str(&tail[..query]);
385            out.push('?');
386            out.push_str(REDACTED);
387        }
388        None => out.push_str(tail),
389    }
390    out
391}
392
393/// The path pass of [`scrub_text`]: a whitespace-delimited word that is
394/// itself a path — a `uri` field, a redirect target inside a message —
395/// loses its query: "/v1/waitlist/status?token=…".
396fn scrub_path_queries(value: &str) -> String {
397    let mut out = String::with_capacity(value.len());
398    let mut rest = value;
399    while !rest.is_empty() {
400        let lead = rest
401            .find(|c: char| !c.is_ascii_whitespace())
402            .unwrap_or(rest.len());
403        out.push_str(&rest[..lead]);
404        let word_end = rest[lead..]
405            .find(|c: char| c.is_ascii_whitespace())
406            .map_or(rest.len(), |offset| lead + offset);
407        let word = &rest[lead..word_end];
408        match word.strip_prefix('/') {
409            Some(_) => match word.find('?') {
410                Some(query) => {
411                    out.push_str(&word[..query]);
412                    out.push('?');
413                    out.push_str(REDACTED);
414                }
415                None => out.push_str(word),
416            },
417            None => out.push_str(word),
418        }
419        rest = &rest[word_end..];
420    }
421    out
422}
423
424/// The signed-token pass of [`scrub_text`]: a run of dot-separated
425/// base64url segments, each at least 8 chars, starting with `eyJ`.
426fn scrub_dotted_tokens(value: &str) -> String {
427    let bytes = value.as_bytes();
428    let mut out = String::with_capacity(value.len());
429    let mut processed = 0;
430    let mut search = 0;
431    while let Some(relative) = value[search..].find("eyJ") {
432        let start = search + relative;
433        if start > processed && (base64url_byte(bytes[start - 1]) || bytes[start - 1] == b'.') {
434            // A mid-run hit inside a longer token or word, not its start.
435            search = start + 3;
436            continue;
437        }
438        let mut end = start;
439        let mut segments = 0;
440        let mut all_segments_long = true;
441        loop {
442            let segment_start = end;
443            while end < bytes.len() && base64url_byte(bytes[end]) {
444                end += 1;
445            }
446            segments += 1;
447            if end - segment_start < 8 {
448                all_segments_long = false;
449            }
450            if end + 1 < bytes.len() && bytes[end] == b'.' && base64url_byte(bytes[end + 1]) {
451                end += 1;
452                continue;
453            }
454            break;
455        }
456        if segments >= 2 && all_segments_long {
457            out.push_str(&value[processed..start]);
458            out.push_str(REDACTED);
459            processed = end;
460        }
461        search = end.max(start + 3);
462    }
463    out.push_str(&value[processed..]);
464    out
465}
466
467/// The `Bearer`-credential pass of [`scrub_text`].
468fn scrub_bearer(value: &str) -> String {
469    const LABEL: &str = "Bearer ";
470    let bytes = value.as_bytes();
471    let mut out = String::with_capacity(value.len());
472    let mut processed = 0;
473    let mut search = 0;
474    while let Some(relative) = value[search..].find(LABEL) {
475        let label_end = search + relative + LABEL.len();
476        let mut end = label_end;
477        while end < bytes.len()
478            && (base64url_byte(bytes[end])
479                || matches!(bytes[end], b'.' | b'+' | b'/' | b'=' | b'~'))
480        {
481            end += 1;
482        }
483        if end - label_end >= 8 {
484            out.push_str(&value[processed..label_end]);
485            out.push_str(REDACTED);
486            processed = end;
487        }
488        search = label_end.max(processed);
489    }
490    out.push_str(&value[processed..]);
491    out
492}
493
494/// The `SigV4` pass of [`scrub_text`]: the credential-bearing parameters of a
495/// presigned URL — or of the `Authorization: AWS4-HMAC-SHA256 …` header form
496/// — lose their values (issue #622). The URL pass already drops the whole
497/// query of an absolute URL; this catches a bare query string, or a header
498/// value, which have neither a scheme nor a secret field name around them.
499fn scrub_sigv4(value: &str) -> String {
500    let mut out = String::with_capacity(value.len());
501    let mut rest = value;
502    loop {
503        let found = SIGV4_SECRET_NAMES
504            .iter()
505            .filter_map(|name| rest.find(name).map(|at| (at, *name)))
506            .min_by_key(|(at, _)| *at);
507        let Some((at, name)) = found else {
508            out.push_str(rest);
509            return out;
510        };
511        let value_start = at + name.len();
512        out.push_str(&rest[..value_start]);
513        let end = rest[value_start..]
514            .find(sigv4_value_end)
515            .map_or(rest.len(), |offset| value_start + offset);
516        out.push_str(REDACTED);
517        rest = &rest[end..];
518    }
519}
520
521/// The GitHub-token pass of [`scrub_text`] (issue #623): a GitHub token —
522/// installation (`ghs_`), user-to-server (`ghu_`), OAuth (`gho_`),
523/// personal (`ghp_`), refresh (`ghr_`), or a `github_pat_` fine-grained
524/// PAT — becomes `[redacted]` wherever it appears.
525///
526/// A GitHub token is opaque and high-entropy, with no internal structure
527/// beyond its prefix and body alphabet; a leaked one grants API access for
528/// its whole lifetime, and it turns up in a log line as readily as in a
529/// `uri` or an `error` (a request that echoed `Authorization: token
530/// ghp_…`). The body must be at least [`MIN_GITHUB_TOKEN_BODY`] chars — a
531/// real token's is 36 or more — so ordinary values that merely begin with
532/// the prefix's letters (`ghost_town`, a bare `ghs`) survive, and a prefix
533/// only counted when it starts a token (the `ghs_` in `highs_levels` does
534/// not).
535fn scrub_github_tokens(value: &str) -> String {
536    let bytes = value.as_bytes();
537    let mut out = String::with_capacity(value.len());
538    let mut processed = 0;
539    let mut search = 0;
540    while let Some((start, body_start)) = github_token_at(bytes, search) {
541        let mut end = body_start;
542        while end < bytes.len() && token_body_byte(bytes[end]) {
543            end += 1;
544        }
545        if end - body_start >= MIN_GITHUB_TOKEN_BODY {
546            out.push_str(&value[processed..start]);
547            out.push_str(REDACTED);
548            processed = end;
549            search = end;
550        } else {
551            // Too short to be a token: resume after the prefix rather than
552            // the body, so a later, real token cannot be skipped.
553            search = body_start;
554        }
555    }
556    out.push_str(&value[processed..]);
557    out
558}
559
560/// The prefixes GitHub puts on its tokens, longest first so a prefix that
561/// is a tail of a longer one cannot shadow it.
562const GITHUB_TOKEN_PREFIXES: [&str; 6] = ["github_pat_", "ghs_", "ghu_", "gho_", "ghp_", "ghr_"];
563
564/// Shortest body the scrubber will call a GitHub token. Real bodies are 36
565/// chars and up; the floor keeps ordinary words out of the redactor.
566const MIN_GITHUB_TOKEN_BODY: usize = 20;
567
568/// A byte GitHub's token body alphabet allows: `[A-Za-z0-9_]`.
569fn token_body_byte(b: u8) -> bool {
570    b.is_ascii_alphanumeric() || b == b'_'
571}
572
573/// The first GitHub token prefix at or after `from`, as `(prefix_start,
574/// body_start)`; `None` when there is none. A prefix is only counted when
575/// it starts a token, and the left boundary is **alphanumeric**, not the
576/// whole token-body alphabet: `_` ends a word as a space does, so
577/// `prefix_ghp_…` is a token while the `ghs_` inside `highs_levels` is not
578/// (the `i` before it is alphanumeric).
579fn github_token_at(bytes: &[u8], from: usize) -> Option<(usize, usize)> {
580    let mut at = from;
581    while at < bytes.len() {
582        let starts_token = at == 0 || !bytes[at - 1].is_ascii_alphanumeric();
583        if starts_token {
584            for prefix in GITHUB_TOKEN_PREFIXES {
585                if bytes[at..].starts_with(prefix.as_bytes()) {
586                    return Some((at, at + prefix.len()));
587                }
588            }
589        }
590        at += 1;
591    }
592    None
593}
594
595/// The PEM private-key pass of [`scrub_text`] (issue #623): a
596/// `-----BEGIN … PRIVATE KEY-----` … `-----END … PRIVATE KEY-----` block
597/// becomes `[redacted]` whole.
598///
599/// A private key pasted into a config error or a `message` is the most
600/// damaging value a log can carry; headers, base64 body and footer are
601/// replaced together, so no partial key survives. Public-key and
602/// certificate blocks (`BEGIN PUBLIC KEY`, `BEGIN CERTIFICATE`) do not
603/// carry `PRIVATE KEY` and are left alone. A block with no readable
604/// footer runs to the end of the value and is redacted in full rather
605/// than half.
606fn scrub_pem_private_keys(value: &str) -> String {
607    const BEGIN: &str = "-----BEGIN ";
608    const END: &str = "-----END ";
609    const PRIVATE_KEY: &str = " PRIVATE KEY-----";
610    // Longest PEM header lookahead. A `-----BEGIN …-----` line is well
611    // under this; bounding the scan keeps the pass linear when one long
612    // line holds many `-----BEGIN ` candidates — searching to the newline
613    // (or the end) for each would rescan the tail and go quadratic.
614    const MAX_HEADER_LEN: usize = 128;
615    let mut out = String::with_capacity(value.len());
616    let mut processed = 0;
617    let mut search = 0;
618    while let Some(relative) = value[search..].find(BEGIN) {
619        let start = search + relative;
620        // The `-----BEGIN …-----` line ends at the next newline, within
621        // the bound.
622        let header_end = header_line_end(value, start, MAX_HEADER_LEN);
623        if !value[start..header_end].contains(PRIVATE_KEY) {
624            search = start + BEGIN.len();
625            continue;
626        }
627        let block_end = value[header_end..]
628            .find(END)
629            .and_then(|rel| {
630                let at = header_end + rel;
631                let line_end = header_line_end(value, at, MAX_HEADER_LEN);
632                let line = &value[at..line_end];
633                if !line.contains(PRIVATE_KEY) {
634                    return None;
635                }
636                // Stop after the footer's closing `-----`, not at the end
637                // of the line: text may follow the footer on the same line
638                // and is not part of the key.
639                Some(line.rfind("-----").map_or(line_end, |o| at + o + 5))
640            })
641            .unwrap_or(value.len());
642        out.push_str(&value[processed..start]);
643        out.push_str(REDACTED);
644        processed = block_end;
645        search = block_end;
646    }
647    out.push_str(&value[processed..]);
648    out
649}
650
651/// Where the line beginning at `start` ends — the next newline, or the end
652/// of the value — looked at over at most `max_len` bytes, snapped forward
653/// to a char boundary. The bound is what keeps [`scrub_pem_private_keys`]
654/// linear: a line that never ends is read once per candidate, not once per
655/// candidate to the end of the value.
656fn header_line_end(value: &str, start: usize, max_len: usize) -> usize {
657    let mut window_end = start.saturating_add(max_len).min(value.len());
658    while window_end < value.len() && !value.is_char_boundary(window_end) {
659        window_end += 1;
660    }
661    value[start..window_end]
662        .find('\n')
663        .map_or(window_end, |offset| start + offset)
664}
665
666/// The email pass of [`scrub_text`]: RFC-ish local part, dotted domain
667/// whose last label is alphabetic — conservative enough that dependency
668/// specs (`crate@1.0.0`) and versions survive, strict enough that every
669/// address the harness itself accepts ([`crate::email::is_valid`]) is
670/// caught.
671fn scrub_emails(value: &str) -> String {
672    let bytes = value.as_bytes();
673    let mut out = String::with_capacity(value.len());
674    let mut processed = 0;
675    let mut search = 0;
676    while let Some(relative) = value[search..].find('@') {
677        let at = search + relative;
678        let mut start = at;
679        while start > processed && email_local_byte(bytes[start - 1]) {
680            start -= 1;
681        }
682        let mut end = at + 1;
683        while end < bytes.len() && email_domain_byte(bytes[end]) {
684            end += 1;
685        }
686        let local = &value[start..at];
687        let domain = &value[at + 1..end];
688        if email_shape_ok(local, domain) {
689            out.push_str(&value[processed..start]);
690            out.push_str("[subject_hash:");
691            out.push_str(&subject_hash(&value[start..end]));
692            out.push(']');
693            processed = end;
694        }
695        search = (at + 1).max(processed);
696    }
697    out.push_str(&value[processed..]);
698    out
699}
700
701fn email_shape_ok(local: &str, domain: &str) -> bool {
702    if local.is_empty() || local.len() > crate::email::MAX_LOCAL_BYTES {
703        return false;
704    }
705    if local.starts_with('.') || local.ends_with('.') {
706        return false;
707    }
708    if domain.is_empty() || !domain.contains('.') {
709        return false;
710    }
711    if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
712        return false;
713    }
714    if domain.len() > crate::email::MAX_EMAIL_BYTES {
715        return false;
716    }
717    domain
718        .split('.')
719        .all(|label| !label.is_empty() && !label.starts_with('-') && !label.ends_with('-'))
720        && domain
721            .rsplit('.')
722            .next()
723            .is_some_and(|tld| tld.len() >= 2 && tld.bytes().all(|b| b.is_ascii_alphabetic()))
724}
725
726/// A `tracing` field visitor that records `(name, redacted value)` pairs
727/// into a map. Runtimes use it in their formatters; tests use it to
728/// prove the redaction rules.
729#[derive(Debug, Default)]
730pub struct RedactingVisitor {
731    pub fields: BTreeMap<String, String>,
732}
733
734impl RedactingVisitor {
735    #[must_use]
736    pub fn new() -> Self {
737        Self::default()
738    }
739
740    fn record(&mut self, name: &str, value: &str) {
741        self.fields
742            .insert(name.to_owned(), redacted_value(name, value));
743    }
744}
745
746impl Visit for RedactingVisitor {
747    fn record_str(&mut self, field: &Field, value: &str) {
748        self.record(field.name(), value);
749    }
750
751    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
752        self.record(field.name(), &format!("{value:?}"));
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    /// The 12-hex truncation of `bytes`, same as `pseudonym_hex` does.
761    fn hex_prefix(bytes: &[u8]) -> String {
762        use std::fmt::Write as _;
763        let mut hex = String::with_capacity(12);
764        for byte in bytes.iter().take(6) {
765            let _ = write!(hex, "{byte:02x}");
766        }
767        hex
768    }
769
770    #[test]
771    fn secret_names_are_detected_case_insensitively() {
772        for name in [
773            "authorization",
774            "Authorization",
775            "api_token",
776            "captchaToken",
777            "HARNESS_SECRET",
778            "CF_SAAS_API_TOKEN",
779            "kid_key",
780            "password",
781        ] {
782            assert!(is_secret_field(name), "{name}");
783        }
784        assert!(!is_secret_field("outcome"));
785        // Deliberately over-broad: `idempotency_key` matches too, so the
786        // mailer-outcome logs name the field `idempotency` (issue #14).
787        assert!(is_secret_field("idempotency_key"));
788        // And the field-name rule is what actually hides the value: a
789        // custom-hostnames API token field never reaches a sink.
790        assert_eq!(redacted_value("api_token", "cf-abcdef"), "[redacted]");
791    }
792
793    #[test]
794    fn subject_hash_without_a_key_is_the_fixed_placeholder() {
795        // This binary never installs the process-wide `LOG_PSEUDONYM_KEY`,
796        // so the public entry point exercises the fail-closed arm: the
797        // output shape survives, the input sensitivity does not.
798        let hash = subject_hash("nick@example.com");
799        assert_eq!(hash, UNKEYED_PSEUDONYM, "no key, no digest");
800        assert_eq!(hash.len(), 12);
801        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
802        assert!(!hash.contains('@'));
803        assert_eq!(
804            hash,
805            subject_hash("nick2@example.com"),
806            "unkeyed output must not vary with the address"
807        );
808    }
809
810    #[test]
811    fn pseudonym_hex_without_a_key_is_constant_and_not_a_bare_digest() {
812        // The fail-open regression (issue #135): with no key the old code
813        // emitted the first 12 hex of a bare SHA-256 — deterministic and
814        // dictionary-attackable. Every unkeyed pseudonym must be the fixed
815        // placeholder instead, and never a digest prefix of its input.
816        use sha2::Digest as _;
817        for value in ["a@x.com", "b@x.com", "nick@example.com", ""] {
818            assert_eq!(pseudonym_hex(None, value), UNKEYED_PSEUDONYM, "{value}");
819            let digest: [u8; 32] = Sha256::digest(value.as_bytes()).into();
820            assert_ne!(
821                hex_prefix(&digest),
822                pseudonym_hex(None, value),
823                "the placeholder must not be a SHA-256 prefix of {value}"
824            );
825        }
826        assert_eq!(
827            pseudonym_hex(None, "a@x.com"),
828            pseudonym_hex(None, "b@x.com"),
829            "input-independent"
830        );
831    }
832
833    #[test]
834    fn pseudonym_hex_with_a_key_is_the_input_sensitive_hmac_form() {
835        let key: &[u8] = b"harness-secret-long-enough-for-this-test-0123456789";
836        let a = pseudonym_hex(Some(key), "a@x.com");
837        assert_eq!(a.len(), 12, "keyed pseudonym keeps the shape: {a}");
838        assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
839        assert_ne!(a, UNKEYED_PSEUDONYM, "keyed output is not the placeholder");
840        assert_eq!(
841            a,
842            pseudonym_hex(Some(key), "a@x.com"),
843            "stable under one key"
844        );
845        assert_ne!(
846            a,
847            pseudonym_hex(Some(key), "b@x.com"),
848            "keyed pseudonym stays input-sensitive"
849        );
850        // Byte-for-byte the same scheme as before the fail-closed change:
851        // HMAC-SHA256(key, PSEUDONYM_DOMAIN ‖ value), first 6 bytes as hex.
852        let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
853        mac.update(PSEUDONYM_DOMAIN);
854        mac.update(b"a@x.com");
855        assert_eq!(a, hex_prefix(&mac.finalize().into_bytes()));
856    }
857
858    #[test]
859    fn visitor_rules_match_redacted_value() {
860        for (name, value) in [
861            ("authorization", "Bearer super-secret-token"),
862            ("token", "captcha-value"),
863            ("email", "nick@example.com"),
864            ("subject", "nick@example.com"),
865            ("outcome", "sent"),
866        ] {
867            let stored = redacted_value(name, value);
868            if is_secret_field(name) {
869                assert_eq!(stored, "[redacted]", "{name}");
870            } else if is_email_field(name) && value.contains('@') {
871                assert!(stored.starts_with("subject_hash:"), "{name}: {stored}");
872                assert!(!stored.contains('@'));
873            } else {
874                assert_eq!(stored, value, "{name}");
875            }
876        }
877    }
878
879    #[test]
880    fn non_email_values_in_email_fields_pass_through() {
881        assert_eq!(
882            redacted_value("email_domain", "factory0.ventures"),
883            "factory0.ventures"
884        );
885    }
886
887    /// The leak the field-name rules miss (issue #135): a Postgres unique
888    /// violation quotes the offending row — an email — in a generic
889    /// `error` field, and the raw driver text must never reach the logs.
890    #[test]
891    fn scrub_text_pseudonymises_emails_inside_generic_values() {
892        let driver = "duplicate key value violates unique constraint \
893             \"subscribers_email_normalized_key\"\n\
894             DETAIL:  Key (email_normalized)=(nick@example.com) already exists.";
895        let scrubbed = scrub_text(driver);
896        assert!(!scrubbed.contains('@'), "{scrubbed}");
897        assert!(!scrubbed.contains("nick"), "{scrubbed}");
898        assert!(scrubbed.contains("[subject_hash:"), "{scrubbed}");
899        assert!(
900            scrubbed.contains(&subject_hash("nick@example.com")),
901            "the pseudonym is the same one the email fields use: {scrubbed}"
902        );
903        assert!(
904            scrubbed.contains("duplicate key value violates unique constraint"),
905            "the diagnostic itself survives: {scrubbed}"
906        );
907
908        assert_eq!(
909            redacted_value("message", "mailer rejected bob@example.com"),
910            format!(
911                "mailer rejected [subject_hash:{}]",
912                subject_hash("bob@example.com")
913            )
914        );
915    }
916
917    #[test]
918    fn scrub_text_drops_query_strings_from_urls_and_paths() {
919        let message = "GET https://api.factory0.ventures/v1/email-signup/confirm?token=abc \
920             redirected to /v1/waitlist/status?token=def";
921        let scrubbed = scrub_text(message);
922        assert!(!scrubbed.contains("token=abc"), "{scrubbed}");
923        assert!(!scrubbed.contains("token=def"), "{scrubbed}");
924        assert!(
925            scrubbed.contains("https://api.factory0.ventures/v1/email-signup/confirm?[redacted]"),
926            "{scrubbed}"
927        );
928        assert!(
929            scrubbed.contains("redirected to /v1/waitlist/status?[redacted]"),
930            "{scrubbed}"
931        );
932
933        let uri = scrub_text("/ui/waitlist/status?token=01J.secret");
934        assert_eq!(uri, "/ui/waitlist/status?[redacted]");
935
936        assert_eq!(
937            scrub_text("/v1/email-signup/confirm"),
938            "/v1/email-signup/confirm",
939            "a path without a query is untouched"
940        );
941    }
942
943    #[test]
944    fn scrub_text_redacts_url_credentials() {
945        let connect = "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app";
946        let scrubbed = scrub_text(connect);
947        assert!(!scrubbed.contains("sup3r-s3cret"), "{scrubbed}");
948        assert!(!scrubbed.contains("venture:"), "{scrubbed}");
949        assert!(
950            scrubbed.contains("postgres://[redacted]@db.internal:5432/app"),
951            "{scrubbed}"
952        );
953    }
954
955    #[test]
956    fn scrub_text_redacts_signed_tokens_wherever_they_appear() {
957        use crate::ports::signer::{Kid, Payload, Signer};
958        use crate::signer::HmacSigner;
959
960        let signer = HmacSigner::new("0".repeat(crate::signer::MIN_SECRET_BYTES), None)
961            .expect("test secret is long enough");
962        let token = signer.sign(&Payload {
963            purpose: "email-signup.confirm".to_owned(),
964            subject: "01J0000000000000000000000A".to_owned(),
965            exp: None,
966            kid: Kid::Cur,
967        });
968        assert!(token.starts_with("eyJ"), "test premise: {token}");
969
970        let scrubbed = scrub_text(&format!("confirm token {token} was rejected"));
971        assert!(!scrubbed.contains(&token), "{scrubbed}");
972        assert!(!scrubbed.contains("eyJ"), "{scrubbed}");
973        assert!(scrubbed.contains(REDACTED), "{scrubbed}");
974        assert!(
975            scrubbed.contains("confirm token") && scrubbed.contains("was rejected"),
976            "surrounding words survive: {scrubbed}"
977        );
978
979        // Inside a URL the query rule already dropped it; no `eyJ` survives
980        // either way.
981        let in_url = scrub_text(&format!("https://x.example/confirm?token={token}"));
982        assert!(!in_url.contains("eyJ"), "{in_url}");
983
984        // A JWT-shaped three-segment token is caught too.
985        let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMUoifQ.c2lnbmF0dXJlLXJ1bg";
986        assert!(!scrub_text(jwt).contains("eyJ"));
987
988        // Short dotted runs that merely start with eyJ are left alone.
989        assert_eq!(scrub_text("eyJhYi.短"), "eyJhYi.短");
990    }
991
992    #[test]
993    fn scrub_text_redacts_bearer_credentials() {
994        let scrubbed = scrub_text("request carried Authorization: Bearer 01Jsupersecretvalue");
995        assert!(!scrubbed.contains("01Jsupersecretvalue"), "{scrubbed}");
996        assert!(scrubbed.contains("Bearer [redacted]"), "{scrubbed}");
997    }
998
999    #[test]
1000    fn scrub_text_redacts_every_github_token_shape() {
1001        // One body long enough for the token floor, per prefix.
1002        let body = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // 36 chars
1003        for prefix in ["ghs_", "ghu_", "gho_", "ghp_", "ghr_", "github_pat_"] {
1004            let token = format!("{prefix}{body}");
1005            let scrubbed = scrub_text(&format!(
1006                "request failed: sent Authorization: token {token}"
1007            ));
1008            assert!(!scrubbed.contains(&token), "{prefix}: {scrubbed}");
1009            assert!(
1010                !scrubbed.contains(body),
1011                "{prefix}: token body survived: {scrubbed}"
1012            );
1013            assert!(scrubbed.contains(REDACTED), "{prefix}: {scrubbed}");
1014            assert!(
1015                scrubbed.contains("request failed") && scrubbed.contains("Authorization"),
1016                "{prefix}: surrounding words should survive: {scrubbed}"
1017            );
1018            // Idempotent.
1019            assert_eq!(scrub_text(&scrubbed), scrubbed, "{prefix}");
1020        }
1021        // The token can be the whole value, and one value may hold several.
1022        assert_eq!(scrub_text(&format!("ghp_{body}")), REDACTED);
1023        let two = scrub_text(&format!("ghp_{body} and ghs_{body}"));
1024        assert!(!two.contains(body), "{two}");
1025
1026        // A token glued after an underscore is still a token: `_` ends a
1027        // word just as a space does, so the `_` before the prefix must not
1028        // shield it (issue #623 review).
1029        for (glued, leading) in [
1030            (format!("prefix_ghp_{body}"), "prefix_"),
1031            (format!("x_ghs_{body}"), "x_"),
1032            (format!("KEY_github_pat_{body}"), "KEY_"),
1033        ] {
1034            let scrubbed = scrub_text(&glued);
1035            assert!(!scrubbed.contains(body), "glued: {scrubbed}");
1036            assert!(scrubbed.contains(REDACTED), "glued: {scrubbed}");
1037            assert!(
1038                scrubbed.contains(leading),
1039                "leading word survives: {scrubbed}"
1040            );
1041            assert!(
1042                !scrubbed.contains("ghp_")
1043                    && !scrubbed.contains("ghs_")
1044                    && !scrubbed.contains("github_pat_"),
1045                "no prefix survives: {scrubbed}"
1046            );
1047        }
1048    }
1049
1050    #[test]
1051    fn scrub_text_leaves_github_prefix_lookalikes_alone() {
1052        for value in [
1053            // The prefix's letters, but no `_` after them.
1054            "ghost_town",
1055            "ghs",
1056            "ghp",
1057            // `ghs_` inside a longer word is not the start of a token.
1058            "highs_levels_of_the_thing",
1059            // Prefixed, but far too short to be a token.
1060            "ghp_short",
1061            "ghs_1234",
1062            "github_pat_shortish",
1063        ] {
1064            assert_eq!(scrub_text(value), value, "over-redacted: {value}");
1065        }
1066    }
1067
1068    #[test]
1069    fn scrub_text_redacts_pem_private_key_blocks() {
1070        let block = "-----BEGIN RSA PRIVATE KEY-----\n\
1071             MIIEpAIBAAKCAQEAtqo1ZXcvbnmlkjihgfedcba0987654321\n\
1072             c2FtcGxlLXNlY3JldC1rZXktbWF0ZXJpYWwtZ29lcy1oZXJl\n\
1073             -----END RSA PRIVATE KEY-----";
1074        let message = format!("failed to load signing key: {block} (from /etc/key.pem)");
1075        let scrubbed = scrub_text(&message);
1076        assert!(!scrubbed.contains("-----BEGIN"), "{scrubbed}");
1077        assert!(!scrubbed.contains("PRIVATE KEY"), "{scrubbed}");
1078        assert!(!scrubbed.contains("c2FtcGxlLXNlY3JldC"), "{scrubbed}");
1079        assert!(scrubbed.contains(REDACTED), "{scrubbed}");
1080        assert!(
1081            scrubbed.contains("failed to load signing key")
1082                && scrubbed.contains("(from /etc/key.pem)"),
1083            "surrounding words survive: {scrubbed}"
1084        );
1085        assert_eq!(scrub_text(&scrubbed), scrubbed, "idempotent");
1086
1087        // A public key is not a private key and is left alone.
1088        let public = "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----";
1089        assert_eq!(scrub_text(public), public);
1090
1091        // A block with no readable footer is redacted whole, not half.
1092        let unterminated = "-----BEGIN PRIVATE KEY-----\nc2VjcmV0LWJvZHk=\nmore";
1093        let half = scrub_text(unterminated);
1094        assert!(!half.contains("c2VjcmV0LWJvZHk="), "{half}");
1095        assert!(half.contains(REDACTED), "{half}");
1096    }
1097
1098    #[test]
1099    fn scrub_text_handles_a_very_long_line_of_begin_markers() {
1100        // A single line holding many `-----BEGIN ` candidates, none a
1101        // `PRIVATE KEY` header: the pass must leave it untouched. The bound
1102        // on the header lookahead is what keeps this from rescanning the
1103        // tail once per candidate (issue #623 review).
1104        let value = "-----BEGIN ".repeat(10_000);
1105        assert_eq!(scrub_text(&value), value);
1106    }
1107
1108    #[test]
1109    fn scrub_text_leaves_ordinary_log_values_alone() {
1110        for value in [
1111            "sent",
1112            "not_configured",
1113            "01J8Z4QWERTYUIOPASDFGH",
1114            "/v1/email-signup/confirm",
1115            "factory0.ventures",
1116            "cratefield-core@0.1.0",
1117            "database did not answer within 2 s",
1118            "[redacted]",
1119        ] {
1120            assert_eq!(scrub_text(value), value, "{value}");
1121        }
1122    }
1123
1124    #[test]
1125    fn scrub_text_is_idempotent() {
1126        let nasty = "failed for nick@example.com with Bearer abcdefgh12345 at \
1127             https://x.example/v1/confirm?token=eyJ and postgres://u:p@h/db; \
1128             X-Amz-Credential=AKID%2F20260101%2Fauto%2Fs3%2Faws4_request \
1129             Authorization: AWS4-HMAC-SHA256 Credential=AKID/20260101/auto/s3/aws4_request, \
1130             Signature=abcdef";
1131        let once = scrub_text(nasty);
1132        assert_eq!(scrub_text(&once), once, "{once}");
1133    }
1134
1135    /// The disclosure [`scrub_request_url`] exists for, and the one
1136    /// [`scrub_text`] provably cannot stop on its own: APNs addresses a
1137    /// device by *path*, and a path is not a shape any general rule can
1138    /// call a secret.
1139    #[test]
1140    fn scrub_request_url_removes_a_credential_path_scrub_text_cannot_see() {
1141        const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f9";
1142        let url = &format!("https://api.push.apple.com/3/device/{TOKEN}");
1143
1144        assert!(
1145            scrub_text(url).contains(TOKEN),
1146            "if scrub_text ever learns to do this, this pass can go"
1147        );
1148
1149        // Both shapes a layer below is free to quote: the whole URL, and
1150        // the bare request target on its own.
1151        let message = format!("Fetch API cannot load: {url}. sending POST /3/device/{TOKEN}");
1152        let safe = scrub_request_url(&message, url);
1153        assert!(!safe.contains(TOKEN), "{safe}");
1154        assert!(!safe.contains("/3/device"), "{safe}");
1155        assert!(safe.contains("https://api.push.apple.com"), "{safe}");
1156        // Still an error somebody can act on.
1157        assert!(safe.contains("Fetch API cannot load"), "{safe}");
1158        assert!(safe.contains(REDACTED), "{safe}");
1159    }
1160
1161    #[test]
1162    fn scrub_request_url_keeps_the_port_and_never_the_userinfo() {
1163        let url = "http://user:pw@127.0.0.1:8787/3/device/tok";
1164        let safe = scrub_request_url(&format!("connect failed: {url}"), url);
1165        assert_eq!(safe, "connect failed: http://127.0.0.1:8787");
1166    }
1167
1168    #[test]
1169    fn scrub_request_url_ignores_anything_that_is_not_an_absolute_url() {
1170        // An empty needle would otherwise splice the marker between every
1171        // character of the message, and a bare path has no origin to keep.
1172        for url in ["", "/3/device/tok", "api.push.apple.com", "://no-scheme/x"] {
1173            let message = "the message is not the place to find out";
1174            assert_eq!(scrub_request_url(message, url), message, "{url:?}");
1175        }
1176    }
1177
1178    #[test]
1179    fn scrub_request_url_leaves_a_root_target_alone() {
1180        // "/" is in half the prose there is; redacting it would make every
1181        // message worse and hide nothing.
1182        let safe = scrub_request_url("GET / failed at https://x.example/", "https://x.example/");
1183        assert_eq!(safe, "GET / failed at https://x.example");
1184    }
1185
1186    /// A `SigV4` credential in any of the forms a value can carry it — a
1187    /// presigned URL's query, a bare query string, or the
1188    /// `Authorization: AWS4-HMAC-SHA256 …` header — loses its value
1189    /// (issue #622).
1190    #[test]
1191    fn scrub_text_redacts_sigv4_credentials() {
1192        // An absolute URL: the URL pass drops the whole query, and the same
1193        // URL as a generic field value (a `uri`) is scrubbed before storage.
1194        const URL: &str = "https://examplebucket.s3.amazonaws.com/test.txt\
1195             ?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1196             &X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request\
1197             &X-Amz-Date=20130524T000000Z\
1198             &X-Amz-Expires=86400\
1199             &X-Amz-SignedHeaders=host\
1200             &X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404";
1201        assert_eq!(
1202            scrub_text(URL),
1203            "https://examplebucket.s3.amazonaws.com/test.txt?[redacted]"
1204        );
1205        assert_eq!(redacted_value("uri", URL), scrub_text(URL));
1206
1207        // A bare query has no scheme for the URL pass to anchor on, so the
1208        // `SigV4` names themselves are what the rule catches.
1209        assert_eq!(
1210            scrub_text(
1211                "X-Amz-Algorithm=AWS4-HMAC-SHA256\
1212                 &X-Amz-Credential=AKID%2F20260101%2Fauto%2Fs3%2Faws4_request\
1213                 &X-Amz-Date=20260101T000000Z\
1214                 &X-Amz-Signature=abcd0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab"
1215            ),
1216            "X-Amz-Algorithm=AWS4-HMAC-SHA256\
1217             &X-Amz-Credential=[redacted]\
1218             &X-Amz-Date=20260101T000000Z\
1219             &X-Amz-Signature=[redacted]"
1220        );
1221
1222        // The `Authorization` header form, with no query around it.
1223        assert_eq!(
1224            scrub_text(
1225                "Authorization: AWS4-HMAC-SHA256 \
1226                 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
1227                 SignedHeaders=host;x-amz-date, \
1228                 Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
1229            ),
1230            "Authorization: AWS4-HMAC-SHA256 \
1231             Credential=[redacted], \
1232             SignedHeaders=host;x-amz-date, \
1233             Signature=[redacted]"
1234        );
1235
1236        // An STS session token (`X-Amz-Security-Token=`) is a credential too:
1237        // it accompanies temporary credentials, so it is redacted as well, and
1238        // re-scrubbing the result is a no-op.
1239        assert_eq!(
1240            scrub_text(
1241                "X-Amz-Security-Token=IQoJb3JpZ2luX2VjECTEMP\
1242                 &X-Amz-Signature=abcd0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab"
1243            ),
1244            "X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"
1245        );
1246        assert_eq!(
1247            scrub_text("X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"),
1248            "X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"
1249        );
1250    }
1251}
1252
1253// ---------------------------------------------------------------------------
1254// Control-event forwarder (issue #107; widened to every boot-time control
1255// event by issue #441)
1256
1257/// A process-wide sink for internal-error diagnostics, installed by the
1258/// runtime. See [`set_error_forwarder`].
1259type ErrorForwarder = fn(&str);
1260
1261static ERROR_FORWARDER: OnceLock<ErrorForwarder> = OnceLock::new();
1262
1263/// How severe a forwarded control event is (issue #441).
1264///
1265/// On `wasm32` no tracing dispatcher can be installed (it hangs the
1266/// workerd isolate), so the accountability `tracing` carries — the
1267/// `HARNESS_ALLOW_UNPROTECTED_WRITES` acceptance, the production-readiness
1268/// refusal, a gateway secret that would not load, the secret-access audit —
1269/// went nowhere, exactly as the 500-mapped internal errors of issue #107
1270/// did. Those events ride the same forwarder as the errors now, but they
1271/// are not all failures: the level travels with the line so Workers Logs
1272/// shows an acceptance as a warning and an audit record as information,
1273/// rather than everything as an error.
1274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1275pub enum ControlLevel {
1276    Info,
1277    Warn,
1278    Error,
1279}
1280
1281impl ControlLevel {
1282    /// The lowercase tag prefixed onto a forwarded line (`"[warn] …"`),
1283    /// matching the level string the native subscriber writes.
1284    #[must_use]
1285    pub fn as_str(self) -> &'static str {
1286        match self {
1287            ControlLevel::Info => "info",
1288            ControlLevel::Warn => "warn",
1289            ControlLevel::Error => "error",
1290        }
1291    }
1292}
1293
1294/// Installs a process-wide forwarder for internal-error diagnostics
1295/// (architecture section 11).
1296///
1297/// On `wasm32` a tracing dispatcher cannot be installed — it hangs the
1298/// workerd/miniflare isolate — so every `tracing::error!` core emits when it
1299/// maps an internal failure to a 500 is dropped, and a Workers 500 becomes a
1300/// black box (issue #107). The Cloudflare runtime therefore points this
1301/// forwarder at `worker::console_error!`, and core calls it alongside its
1302/// `tracing::error!` so the same one-line diagnostic reaches Workers Logs.
1303/// Since issue #441 the same sink also carries boot-time **control events**
1304/// at their own level — see [`forward_control_event`].
1305///
1306/// Native runs leave it unset and rely on the tracing subscriber. This is
1307/// boot-time infrastructure installed before the first response, not request
1308/// state (ADR 0007); the first installation wins and later calls are ignored.
1309pub fn set_error_forwarder(forwarder: ErrorForwarder) {
1310    let _ = ERROR_FORWARDER.set(forwarder);
1311}
1312
1313/// Forwards a one-line boot-time control event to the installed sink at its
1314/// own level (issue #441); a no-op when none is installed (native, tests).
1315///
1316/// The same escape hatch the crate-internal `forward_internal_error`
1317/// opens, extended past the internal errors mapped to a 500 (issue #107)
1318/// to the boot-time control events a wasm target's absent tracing
1319/// dispatcher would otherwise swallow (issue #441). Call sites forward
1320/// *in addition to* — never instead of — their `tracing::*` event, so
1321/// native keeps the structured fields and Workers keeps the fact.
1322/// The payload passes [`scrub_text`] (issue #135) and is prefixed second:
1323/// the level tag is ours, and must not be scrubbed with what it carries.
1324pub fn forward_control_event(level: ControlLevel, line: &str) {
1325    if let Some(forwarder) = ERROR_FORWARDER.get() {
1326        forwarder(&format!("[{}] {}", level.as_str(), scrub_text(line)));
1327    }
1328}
1329
1330/// Forwards a one-line internal-error diagnostic to the installed sink, if
1331/// any; a no-op when none is installed (native, tests). The line is a
1332/// pre-formatted string, so the structured-field rules ([`redacted_value`])
1333/// never see it; it passes [`scrub_text`] (issue #135), which is idempotent
1334/// for callers that already scrubbed what they formatted in. This is a
1335/// [`ControlLevel::Error`] event, and delegates to
1336/// [`forward_control_event`] (issue #441).
1337pub(crate) fn forward_internal_error(line: &str) {
1338    forward_control_event(ControlLevel::Error, line);
1339}
1340
1341#[cfg(test)]
1342#[allow(clippy::disallowed_types)] // test-only capture of the forwarded line
1343mod forwarder_tests {
1344    use super::*;
1345    use std::sync::Mutex;
1346
1347    static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
1348    static INSTALL: std::sync::Once = std::sync::Once::new();
1349
1350    fn capture(line: &str) {
1351        CAPTURED.lock().unwrap().push(line.to_owned());
1352    }
1353
1354    /// Installs the capture sink once per test binary.
1355    ///
1356    /// **Process-global on purpose.** The first version of this captured
1357    /// `tracing` through `with_default`, which is *thread-local*: it held
1358    /// locally and failed on CI, because a report emitted on any thread but
1359    /// the one running the closure is simply not seen. The sink
1360    /// `set_error_forwarder` installs is global and `Mutex`-guarded, so it
1361    /// cannot miss a line for scheduling reasons.
1362    ///
1363    /// Being global means every test writes into one buffer, so an assertion
1364    /// has to name something only its own test produces — hence the unique
1365    /// needle per test below, rather than counting a shared phrase.
1366    fn install_sink() {
1367        INSTALL.call_once(|| set_error_forwarder(capture));
1368    }
1369
1370    #[test]
1371    fn an_installed_forwarder_receives_the_line() {
1372        install_sink();
1373        forward_internal_error("database error mapped to internal problem: forwarder-107-boom");
1374        assert!(
1375            CAPTURED
1376                .lock()
1377                .unwrap()
1378                .iter()
1379                .any(|line| line.contains("forwarder-107-boom")),
1380            "the installed forwarder should have received the diagnostic"
1381        );
1382
1383        // The forwarded line is a pre-formatted string the structured-field
1384        // rules never see, so the forwarder itself scrubs it (issue #135):
1385        // a driver DETAIL quoting a row, or a redirect target with a token,
1386        // must not reach Workers Logs verbatim.
1387        forward_internal_error(
1388            "database error: DETAIL: Key (email)=(nick@example.com) already exists",
1389        );
1390        forward_internal_error("redirect to /v1/waitlist/status?token=eyJhYmNkZWZnaA.mac failed");
1391        let captured = CAPTURED.lock().unwrap();
1392        let joined = captured.join("\n");
1393        assert!(!joined.contains('@'), "email reached the sink: {joined}");
1394        assert!(
1395            !joined.contains("token=eyJ"),
1396            "token reached the sink: {joined}"
1397        );
1398        assert!(
1399            joined.contains("[subject_hash:"),
1400            "the pseudonym should survive for correlation: {joined}"
1401        );
1402    }
1403
1404    #[test]
1405    fn control_events_reach_the_sink_prefixed_with_their_level() {
1406        install_sink();
1407        // One line per level, each with a needle no other test produces: the
1408        // sink is shared, so an assertion must find *its own* line.
1409        forward_control_event(
1410            ControlLevel::Info,
1411            "secret access forwarder-441-info store global read allowed",
1412        );
1413        forward_control_event(
1414            ControlLevel::Warn,
1415            "forwarder-441-warn serving guarded routes unprotected on an acceptance",
1416        );
1417        forward_control_event(
1418            ControlLevel::Error,
1419            "forwarder-441-error refusing guarded routes",
1420        );
1421        let captured = CAPTURED.lock().unwrap();
1422        for (level, needle) in [
1423            ("info", "forwarder-441-info"),
1424            ("warn", "forwarder-441-warn"),
1425            ("error", "forwarder-441-error"),
1426        ] {
1427            let line = captured
1428                .iter()
1429                .find(|line| line.contains(needle))
1430                .unwrap_or_else(|| panic!("no forwarded line carried {needle}"));
1431            let prefix = format!("[{level}] ");
1432            assert!(
1433                line.starts_with(&prefix),
1434                "the {needle} line should be prefixed {prefix:?}: {line}"
1435            );
1436        }
1437    }
1438
1439    #[test]
1440    fn the_internal_error_still_scrubs_and_now_carries_the_error_prefix() {
1441        install_sink();
1442        forward_internal_error(
1443            "forwarder-441-scrub database error: DETAIL: Key (email)=(nick@example.com) \
1444             already exists",
1445        );
1446        let captured = CAPTURED.lock().unwrap();
1447        // The plain-text needle survives the scrub; the email must not.
1448        let line = captured
1449            .iter()
1450            .find(|line| line.contains("forwarder-441-scrub"))
1451            .expect("the internal error should reach the sink");
1452        assert!(line.starts_with("[error] "), "{line}");
1453        assert!(!line.contains('@'), "email reached the sink: {line}");
1454        assert!(
1455            line.contains("[subject_hash:"),
1456            "the pseudonym should survive for correlation: {line}"
1457        );
1458    }
1459
1460    #[test]
1461    fn forwarding_without_a_sink_is_a_noop() {
1462        // No panic when nothing is installed. Other tests in this binary may
1463        // already have installed the shared sink, so nothing is asserted
1464        // about output; this asserts only that the call is safe regardless
1465        // of ordering.
1466        forward_control_event(
1467            ControlLevel::Warn,
1468            "ignored when no sink or captured when set",
1469        );
1470        forward_internal_error("ignored when no sink or captured when set");
1471    }
1472}