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}