Skip to main content

mcp_execution_core/
redact.rs

1//! Shared `Debug`-redaction helpers for secret-shaped fields.
2//!
3//! `ServerConfig` and the transport types built on top of it (in
4//! `mcp-execution-cli`) carry fields that routinely hold secrets: header/env
5//! values, CLI argument lists, and URLs with embedded credentials or a
6//! `?token=`-style query string. Every one of those types needs the same
7//! redaction behavior in its hand-written [`Debug`] impl, so the wrapper
8//! types here are the single source of truth — implement it once, reuse it
9//! everywhere a `{:?}` might otherwise leak a credential into a log line or
10//! error message.
11//!
12//! [`RedactedUrl`] redacts a value already isolated behind its own field.
13//! [`redact_urls_in_text`] handles the other shape: a URL buried inside
14//! already-assembled prose (a `reqwest`/`rmcp` error's `Display` text, a log
15//! line) where there is no field boundary to wrap — it locates each
16//! URL-shaped token itself and redacts it with the same rules.
17
18use std::collections::HashMap;
19use std::fmt;
20
21/// Fixed placeholder substituted for every redacted value.
22///
23/// A single constant so every redacting [`Debug`] impl (and every test that
24/// asserts on the placeholder) stays in sync if the text ever changes.
25///
26/// # Examples
27///
28/// ```
29/// use mcp_execution_core::REDACTED_PLACEHOLDER;
30///
31/// assert_eq!(REDACTED_PLACEHOLDER, "<redacted>");
32/// ```
33pub const REDACTED_PLACEHOLDER: &str = "<redacted>";
34
35/// Debug-formats a `String`-valued map with keys visible and every value
36/// replaced by [`REDACTED_PLACEHOLDER`].
37///
38/// Intended for `env`/`headers`-style maps: the key (e.g. `"Authorization"`
39/// or `"GITHUB_PERSONAL_ACCESS_TOKEN"`) is a caller-chosen identifier and
40/// useful for debugging, but the value routinely holds a bearer token or API
41/// key and must never be echoed.
42///
43/// # Examples
44///
45/// ```
46/// use mcp_execution_core::RedactedMapValues;
47/// use std::collections::HashMap;
48///
49/// let mut headers = HashMap::new();
50/// headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
51///
52/// let debug_output = format!("{:?}", RedactedMapValues(&headers));
53/// assert!(debug_output.contains("Authorization"));
54/// assert!(!debug_output.contains("sk-secret"));
55/// ```
56#[derive(Clone, Copy)]
57pub struct RedactedMapValues<'a>(pub &'a HashMap<String, String>);
58
59impl fmt::Debug for RedactedMapValues<'_> {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.debug_map()
62            .entries(self.0.keys().map(|key| (key, REDACTED_PLACEHOLDER)))
63            .finish()
64    }
65}
66
67/// Debug-formats a list of strings, replacing every entry wholesale with
68/// [`REDACTED_PLACEHOLDER`].
69///
70/// Unlike [`RedactedMapValues`] (which redacts only the value half of an
71/// already-split key/value pair), this is for lists where a single entry may
72/// not have a discernible key at all — a raw pre-parse `KEY=VALUE` CLI
73/// argument may not even contain a `=`, and a `ServerConfig::args` entry can
74/// itself be an entire `--api-key sk-...`-style secret. Since there is no
75/// safe-to-keep half, the whole entry is replaced.
76///
77/// # Examples
78///
79/// ```
80/// use mcp_execution_core::RedactedItems;
81///
82/// let args = vec!["--api-key".to_string(), "sk-secret".to_string()];
83/// let debug_output = format!("{:?}", RedactedItems(&args));
84/// assert!(!debug_output.contains("sk-secret"));
85/// assert!(debug_output.contains("<redacted>"));
86/// ```
87#[derive(Clone, Copy)]
88pub struct RedactedItems<'a>(pub &'a [String]);
89
90impl fmt::Debug for RedactedItems<'_> {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.debug_list()
93            .entries(self.0.iter().map(|_| REDACTED_PLACEHOLDER))
94            .finish()
95    }
96}
97
98/// Debug-formats a URL with userinfo credentials and query string hidden,
99/// keeping the scheme, host, and path readable.
100///
101/// A URL can carry a secret two ways: `user:pass@host` userinfo, or a
102/// `?api_key=...`-style query parameter. Both are stripped; everything else
103/// (scheme, host, path) is left intact since it's the most useful part of a
104/// URL for telling two server entries apart in a log.
105///
106/// This is deliberately parse-free (mcp-core does not depend on the `url`
107/// crate) rather than a strict parser: if the input doesn't contain `://`, if
108/// the scheme before it contains a character that is never valid in a URI
109/// scheme, or if userinfo redaction would be ambiguous (see below), the whole
110/// input is treated as unparseable and redacted in full — mirroring the
111/// discard-on-parse-failure rule `mcp-execution-cli` already applies when
112/// deriving a server ID from a URL. Ambiguity arises when the authority
113/// terminator (the first `/`, `?`, or `#` after the scheme) lands *inside*
114/// unencoded userinfo rather than at a true authority boundary — e.g. an
115/// unencoded `/` in a password — which would otherwise let the userinfo
116/// escape redaction entirely. Detected by checking whether an `@` still
117/// appears after that terminator.
118///
119/// # Examples
120///
121/// A URL with userinfo and a query string has both hidden, while the host
122/// and path stay readable:
123///
124/// ```
125/// use mcp_execution_core::RedactedUrl;
126///
127/// let url = "https://user:sk-secret@api.example.com/mcp?token=sk-secret";
128/// let debug_output = format!("{:?}", RedactedUrl(url));
129/// assert!(!debug_output.contains("sk-secret"));
130/// assert!(debug_output.contains("api.example.com/mcp"));
131/// ```
132///
133/// A plain URL with neither is unchanged:
134///
135/// ```
136/// use mcp_execution_core::RedactedUrl;
137///
138/// let url = "https://api.example.com/mcp";
139/// let debug_output = format!("{:?}", RedactedUrl(url));
140/// assert_eq!(debug_output, "https://api.example.com/mcp");
141/// ```
142#[derive(Clone, Copy)]
143pub struct RedactedUrl<'a>(pub &'a str);
144
145impl fmt::Debug for RedactedUrl<'_> {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        let Some(parts) = split_url(self.0) else {
148            return f.write_str(REDACTED_PLACEHOLDER);
149        };
150
151        if parts.userinfo_present {
152            write!(
153                f,
154                "{}://{REDACTED_PLACEHOLDER}@{}{}",
155                parts.scheme, parts.authority, parts.path
156            )?;
157        } else {
158            write!(f, "{}://{}{}", parts.scheme, parts.authority, parts.path)?;
159        }
160
161        if let Some((kind, _)) = parts.tail {
162            let separator = match kind {
163                UrlTailKind::Query => '?',
164                UrlTailKind::Fragment => '#',
165            };
166            write!(f, "{separator}{REDACTED_PLACEHOLDER}")?;
167        }
168        Ok(())
169    }
170}
171
172/// Which separator introduced a [`SplitUrl`]'s `tail` — the first `?` or `#` found after the
173/// authority. Mirrors [`split_url`]'s single-separator rule: whichever character comes first
174/// determines the whole kind, even if the other character also appears later inside `tail`'s
175/// text (e.g. a `#` inside an unparsed query string, or a `?` inside a fragment).
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum UrlTailKind {
178    /// The separator was `?`: `tail` is the URL's query string.
179    Query,
180    /// The separator was `#`: `tail` is the URL's fragment.
181    Fragment,
182}
183
184/// Parsed pieces of a `scheme://...` URL, shared by [`RedactedUrl`]'s [`Debug`] impl and the
185/// config-fingerprint preimage (`mcp_execution_core::provenance`). Two renderings over one
186/// parser, so they cannot disagree about where the authority ends — see [`split_url`].
187#[derive(Clone, Copy)]
188pub struct SplitUrl<'a> {
189    /// URL scheme, validated to contain only characters legal in a URI scheme.
190    pub(crate) scheme: &'a str,
191    /// Whether the authority carried a `user[:pass]@` prefix. The credentials themselves are
192    /// never exposed by this type — only their presence.
193    pub(crate) userinfo_present: bool,
194    /// Host\[:port\], with any userinfo prefix already stripped.
195    pub(crate) authority: &'a str,
196    /// Path segment, up to (not including) the first `?`/`#` after the authority. Empty if the
197    /// URL has no path.
198    pub(crate) path: &'a str,
199    /// The first `?`/`#` found after the authority, paired with everything from just past that
200    /// character to the end of the URL — verbatim, even if it itself contains further `?`/`#`
201    /// characters. `None` if the URL has no query string or fragment.
202    pub(crate) tail: Option<(UrlTailKind, &'a str)>,
203}
204
205impl fmt::Debug for SplitUrl<'_> {
206    /// Hand-written rather than derived: `tail` carries the raw, unredacted query string or
207    /// fragment — the exact secret-shaped text this module exists to keep out of `Debug`
208    /// output. Currently unreachable from outside the crate (`SplitUrl` is `pub` but `mod
209    /// redact` is private and this type is not re-exported at `lib.rs`), but a derived impl
210    /// would silently reopen that leak the moment either changes, inside the one module whose
211    /// stated purpose is preventing it.
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        f.debug_struct("SplitUrl")
214            .field("scheme", &self.scheme)
215            .field("userinfo_present", &self.userinfo_present)
216            .field("authority", &self.authority)
217            .field("path", &self.path)
218            .field(
219                "tail",
220                &self
221                    .tail
222                    .as_ref()
223                    .map(|(kind, _)| (kind, REDACTED_PLACEHOLDER)),
224            )
225            .finish()
226    }
227}
228
229/// Parses `url` into [`SplitUrl`]'s pieces, or returns `None` for every shape [`RedactedUrl`]
230/// redacts in full: no `://`, an invalid scheme, or an authority/userinfo split that can't be
231/// trusted (see [`RedactedUrl`]'s own doc comment for the ambiguity this last case guards
232/// against).
233///
234/// Deliberately parse-free, matching [`RedactedUrl`]'s existing documented tradeoff: this crate
235/// does not depend on the `url` crate, so a URL that a strict parser would accept can still be
236/// rejected here in favor of staying consistent with the rest of this module's boundary.
237pub fn split_url(url: &str) -> Option<SplitUrl<'_>> {
238    let (scheme, rest) = url.split_once("://")?;
239
240    let scheme_is_valid = !scheme.is_empty() && scheme.chars().all(is_scheme_char);
241    if !scheme_is_valid {
242        return None;
243    }
244
245    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
246    let (authority_with_userinfo, remainder) = rest.split_at(authority_end);
247
248    // See `RedactedUrl`'s doc comment: an `@` past the authority terminator means the
249    // terminator landed inside unencoded userinfo rather than at a true authority boundary, so
250    // the split can't be trusted.
251    if remainder.contains('@') {
252        return None;
253    }
254
255    let (userinfo_present, authority) = authority_with_userinfo
256        .rfind('@')
257        .map_or((false, authority_with_userinfo), |at| {
258            (true, &authority_with_userinfo[at + 1..])
259        });
260
261    let separator_pos = remainder.find(['?', '#']);
262    let path = separator_pos.map_or(remainder, |pos| &remainder[..pos]);
263    let tail = separator_pos.map(|pos| {
264        let kind = if remainder.as_bytes()[pos] == b'?' {
265            UrlTailKind::Query
266        } else {
267            UrlTailKind::Fragment
268        };
269        (kind, &remainder[pos + 1..])
270    });
271
272    Some(SplitUrl {
273        scheme,
274        userinfo_present,
275        authority,
276        path,
277        tail,
278    })
279}
280
281/// Characters legal in a URI scheme, per [`RedactedUrl`]'s own scheme check.
282const fn is_scheme_char(c: char) -> bool {
283    c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')
284}
285
286/// Characters that end a bare URL token embedded in prose: whitespace,
287/// control characters, and the punctuation a human or a log formatter
288/// commonly wraps a whole URL in (quotes, backtick, parens).
289///
290/// Deliberately narrow: `[`/`]` (RFC 3986 IP-literal delimiters, needed
291/// verbatim inside an IPv6 authority like `http://[::1]:1/path`) and every
292/// other RFC 3986 "unsafe" character (`<` `>` `{` `}` `\` `|` `^`) are *not*
293/// terminators here, even though none of them can legally appear unescaped
294/// in a URL. Terminating on them would truncate the token before the query
295/// string that [`RedactedUrl`] needs to see in full to redact it — a
296/// dependency's `Display` impl routinely embeds a query value verbatim,
297/// unescaped, exactly where this scan runs. Erring toward capturing *too
298/// much* surrounding text into the token (over-redaction) is safe; erring
299/// toward cutting a token short before its secret is not.
300fn is_token_terminator(c: char) -> bool {
301    c.is_whitespace() || c.is_control() || matches!(c, '"' | '\'' | '`' | '(' | ')')
302}
303
304/// Finds every URL-shaped token in `text` and redacts each one.
305///
306/// Each token is redacted by handing it to [`RedactedUrl`] — the actual masking decision (what
307/// counts as authority/query, what gets hidden) always defers to that one implementation, so it
308/// can't drift between the two.
309///
310/// Unlike [`RedactedUrl`], which redacts a value already isolated behind its
311/// own field, this scans arbitrary already-assembled text — typically a
312/// `reqwest`/`rmcp` transport error's `Display` output, which embeds the
313/// full request URL (query string included) inline in a sentence — and
314/// redacts each `scheme://…` run it finds in place, leaving the surrounding
315/// prose untouched.
316///
317/// A token's boundaries are found by walking left from `://` over
318/// scheme-legal characters, then walking right to the first whitespace,
319/// control character, or wrapping-punctuation character (quote, backtick,
320/// paren — or the end of `text`), then trimming trailing sentence
321/// punctuation (`,` `.` `;` `:` `\`) that a log message or `Display` impl
322/// commonly appends after a URL — the backslash covers the escaping
323/// backslash a JSON string serializer inserts before a `"` that closes a
324/// quoted URL, which the token walk otherwise absorbs as part of the URL
325/// and then deletes, leaving the quote unescaped and the JSON invalid.
326/// That terminator set is deliberately
327/// narrower than "every character invalid in a URL" — RFC 3986 IP-literal
328/// delimiters (`[`/`]`, needed verbatim for an IPv6 authority like
329/// `http://[::1]:1/path`) and every other RFC 3986 "unsafe" character are
330/// left out on purpose, because capturing too much into the token is the
331/// safe failure mode here, unlike [`RedactedUrl`]'s ambiguity handling,
332/// which fails closed by redacting a whole *field* in full. This function
333/// instead fails toward widening a *token*: it does not know a URL's true
334/// end any more precisely than "the next character a log line would use to
335/// wrap one", so a token capturing a few extra characters of trailing
336/// prose is the accepted cost of never capturing too few and truncating a
337/// secret.
338///
339/// The `\` addition to the trim set (above) has its own over-capture corollary in JSON mode: a
340/// raw control character (e.g. a literal newline) immediately after a URL, once JSON-escaped to
341/// a printable two-character sequence like `\n`, is no longer a `is_token_terminator` match —
342/// neither `\` nor the following letter is whitespace, control, or wrapping punctuation — so the
343/// token walk continues past it and swallows whatever prose follows, right up to the next real
344/// terminator, into the redaction. This is the same "over-capture is the safe failure mode"
345/// contract already documented above, not a new class of gap: no secret leaks and the output
346/// stays valid JSON, the same guarantee the `\"`-preserving fix targets — it just means slightly
347/// more trailing prose than usual is swallowed on this specific input shape.
348///
349/// The one residual gap from this heuristic: a raw, un-percent-encoded
350/// instance of *any* terminator character — whitespace and control
351/// characters, not just the quote/backtick/paren wrappers (`"` `'` `` ` ``
352/// `(` `)`), though the latter is the realistic case, since a real
353/// `reqwest`-sent URL would already have percent-encoded whitespace/control
354/// bytes — *inside* the secret itself, rather than used by the surrounding
355/// log line to wrap the URL, still ends the token early, exactly the same
356/// class of unencoded-delimiter ambiguity [`RedactedUrl`] documents for an
357/// unencoded `/` or `?` inside userinfo. Distinguishing the two would need
358/// a real URL parser; this module stays parse-free by design (see
359/// [`RedactedUrl`]'s doc comment).
360///
361/// One boundary case needs a deliberate widening step on the *left* side
362/// too: if the character immediately left of the scheme run is itself a
363/// non-scheme, non-terminator character (e.g. `ghp_leakedtoken://host.com/`,
364/// where `_` breaks the scheme-char walk but isn't a terminator either), the
365/// token is widened left to the nearest terminator before redaction.
366/// Without this, the walk would stop at `_`, treat `leakedtoken` as a valid
367/// scheme, and leave `ghp_leakedtoken` exposed — whereas `RedactedUrl` given
368/// that same text as a whole string redacts it in full, because
369/// `leakedtoken` alone isn't what the malformed-scheme check sees. Widening
370/// first makes the two agree: the widened token's "scheme"
371/// (`ghp_leakedtoken`) fails `RedactedUrl`'s validity check and so is
372/// redacted wholesale. This widening never looks further left than the end
373/// of the previous token this function already emitted — it cannot rewind
374/// into text it has already committed to the output.
375///
376/// Widening only ever runs when at least one scheme-legal character
377/// immediately precedes `://` (`leakedtoken` above): if the very first
378/// character to its left is itself not scheme-legal (e.g. `tok_://host.com/`,
379/// where `_` sits right against `://`), there is no scheme run to widen from
380/// at all, and the whole `://` is left untouched as ordinary text rather
381/// than redacted — not a realistic URL shape a dependency's `Display` impl
382/// would ever produce, so not a regression from before this function
383/// existed, but also not the same guarantee `RedactedUrl` gives a whole
384/// string containing that same text.
385///
386/// Known accepted limitation, inherited unchanged from `RedactedUrl`: a
387/// secret glued to a URL by scheme-*legal* characters (letters, digits,
388/// `+`, `-`, `.` — e.g. `Bearer sk-abc.https://h/p?t=1`) is absorbed into
389/// the token as part of its "scheme" and survives redaction, since nothing
390/// distinguishes it from a URL that legitimately has a long scheme name.
391/// This is exact parity with what `RedactedUrl` itself does when given that
392/// same string, not a gap introduced here, and is not worth a heuristic
393/// that would risk swallowing legitimate text instead.
394///
395/// # Examples
396///
397/// ```
398/// use mcp_execution_core::redact_urls_in_text;
399///
400/// let line = "error sending request for url (https://api.example.com/mcp?token=hunter2), \
401///             when send initialize request";
402/// let redacted = redact_urls_in_text(line);
403/// assert!(!redacted.contains("hunter2"));
404/// assert!(redacted.contains("https://api.example.com/mcp?<redacted>"));
405/// assert!(redacted.contains("when send initialize request"));
406/// ```
407///
408/// An IPv6-literal authority is redacted correctly — `[`/`]` are RFC 3986
409/// authority syntax, not token boundaries, so the query string after them
410/// is still reached and hidden:
411///
412/// ```
413/// use mcp_execution_core::redact_urls_in_text;
414///
415/// let line = "error sending request for url (http://[::1]:1/mcp?token=hunter2), when send initialize request";
416/// let redacted = redact_urls_in_text(line);
417/// assert!(!redacted.contains("hunter2"));
418/// assert!(redacted.contains("http://[::1]:1/mcp?<redacted>"));
419/// ```
420///
421/// Text with no URL at all passes through unchanged, and a malformed
422/// "scheme" glued to secret-shaped text is redacted in full:
423///
424/// ```
425/// use mcp_execution_core::redact_urls_in_text;
426///
427/// assert_eq!(redact_urls_in_text("connecting to 127.0.0.1:18801"), "connecting to 127.0.0.1:18801");
428///
429/// let leaked = "weird ghp_leakedtoken://host.com/ token";
430/// let redacted = redact_urls_in_text(leaked);
431/// assert!(!redacted.contains("ghp_leakedtoken"));
432/// ```
433#[must_use]
434pub fn redact_urls_in_text(text: &str) -> String {
435    use std::fmt::Write as _;
436
437    let mut out = String::with_capacity(text.len());
438    let mut cursor = 0usize;
439
440    while let Some(relative) = text[cursor..].find("://") {
441        let separator = cursor + relative;
442
443        let mut start = separator;
444        for (i, c) in text[cursor..separator].char_indices().rev() {
445            if is_scheme_char(c) {
446                start = cursor + i;
447            } else {
448                break;
449            }
450        }
451
452        if start == separator {
453            // No scheme characters immediately precede "://" -- nothing to
454            // redact here, emit it literally and keep scanning past it.
455            out.push_str(&text[cursor..separator + 3]);
456            cursor = separator + 3;
457            continue;
458        }
459
460        if let Some(preceding) = text[..start].chars().next_back()
461            && !is_token_terminator(preceding)
462        {
463            // Bounded to `text[cursor..start]`, never `text[..start]`: widening
464            // past `cursor` would rewind `start` behind the start of the still-
465            // unemitted slice below, an out-of-order range that panics. Falling
466            // back to `cursor` itself (not `0`) when no terminator appears in
467            // that bounded window keeps the same invariant.
468            start = text[cursor..start]
469                .rfind(is_token_terminator)
470                .and_then(|i| {
471                    text[cursor + i..]
472                        .chars()
473                        .next()
474                        .map(|c| cursor + i + c.len_utf8())
475                })
476                .unwrap_or(cursor);
477        }
478
479        let mut end = text.len();
480        for (i, c) in text[separator + 3..].char_indices() {
481            if is_token_terminator(c) {
482                end = separator + 3 + i;
483                break;
484            }
485        }
486
487        let token = text[start..end].trim_end_matches([',', '.', ';', ':', '\\']);
488
489        out.push_str(&text[cursor..start]);
490        // Infallible: `String`'s `fmt::Write` impl never returns `Err`.
491        let _ = write!(out, "{:?}", RedactedUrl(token));
492        out.push_str(&text[start + token.len()..end]);
493        cursor = end;
494    }
495
496    out.push_str(&text[cursor..]);
497    out
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn redacted_map_values_keeps_keys_hides_values() {
506        let mut map = HashMap::new();
507        map.insert("Authorization".to_string(), "Bearer secret".to_string());
508
509        let debug_output = format!("{:?}", RedactedMapValues(&map));
510        assert!(debug_output.contains("Authorization"));
511        assert!(!debug_output.contains("Bearer secret"));
512    }
513
514    #[test]
515    fn redacted_items_hides_every_entry() {
516        let items = vec!["KEY=VALUE".to_string(), "just-a-secret".to_string()];
517        let debug_output = format!("{:?}", RedactedItems(&items));
518        assert!(!debug_output.contains("KEY=VALUE"));
519        assert!(!debug_output.contains("just-a-secret"));
520        assert_eq!(debug_output.matches(REDACTED_PLACEHOLDER).count(), 2);
521    }
522
523    #[test]
524    fn redacted_url_hides_userinfo() {
525        let debug_output = format!("{:?}", RedactedUrl("https://user:pass@host.com/path"));
526        assert_eq!(debug_output, "https://<redacted>@host.com/path");
527    }
528
529    #[test]
530    fn redacted_url_hides_query_string() {
531        let debug_output = format!(
532            "{:?}",
533            RedactedUrl("https://host.com/path?api_key=sk-secret")
534        );
535        assert_eq!(debug_output, "https://host.com/path?<redacted>");
536        assert!(!debug_output.contains("sk-secret"));
537    }
538
539    #[test]
540    fn redacted_url_hides_fragment() {
541        // M2: a fragment-only URL must be labeled `#<redacted>`, not
542        // `?<redacted>` — no query string was ever present.
543        let debug_output = format!("{:?}", RedactedUrl("https://host.com/path#sk-secret"));
544        assert_eq!(debug_output, "https://host.com/path#<redacted>");
545    }
546
547    #[test]
548    fn redacted_url_hides_userinfo_and_query_together() {
549        let debug_output = format!(
550            "{:?}",
551            RedactedUrl("https://user:pass@host.com/path?token=secret")
552        );
553        assert_eq!(debug_output, "https://<redacted>@host.com/path?<redacted>");
554    }
555
556    #[test]
557    fn redacted_url_leaves_plain_url_unchanged() {
558        let debug_output = format!("{:?}", RedactedUrl("https://api.example.com/mcp"));
559        assert_eq!(debug_output, "https://api.example.com/mcp");
560    }
561
562    #[test]
563    fn redacted_url_no_path_with_userinfo() {
564        let debug_output = format!("{:?}", RedactedUrl("https://user:pass@host.com?q=secret"));
565        assert_eq!(debug_output, "https://<redacted>@host.com?<redacted>");
566    }
567
568    #[test]
569    fn redacted_url_redacts_unparseable_input_entirely() {
570        let debug_output = format!("{:?}", RedactedUrl("not-a-url"));
571        assert_eq!(debug_output, REDACTED_PLACEHOLDER);
572    }
573
574    #[test]
575    fn redacted_url_uses_last_at_sign_for_authority_split() {
576        // A literal '@' can legally appear (percent-decoded) inside userinfo;
577        // splitting on the *last* '@' keeps the host resolution correct.
578        let debug_output = format!("{:?}", RedactedUrl("https://a@b:pass@host.com/path"));
579        assert_eq!(debug_output, "https://<redacted>@host.com/path");
580    }
581
582    #[test]
583    fn redacted_url_redacts_entirely_when_userinfo_contains_slash() {
584        // S1: an unencoded '/' inside a password moves the authority
585        // terminator into the middle of the credentials, so the naive split
586        // would leak them verbatim. The whole URL must be redacted instead.
587        let secret = "p/assw0rd";
588        let debug_output = format!(
589            "{:?}",
590            RedactedUrl(&format!("https://user:{secret}@host.com/mcp"))
591        );
592        assert_eq!(debug_output, REDACTED_PLACEHOLDER);
593        assert!(!debug_output.contains(secret));
594        assert!(!debug_output.contains("host.com"));
595    }
596
597    #[test]
598    fn redacted_url_redacts_entirely_when_userinfo_contains_query_marker() {
599        // Same ambiguity as above, via '?' instead of '/'.
600        let secret = "pa?ssw0rd";
601        let debug_output = format!(
602            "{:?}",
603            RedactedUrl(&format!("https://user:{secret}@host.com/mcp"))
604        );
605        assert_eq!(debug_output, REDACTED_PLACEHOLDER);
606        assert!(!debug_output.contains(secret));
607    }
608
609    #[test]
610    fn redacted_url_redacts_entirely_when_scheme_is_malformed() {
611        // M3: a scheme containing a character that can never legally appear
612        // in a URI scheme (e.g. '_') is a sign the "scheme" is actually
613        // secret-shaped text that happens to contain "://" — redact in full
614        // rather than echoing it verbatim.
615        let secret = "ghp_leakedtoken";
616        let debug_output = format!("{:?}", RedactedUrl(&format!("{secret}://host.com/")));
617        assert_eq!(debug_output, REDACTED_PLACEHOLDER);
618        assert!(!debug_output.contains(secret));
619    }
620
621    #[test]
622    fn redact_urls_in_text_hides_query_string_keeps_surrounding_prose() {
623        let line = "error sending request for url (https://api.example.invalid/mcp?token=hunter2secret), when send initialize request";
624        let redacted = redact_urls_in_text(line);
625        assert!(!redacted.contains("hunter2secret"));
626        assert!(redacted.contains("error sending request for url ("));
627        assert!(redacted.contains("https://api.example.invalid/mcp?<redacted>"));
628        assert!(redacted.contains("when send initialize request"));
629    }
630
631    #[test]
632    fn redact_urls_in_text_leaves_plain_text_unchanged() {
633        let line = "connecting to 127.0.0.1:18801 with no scheme";
634        assert_eq!(redact_urls_in_text(line), line);
635    }
636
637    #[test]
638    fn redact_urls_in_text_leaves_urls_without_secrets_unchanged() {
639        let line = "see https://docs.rs/rmcp for details";
640        assert_eq!(redact_urls_in_text(line), line);
641    }
642
643    #[test]
644    fn redact_urls_in_text_redacts_multiple_urls_in_one_line() {
645        let line = "two urls http://a.com/p?x=1 and http://b.com/q?y=2 done";
646        let redacted = redact_urls_in_text(line);
647        assert!(!redacted.contains("x=1"));
648        assert!(!redacted.contains("y=2"));
649        assert!(redacted.contains("http://a.com/p?<redacted>"));
650        assert!(redacted.contains("http://b.com/q?<redacted>"));
651        assert!(redacted.starts_with("two urls "));
652        assert!(redacted.ends_with(" done"));
653    }
654
655    #[test]
656    fn redact_urls_in_text_hides_glued_prefix_that_breaks_a_naive_scheme_walk() {
657        // Regression for the audit-flagged edge case: a naive left-walk over
658        // scheme characters stops at '_', would treat "leakedtoken" as a
659        // valid scheme, and would leave "ghp_leakedtoken" exposed --
660        // whereas `RedactedUrl` on the same whole string redacts it fully.
661        // Widening the token left to the nearest terminator before handing
662        // it to `RedactedUrl` must match that behavior exactly.
663        let secret = "ghp_leakedtoken";
664        let line = format!("weird {secret}://host.com/ token");
665        let redacted = redact_urls_in_text(&line);
666        assert!(!redacted.contains(secret));
667        assert!(!redacted.contains("leakedtoken"));
668        assert_eq!(redacted, format!("weird {REDACTED_PLACEHOLDER} token"));
669    }
670
671    #[test]
672    fn redact_urls_in_text_quoted_and_paren_wrapped_shapes() {
673        let quoted = redact_urls_in_text(
674            r#"url "https://user:hunter2@api.example.com/mcp?token=s3cr3t" failed"#,
675        );
676        assert!(!quoted.contains("hunter2"));
677        assert!(!quoted.contains("s3cr3t"));
678        assert!(quoted.contains(r#""https://<redacted>@api.example.com/mcp?<redacted>""#));
679
680        let paren = redact_urls_in_text(
681            "error sending request for url (http://127.0.0.1:1/mcp?token=REFUSEDSECRET), when send initialize request",
682        );
683        assert!(!paren.contains("REFUSEDSECRET"));
684        assert!(paren.contains("(http://127.0.0.1:1/mcp?<redacted>)"));
685    }
686
687    #[test]
688    fn redact_urls_in_text_handles_non_ascii_prose() {
689        let line = "unicode ünïcode https://h.example.com/p?t=sec end";
690        let redacted = redact_urls_in_text(line);
691        assert!(!redacted.contains("t=sec"));
692        assert!(redacted.contains("unicode ünïcode "));
693        assert!(redacted.ends_with(" end"));
694    }
695
696    #[test]
697    fn redact_urls_in_text_documents_residual_scheme_legal_glue_limitation() {
698        // Accepted parity limitation (see doc comment): a secret glued to a
699        // URL by scheme-*legal* characters is absorbed into the token's
700        // "scheme" and survives, identically to `RedactedUrl` on the same
701        // whole string. Pin the behavior so a future change doesn't silently
702        // alter it without updating the doc comment.
703        let line = "Bearer sk-abc.https://h.example.com/p?t=1 tail";
704        let redacted = redact_urls_in_text(line);
705        assert!(redacted.contains("sk-abc.https://h.example.com/p?<redacted>"));
706        assert!(redacted.ends_with(" tail"));
707    }
708
709    /// Regression for a critic-found panic (C1): a second `://` run whose left-widen step
710    /// scanned unbounded back to the start of `text` (instead of stopping at the previous
711    /// token's already-emitted end) could rewind `start` behind `cursor`, producing an
712    /// out-of-order range in a later slice and panicking. Each of these four inputs panicked on
713    /// the unfixed version; they must now redact without panicking.
714    #[test]
715    fn redact_urls_in_text_does_not_panic_on_adjacent_scheme_runs() {
716        for input in [
717            "_://a://b",
718            "msg: _://a://b",
719            "prefix ?://a_bc://x tail",
720            "ü://abc://x",
721        ] {
722            let _ = redact_urls_in_text(input);
723        }
724    }
725
726    /// Regression for the actual #353 vulnerability (C2): an IPv6-literal authority
727    /// (`http://[::1]:1/...`) must still be recognized and have its query string redacted.
728    /// `[`/`]` are RFC 3986 authority syntax, not prose delimiters, so they must not terminate
729    /// the token before the query string is reached -- unlike the pre-fix version, which cut the
730    /// token at `[`, leaving everything after it (the secret) exposed.
731    #[test]
732    fn redact_urls_in_text_redacts_ipv6_literal_authority_query_string() {
733        let line = "error sending request for url (http://[::1]:1/mcp?token=IPV6LEAKTEST), when send initialize request";
734        let redacted = redact_urls_in_text(line);
735        assert!(
736            !redacted.contains("IPV6LEAKTEST"),
737            "secret leaked: {redacted}"
738        );
739        assert!(redacted.contains("http://[::1]:1/mcp?<redacted>"));
740        assert!(redacted.contains("when send initialize request"));
741    }
742
743    /// Same IPv6 case without a trailing paren wrapper, to confirm the fix isn't an artifact of
744    /// that shape specifically.
745    #[test]
746    fn redact_urls_in_text_redacts_ipv6_literal_authority_at_end_of_text() {
747        let line = "connecting to http://[::1]:8080/mcp?token=IPV6LEAKTEST2";
748        let redacted = redact_urls_in_text(line);
749        assert!(
750            !redacted.contains("IPV6LEAKTEST2"),
751            "secret leaked: {redacted}"
752        );
753        assert!(redacted.contains("http://[::1]:8080/mcp?<redacted>"));
754    }
755
756    /// A query value containing `|`, `^`, or `\` -- RFC 3986 "unsafe" characters that can appear
757    /// raw in an unescaped secret -- must not truncate the token and leak the remainder, unlike
758    /// the pre-fix terminator set which treated all three as token boundaries.
759    #[test]
760    fn redact_urls_in_text_redacts_query_value_containing_unsafe_chars() {
761        let line = "url http://host.example.com/p?token=abc|def^ghi\\jkl failed";
762        let redacted = redact_urls_in_text(line);
763        // `assert_eq!` against the exact expected string, not just a `!contains` check: the
764        // pre-fix terminator set already passed a `!contains(secret)` assertion here too, since
765        // `abc` (before the first unsafe char) was already swallowed into the marker -- only an
766        // exact match on the whole line proves the *rest* of the query (`def^ghi\jkl`) didn't
767        // survive as trailing raw text after an early-truncated token.
768        assert_eq!(redacted, "url http://host.example.com/p?<redacted> failed");
769    }
770
771    /// Documents the residual gap left by narrowing the terminator set to close C2: a raw quote
772    /// or paren character *inside* the secret itself (not used by the log line to wrap the URL)
773    /// still ends the token early. Everything in the query *before* the embedded quote is safely
774    /// swallowed into the redaction marker, but the quote is still treated as a token-ending
775    /// wrapper, so whatever follows it survives verbatim -- the same class of ambiguity
776    /// `RedactedUrl` accepts for an unencoded `/` or `?` inside userinfo. Pinned so a future
777    /// change to the terminator set doesn't silently alter this without updating the doc comment.
778    #[test]
779    fn redact_urls_in_text_residual_gap_raw_quote_inside_secret_still_truncates() {
780        let line = r#"url http://host.example.com/p?token=abc"def failed"#;
781        let redacted = redact_urls_in_text(line);
782        assert!(!redacted.contains("abc"));
783        assert!(redacted.contains("http://host.example.com/p?<redacted>"));
784        assert!(redacted.contains("def"));
785    }
786
787    /// Applying `redact_urls_in_text` to text that already contains a `RedactedUrl`-redacted URL
788    /// (e.g. a `ServerConfig` `Debug` line, then re-scanned by `RedactingWriter`) must not double
789    /// up the marker into `?<redacted><redacted>`. Since `[`/`]`/`<`/`>` are no longer terminators
790    /// (C2 fix), the whole already-redacted URL is re-captured as one token and handed back to
791    /// `RedactedUrl`, which reproduces the same output -- making the function idempotent on its
792    /// own output rather than needing a dedicated already-redacted check.
793    #[test]
794    fn redact_urls_in_text_is_idempotent_on_already_redacted_url() {
795        let line = "url http://127.0.0.1:1/mcp?<redacted> failed";
796        let redacted = redact_urls_in_text(line);
797        assert_eq!(redacted, line);
798    }
799
800    /// Regression test for the JSON-mode logging bug (`MCP_EXECUTION_LOG_FORMAT=json`): when
801    /// `redact_urls_in_text` runs on text that is itself a `serde_json`-escaped JSON string value
802    /// -- the shape `RedactingWriter` sees when it wraps a `tracing_subscriber` JSON formatter's
803    /// already-serialized output -- the backslash `serde_json` inserts before an escaped `"` must
804    /// survive redaction. Before the `'\\'` trim-set fix, the token walk absorbed that backslash
805    /// into the URL token and deleted it on replacement, leaving a bare unescaped `"` and invalid
806    /// JSON. Covers the quoted-URL shape (the one that reproduced the bug) plus four related
807    /// shapes that must keep working.
808    #[test]
809    fn redact_urls_in_text_output_stays_valid_json_after_escaping() {
810        let raw_lines = [
811            r#"failed to connect to "https://api.example.invalid/mcp?token=hunter2secret" after 3 tries"#,
812            "failed to connect to https://api.example.invalid/mcp?token=hunter2secret after 3 tries",
813            "failed to connect to (https://api.example.invalid/mcp?token=hunter2secret) after 3 tries",
814            "failed to connect to https://user:hunter2secret@api.example.invalid/mcp?x=1 after 3 tries",
815            "failed to connect to https://api.example.invalid/mcp?token=hunter2secret\\ after 3 tries",
816        ];
817
818        for raw in raw_lines {
819            let json_field = serde_json::to_string(raw).expect("string always encodes");
820            let redacted = redact_urls_in_text(&json_field);
821            assert!(
822                !redacted.contains("hunter2secret"),
823                "secret leaked for {raw:?}: {redacted}"
824            );
825
826            let json_line = format!(r#"{{"message":{redacted}}}"#);
827            serde_json::from_str::<serde_json::Value>(&json_line)
828                .unwrap_or_else(|e| panic!("invalid JSON for {raw:?}: {e}\n{json_line}"));
829        }
830    }
831
832    /// `SplitUrl`'s hand-written `Debug` impl (not derived) must redact `tail` the same way
833    /// `RedactedUrl` redacts a query string — a secret placed there must never appear in
834    /// `{:?}` output, even though `SplitUrl` itself is currently unreachable from outside the
835    /// crate.
836    #[test]
837    fn split_url_debug_redacts_tail() {
838        let secret = "sk-secret-token";
839        let url = format!("https://host.com/path?api_key={secret}");
840        let parts = split_url(&url).unwrap();
841        let debug_output = format!("{parts:?}");
842        assert!(!debug_output.contains(secret));
843        assert!(debug_output.contains(REDACTED_PLACEHOLDER));
844        assert!(debug_output.contains("host.com"));
845    }
846
847    /// Companion to the query case above: a fragment must be redacted the same way.
848    #[test]
849    fn split_url_debug_redacts_fragment_tail() {
850        let secret = "sk-secret-fragment";
851        let url = format!("https://host.com/path#{secret}");
852        let parts = split_url(&url).unwrap();
853        let debug_output = format!("{parts:?}");
854        assert!(!debug_output.contains(secret));
855        assert!(debug_output.contains(REDACTED_PLACEHOLDER));
856    }
857
858    /// A URL with no query/fragment must render `tail` as `None`, not a spurious placeholder.
859    #[test]
860    fn split_url_debug_shows_none_tail_when_absent() {
861        let parts = split_url("https://host.com/path").unwrap();
862        let debug_output = format!("{parts:?}");
863        assert!(debug_output.contains("tail: None"));
864    }
865}