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((scheme, rest)) = self.0.split_once("://") else {
148 return f.write_str(REDACTED_PLACEHOLDER);
149 };
150
151 let scheme_is_valid = !scheme.is_empty()
152 && scheme
153 .chars()
154 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
155 if !scheme_is_valid {
156 return f.write_str(REDACTED_PLACEHOLDER);
157 }
158
159 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
160 let (authority, remainder) = rest.split_at(authority_end);
161
162 // See the type doc comment: an `@` past the authority terminator
163 // means the terminator landed inside unencoded userinfo rather than
164 // at a true authority boundary, so the split can't be trusted.
165 if remainder.contains('@') {
166 return f.write_str(REDACTED_PLACEHOLDER);
167 }
168
169 let authority = authority.rfind('@').map_or_else(
170 || authority.to_string(),
171 |at| format!("{REDACTED_PLACEHOLDER}@{}", &authority[at + 1..]),
172 );
173
174 let separator_pos = remainder.find(['?', '#']);
175 let path = separator_pos.map_or(remainder, |pos| &remainder[..pos]);
176
177 write!(f, "{scheme}://{authority}{path}")?;
178 if let Some(pos) = separator_pos {
179 let separator = &remainder[pos..=pos];
180 write!(f, "{separator}{REDACTED_PLACEHOLDER}")?;
181 }
182 Ok(())
183 }
184}
185
186/// Characters legal in a URI scheme, per [`RedactedUrl`]'s own scheme check.
187const fn is_scheme_char(c: char) -> bool {
188 c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')
189}
190
191/// Characters that end a bare URL token embedded in prose: whitespace,
192/// control characters, and the punctuation a human or a log formatter
193/// commonly wraps a whole URL in (quotes, backtick, parens).
194///
195/// Deliberately narrow: `[`/`]` (RFC 3986 IP-literal delimiters, needed
196/// verbatim inside an IPv6 authority like `http://[::1]:1/path`) and every
197/// other RFC 3986 "unsafe" character (`<` `>` `{` `}` `\` `|` `^`) are *not*
198/// terminators here, even though none of them can legally appear unescaped
199/// in a URL. Terminating on them would truncate the token before the query
200/// string that [`RedactedUrl`] needs to see in full to redact it — a
201/// dependency's `Display` impl routinely embeds a query value verbatim,
202/// unescaped, exactly where this scan runs. Erring toward capturing *too
203/// much* surrounding text into the token (over-redaction) is safe; erring
204/// toward cutting a token short before its secret is not.
205fn is_token_terminator(c: char) -> bool {
206 c.is_whitespace() || c.is_control() || matches!(c, '"' | '\'' | '`' | '(' | ')')
207}
208
209/// Finds every URL-shaped token in `text` and redacts each one.
210///
211/// Each token is redacted by handing it to [`RedactedUrl`] — the actual masking decision (what
212/// counts as authority/query, what gets hidden) always defers to that one implementation, so it
213/// can't drift between the two.
214///
215/// Unlike [`RedactedUrl`], which redacts a value already isolated behind its
216/// own field, this scans arbitrary already-assembled text — typically a
217/// `reqwest`/`rmcp` transport error's `Display` output, which embeds the
218/// full request URL (query string included) inline in a sentence — and
219/// redacts each `scheme://…` run it finds in place, leaving the surrounding
220/// prose untouched.
221///
222/// A token's boundaries are found by walking left from `://` over
223/// scheme-legal characters, then walking right to the first whitespace,
224/// control character, or wrapping-punctuation character (quote, backtick,
225/// paren — or the end of `text`), then trimming trailing sentence
226/// punctuation (`,` `.` `;` `:`) that a log message or `Display` impl
227/// commonly appends after a URL. That terminator set is deliberately
228/// narrower than "every character invalid in a URL" — RFC 3986 IP-literal
229/// delimiters (`[`/`]`, needed verbatim for an IPv6 authority like
230/// `http://[::1]:1/path`) and every other RFC 3986 "unsafe" character are
231/// left out on purpose, because capturing too much into the token is the
232/// safe failure mode here, unlike [`RedactedUrl`]'s ambiguity handling,
233/// which fails closed by redacting a whole *field* in full. This function
234/// instead fails toward widening a *token*: it does not know a URL's true
235/// end any more precisely than "the next character a log line would use to
236/// wrap one", so a token capturing a few extra characters of trailing
237/// prose is the accepted cost of never capturing too few and truncating a
238/// secret.
239///
240/// The one residual gap from this heuristic: a raw, un-percent-encoded
241/// instance of *any* terminator character — whitespace and control
242/// characters, not just the quote/backtick/paren wrappers (`"` `'` `` ` ``
243/// `(` `)`), though the latter is the realistic case, since a real
244/// `reqwest`-sent URL would already have percent-encoded whitespace/control
245/// bytes — *inside* the secret itself, rather than used by the surrounding
246/// log line to wrap the URL, still ends the token early, exactly the same
247/// class of unencoded-delimiter ambiguity [`RedactedUrl`] documents for an
248/// unencoded `/` or `?` inside userinfo. Distinguishing the two would need
249/// a real URL parser; this module stays parse-free by design (see
250/// [`RedactedUrl`]'s doc comment).
251///
252/// One boundary case needs a deliberate widening step on the *left* side
253/// too: if the character immediately left of the scheme run is itself a
254/// non-scheme, non-terminator character (e.g. `ghp_leakedtoken://host.com/`,
255/// where `_` breaks the scheme-char walk but isn't a terminator either), the
256/// token is widened left to the nearest terminator before redaction.
257/// Without this, the walk would stop at `_`, treat `leakedtoken` as a valid
258/// scheme, and leave `ghp_leakedtoken` exposed — whereas `RedactedUrl` given
259/// that same text as a whole string redacts it in full, because
260/// `leakedtoken` alone isn't what the malformed-scheme check sees. Widening
261/// first makes the two agree: the widened token's "scheme"
262/// (`ghp_leakedtoken`) fails `RedactedUrl`'s validity check and so is
263/// redacted wholesale. This widening never looks further left than the end
264/// of the previous token this function already emitted — it cannot rewind
265/// into text it has already committed to the output.
266///
267/// Widening only ever runs when at least one scheme-legal character
268/// immediately precedes `://` (`leakedtoken` above): if the very first
269/// character to its left is itself not scheme-legal (e.g. `tok_://host.com/`,
270/// where `_` sits right against `://`), there is no scheme run to widen from
271/// at all, and the whole `://` is left untouched as ordinary text rather
272/// than redacted — not a realistic URL shape a dependency's `Display` impl
273/// would ever produce, so not a regression from before this function
274/// existed, but also not the same guarantee `RedactedUrl` gives a whole
275/// string containing that same text.
276///
277/// Known accepted limitation, inherited unchanged from `RedactedUrl`: a
278/// secret glued to a URL by scheme-*legal* characters (letters, digits,
279/// `+`, `-`, `.` — e.g. `Bearer sk-abc.https://h/p?t=1`) is absorbed into
280/// the token as part of its "scheme" and survives redaction, since nothing
281/// distinguishes it from a URL that legitimately has a long scheme name.
282/// This is exact parity with what `RedactedUrl` itself does when given that
283/// same string, not a gap introduced here, and is not worth a heuristic
284/// that would risk swallowing legitimate text instead.
285///
286/// # Examples
287///
288/// ```
289/// use mcp_execution_core::redact_urls_in_text;
290///
291/// let line = "error sending request for url (https://api.example.com/mcp?token=hunter2), \
292/// when send initialize request";
293/// let redacted = redact_urls_in_text(line);
294/// assert!(!redacted.contains("hunter2"));
295/// assert!(redacted.contains("https://api.example.com/mcp?<redacted>"));
296/// assert!(redacted.contains("when send initialize request"));
297/// ```
298///
299/// An IPv6-literal authority is redacted correctly — `[`/`]` are RFC 3986
300/// authority syntax, not token boundaries, so the query string after them
301/// is still reached and hidden:
302///
303/// ```
304/// use mcp_execution_core::redact_urls_in_text;
305///
306/// let line = "error sending request for url (http://[::1]:1/mcp?token=hunter2), when send initialize request";
307/// let redacted = redact_urls_in_text(line);
308/// assert!(!redacted.contains("hunter2"));
309/// assert!(redacted.contains("http://[::1]:1/mcp?<redacted>"));
310/// ```
311///
312/// Text with no URL at all passes through unchanged, and a malformed
313/// "scheme" glued to secret-shaped text is redacted in full:
314///
315/// ```
316/// use mcp_execution_core::redact_urls_in_text;
317///
318/// assert_eq!(redact_urls_in_text("connecting to 127.0.0.1:18801"), "connecting to 127.0.0.1:18801");
319///
320/// let leaked = "weird ghp_leakedtoken://host.com/ token";
321/// let redacted = redact_urls_in_text(leaked);
322/// assert!(!redacted.contains("ghp_leakedtoken"));
323/// ```
324#[must_use]
325pub fn redact_urls_in_text(text: &str) -> String {
326 use std::fmt::Write as _;
327
328 let mut out = String::with_capacity(text.len());
329 let mut cursor = 0usize;
330
331 while let Some(relative) = text[cursor..].find("://") {
332 let separator = cursor + relative;
333
334 let mut start = separator;
335 for (i, c) in text[cursor..separator].char_indices().rev() {
336 if is_scheme_char(c) {
337 start = cursor + i;
338 } else {
339 break;
340 }
341 }
342
343 if start == separator {
344 // No scheme characters immediately precede "://" -- nothing to
345 // redact here, emit it literally and keep scanning past it.
346 out.push_str(&text[cursor..separator + 3]);
347 cursor = separator + 3;
348 continue;
349 }
350
351 if let Some(preceding) = text[..start].chars().next_back()
352 && !is_token_terminator(preceding)
353 {
354 // Bounded to `text[cursor..start]`, never `text[..start]`: widening
355 // past `cursor` would rewind `start` behind the start of the still-
356 // unemitted slice below, an out-of-order range that panics. Falling
357 // back to `cursor` itself (not `0`) when no terminator appears in
358 // that bounded window keeps the same invariant.
359 start = text[cursor..start]
360 .rfind(is_token_terminator)
361 .and_then(|i| {
362 text[cursor + i..]
363 .chars()
364 .next()
365 .map(|c| cursor + i + c.len_utf8())
366 })
367 .unwrap_or(cursor);
368 }
369
370 let mut end = text.len();
371 for (i, c) in text[separator + 3..].char_indices() {
372 if is_token_terminator(c) {
373 end = separator + 3 + i;
374 break;
375 }
376 }
377
378 let token = text[start..end].trim_end_matches([',', '.', ';', ':']);
379
380 out.push_str(&text[cursor..start]);
381 // Infallible: `String`'s `fmt::Write` impl never returns `Err`.
382 let _ = write!(out, "{:?}", RedactedUrl(token));
383 out.push_str(&text[start + token.len()..end]);
384 cursor = end;
385 }
386
387 out.push_str(&text[cursor..]);
388 out
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn redacted_map_values_keeps_keys_hides_values() {
397 let mut map = HashMap::new();
398 map.insert("Authorization".to_string(), "Bearer secret".to_string());
399
400 let debug_output = format!("{:?}", RedactedMapValues(&map));
401 assert!(debug_output.contains("Authorization"));
402 assert!(!debug_output.contains("Bearer secret"));
403 }
404
405 #[test]
406 fn redacted_items_hides_every_entry() {
407 let items = vec!["KEY=VALUE".to_string(), "just-a-secret".to_string()];
408 let debug_output = format!("{:?}", RedactedItems(&items));
409 assert!(!debug_output.contains("KEY=VALUE"));
410 assert!(!debug_output.contains("just-a-secret"));
411 assert_eq!(debug_output.matches(REDACTED_PLACEHOLDER).count(), 2);
412 }
413
414 #[test]
415 fn redacted_url_hides_userinfo() {
416 let debug_output = format!("{:?}", RedactedUrl("https://user:pass@host.com/path"));
417 assert_eq!(debug_output, "https://<redacted>@host.com/path");
418 }
419
420 #[test]
421 fn redacted_url_hides_query_string() {
422 let debug_output = format!(
423 "{:?}",
424 RedactedUrl("https://host.com/path?api_key=sk-secret")
425 );
426 assert_eq!(debug_output, "https://host.com/path?<redacted>");
427 assert!(!debug_output.contains("sk-secret"));
428 }
429
430 #[test]
431 fn redacted_url_hides_fragment() {
432 // M2: a fragment-only URL must be labeled `#<redacted>`, not
433 // `?<redacted>` — no query string was ever present.
434 let debug_output = format!("{:?}", RedactedUrl("https://host.com/path#sk-secret"));
435 assert_eq!(debug_output, "https://host.com/path#<redacted>");
436 }
437
438 #[test]
439 fn redacted_url_hides_userinfo_and_query_together() {
440 let debug_output = format!(
441 "{:?}",
442 RedactedUrl("https://user:pass@host.com/path?token=secret")
443 );
444 assert_eq!(debug_output, "https://<redacted>@host.com/path?<redacted>");
445 }
446
447 #[test]
448 fn redacted_url_leaves_plain_url_unchanged() {
449 let debug_output = format!("{:?}", RedactedUrl("https://api.example.com/mcp"));
450 assert_eq!(debug_output, "https://api.example.com/mcp");
451 }
452
453 #[test]
454 fn redacted_url_no_path_with_userinfo() {
455 let debug_output = format!("{:?}", RedactedUrl("https://user:pass@host.com?q=secret"));
456 assert_eq!(debug_output, "https://<redacted>@host.com?<redacted>");
457 }
458
459 #[test]
460 fn redacted_url_redacts_unparseable_input_entirely() {
461 let debug_output = format!("{:?}", RedactedUrl("not-a-url"));
462 assert_eq!(debug_output, REDACTED_PLACEHOLDER);
463 }
464
465 #[test]
466 fn redacted_url_uses_last_at_sign_for_authority_split() {
467 // A literal '@' can legally appear (percent-decoded) inside userinfo;
468 // splitting on the *last* '@' keeps the host resolution correct.
469 let debug_output = format!("{:?}", RedactedUrl("https://a@b:pass@host.com/path"));
470 assert_eq!(debug_output, "https://<redacted>@host.com/path");
471 }
472
473 #[test]
474 fn redacted_url_redacts_entirely_when_userinfo_contains_slash() {
475 // S1: an unencoded '/' inside a password moves the authority
476 // terminator into the middle of the credentials, so the naive split
477 // would leak them verbatim. The whole URL must be redacted instead.
478 let secret = "p/assw0rd";
479 let debug_output = format!(
480 "{:?}",
481 RedactedUrl(&format!("https://user:{secret}@host.com/mcp"))
482 );
483 assert_eq!(debug_output, REDACTED_PLACEHOLDER);
484 assert!(!debug_output.contains(secret));
485 assert!(!debug_output.contains("host.com"));
486 }
487
488 #[test]
489 fn redacted_url_redacts_entirely_when_userinfo_contains_query_marker() {
490 // Same ambiguity as above, via '?' instead of '/'.
491 let secret = "pa?ssw0rd";
492 let debug_output = format!(
493 "{:?}",
494 RedactedUrl(&format!("https://user:{secret}@host.com/mcp"))
495 );
496 assert_eq!(debug_output, REDACTED_PLACEHOLDER);
497 assert!(!debug_output.contains(secret));
498 }
499
500 #[test]
501 fn redacted_url_redacts_entirely_when_scheme_is_malformed() {
502 // M3: a scheme containing a character that can never legally appear
503 // in a URI scheme (e.g. '_') is a sign the "scheme" is actually
504 // secret-shaped text that happens to contain "://" — redact in full
505 // rather than echoing it verbatim.
506 let secret = "ghp_leakedtoken";
507 let debug_output = format!("{:?}", RedactedUrl(&format!("{secret}://host.com/")));
508 assert_eq!(debug_output, REDACTED_PLACEHOLDER);
509 assert!(!debug_output.contains(secret));
510 }
511
512 #[test]
513 fn redact_urls_in_text_hides_query_string_keeps_surrounding_prose() {
514 let line = "error sending request for url (https://api.example.invalid/mcp?token=hunter2secret), when send initialize request";
515 let redacted = redact_urls_in_text(line);
516 assert!(!redacted.contains("hunter2secret"));
517 assert!(redacted.contains("error sending request for url ("));
518 assert!(redacted.contains("https://api.example.invalid/mcp?<redacted>"));
519 assert!(redacted.contains("when send initialize request"));
520 }
521
522 #[test]
523 fn redact_urls_in_text_leaves_plain_text_unchanged() {
524 let line = "connecting to 127.0.0.1:18801 with no scheme";
525 assert_eq!(redact_urls_in_text(line), line);
526 }
527
528 #[test]
529 fn redact_urls_in_text_leaves_urls_without_secrets_unchanged() {
530 let line = "see https://docs.rs/rmcp for details";
531 assert_eq!(redact_urls_in_text(line), line);
532 }
533
534 #[test]
535 fn redact_urls_in_text_redacts_multiple_urls_in_one_line() {
536 let line = "two urls http://a.com/p?x=1 and http://b.com/q?y=2 done";
537 let redacted = redact_urls_in_text(line);
538 assert!(!redacted.contains("x=1"));
539 assert!(!redacted.contains("y=2"));
540 assert!(redacted.contains("http://a.com/p?<redacted>"));
541 assert!(redacted.contains("http://b.com/q?<redacted>"));
542 assert!(redacted.starts_with("two urls "));
543 assert!(redacted.ends_with(" done"));
544 }
545
546 #[test]
547 fn redact_urls_in_text_hides_glued_prefix_that_breaks_a_naive_scheme_walk() {
548 // Regression for the audit-flagged edge case: a naive left-walk over
549 // scheme characters stops at '_', would treat "leakedtoken" as a
550 // valid scheme, and would leave "ghp_leakedtoken" exposed --
551 // whereas `RedactedUrl` on the same whole string redacts it fully.
552 // Widening the token left to the nearest terminator before handing
553 // it to `RedactedUrl` must match that behavior exactly.
554 let secret = "ghp_leakedtoken";
555 let line = format!("weird {secret}://host.com/ token");
556 let redacted = redact_urls_in_text(&line);
557 assert!(!redacted.contains(secret));
558 assert!(!redacted.contains("leakedtoken"));
559 assert_eq!(redacted, format!("weird {REDACTED_PLACEHOLDER} token"));
560 }
561
562 #[test]
563 fn redact_urls_in_text_quoted_and_paren_wrapped_shapes() {
564 let quoted = redact_urls_in_text(
565 r#"url "https://user:hunter2@api.example.com/mcp?token=s3cr3t" failed"#,
566 );
567 assert!(!quoted.contains("hunter2"));
568 assert!(!quoted.contains("s3cr3t"));
569 assert!(quoted.contains(r#""https://<redacted>@api.example.com/mcp?<redacted>""#));
570
571 let paren = redact_urls_in_text(
572 "error sending request for url (http://127.0.0.1:1/mcp?token=REFUSEDSECRET), when send initialize request",
573 );
574 assert!(!paren.contains("REFUSEDSECRET"));
575 assert!(paren.contains("(http://127.0.0.1:1/mcp?<redacted>)"));
576 }
577
578 #[test]
579 fn redact_urls_in_text_handles_non_ascii_prose() {
580 let line = "unicode ünïcode https://h.example.com/p?t=sec end";
581 let redacted = redact_urls_in_text(line);
582 assert!(!redacted.contains("t=sec"));
583 assert!(redacted.contains("unicode ünïcode "));
584 assert!(redacted.ends_with(" end"));
585 }
586
587 #[test]
588 fn redact_urls_in_text_documents_residual_scheme_legal_glue_limitation() {
589 // Accepted parity limitation (see doc comment): a secret glued to a
590 // URL by scheme-*legal* characters is absorbed into the token's
591 // "scheme" and survives, identically to `RedactedUrl` on the same
592 // whole string. Pin the behavior so a future change doesn't silently
593 // alter it without updating the doc comment.
594 let line = "Bearer sk-abc.https://h.example.com/p?t=1 tail";
595 let redacted = redact_urls_in_text(line);
596 assert!(redacted.contains("sk-abc.https://h.example.com/p?<redacted>"));
597 assert!(redacted.ends_with(" tail"));
598 }
599
600 /// Regression for a critic-found panic (C1): a second `://` run whose left-widen step
601 /// scanned unbounded back to the start of `text` (instead of stopping at the previous
602 /// token's already-emitted end) could rewind `start` behind `cursor`, producing an
603 /// out-of-order range in a later slice and panicking. Each of these four inputs panicked on
604 /// the unfixed version; they must now redact without panicking.
605 #[test]
606 fn redact_urls_in_text_does_not_panic_on_adjacent_scheme_runs() {
607 for input in [
608 "_://a://b",
609 "msg: _://a://b",
610 "prefix ?://a_bc://x tail",
611 "ü://abc://x",
612 ] {
613 let _ = redact_urls_in_text(input);
614 }
615 }
616
617 /// Regression for the actual #353 vulnerability (C2): an IPv6-literal authority
618 /// (`http://[::1]:1/...`) must still be recognized and have its query string redacted.
619 /// `[`/`]` are RFC 3986 authority syntax, not prose delimiters, so they must not terminate
620 /// the token before the query string is reached -- unlike the pre-fix version, which cut the
621 /// token at `[`, leaving everything after it (the secret) exposed.
622 #[test]
623 fn redact_urls_in_text_redacts_ipv6_literal_authority_query_string() {
624 let line = "error sending request for url (http://[::1]:1/mcp?token=IPV6LEAKTEST), when send initialize request";
625 let redacted = redact_urls_in_text(line);
626 assert!(
627 !redacted.contains("IPV6LEAKTEST"),
628 "secret leaked: {redacted}"
629 );
630 assert!(redacted.contains("http://[::1]:1/mcp?<redacted>"));
631 assert!(redacted.contains("when send initialize request"));
632 }
633
634 /// Same IPv6 case without a trailing paren wrapper, to confirm the fix isn't an artifact of
635 /// that shape specifically.
636 #[test]
637 fn redact_urls_in_text_redacts_ipv6_literal_authority_at_end_of_text() {
638 let line = "connecting to http://[::1]:8080/mcp?token=IPV6LEAKTEST2";
639 let redacted = redact_urls_in_text(line);
640 assert!(
641 !redacted.contains("IPV6LEAKTEST2"),
642 "secret leaked: {redacted}"
643 );
644 assert!(redacted.contains("http://[::1]:8080/mcp?<redacted>"));
645 }
646
647 /// A query value containing `|`, `^`, or `\` -- RFC 3986 "unsafe" characters that can appear
648 /// raw in an unescaped secret -- must not truncate the token and leak the remainder, unlike
649 /// the pre-fix terminator set which treated all three as token boundaries.
650 #[test]
651 fn redact_urls_in_text_redacts_query_value_containing_unsafe_chars() {
652 let line = "url http://host.example.com/p?token=abc|def^ghi\\jkl failed";
653 let redacted = redact_urls_in_text(line);
654 // `assert_eq!` against the exact expected string, not just a `!contains` check: the
655 // pre-fix terminator set already passed a `!contains(secret)` assertion here too, since
656 // `abc` (before the first unsafe char) was already swallowed into the marker -- only an
657 // exact match on the whole line proves the *rest* of the query (`def^ghi\jkl`) didn't
658 // survive as trailing raw text after an early-truncated token.
659 assert_eq!(redacted, "url http://host.example.com/p?<redacted> failed");
660 }
661
662 /// Documents the residual gap left by narrowing the terminator set to close C2: a raw quote
663 /// or paren character *inside* the secret itself (not used by the log line to wrap the URL)
664 /// still ends the token early. Everything in the query *before* the embedded quote is safely
665 /// swallowed into the redaction marker, but the quote is still treated as a token-ending
666 /// wrapper, so whatever follows it survives verbatim -- the same class of ambiguity
667 /// `RedactedUrl` accepts for an unencoded `/` or `?` inside userinfo. Pinned so a future
668 /// change to the terminator set doesn't silently alter this without updating the doc comment.
669 #[test]
670 fn redact_urls_in_text_residual_gap_raw_quote_inside_secret_still_truncates() {
671 let line = r#"url http://host.example.com/p?token=abc"def failed"#;
672 let redacted = redact_urls_in_text(line);
673 assert!(!redacted.contains("abc"));
674 assert!(redacted.contains("http://host.example.com/p?<redacted>"));
675 assert!(redacted.contains("def"));
676 }
677
678 /// Applying `redact_urls_in_text` to text that already contains a `RedactedUrl`-redacted URL
679 /// (e.g. a `ServerConfig` `Debug` line, then re-scanned by `RedactingWriter`) must not double
680 /// up the marker into `?<redacted><redacted>`. Since `[`/`]`/`<`/`>` are no longer terminators
681 /// (C2 fix), the whole already-redacted URL is re-captured as one token and handed back to
682 /// `RedactedUrl`, which reproduces the same output -- making the function idempotent on its
683 /// own output rather than needing a dedicated already-redacted check.
684 #[test]
685 fn redact_urls_in_text_is_idempotent_on_already_redacted_url() {
686 let line = "url http://127.0.0.1:1/mcp?<redacted> failed";
687 let redacted = redact_urls_in_text(line);
688 assert_eq!(redacted, line);
689 }
690}