Skip to main content

agent_first_data/
redaction.rs

1use serde_json::Value;
2use std::collections::HashSet;
3
4// ═══════════════════════════════════════════
5// Public API: Output Formatters
6// ═══════════════════════════════════════════
7
8/// Which fields a [`Redactor`] scrubs. The default is [`RedactionPolicy::All`].
9///
10/// The policy selects a *scope* inside a structured value. A command line
11/// ([`Redactor::argv`]) and a bare URL string ([`Redactor::url`]) have no
12/// `result`/`trace` split to scope to, so on those two paths `TraceOnly`
13/// redacts in full like `All`, and only [`RedactionPolicy::Off`] turns
14/// redaction off.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum RedactionPolicy {
17    /// Redact every secret field anywhere in the value (the default).
18    #[default]
19    All,
20    /// Redact only inside the top-level `trace` object.
21    TraceOnly,
22    /// Do not redact anything.
23    Off,
24}
25
26impl RedactionPolicy {
27    /// Whether this policy redacts an input that carries no scope of its own —
28    /// a command line or a single URL string, as opposed to a JSON value with a
29    /// `trace` object to narrow to.
30    ///
31    /// `TraceOnly` narrows *where* redaction applies within a value; it does not
32    /// weaken redaction. A standalone argv or URL has no non-`trace` half to
33    /// leave alone — and both are diagnostic material by construction, the very
34    /// thing `TraceOnly` scrubs — so `TraceOnly` redacts them in full, exactly
35    /// like `All`. Only `Off`, the caller explicitly asking for raw output,
36    /// disables redaction, and it does so on all three paths alike.
37    fn redacts_unscoped_input(self) -> bool {
38        !matches!(self, RedactionPolicy::Off)
39    }
40}
41
42/// Rendering style for plain (logfmt) output only. JSON and YAML are always
43/// structure-preserving and ignore this.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub enum PlainStyle {
46    /// Human-readable AFDATA rendering: strip suffixes and format values.
47    #[default]
48    Readable,
49    /// Schema-preserving rendering: keep keys and values unchanged after redaction.
50    Raw,
51}
52
53/// Configurable redaction builder for secrets and legacy field names.
54///
55/// `Redactor` encapsulates redaction policy, custom secret field names, and
56/// exact legacy URL field names.
57/// Build with [`Redactor::new()`], configure via builder methods, then pass to
58/// redaction functions like [`redacted_value`] or [`redact_url_secrets`].
59#[derive(Clone, Debug, Default, PartialEq, Eq)]
60pub struct Redactor {
61    policy: RedactionPolicy,
62    secret_names: Vec<String>,
63    url_names: Vec<String>,
64}
65
66impl Redactor {
67    /// Create a new default redactor (full redaction, no custom secret names).
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Set custom field names to treat as secrets in addition to `_secret` suffixes.
73    ///
74    /// Matching is exact field-name equality at any nesting level. The same
75    /// list also matches URL query-parameter names inside `_url` fields.
76    /// Builder style: returns `self`.
77    pub fn secret_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
78        self.secret_names = names.into_iter().map(|s| s.into()).collect();
79        self
80    }
81
82    /// Set exact legacy field names whose values should receive URL-aware
83    /// redaction in addition to `_url`/`_URL` suffixed fields.
84    ///
85    /// A matching string is handled like an `_url` value. Arrays and nested
86    /// collections recursively apply that treatment to every string leaf.
87    /// Matching is exact and does not normalize case, whitespace, or spelling.
88    #[must_use]
89    pub fn url_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
90        self.url_names = names.into_iter().map(Into::into).collect();
91        self
92    }
93
94    /// Set the redaction policy (default: full redaction).
95    /// Builder style: returns `self`.
96    pub fn policy(mut self, policy: RedactionPolicy) -> Self {
97        self.policy = policy;
98        self
99    }
100
101    /// Redact a JSON value copy using this redactor's policy and secret names.
102    ///
103    /// Clones `value` first; for a large payload you already own and can
104    /// mutate, prefer [`Redactor::redact_in_place`] to avoid the copy.
105    pub fn value(&self, value: &Value) -> Value {
106        let mut v = value.clone();
107        self.redact_in_place(&mut v);
108        v
109    }
110
111    /// Redact secret components of a URL string using this redactor's settings.
112    ///
113    /// A query parameter is redacted iff its (form-decoded) name ends in
114    /// `_secret`/`_SECRET` or matches an exact entry in `secret_names`. A
115    /// fragment written in the same `k=v&k=v` shape — how an OAuth implicit-flow
116    /// response carries its token — is redacted by that same rule; any other
117    /// fragment passes through byte-for-byte. The userinfo password
118    /// (`scheme://user:pass@host`) is always redacted as a structural rule.
119    /// Only the secret spans are replaced with `***`; every other byte is
120    /// preserved. A string that is not a single, whitespace-free,
121    /// scheme-prefixed URL (including a URL embedded in surrounding prose) is
122    /// returned unchanged.
123    ///
124    /// A URL is unscoped input: `RedactionPolicy::Off` returns it unchanged,
125    /// every other policy redacts it in full.
126    pub fn url(&self, url: &str) -> String {
127        if !self.policy.redacts_unscoped_input() {
128            return url.to_string();
129        }
130        let context = RedactionContext::from_redactor(self);
131        redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
132    }
133
134    /// Redact complete scheme-prefixed URLs embedded in prose.
135    ///
136    /// This is deliberately explicit and independent from structured field
137    /// redaction. It recognizes URL spans only; it never scans surrounding text
138    /// for secret-looking names or values.
139    pub fn urls_in_text(&self, text: &str) -> String {
140        if !self.policy.redacts_unscoped_input() {
141            return text.to_string();
142        }
143        let context = RedactionContext::from_redactor(self);
144        redact_urls_in_text_with_context(text, &context)
145    }
146
147    /// Redact `value` in place, using this redactor's policy and secret names.
148    ///
149    /// The zero-copy counterpart of [`Redactor::value`] — use it on a large
150    /// payload you already own to avoid cloning.
151    pub fn redact_in_place(&self, value: &mut Value) {
152        let context = RedactionContext::from_redactor(self);
153        apply_redaction_policy_with_context(value, self.policy, &context);
154    }
155
156    /// Redact secret *values* out of a command line, using this redactor's
157    /// policy and secret names.
158    ///
159    /// A long flag whose name is secret by AFDATA naming (`--api-key-secret`,
160    /// or an exact `secret_names` entry) has its value replaced by `***`, in
161    /// both `--flag=value` and `--flag value` spellings. Everything else is
162    /// preserved byte-for-byte.
163    ///
164    /// Free text is deliberately never scanned: a bare `api_key_secret=sk-live`
165    /// positional, or a secret-looking token after a non-secret flag, is left
166    /// alone. AFDATA decides sensitivity from the *field name*, and argv is no
167    /// exception — rename the flag rather than pattern-matching values. A flag
168    /// with no value (end of argv, or followed by another flag) is likewise
169    /// left inspectable.
170    ///
171    /// Only long (`--`) flags are recognized, matching the convention's
172    /// long-flags-only rule.
173    ///
174    /// A command line is unscoped input: `RedactionPolicy::Off` returns `args`
175    /// unchanged, every other policy redacts in full.
176    pub fn argv<S: AsRef<str>>(&self, args: &[S]) -> Vec<String> {
177        if !self.policy.redacts_unscoped_input() {
178            return args.iter().map(|arg| arg.as_ref().to_string()).collect();
179        }
180        let context = RedactionContext::from_redactor(self);
181        let mut out = Vec::with_capacity(args.len());
182        let mut redact_next = false;
183        for arg in args {
184            let arg = arg.as_ref();
185            if redact_next {
186                redact_next = false;
187                if !arg.starts_with('-') {
188                    out.push(REDACTED_MARKER.to_string());
189                    continue;
190                }
191            }
192            if let Some(rest) = arg.strip_prefix("--") {
193                if let Some((name, _)) = rest.split_once('=') {
194                    if is_secret_flag_name(name, &context) {
195                        out.push(format!("--{name}={REDACTED_MARKER}"));
196                        continue;
197                    }
198                } else if is_secret_flag_name(rest, &context) {
199                    redact_next = true;
200                }
201            }
202            out.push(arg.to_string());
203        }
204        out
205    }
206
207    /// True when `name` would be treated as a secret field name by this
208    /// redactor: an exact `_secret`/`_SECRET` suffix, or an exact match
209    /// against a configured `secret_names` entry.
210    ///
211    /// Exposed for callers that must gate on a single *targeted* field name
212    /// (for example a CLI dot-path leaf) rather than redact a whole value —
213    /// [`Redactor::value`] only rewrites fields it finds while walking an
214    /// object, so a bare scalar pulled out from under its field name needs
215    /// this explicit check instead.
216    pub fn is_secret_name(&self, name: &str) -> bool {
217        RedactionContext::from_redactor(self).is_secret_key(name)
218    }
219
220    /// True when `name` receives URL-aware structured-field redaction: an
221    /// exact `_url`/`_URL` suffix or an exact configured `url_names` entry.
222    ///
223    /// The counterpart to [`Redactor::is_secret_name`], for the same targeted
224    /// single-field case. Kept even with no in-tree caller: a consumer that
225    /// gates on one name has to ask both questions, and an API that answers
226    /// only half of a symmetric pair sends the caller back to reimplementing
227    /// the suffix rule — which is exactly how the rule drifts.
228    pub fn is_url_name(&self, name: &str) -> bool {
229        RedactionContext::from_redactor(self).is_url_key(name)
230    }
231}
232
233impl From<RedactionPolicy> for Redactor {
234    fn from(policy: RedactionPolicy) -> Self {
235        Self {
236            policy,
237            secret_names: Vec::new(),
238            url_names: Vec::new(),
239        }
240    }
241}
242
243/// Output options combining redaction and rendering style.
244#[derive(Clone, Debug, Default, PartialEq, Eq)]
245pub struct OutputOptions {
246    /// Redactor applied before rendering.
247    pub redaction: Redactor,
248    /// Rendering style for plain output only.
249    pub style: PlainStyle,
250}
251
252impl From<RedactionPolicy> for OutputOptions {
253    fn from(policy: RedactionPolicy) -> Self {
254        Self {
255            redaction: Redactor::from(policy),
256            style: PlainStyle::default(),
257        }
258    }
259}
260
261// ═══════════════════════════════════════════
262// Public API: Redaction & Utility
263// ═══════════════════════════════════════════
264
265/// Return a JSON value copy with default `_secret` redaction applied.
266pub fn redacted_value(value: &Value) -> Value {
267    Redactor::new().value(value)
268}
269
270/// Redact secret values out of a command line, using default options.
271///
272/// Returns `args` with the value of every `_secret`-suffixed long flag replaced
273/// by `***`, covering both `--flag=value` and `--flag value`. Use
274/// [`Redactor::argv`] for custom `secret_names` or a non-default policy.
275///
276/// Intended for CLIs that record their own invocation — startup diagnostics,
277/// audit trails, crash reports — where writing argv verbatim would put a
278/// credential in the log.
279pub fn redact_argv<S: AsRef<str>>(args: &[S]) -> Vec<String> {
280    Redactor::new().argv(args)
281}
282
283/// Redact secret components of a single URL string, using default options.
284///
285/// Returns `url` with its userinfo password and any `_secret`-suffixed query
286/// parameter values replaced by `***`.
287pub fn redact_url_secrets(url: &str) -> String {
288    Redactor::new().url(url)
289}
290
291/// Redact complete scheme-prefixed URLs embedded in prose, using default
292/// secret-name rules.
293///
294/// This helper is opt-in. Ordinary structured redaction never scans prose.
295pub fn redact_urls_in_text(text: &str) -> String {
296    Redactor::new().urls_in_text(text)
297}
298
299// ═══════════════════════════════════════════
300// Secret Redaction
301// ═══════════════════════════════════════════
302
303/// The scalar every redacted span, value, and subtree is replaced with. Also
304/// the signal plain rendering reads to tell a hidden field from a live one.
305pub(crate) const REDACTED_MARKER: &str = "***";
306
307#[derive(Default)]
308pub(crate) struct RedactionContext {
309    secret_names: HashSet<String>,
310    url_names: HashSet<String>,
311}
312
313impl RedactionContext {
314    fn from_redactor(redactor: &Redactor) -> Self {
315        let secret_names = redactor.secret_names.iter().cloned().collect();
316        let url_names = redactor.url_names.iter().cloned().collect();
317        Self {
318            secret_names,
319            url_names,
320        }
321    }
322
323    fn is_secret_key(&self, key: &str) -> bool {
324        key_has_secret_suffix(key) || self.secret_names.contains(key)
325    }
326
327    fn is_url_key(&self, key: &str) -> bool {
328        key_has_url_suffix(key) || self.url_names.contains(key)
329    }
330}
331
332fn key_has_secret_suffix(key: &str) -> bool {
333    key.ends_with("_secret") || key.ends_with("_SECRET")
334}
335
336fn key_has_url_suffix(key: &str) -> bool {
337    key.ends_with("_url") || key.ends_with("_URL")
338}
339
340/// Whether a long flag's name is secret, normalizing the kebab-case flag
341/// spelling to the snake_case field spelling the convention is defined in.
342///
343/// Ungated: core argv redaction relies on it.
344pub(crate) fn is_secret_flag_name(flag_name: &str, context: &RedactionContext) -> bool {
345    let normalized = flag_name.replace('-', "_");
346    context.is_secret_key(&normalized) || context.is_secret_key(flag_name)
347}
348
349const MAX_DEPTH: usize = 256;
350const MAX_DEPTH_MARKER: &str = "<afdata:max-depth>";
351
352fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
353    redact_secrets_with_context_depth(value, context, 0);
354}
355
356fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
357    if depth >= MAX_DEPTH {
358        *value = Value::String(MAX_DEPTH_MARKER.into());
359        return;
360    }
361    match value {
362        Value::Object(map) => {
363            let keys: Vec<String> = map.keys().cloned().collect();
364            for key in keys {
365                if context.is_secret_key(&key) {
366                    // A null secret is an *absent* secret. Masking it would
367                    // manufacture the appearance of a configured credential:
368                    // readers cannot tell `"***"`-because-set from
369                    // `"***"`-because-null, so a tool showing its own config
370                    // would report every unset secret as configured. Redaction
371                    // hides a value that exists; it does not invent one.
372                    if !map.get(&key).is_some_and(Value::is_null) {
373                        map.insert(key, Value::String(REDACTED_MARKER.into()));
374                    }
375                } else if context.is_url_key(&key) {
376                    if let Some(v) = map.get_mut(&key) {
377                        redact_url_value_depth(v, context, depth + 1);
378                    }
379                } else if let Some(v) = map.get_mut(&key) {
380                    redact_secrets_with_context_depth(v, context, depth + 1);
381                }
382            }
383        }
384        Value::Array(arr) => {
385            for v in arr {
386                redact_secrets_with_context_depth(v, context, depth + 1);
387            }
388        }
389        _ => {}
390    }
391}
392
393fn redact_url_value_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
394    if depth >= MAX_DEPTH {
395        *value = Value::String(MAX_DEPTH_MARKER.into());
396        return;
397    }
398    match value {
399        Value::String(text) => {
400            *text = redact_url_field_value(text, context);
401        }
402        Value::Array(values) => {
403            for value in values {
404                redact_url_value_depth(value, context, depth + 1);
405            }
406        }
407        Value::Object(values) => {
408            let keys = values.keys().cloned().collect::<Vec<_>>();
409            for key in keys {
410                if context.is_secret_key(&key) {
411                    if !values.get(&key).is_some_and(Value::is_null) {
412                        values.insert(key, Value::String(REDACTED_MARKER.into()));
413                    }
414                } else if let Some(value) = values.get_mut(&key) {
415                    redact_url_value_depth(value, context, depth + 1);
416                }
417            }
418        }
419        _ => {}
420    }
421}
422
423/// Redact secret components of a single URL string, returning `Some(redacted)`
424/// when `s` is a processable URL, or `None` when it is not (so callers can keep
425/// the original). Only secret spans change; all other bytes are preserved.
426fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
427    // Precondition (spec): a single, whitespace-free, scheme-prefixed URL.
428    // The gate is scheme + no-whitespace only — NOT "parses as a URL library
429    // object". Span location below is purely byte-wise, so we never re-serialize
430    // the URL; adding a `url::Url::parse` gate here would diverge across
431    // languages (e.g. ports > 65535 or empty hosts that one library rejects and
432    // another accepts) and silently leak secrets in the values it rejects.
433    if !s.contains("://") || !is_single_url(s) {
434        return None;
435    }
436    let scheme_sep = s.find("://")?;
437    let scheme = &s[..scheme_sep];
438    let rest = &s[scheme_sep + 3..];
439
440    // Authority runs from after "://" to the first '/', '?', or '#'.
441    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
442    let authority = &rest[..auth_end];
443    let remainder = &rest[auth_end..];
444
445    let new_authority = redact_userinfo_password(authority);
446
447    // `remainder` is `path[?query][#fragment]`; '#' ends the query, so split the
448    // fragment off first and the query out of what is left.
449    let (before_fragment, fragment) = match remainder.split_once('#') {
450        Some((before, fragment)) => (before, Some(fragment)),
451        None => (remainder, None),
452    };
453    let new_before_fragment = match before_fragment.split_once('?') {
454        Some((path, query)) => format!("{path}?{}", redact_query(query, context)),
455        None => before_fragment.to_string(),
456    };
457    // A fragment gets the same treatment as the query: `k=v&k=v` after the '#'
458    // is exactly how an OAuth implicit-flow response hands back a token, so a
459    // secret-named fragment parameter must not survive where the identically
460    // named query parameter would not. A fragment that is not in that shape has
461    // no '=' in its segments and passes through byte-for-byte.
462    let new_fragment = match fragment {
463        Some(fragment) => format!("#{}", redact_query(fragment, context)),
464        None => String::new(),
465    };
466
467    Some(format!(
468        "{scheme}://{new_authority}{new_before_fragment}{new_fragment}"
469    ))
470}
471
472fn redact_urls_in_text_with_context(text: &str, context: &RedactionContext) -> String {
473    let mut output = String::with_capacity(text.len());
474    let mut copied_through = 0;
475    let mut search_from = 0;
476    let mut found = false;
477    while let Some((start, end)) = next_scheme_url_span(text, search_from) {
478        found = true;
479        output.push_str(&text[copied_through..start]);
480        let url = &text[start..end];
481        output.push_str(&redact_url_in_str(url, context).unwrap_or_else(|| url.to_string()));
482        copied_through = end;
483        search_from = end;
484    }
485    if !found {
486        return text.to_string();
487    }
488    output.push_str(&text[copied_through..]);
489    output
490}
491
492fn next_scheme_url_span(text: &str, from: usize) -> Option<(usize, usize)> {
493    let bytes = text.as_bytes();
494    let mut start = from;
495    while start < bytes.len() {
496        // Only consider the head of a maximal scheme-byte run: a scheme cannot
497        // begin mid-run, so a position whose predecessor is a scheme byte was
498        // already covered by the run that contains it.
499        if start == 0 || !is_scheme_byte(bytes[start - 1]) {
500            let mut scheme_end = start;
501            while scheme_end < bytes.len() && is_scheme_byte(bytes[scheme_end]) {
502                scheme_end += 1;
503            }
504            // A scheme must start with a letter, but the run need not: prose
505            // like `2https://h/?token_secret=x` glues a digit onto the URL. Retry
506            // from the first letter *inside* the run instead of abandoning the
507            // span — giving up there fails open and leaks the whole query.
508            let scheme_start = (start..scheme_end).find(|&i| bytes[i].is_ascii_alphabetic());
509            if let Some(scheme_start) = scheme_start
510                && bytes
511                    .get(scheme_end..scheme_end.saturating_add(3))
512                    .is_some_and(|separator| separator == b"://")
513            {
514                let mut end = scheme_end + 3;
515                while end < bytes.len() {
516                    let Some(character) = text[end..].chars().next() else {
517                        break;
518                    };
519                    if is_span_terminator_whitespace(character) || is_text_url_delimiter(character)
520                    {
521                        break;
522                    }
523                    end += character.len_utf8();
524                }
525                while end > scheme_end + 3 {
526                    let Some(character) = text[..end].chars().next_back() else {
527                        break;
528                    };
529                    if !is_trailing_text_url_punctuation(character) {
530                        break;
531                    }
532                    end -= character.len_utf8();
533                }
534                if end > scheme_end + 3 {
535                    return Some((scheme_start, end));
536                }
537            }
538        }
539        start += 1;
540    }
541    None
542}
543
544fn is_scheme_byte(byte: u8) -> bool {
545    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')
546}
547
548/// Whitespace that ends a URL span in prose: the Unicode `White_Space`
549/// property, written out here instead of delegated to `char::is_whitespace`.
550///
551/// The four AFDATA implementations must cut the span at the same byte, and each
552/// language's native predicate covers a different set — JS `\s` counts U+FEFF
553/// but not U+0085, Python's `str.isspace()` counts U+001C–U+001F, Rust and Go
554/// count neither. Divergence is a defect in both directions: ending a span early
555/// leaves the rest of the query readable, and ending it late pulls the following
556/// prose into the URL, where redacting the parameter it lands in deletes it. So
557/// the set is enumerated, and it is generous — a redaction helper exists to keep
558/// a log line readable, so anything a human reads as a space must end the URL.
559///
560/// Two exclusions are deliberate, not oversights:
561/// * **U+FEFF** (zero-width no-break space) is not `White_Space`. Treating it as
562///   one would cut `https://h/x\u{feff}?token_secret=leak` short and leave the
563///   secret in the clear — the exact leak JS `\s` used to cause here.
564/// * **U+001C–U+001F** (the file/group/record/unit separators) are not
565///   `White_Space` either; only Python's `str.isspace()` counted them.
566///
567/// This is a different question from [`is_single_url`], which asks whether a
568/// whole string is one URL and stays ASCII-only. A span this scanner produces
569/// contains no whitespace at all, so it satisfies that stricter gate either way.
570fn is_span_terminator_whitespace(character: char) -> bool {
571    matches!(
572        character,
573        // TAB, LF, VT, FF, CR, SPACE
574        '\u{0009}'..='\u{000d}' | '\u{0020}'
575            // NEL, NBSP, OGHAM SPACE MARK
576            | '\u{0085}' | '\u{00a0}' | '\u{1680}'
577            // EN QUAD .. HAIR SPACE
578            | '\u{2000}'..='\u{200a}'
579            // LINE SEPARATOR, PARAGRAPH SEPARATOR, NARROW NBSP,
580            // MEDIUM MATHEMATICAL SPACE, IDEOGRAPHIC SPACE
581            | '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
582    )
583}
584
585fn is_text_url_delimiter(character: char) -> bool {
586    matches!(
587        character,
588        '"' | '\''
589            | '<'
590            | '>'
591            | '`'
592            | ','
593            | '。'
594            | ';'
595            | '!'
596            | '?'
597            | ')'
598            | '》'
599            | '】'
600            | '」'
601            | '』'
602    )
603}
604
605fn is_trailing_text_url_punctuation(character: char) -> bool {
606    matches!(
607        character,
608        '.' | ',' | ';' | '!' | ')' | ']' | '}' | ',' | '。' | ';' | '!' | ')' | '》' | '】'
609    )
610}
611
612fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
613    if let Some(redacted) = redact_url_in_str(s, context) {
614        return redacted;
615    }
616    let trimmed = s.trim();
617    if trimmed != s
618        && let Some(redacted) = redact_url_in_str(trimmed, context)
619    {
620        return redacted;
621    }
622    // Fail closed: a `_url` value we could not parse as a clean scheme-prefixed
623    // URL, yet which carries a credential sigil (`@` userinfo) or internal
624    // whitespace, is redacted wholesale rather than passed through. A schemeless
625    // connection string like `user:pass@host/db` has no scheme anchor for the
626    // surgical span logic above, so blanket redaction is the safe default.
627    if s.chars().any(char::is_whitespace) || s.contains('@') {
628        return REDACTED_MARKER.to_string();
629    }
630    s.to_string()
631}
632
633/// Replace the userinfo password (`user:pass@`) with `***`, preserving the
634/// username. Authority without `@`, or userinfo without `:`, is unchanged.
635fn redact_userinfo_password(authority: &str) -> String {
636    let Some(at) = authority.rfind('@') else {
637        return authority.to_string();
638    };
639    let userinfo = &authority[..at];
640    match userinfo.find(':') {
641        Some(colon) => format!(
642            "{}:{REDACTED_MARKER}{}",
643            &authority[..colon],
644            &authority[at..]
645        ),
646        None => authority.to_string(),
647    }
648}
649
650/// Redact the values of secret-named query parameters, preserving raw bytes of
651/// every other segment (keys, benign values, encoding, ordering, separators).
652fn redact_query(query: &str, context: &RedactionContext) -> String {
653    query
654        .split('&')
655        .map(|segment| {
656            let Some(eq) = segment.find('=') else {
657                return segment.to_string();
658            };
659            let raw_key = &segment[..eq];
660            // Form-decode the name (`+` → space, percent-decode) for the check.
661            let name = url::form_urlencoded::parse(segment.as_bytes())
662                .next()
663                .map(|(k, _)| k.into_owned())
664                .unwrap_or_default();
665            if context.is_secret_key(&name) {
666                format!("{raw_key}={REDACTED_MARKER}")
667            } else {
668                segment.to_string()
669            }
670        })
671        .collect::<Vec<_>>()
672        .join("&")
673}
674
675/// True when `s` begins with a URL scheme (`ALPHA *(ALPHA / DIGIT / "+" / "-" /
676/// ".") "://"`) and contains no ASCII whitespace — i.e. a single bare URL, not
677/// a URL embedded in prose.
678fn is_single_url(s: &str) -> bool {
679    if s.bytes().any(|b| b.is_ascii_whitespace()) {
680        return false;
681    }
682    let bytes = s.as_bytes();
683    if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
684        return false;
685    }
686    let mut i = 1;
687    while i < bytes.len() {
688        let c = bytes[i];
689        if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
690            i += 1;
691        } else {
692            break;
693        }
694    }
695    s[i..].starts_with("://")
696}
697
698fn apply_redaction_policy_with_context(
699    value: &mut Value,
700    redaction_policy: RedactionPolicy,
701    context: &RedactionContext,
702) {
703    match redaction_policy {
704        RedactionPolicy::All => redact_secrets_with_context(value, context),
705        RedactionPolicy::TraceOnly => {
706            if let Value::Object(map) = value
707                && let Some(trace) = map.get_mut("trace")
708            {
709                redact_secrets_with_context(trace, context);
710            }
711        }
712        RedactionPolicy::Off => {}
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use serde_json::json;
720
721    // ── URL fragments carry secrets too ──────────
722
723    #[test]
724    fn url_fragment_params_redacted_like_query_params() {
725        assert_eq!(
726            redact_url_secrets("https://h/p?token_secret=QUERYLEAK#token_secret=FRAGLEAK"),
727            "https://h/p?token_secret=***#token_secret=***"
728        );
729    }
730
731    #[test]
732    fn url_fragment_params_redacted_without_a_query() {
733        // The OAuth implicit-flow shape: the credential exists only after '#'.
734        assert_eq!(
735            redact_url_secrets("https://h/cb#access_token_secret=abc&state=xyz"),
736            "https://h/cb#access_token_secret=***&state=xyz"
737        );
738    }
739
740    #[test]
741    fn url_fragment_without_params_is_preserved() {
742        for url in [
743            "https://h/p?a=1#section",
744            "https://h/p#",
745            "https://h/p#a/b?c",
746        ] {
747            assert_eq!(redact_url_secrets(url), url);
748        }
749    }
750
751    #[test]
752    fn url_fragment_honors_secret_names() {
753        let redactor = Redactor::new().secret_names(vec!["token".to_string()]);
754        assert_eq!(
755            redactor.url("https://h/cb#token=abc&page=2"),
756            "https://h/cb#token=***&page=2"
757        );
758    }
759
760    #[test]
761    fn url_names_are_exact_and_recurse_through_collections() {
762        let redactor = Redactor::new().secret_names(["token"]).url_names([
763            "url",
764            "relays",
765            "synapse_selector",
766        ]);
767        let input = json!({
768            "url": "https://u:pw@h/?token=one",
769            "URL": "https://u:visible@h/?token=two",
770            "relays": [
771                "wss://relay.example/?token=three",
772                {"nested": "https://u:pw@nested.example/"}
773            ],
774            "synapse_selector": "user:pass@host/db",
775            "other": "https://u:visible@h/?token=four"
776        });
777
778        assert_eq!(
779            redactor.value(&input),
780            json!({
781                "url": "https://u:***@h/?token=***",
782                "URL": "https://u:visible@h/?token=two",
783                "relays": [
784                    "wss://relay.example/?token=***",
785                    {"nested": "https://u:***@nested.example/"}
786                ],
787                "synapse_selector": "***",
788                "other": "https://u:visible@h/?token=four"
789            })
790        );
791        assert!(redactor.is_url_name("url"));
792        assert!(!redactor.is_url_name("URL"));
793    }
794
795    #[test]
796    fn prose_url_redaction_is_explicit_and_preserves_punctuation() {
797        let message = "connect (https://u:pw@h/?token_secret=one), then wss://h/?token_secret=two. bare user:pw@h stays";
798        assert_eq!(
799            redact_urls_in_text(message),
800            "connect (https://u:***@h/?token_secret=***), then wss://h/?token_secret=***. bare user:pw@h stays"
801        );
802        assert_eq!(
803            redacted_value(&json!({"message": message})),
804            json!({"message": message})
805        );
806    }
807
808    #[test]
809    fn prose_url_redaction_honors_policy_and_secret_names() {
810        let text = "see https://h/?token=abc";
811        assert_eq!(
812            Redactor::new().secret_names(["token"]).urls_in_text(text),
813            "see https://h/?token=***"
814        );
815        assert_eq!(
816            Redactor::new()
817                .policy(RedactionPolicy::Off)
818                .secret_names(["token"])
819                .urls_in_text(text),
820            text
821        );
822    }
823
824    // ── One policy meaning across value, argv, url ──────────
825
826    fn scoped_value() -> Value {
827        json!({
828            "result": {"api_key_secret": "sk-result"},
829            "trace": {"api_key_secret": "sk-trace"}
830        })
831    }
832
833    fn argv() -> Vec<String> {
834        vec!["tool".to_string(), "--api-key-secret=sk-live".to_string()]
835    }
836
837    #[test]
838    fn all_policy_redacts_every_path() {
839        let redactor = Redactor::new().policy(RedactionPolicy::All);
840        assert_eq!(
841            redactor.value(&scoped_value()),
842            json!({
843                "result": {"api_key_secret": "***"},
844                "trace": {"api_key_secret": "***"}
845            })
846        );
847        assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
848        assert_eq!(
849            redactor.url("https://u:pw@h/cb?token_secret=abc"),
850            "https://u:***@h/cb?token_secret=***"
851        );
852    }
853
854    #[test]
855    fn trace_only_scopes_a_value_but_redacts_argv_and_url_in_full() {
856        let redactor = Redactor::new().policy(RedactionPolicy::TraceOnly);
857        // Scoped input: only the `trace` half is scrubbed.
858        assert_eq!(
859            redactor.value(&scoped_value()),
860            json!({
861                "result": {"api_key_secret": "sk-result"},
862                "trace": {"api_key_secret": "***"}
863            })
864        );
865        // Unscoped input: a command line and a bare URL have no non-`trace`
866        // half to leave alone, so they are redacted like `All`.
867        assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
868        assert_eq!(
869            redactor.url("https://u:pw@h/cb?token_secret=abc"),
870            "https://u:***@h/cb?token_secret=***"
871        );
872    }
873
874    #[test]
875    fn off_policy_disables_every_path() {
876        let redactor = Redactor::new().policy(RedactionPolicy::Off);
877        assert_eq!(redactor.value(&scoped_value()), scoped_value());
878        assert_eq!(redactor.argv(&argv()), argv());
879        assert_eq!(
880            redactor.url("https://u:pw@h/cb?token_secret=abc"),
881            "https://u:pw@h/cb?token_secret=abc"
882        );
883    }
884}