1use crate::theme::FastMcpTheme;
18use rich_rust::prelude::*;
19use rich_rust::renderables::Renderable;
20use std::io::{self, Write};
21use std::sync::{Mutex, MutexGuard, OnceLock};
22
23pub(crate) const DEFAULT_TERMINAL_FIELD_MAX_CHARS: usize = 512;
24pub(crate) const DEFAULT_LOG_MESSAGE_MAX_CHARS: usize = 2_048;
25pub(crate) const REDACTED_VALUE: &str = "[REDACTED]";
26const TERMINAL_TEXT_HARD_MAX_CHARS: usize = 4_096;
27const TERMINAL_TRUNCATION_MARKER: &str = "...";
28const CREDENTIAL_KEY_SCAN_MAX_CHARS: usize = 256;
29
30#[derive(Clone, Debug, Eq, Hash, PartialEq)]
37pub struct UntrustedDisplayText(String);
38
39impl UntrustedDisplayText {
40 #[must_use]
42 pub fn new(text: &str) -> Self {
43 Self::with_max_chars(text, DEFAULT_TERMINAL_FIELD_MAX_CHARS)
44 }
45
46 #[must_use]
50 pub fn with_max_chars(text: &str, max_chars: usize) -> Self {
51 Self(bounded_redacted_terminal_text(text, max_chars))
52 }
53
54 #[must_use]
56 pub fn as_str(&self) -> &str {
57 &self.0
58 }
59}
60
61impl std::fmt::Display for UntrustedDisplayText {
62 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 formatter.write_str(self.as_str())
64 }
65}
66
67pub fn is_credential_key(key: &str) -> bool {
69 if key
73 .chars()
74 .take(CREDENTIAL_KEY_SCAN_MAX_CHARS.saturating_add(1))
75 .count()
76 > CREDENTIAL_KEY_SCAN_MAX_CHARS
77 {
78 return true;
79 }
80
81 let compact = compact_credential_key(key);
86 let exact_compact = matches!(
87 compact.as_str(),
88 "authorization"
89 | "auth"
90 | "token"
91 | "secret"
92 | "credential"
93 | "credentials"
94 | "cookie"
95 | "password"
96 | "passphrase"
97 | "signature"
98 | "apikey"
99 | "privatekey"
100 | "codeverifier"
101 | "setcookie"
102 | "accesstoken"
103 | "refreshtoken"
104 | "clientsecret"
105 | "idtoken"
106 | "xamzcredential"
107 | "xamzsignature"
108 | "awsaccesskeyid"
109 | "awssecretaccesskey"
110 );
111
112 let words = credential_key_words(key);
113 let word_refs: Vec<&str> = words.iter().map(String::as_str).collect();
114 let benign_metadata_suffix = word_refs.len() > 1
115 && word_refs.last().is_some_and(|word| {
116 matches!(
117 *word,
118 "algorithm" | "count" | "hint" | "length" | "name" | "policy" | "type" | "version"
119 )
120 });
121 let exact_single = matches!(
122 word_refs.as_slice(),
123 ["authorization"
124 | "auth"
125 | "token"
126 | "secret"
127 | "credential"
128 | "credentials"
129 | "cookie"
130 | "password"
131 | "passphrase"
132 | "signature"]
133 );
134 let sensitive_suffix = !benign_metadata_suffix
135 && (word_refs.last().is_some_and(|word| {
136 matches!(
137 *word,
138 "token"
139 | "secret"
140 | "credential"
141 | "credentials"
142 | "password"
143 | "passphrase"
144 | "authorization"
145 | "auth"
146 | "signature"
147 )
148 }) || word_refs.ends_with(&["api", "key"])
149 || word_refs.ends_with(&["private", "key"])
150 || word_refs.ends_with(&["code", "verifier"])
151 || word_refs.ends_with(&["set", "cookie"]));
152 let sensitive_prefix = word_refs.first().is_some_and(|word| {
153 matches!(
154 *word,
155 "token"
156 | "secret"
157 | "credential"
158 | "credentials"
159 | "password"
160 | "passphrase"
161 | "authorization"
162 | "auth"
163 | "signature"
164 )
165 }) && word_refs.len() > 1
166 && !benign_metadata_suffix;
167
168 let compact_sensitive_suffix = !benign_metadata_suffix
172 && !matches!(
173 compact.as_str(),
174 "authentication" | "tokenizer" | "passwordless"
175 )
176 && [
177 "accesstoken",
178 "refreshtoken",
179 "clientsecret",
180 "idtoken",
181 "apikey",
182 "privatekey",
183 "codeverifier",
184 "setcookie",
185 "passphrase",
186 "password",
187 "credentials",
188 "credential",
189 "signature",
190 "secret",
191 "token",
192 "cookie",
193 "auth",
194 "authorization",
195 ]
196 .iter()
197 .any(|suffix| compact.ends_with(suffix));
198
199 exact_compact
200 || exact_single
201 || sensitive_suffix
202 || sensitive_prefix
203 || compact_sensitive_suffix
204}
205
206fn compact_credential_key(key: &str) -> String {
207 key.chars()
208 .filter(char::is_ascii_alphanumeric)
209 .map(|character| character.to_ascii_lowercase())
210 .collect()
211}
212
213fn credential_key_words(key: &str) -> Vec<String> {
214 let characters: Vec<char> = key.chars().collect();
215 let mut words = Vec::new();
216 let mut current = String::new();
217
218 for (index, character) in characters.iter().copied().enumerate() {
219 if !character.is_ascii_alphanumeric() {
220 if !current.is_empty() {
221 words.push(std::mem::take(&mut current));
222 }
223 continue;
224 }
225
226 let previous = index.checked_sub(1).and_then(|index| characters.get(index));
227 let next = characters.get(index + 1);
228 let camel_boundary = !current.is_empty()
229 && character.is_ascii_uppercase()
230 && (previous.is_some_and(|previous| {
231 previous.is_ascii_lowercase() || previous.is_ascii_digit()
232 }) || (previous.is_some_and(char::is_ascii_uppercase)
233 && next.is_some_and(char::is_ascii_lowercase)));
234 if camel_boundary {
235 words.push(std::mem::take(&mut current));
236 }
237 current.push(character.to_ascii_lowercase());
238 }
239 if !current.is_empty() {
240 words.push(current);
241 }
242 words
243}
244
245pub fn redact_free_text_credentials(text: &str) -> String {
247 redact_free_text_credentials_with(text, REDACTED_VALUE)
248}
249
250pub fn redact_free_text_credentials_with(text: &str, replacement: &str) -> String {
256 if text.is_empty() {
257 return String::new();
258 }
259
260 let without_userinfo = redact_uri_userinfo(text, replacement);
264 let without_assignments = redact_credential_assignments(&without_userinfo, replacement);
265 redact_standalone_bearer(&without_assignments, replacement)
266}
267
268fn redact_uri_userinfo(text: &str, replacement: &str) -> String {
269 let bytes = text.as_bytes();
270 let mut output = String::with_capacity(text.len());
271 let mut emitted_through = 0usize;
272 let mut search_from = 0usize;
273
274 while let Some(relative_colon) = text[search_from..].find("://") {
275 let authority_start = search_from + relative_colon + 3;
276 let mut authority_end = authority_start;
277 while authority_end < bytes.len()
278 && !matches!(
279 bytes[authority_end],
280 b'/' | b'?' | b'#' | b'\r' | b'\n' | b' ' | b'\t'
281 )
282 {
283 authority_end += 1;
284 }
285
286 let userinfo_end = bytes[authority_start..authority_end]
287 .iter()
288 .rposition(|byte| *byte == b'@')
289 .map(|relative| authority_start + relative);
290 if let Some(userinfo_end) = userinfo_end.filter(|end| *end > authority_start) {
291 output.push_str(&text[emitted_through..authority_start]);
292 output.push_str(replacement);
293 emitted_through = userinfo_end;
294 }
295
296 search_from = authority_end.max(authority_start);
297 if search_from >= bytes.len() {
298 break;
299 }
300 }
301
302 if emitted_through == 0 {
303 return text.to_owned();
304 }
305 output.push_str(&text[emitted_through..]);
306 output
307}
308
309fn redact_credential_assignments(text: &str, replacement: &str) -> String {
310 let bytes = text.as_bytes();
311 let mut output = String::with_capacity(text.len());
312 let mut emitted_through = 0usize;
313 let mut cursor = 0usize;
314
315 while cursor < bytes.len() {
316 let separator = bytes[cursor];
317 if !matches!(separator, b':' | b'=') {
318 cursor += 1;
319 continue;
320 }
321
322 let Some((key, quoted_key)) = key_before_separator(text, cursor) else {
323 cursor += 1;
324 continue;
325 };
326 if !is_credential_key(key) {
327 cursor += 1;
328 continue;
329 }
330
331 let mut value_start = cursor + 1;
332 while value_start < bytes.len() && matches!(bytes[value_start], b' ' | b'\t') {
333 value_start += 1;
334 }
335 if value_start >= bytes.len() || matches!(bytes[value_start], b'\r' | b'\n') {
336 cursor += 1;
337 continue;
338 }
339
340 let authorization = is_authorization_key(key);
341 let cookie_header = is_cookie_key(key);
342 let header_value = separator == b':' && !quoted_key;
343 let Some((redaction_start, redaction_end)) = credential_value_range(
344 text,
345 value_start,
346 authorization,
347 header_value,
348 header_value && cookie_header,
349 ) else {
350 cursor += 1;
351 continue;
352 };
353
354 output.push_str(&text[emitted_through..redaction_start]);
355 output.push_str(replacement);
356 emitted_through = redaction_end;
357 cursor = redaction_end.max(cursor + 1);
358 }
359
360 if emitted_through == 0 {
361 return text.to_owned();
362 }
363 output.push_str(&text[emitted_through..]);
364 output
365}
366
367fn key_before_separator(text: &str, separator: usize) -> Option<(&str, bool)> {
368 let bytes = text.as_bytes();
369 let mut key_end = separator;
370 while key_end > 0 && matches!(bytes[key_end - 1], b' ' | b'\t') {
371 key_end -= 1;
372 }
373
374 let quoted_key = key_end > 0 && matches!(bytes[key_end - 1], b'\'' | b'"');
375 if quoted_key {
376 key_end -= 1;
377 }
378
379 let mut key_start = key_end;
380 while key_start > 0 && credential_key_byte(bytes[key_start - 1]) {
381 key_start -= 1;
382 }
383 (key_start < key_end).then(|| (&text[key_start..key_end], quoted_key))
384}
385
386fn credential_key_byte(byte: u8) -> bool {
387 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
388}
389
390fn is_authorization_key(key: &str) -> bool {
391 let compact = compact_credential_key(key);
392 let words = credential_key_words(key);
393 is_credential_key(key)
394 && (compact.ends_with("authorization")
395 || compact.ends_with("auth")
396 || words
397 .iter()
398 .any(|word| matches!(word.as_str(), "auth" | "authorization")))
399}
400
401fn is_cookie_key(key: &str) -> bool {
402 let compact = compact_credential_key(key);
403 let words = credential_key_words(key);
404 let word_refs: Vec<&str> = words.iter().map(String::as_str).collect();
405 is_credential_key(key)
406 && (compact.ends_with("cookie")
407 || matches!(word_refs.as_slice(), ["cookie"])
408 || word_refs.ends_with(&["set", "cookie"]))
409}
410
411fn credential_value_range(
412 text: &str,
413 value_start: usize,
414 authorization: bool,
415 header_value: bool,
416 cookie_header_value: bool,
417) -> Option<(usize, usize)> {
418 let bytes = text.as_bytes();
419 let quote = matches!(bytes[value_start], b'\'' | b'"').then_some(bytes[value_start]);
420 let content_start = value_start + usize::from(quote.is_some());
421 let content_end = if let Some(quote) = quote {
422 closing_quote_or_line_end(bytes, content_start, quote)
423 } else if authorization && header_value {
424 line_end(bytes, content_start)
425 } else if authorization {
426 authorization_value_end(bytes, content_start)
427 } else if cookie_header_value {
428 line_end(bytes, content_start)
429 } else {
430 ordinary_value_end(bytes, content_start)
431 };
432 if content_start >= content_end {
433 return None;
434 }
435
436 let redaction_start = if authorization {
437 authorization_scheme_end(text, content_start, content_end).unwrap_or(content_start)
438 } else {
439 content_start
440 };
441 (redaction_start < content_end).then_some((redaction_start, content_end))
442}
443
444fn closing_quote_or_line_end(bytes: &[u8], start: usize, quote: u8) -> usize {
445 let mut cursor = start;
446 let mut escaped = false;
447 while cursor < bytes.len() {
448 let byte = bytes[cursor];
449 if matches!(byte, b'\r' | b'\n') {
450 return cursor;
451 }
452 if byte == quote && !escaped {
453 return cursor;
454 }
455 if byte == b'\\' {
456 escaped = !escaped;
457 } else {
458 escaped = false;
459 }
460 cursor += 1;
461 }
462 cursor
463}
464
465fn authorization_value_end(bytes: &[u8], start: usize) -> usize {
466 let mut cursor = start;
467 let mut quote = None;
468 let mut escaped = false;
469 while cursor < bytes.len() {
470 let byte = bytes[cursor];
471 if matches!(byte, b'\r' | b'\n') {
472 break;
473 }
474 if let Some(active_quote) = quote {
475 if byte == active_quote && !escaped {
476 quote = None;
477 }
478 if byte == b'\\' {
479 escaped = !escaped;
480 } else {
481 escaped = false;
482 }
483 } else if matches!(byte, b'\'' | b'"') {
484 quote = Some(byte);
485 escaped = false;
486 } else if matches!(byte, b'&' | b'#') {
487 break;
488 }
489 cursor += 1;
490 }
491 cursor
492}
493
494fn ordinary_value_end(bytes: &[u8], start: usize) -> usize {
495 let mut cursor = start;
496 while cursor < bytes.len() && !ordinary_value_delimiter(bytes[cursor]) {
497 cursor += 1;
498 }
499 cursor
500}
501
502fn ordinary_value_delimiter(byte: u8) -> bool {
503 matches!(
504 byte,
505 b' ' | b'\t' | b'\r' | b'\n' | b',' | b';' | b'&' | b'#' | b')' | b']' | b'}'
506 )
507}
508
509fn line_end(bytes: &[u8], start: usize) -> usize {
510 let mut cursor = start;
511 while cursor < bytes.len() && !matches!(bytes[cursor], b'\r' | b'\n') {
512 cursor += 1;
513 }
514 cursor
515}
516
517fn authorization_scheme_end(text: &str, start: usize, end: usize) -> Option<usize> {
518 let bytes = text.as_bytes();
519 let mut scheme_end = start;
520 while scheme_end < end
521 && (bytes[scheme_end].is_ascii_alphanumeric() || bytes[scheme_end] == b'-')
522 {
523 scheme_end += 1;
524 }
525 if scheme_end == start || scheme_end >= end || !matches!(bytes[scheme_end], b' ' | b'\t') {
526 return None;
527 }
528
529 let scheme = &text[start..scheme_end];
530 if !["basic", "bearer", "digest", "negotiate", "aws4-hmac-sha256"]
531 .iter()
532 .any(|candidate| scheme.eq_ignore_ascii_case(candidate))
533 {
534 return None;
535 }
536
537 while scheme_end < end && matches!(bytes[scheme_end], b' ' | b'\t') {
538 scheme_end += 1;
539 }
540 Some(scheme_end)
541}
542
543fn redact_standalone_bearer(text: &str, replacement: &str) -> String {
544 let bytes = text.as_bytes();
545 let mut output = String::with_capacity(text.len());
546 let mut emitted_through = 0usize;
547 let mut cursor = 0usize;
548
549 while cursor + "bearer".len() <= bytes.len() {
550 let word_end = cursor + "bearer".len();
551 let has_word_boundaries = (cursor == 0
552 || !matches!(bytes[cursor - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_'))
553 && word_end < bytes.len()
554 && matches!(bytes[word_end], b' ' | b'\t');
555 if !has_word_boundaries || !bytes[cursor..word_end].eq_ignore_ascii_case(b"bearer") {
556 cursor += 1;
557 continue;
558 }
559
560 let mut value_start = word_end;
561 while value_start < bytes.len() && matches!(bytes[value_start], b' ' | b'\t') {
562 value_start += 1;
563 }
564 if value_start >= bytes.len() || matches!(bytes[value_start], b'\r' | b'\n') {
565 cursor = word_end;
566 continue;
567 }
568 let replacement_end = value_start.saturating_add(replacement.len());
569 let starts_with_replacement = !replacement.is_empty()
570 && text[value_start..].starts_with(replacement)
571 && replacement_end <= bytes.len();
572 if starts_with_replacement
573 && (replacement_end == bytes.len() || ordinary_value_delimiter(bytes[replacement_end]))
574 {
575 cursor = replacement_end;
576 continue;
577 }
578
579 let (redaction_start, redaction_end) = if starts_with_replacement {
580 (value_start, ordinary_value_end(bytes, replacement_end))
584 } else if matches!(bytes[value_start], b'\'' | b'"') {
585 let quote = bytes[value_start];
586 let content_start = value_start + 1;
587 (
588 content_start,
589 closing_quote_or_line_end(bytes, content_start, quote),
590 )
591 } else {
592 (value_start, ordinary_value_end(bytes, value_start))
593 };
594 if redaction_start >= redaction_end {
595 cursor = word_end;
596 continue;
597 }
598
599 output.push_str(&text[emitted_through..redaction_start]);
600 output.push_str(replacement);
601 emitted_through = redaction_end;
602 cursor = redaction_end;
603 }
604
605 if emitted_through == 0 {
606 return text.to_owned();
607 }
608 output.push_str(&text[emitted_through..]);
609 output
610}
611
612pub(crate) fn terminal_text_is_unsafe(character: char) -> bool {
613 character.is_control()
614 || matches!(
615 character,
616 '\u{00ad}'
623 | '\u{034f}'
624 | '\u{061c}'
625 | '\u{115f}'..='\u{1160}'
626 | '\u{17b4}'..='\u{17b5}'
627 | '\u{180b}'..='\u{180f}'
628 | '\u{200b}'..='\u{200f}'
629 | '\u{2028}'
630 | '\u{2029}'
631 | '\u{202a}'..='\u{202e}'
632 | '\u{2060}'..='\u{206f}'
633 | '\u{3164}'
634 | '\u{fe00}'..='\u{fe0f}'
635 | '\u{feff}'
636 | '\u{ffa0}'
637 | '\u{fff0}'..='\u{fff8}'
638 | '\u{1bca0}'..='\u{1bca3}'
639 | '\u{1d173}'..='\u{1d17a}'
640 | '\u{e0000}'..='\u{e0fff}'
641 )
642}
643
644#[cfg(any(
645 test,
646 feature = "legacy-2024-11-05",
647 feature = "tasks",
648 feature = "apps"
649))]
650pub(crate) fn bounded_rich_text(text: &str, max_chars: usize) -> String {
656 bounded_terminal_text_impl(text, max_chars, true)
657}
658
659#[cfg(any(
660 test,
661 feature = "legacy-2024-11-05",
662 feature = "tasks",
663 feature = "apps"
664))]
665pub(crate) fn bounded_rich_fragment(text: &str, max_chars: usize) -> String {
671 protect_rich_fragment_right_boundary(bounded_rich_text(text, max_chars))
672}
673
674pub(crate) fn bounded_redacted_terminal_text(text: &str, max_chars: usize) -> String {
676 bounded_redacted_text_impl(text, max_chars, false)
677}
678
679pub(crate) fn bounded_redacted_rich_text(text: &str, max_chars: usize) -> String {
684 bounded_redacted_text_impl(text, max_chars, true)
685}
686
687pub(crate) fn bounded_redacted_rich_fragment(text: &str, max_chars: usize) -> String {
690 protect_rich_fragment_right_boundary(bounded_redacted_rich_text(text, max_chars))
691}
692
693fn protect_rich_fragment_right_boundary(mut fragment: String) -> String {
694 let trailing_backslashes = fragment
695 .as_bytes()
696 .iter()
697 .rev()
698 .take_while(|byte| **byte == b'\\')
699 .count();
700 fragment.reserve(trailing_backslashes);
701 fragment.extend(std::iter::repeat_n('\\', trailing_backslashes));
702 fragment
703}
704
705fn bounded_redacted_text_impl(text: &str, max_chars: usize, escape_markup: bool) -> String {
706 let max_chars = max_chars.min(TERMINAL_TEXT_HARD_MAX_CHARS);
707 if max_chars == 0 {
708 return String::new();
709 }
710
711 let scan_limit = max_chars.saturating_mul(4);
714 let mut characters = text.chars();
715 let bounded_input: String = characters.by_ref().take(scan_limit).collect();
716 let source_was_truncated = characters.next().is_some();
717 let redacted = redact_free_text_credentials(&bounded_input);
718 let rendered = bounded_terminal_text_impl(&redacted, max_chars, escape_markup);
719
720 if !source_was_truncated || rendered.ends_with(TERMINAL_TRUNCATION_MARKER) {
721 return rendered;
722 }
723 if max_chars <= TERMINAL_TRUNCATION_MARKER.len() {
724 return if rendered.is_empty() {
729 TERMINAL_TRUNCATION_MARKER.chars().take(max_chars).collect()
730 } else {
731 rendered
732 };
733 }
734
735 let mut rendered = bounded_terminal_text_impl(
736 &redacted,
737 max_chars - TERMINAL_TRUNCATION_MARKER.len(),
738 escape_markup,
739 );
740 if !rendered.ends_with(TERMINAL_TRUNCATION_MARKER) {
741 rendered.push_str(TERMINAL_TRUNCATION_MARKER);
742 }
743 rendered
744}
745
746fn bounded_terminal_text_impl(text: &str, max_chars: usize, escape_markup: bool) -> String {
747 let max_chars = max_chars.min(TERMINAL_TEXT_HARD_MAX_CHARS);
748 if max_chars == 0 {
749 return String::new();
750 }
751
752 let mut rendered = String::new();
753 let mut rendered_chars = 0usize;
754 let mut component_ends = Vec::new();
755 let mut truncated = false;
756
757 let mut characters = text.chars().peekable();
758 'render: while let Some(character) = characters.next() {
759 let component = if terminal_text_is_unsafe(character) {
760 character.escape_default().collect::<String>()
761 } else if escape_markup && character == '\\' {
762 let run_scan_limit = max_chars.saturating_sub(rendered_chars).saturating_add(1);
768 let mut run_length = 1usize;
769 while characters.peek() == Some(&'\\') {
770 if run_length >= run_scan_limit {
771 truncated = true;
772 break 'render;
773 }
774 characters.next();
775 run_length += 1;
776 }
777
778 if characters.peek() == Some(&'[') {
779 characters.next();
780 let mut escaped = "\\".repeat(run_length.saturating_mul(2).saturating_add(1));
781 escaped.push('[');
782 escaped
783 } else {
784 "\\".repeat(run_length)
785 }
786 } else if escape_markup && character == '[' {
787 "\\[".to_owned()
788 } else {
789 character.to_string()
790 };
791 let component_chars = component.chars().count();
792 if rendered_chars.saturating_add(component_chars) > max_chars {
793 truncated = true;
794 break;
795 }
796 rendered.push_str(&component);
797 rendered_chars += component_chars;
798 component_ends.push((rendered.len(), rendered_chars));
799 }
800
801 if !truncated {
802 return rendered;
803 }
804 if max_chars <= TERMINAL_TRUNCATION_MARKER.len() {
805 if !rendered.is_empty() {
806 return rendered;
807 }
808 return TERMINAL_TRUNCATION_MARKER.chars().take(max_chars).collect();
809 }
810
811 let retained_chars = max_chars - TERMINAL_TRUNCATION_MARKER.len();
812 while component_ends
813 .last()
814 .is_some_and(|(_, characters)| *characters > retained_chars)
815 {
816 component_ends.pop();
817 }
818 if let Some((byte_end, _)) = component_ends.last().copied() {
819 rendered.truncate(byte_end);
820 } else {
821 rendered.clear();
822 }
823 rendered.push_str(TERMINAL_TRUNCATION_MARKER);
824 rendered
825}
826
827pub struct FastMcpConsole {
842 inner: Mutex<Console>,
843 enabled: bool,
844 theme: &'static FastMcpTheme,
845}
846
847impl FastMcpConsole {
848 fn lock_inner(&self) -> MutexGuard<'_, Console> {
849 self.inner
850 .lock()
851 .unwrap_or_else(std::sync::PoisonError::into_inner)
852 }
853
854 #[must_use]
856 pub fn new() -> Self {
857 let enabled = crate::detection::should_enable_rich();
858 Self::with_enabled(enabled)
859 }
860
861 #[must_use]
863 pub fn with_enabled(enabled: bool) -> Self {
864 let inner = if enabled {
865 Console::builder()
866 .file(Box::new(io::stderr()))
867 .force_terminal(true)
868 .markup(true)
869 .emoji(true)
870 .highlight(false)
871 .build()
872 } else {
873 Console::builder()
874 .file(Box::new(io::stderr()))
875 .no_color()
876 .markup(false)
877 .emoji(false)
878 .highlight(false)
879 .build()
880 };
881
882 Self {
883 inner: Mutex::new(inner),
884 enabled,
885 theme: crate::theme::theme(),
886 }
887 }
888
889 #[must_use]
891 pub fn with_writer<W: Write + Send + 'static>(writer: W, enabled: bool) -> Self {
892 let mut builder = Console::builder()
893 .file(Box::new(writer))
894 .markup(enabled)
895 .emoji(enabled)
896 .highlight(false);
897
898 if !enabled {
899 builder = builder.no_color();
900 }
901
902 let inner = if enabled {
903 builder.force_terminal(true).build()
904 } else {
905 builder.build()
906 };
907
908 Self {
909 inner: Mutex::new(inner),
910 enabled,
911 theme: crate::theme::theme(),
912 }
913 }
914
915 pub fn is_rich(&self) -> bool {
921 self.enabled
922 }
923
924 pub fn theme(&self) -> &FastMcpTheme {
926 self.theme
927 }
928
929 pub fn width(&self) -> usize {
931 self.lock_inner().width()
932 }
933
934 pub fn height(&self) -> usize {
936 self.lock_inner().height()
937 }
938
939 pub fn print(&self, content: &str) {
949 let console = self.lock_inner();
950 if self.enabled {
951 console.print(content);
952 } else {
953 console.print_plain(&strip_markup(content));
954 }
955 }
956
957 pub fn print_plain(&self, text: &str) {
962 self.lock_inner().print_plain(text);
966 }
967
968 pub fn print_untrusted(&self, text: &str) {
970 let safe = UntrustedDisplayText::new(text);
971 self.lock_inner().print_plain(safe.as_str());
972 }
973
974 pub fn render<R: Renderable>(&self, renderable: &R) {
976 let console = self.lock_inner();
977 if self.enabled {
978 console.print_renderable(renderable);
979 } else {
980 console.print_plain("[Complex Output]");
982 }
983 }
984
985 pub fn render_or<F>(&self, render_op: F, plain_fallback: &str)
989 where
990 F: FnOnce(&Console),
991 {
992 let console = self.lock_inner();
993 if self.enabled {
994 render_op(&console);
995 } else {
996 console.print_plain(plain_fallback);
997 }
998 }
999
1000 pub fn render_or_untrusted<F>(&self, render_op: F, plain_fallback: &str)
1002 where
1003 F: FnOnce(&Console),
1004 {
1005 let console = self.lock_inner();
1006 if self.enabled {
1007 render_op(&console);
1008 } else {
1009 let safe = UntrustedDisplayText::new(plain_fallback);
1010 console.print_plain(safe.as_str());
1011 }
1012 }
1013
1014 pub fn rule(&self, title: Option<&str>) {
1022 let console = self.lock_inner();
1023 if self.enabled {
1024 match title {
1025 Some(t) => {
1026 console.print_renderable(
1027 &Rule::with_title(t).style(self.theme.border_style.clone()),
1028 );
1029 }
1030 None => {
1031 console.print_renderable(&Rule::new().style(self.theme.border_style.clone()));
1032 }
1033 }
1034 } else {
1035 let fallback =
1036 title.map_or_else(|| "---".to_string(), |title| format!("--- {title} ---"));
1037 console.print_plain(&fallback);
1038 }
1039 }
1040
1041 pub fn rule_untrusted(&self, title: &str) {
1043 let safe = UntrustedDisplayText::new(title);
1044 self.rule(Some(safe.as_str()));
1045 }
1046
1047 pub fn newline(&self) {
1049 self.lock_inner().print_plain("");
1050 }
1051
1052 pub fn print_styled(&self, text: &str, style: Style) {
1056 let console = self.lock_inner();
1057 if self.enabled {
1058 console.print_styled(text, style);
1059 } else {
1060 console.print_plain(text);
1061 }
1062 }
1063
1064 pub fn print_untrusted_styled(&self, text: &str, style: Style) {
1066 let safe = UntrustedDisplayText::new(text);
1067 self.print_styled(safe.as_str(), style);
1068 }
1069
1070 pub fn print_table(&self, table: &Table, plain_fallback: &str) {
1074 let console = self.lock_inner();
1075 if self.enabled {
1076 console.print_renderable(table);
1077 } else {
1078 console.print_plain(plain_fallback);
1079 }
1080 }
1081
1082 pub fn print_table_untrusted(&self, table: &Table, plain_fallback: &str) {
1084 let safe = UntrustedDisplayText::new(plain_fallback);
1085 self.print_table(table, safe.as_str());
1086 }
1087
1088 pub fn print_panel(&self, panel: &Panel, plain_fallback: &str) {
1092 let console = self.lock_inner();
1093 if self.enabled {
1094 console.print_renderable(panel);
1095 } else {
1096 console.print_plain(plain_fallback);
1097 }
1098 }
1099
1100 pub fn print_panel_untrusted(&self, panel: &Panel, plain_fallback: &str) {
1102 let safe = UntrustedDisplayText::new(plain_fallback);
1103 self.print_panel(panel, safe.as_str());
1104 }
1105}
1106
1107impl Default for FastMcpConsole {
1108 fn default() -> Self {
1109 Self::new()
1110 }
1111}
1112
1113static CONSOLE: OnceLock<FastMcpConsole> = OnceLock::new();
1118
1119#[must_use]
1128pub fn console() -> &'static FastMcpConsole {
1129 CONSOLE.get_or_init(FastMcpConsole::new)
1130}
1131
1132pub fn init_console(enabled: bool) -> Result<(), &'static str> {
1144 CONSOLE
1145 .set(FastMcpConsole::with_enabled(enabled))
1146 .map_err(|_| "Console already initialized")
1147}
1148
1149#[must_use]
1157pub fn strip_markup(text: &str) -> String {
1158 let mut out = String::with_capacity(text.len());
1159 let mut chars = text.chars().peekable();
1160
1161 while let Some(ch) = chars.next() {
1162 match ch {
1163 '\\' => {
1164 if let Some(next) = chars.peek().copied() {
1166 if next == '[' || next == ']' || next == '\\' {
1167 out.push(next);
1168 chars.next();
1169 } else {
1170 out.push('\\');
1171 }
1172 } else {
1173 out.push('\\');
1174 }
1175 }
1176 '[' => {
1177 if let Some('[') = chars.peek() {
1179 out.push('[');
1180 chars.next(); } else {
1182 for c in chars.by_ref() {
1186 if c == ']' {
1187 break;
1188 }
1189 }
1190 }
1191 }
1192 _ => out.push(ch),
1193 }
1194 }
1195
1196 out
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202 use std::io::Write;
1203 use std::sync::{Arc, Mutex};
1204
1205 #[derive(Clone, Debug)]
1206 struct SharedWriter {
1207 buf: Arc<Mutex<Vec<u8>>>,
1208 }
1209
1210 impl SharedWriter {
1211 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
1212 let buf = Arc::new(Mutex::new(Vec::new()));
1213 (
1214 Self {
1215 buf: Arc::clone(&buf),
1216 },
1217 buf,
1218 )
1219 }
1220 }
1221
1222 impl Write for SharedWriter {
1223 fn write(&mut self, input: &[u8]) -> std::io::Result<usize> {
1224 if let Ok(mut guard) = self.buf.lock() {
1225 guard.extend_from_slice(input);
1226 }
1227 Ok(input.len())
1228 }
1229
1230 fn flush(&mut self) -> std::io::Result<()> {
1231 Ok(())
1232 }
1233 }
1234
1235 #[test]
1236 fn test_strip_markup_simple() {
1237 assert_eq!(strip_markup("[bold]Hello[/]"), "Hello");
1238 }
1239
1240 #[test]
1241 fn test_strip_markup_nested() {
1242 assert_eq!(strip_markup("[bold][red]Error[/][/]"), "Error");
1243 }
1244
1245 #[test]
1246 fn test_strip_markup_multiple_tags() {
1247 assert_eq!(
1248 strip_markup("[green]✓[/] Success [dim](100ms)[/]"),
1249 "✓ Success (100ms)"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_strip_markup_no_tags() {
1255 assert_eq!(strip_markup("Plain text"), "Plain text");
1256 }
1257
1258 #[test]
1259 fn test_strip_markup_empty() {
1260 assert_eq!(strip_markup(""), "");
1261 }
1262
1263 #[test]
1264 fn test_strip_markup_only_tags() {
1265 assert_eq!(strip_markup("[bold][/]"), "");
1266 }
1267
1268 #[test]
1269 fn test_strip_markup_preserves_unicode() {
1270 assert_eq!(strip_markup("[info]âš¡ Fast[/]"), "âš¡ Fast");
1271 }
1272
1273 #[test]
1274 fn test_strip_markup_preserves_backslash_escaped_brackets() {
1275 assert_eq!(
1276 strip_markup(r"tools/list \[OK\] 12ms"),
1277 "tools/list [OK] 12ms"
1278 );
1279 assert_eq!(strip_markup(r"\[x\]"), "[x]");
1280 assert_eq!(strip_markup(r"\\[bold]x[/]"), r"\x");
1281 }
1282
1283 #[test]
1284 fn test_strip_markup_double_bracket_escape() {
1285 assert_eq!(strip_markup("[[literal]]"), "[literal]]");
1286 }
1287
1288 #[test]
1289 fn test_console_with_enabled_true() {
1290 let console = FastMcpConsole::with_enabled(true);
1291 assert!(console.is_rich());
1292 }
1293
1294 #[test]
1295 fn test_console_with_enabled_false() {
1296 let console = FastMcpConsole::with_enabled(false);
1297 assert!(!console.is_rich());
1298 }
1299
1300 #[test]
1301 fn test_console_theme_access() {
1302 let console = FastMcpConsole::with_enabled(false);
1303 let theme = console.theme();
1304 assert_eq!(theme.primary.triplet.map(|tr| tr.blue), Some(255));
1306 }
1307
1308 #[test]
1309 fn test_console_dimensions_default() {
1310 let console = FastMcpConsole::with_enabled(false);
1311 assert!(console.width() > 0);
1313 assert!(console.height() > 0);
1314 }
1315
1316 #[test]
1317 fn test_with_writer_print_and_print_plain_paths() {
1318 let (writer, captured) = SharedWriter::new();
1319 let console = FastMcpConsole::with_writer(writer, true);
1320
1321 console.print("[bold]Hello[/]");
1322 console.print_plain("[literal]");
1323
1324 let output = String::from_utf8(captured.lock().expect("writer lock poisoned").clone())
1325 .unwrap_or_default();
1326 assert!(output.contains("Hello"));
1327 assert!(output.contains("[literal]"));
1328 assert!(!output.contains("\\[literal]"));
1329 }
1330
1331 #[test]
1332 fn bounded_rich_text_keeps_markup_escaped_after_backslash_runs() {
1333 let (writer, captured) = SharedWriter::new();
1334 let console = FastMcpConsole::with_writer(writer, true);
1335
1336 let hostile_values = [
1337 r"\[bold]FORGED[/]",
1338 r"\\[bold]FORGED[/]",
1339 r"\\\[bold]FORGED[/]",
1340 ];
1341 for hostile in hostile_values {
1342 let safe = bounded_rich_text(hostile, 128);
1343 console.print(&format!("[cyan]{safe}[/]"));
1344 }
1345 let windows_path = r"C:\tmp\fastmcp\config.json";
1346 console.print(&format!(
1347 "[cyan]{}[/]",
1348 bounded_rich_text(windows_path, 128)
1349 ));
1350
1351 let output = captured
1352 .lock()
1353 .expect("rich parity output lock poisoned")
1354 .clone();
1355 let plain = String::from_utf8(strip_ansi_escapes::strip(&output))
1356 .expect("rich parity output must be UTF-8");
1357 assert_eq!(
1358 plain.matches("[bold]FORGED[/]").count(),
1359 hostile_values.len(),
1360 "attacker markup became active: {plain:?}"
1361 );
1362 for expected in [
1363 hostile_values[0],
1364 hostile_values[1],
1365 hostile_values[2],
1366 windows_path,
1367 ] {
1368 assert!(
1369 plain.lines().any(|line| line == expected),
1370 "rich escaping changed benign text or slash count: expected {expected:?}, got {plain:?}"
1371 );
1372 }
1373 }
1374
1375 #[test]
1376 fn bounded_rich_fragments_preserve_trailing_slashes_before_trusted_tags() {
1377 let (writer, captured) = SharedWriter::new();
1378 let console = FastMcpConsole::with_writer(writer, true);
1379
1380 for trailing_slashes in 1..=3 {
1381 let source = format!("plain{}", "\\".repeat(trailing_slashes));
1382 let safe = bounded_rich_fragment(&source, 128);
1383 console.print(&format!("[cyan]{safe}[/]"));
1384
1385 let redacted_source = format!("token=private {}", "\\".repeat(trailing_slashes));
1386 let safe = bounded_redacted_rich_fragment(&redacted_source, 128);
1387 console.print(&format!("[cyan]{safe}[/]"));
1388 }
1389
1390 let output = captured
1391 .lock()
1392 .expect("rich fragment output lock poisoned")
1393 .clone();
1394 let plain = String::from_utf8(strip_ansi_escapes::strip(&output))
1395 .expect("rich fragment output must be UTF-8");
1396 let lines: Vec<&str> = plain.lines().collect();
1397 assert_eq!(lines.len(), 6, "unexpected rendered output: {plain:?}");
1398 for trailing_slashes in 1..=3 {
1399 assert_eq!(
1400 lines[(trailing_slashes - 1) * 2],
1401 format!("plain{}", "\\".repeat(trailing_slashes))
1402 );
1403 assert_eq!(
1404 lines[(trailing_slashes - 1) * 2 + 1],
1405 format!("token=[REDACTED] {}", "\\".repeat(trailing_slashes))
1406 );
1407 }
1408
1409 assert_eq!(bounded_rich_text(r"standalone\", 128), r"standalone\");
1410 }
1411
1412 #[test]
1413 fn print_plain_never_activates_markup_after_backslash_runs() {
1414 let (writer, captured) = SharedWriter::new();
1415 let console = FastMcpConsole::with_writer(writer, true);
1416
1417 for hostile in [
1418 r"\[link=https://example.invalid]click[/link]",
1419 r"\\[link=https://example.invalid]click[/link]",
1420 r"\\\[link=https://example.invalid]click[/link]",
1421 ] {
1422 console.print_plain(hostile);
1423 }
1424
1425 let output = captured
1426 .lock()
1427 .expect("plain markup output lock poisoned")
1428 .clone();
1429 assert!(
1430 !output
1431 .windows(b"\x1b]8;;".len())
1432 .any(|window| window == b"\x1b]8;;"),
1433 "plain output activated a terminal hyperlink: {output:?}"
1434 );
1435 let plain = String::from_utf8(strip_ansi_escapes::strip(&output))
1436 .expect("plain markup output must be UTF-8");
1437 for expected in [
1438 r"\[link=https://example.invalid]click[/link]",
1439 r"\\[link=https://example.invalid]click[/link]",
1440 r"\\\[link=https://example.invalid]click[/link]",
1441 ] {
1442 assert!(
1443 plain.lines().any(|line| line == expected),
1444 "output: {plain:?}"
1445 );
1446 }
1447 }
1448
1449 fn hostile_untrusted_console_text() -> String {
1450 format!(
1451 "auth=secret-canary\r\nFORGED\u{1b}]52;c;clipboard-canary\u{7}\u{202e}\u{115f}{}TAIL_CANARY",
1452 "x".repeat(10_000)
1453 )
1454 }
1455
1456 fn assert_untrusted_console_text_is_safe(output: &[u8]) {
1457 assert!(
1458 !output
1459 .windows(b"\x1b]52;".len())
1460 .any(|window| window == b"\x1b]52;"),
1461 "untrusted OSC 52 sequence reached the terminal: {output:?}"
1462 );
1463 let plain = String::from_utf8(strip_ansi_escapes::strip(output))
1464 .expect("sanitized console output must be UTF-8");
1465 assert!(plain.contains("auth=[REDACTED]"), "output: {plain:?}");
1466 assert!(plain.contains("\\r\\n"), "output: {plain:?}");
1467 assert!(plain.contains("\\u{1b}"), "output: {plain:?}");
1468 assert!(plain.contains("\\u{7}"), "output: {plain:?}");
1469 assert!(plain.contains("\\u{202e}"), "output: {plain:?}");
1470 assert!(plain.contains("\\u{115f}"), "output: {plain:?}");
1471 assert!(plain.contains("..."), "output: {plain:?}");
1472 assert!(!plain.contains("secret-canary"), "output: {plain:?}");
1473 assert!(!plain.contains("TAIL_CANARY"), "output: {plain:?}");
1474 assert!(!plain.lines().any(|line| line == "FORGED"));
1475 }
1476
1477 #[test]
1478 fn untrusted_display_text_is_redacted_bounded_and_single_line() {
1479 let safe = UntrustedDisplayText::new(&hostile_untrusted_console_text());
1480 assert!(safe.as_str().chars().count() <= DEFAULT_TERMINAL_FIELD_MAX_CHARS);
1481 assert!(
1482 !safe
1483 .as_str()
1484 .chars()
1485 .any(|character| matches!(character, '\r' | '\n'))
1486 );
1487 assert!(!safe.as_str().chars().any(terminal_text_is_unsafe));
1488 assert!(safe.as_str().contains("auth=[REDACTED]"));
1489 assert!(safe.as_str().ends_with("..."));
1490 }
1491
1492 #[test]
1493 fn public_untrusted_output_paths_are_safe_in_plain_mode() {
1494 let (writer, captured) = SharedWriter::new();
1495 let console = FastMcpConsole::with_writer(writer, false);
1496 let hostile = hostile_untrusted_console_text();
1497 let table = Table::new().with_column(Column::new("A"));
1498 let panel = Panel::from_text("trusted panel");
1499
1500 console.print_untrusted(&hostile);
1501 console.print_untrusted_styled(&hostile, Style::new().bold());
1502 console.rule_untrusted(&hostile);
1503 console.render_or_untrusted(|_| panic!("plain fallback expected"), &hostile);
1504 console.print_table_untrusted(&table, &hostile);
1505 console.print_panel_untrusted(&panel, &hostile);
1506
1507 let output = captured.lock().expect("plain output lock poisoned").clone();
1508 assert_untrusted_console_text_is_safe(&output);
1509 assert_eq!(
1510 String::from_utf8(output)
1511 .expect("plain output must be UTF-8")
1512 .matches("auth=[REDACTED]")
1513 .count(),
1514 6
1515 );
1516 }
1517
1518 #[test]
1519 fn public_untrusted_output_paths_are_safe_in_rich_mode() {
1520 let (writer, captured) = SharedWriter::new();
1521 let console = FastMcpConsole::with_writer(writer, true);
1522 let hostile = hostile_untrusted_console_text();
1523
1524 console.print_untrusted(&hostile);
1525 console.print_untrusted_styled(&hostile, Style::new().bold());
1526 console.rule_untrusted(&hostile);
1527
1528 let output = captured.lock().expect("rich output lock poisoned").clone();
1529 assert_untrusted_console_text_is_safe(&output);
1530 let plain = String::from_utf8(strip_ansi_escapes::strip(&output))
1531 .expect("rich output must be UTF-8 after ANSI stripping");
1532 assert!(plain.matches("auth=[REDACTED]").count() >= 2, "{plain}");
1537 }
1538
1539 #[test]
1540 fn credential_key_matching_handles_hostile_mixed_case() {
1541 for key in [
1542 "AuTh",
1543 "PassWord",
1544 "AcCeSs_ToKeN",
1545 "githubToken",
1546 "sessionToken",
1547 "dbPassword",
1548 "openaiApiKey",
1549 "credential",
1550 "passphrase",
1551 "X-Amz-Credential",
1552 "X-Amz-Signature",
1553 "aws_access_key_id",
1554 ] {
1555 assert!(is_credential_key(key), "missed credential key {key}");
1556 }
1557 for key in [
1558 "authentication",
1559 "tokenizer",
1560 "accessTokenCount",
1561 "tokenHint",
1562 "tokenLength",
1563 "tokenName",
1564 "tokenPolicy",
1565 "tokenType",
1566 "apiKeyName",
1567 "clientSecretHint",
1568 "codeVerifierLength",
1569 "cookiePolicy",
1570 "idTokenType",
1571 "passwordless",
1572 "refreshTokenCount",
1573 "secretHint",
1574 "credentialsCount",
1575 "signatureAlgorithm",
1576 "signatureVersion",
1577 ] {
1578 assert!(!is_credential_key(key), "over-redacted benign key {key}");
1579 }
1580 assert!(is_credential_key(
1581 &"x".repeat(CREDENTIAL_KEY_SCAN_MAX_CHARS + 1)
1582 ));
1583 }
1584
1585 #[test]
1586 fn terminal_sanitizers_escape_zero_width_spoofing_markers() {
1587 let unsafe_characters = [
1588 '\u{115f}',
1589 '\u{1160}',
1590 '\u{17b4}',
1591 '\u{17b5}',
1592 '\u{180b}',
1593 '\u{180e}',
1594 '\u{200b}',
1595 '\u{200c}',
1596 '\u{200d}',
1597 '\u{2060}',
1598 '\u{3164}',
1599 '\u{fe0f}',
1600 '\u{feff}',
1601 '\u{ffa0}',
1602 '\u{fff0}',
1603 '\u{1bca0}',
1604 '\u{1d173}',
1605 '\u{e0001}',
1606 '\u{e0080}',
1607 '\u{e0100}',
1608 '\u{e01f0}',
1609 '\u{e0fff}',
1610 ];
1611 let hostile = format!(
1612 "a{}b",
1613 unsafe_characters.iter().copied().collect::<String>()
1614 );
1615
1616 for rendered in [
1617 bounded_redacted_terminal_text(&hostile, 1_024),
1618 bounded_redacted_rich_text(&hostile, 1_024),
1619 ] {
1620 assert!(!rendered.chars().any(terminal_text_is_unsafe));
1621 for character in unsafe_characters {
1622 let escaped = character.escape_default().collect::<String>();
1623 assert!(rendered.contains(&escaped), "missing {escaped}: {rendered}");
1624 }
1625 }
1626 }
1627
1628 #[test]
1629 fn free_text_redaction_consumes_complete_authorization_schemes() {
1630 let text = concat!(
1631 "Authorization: Basic Zm9v OmJhcg==\n",
1632 "AuTh: Digest username=\"Mufasa\", realm=\"private\", ",
1633 "uri=\"/private#fragment\", nonce=\"abc\", response=\"deadbeef\"\n",
1634 "CoOkIe: session=secret; csrf=also-secret\n",
1635 "auth=Bearer query-secret&mode=read\n",
1636 "auth=Digest username=\"u\", uri=\"/a&b\", response=\"digest-secret\"&mode=write\n",
1637 "next=visible"
1638 );
1639 let redacted = redact_free_text_credentials_with(text, "<redacted>");
1640
1641 assert_eq!(
1642 redacted,
1643 concat!(
1644 "Authorization: Basic <redacted>\n",
1645 "AuTh: Digest <redacted>\n",
1646 "CoOkIe: <redacted>\n",
1647 "auth=Bearer <redacted>&mode=read\n",
1648 "auth=Digest <redacted>&mode=write\n",
1649 "next=visible"
1650 )
1651 );
1652 for secret in [
1653 "Zm9v",
1654 "OmJhcg",
1655 "Mufasa",
1656 "private",
1657 "fragment",
1658 "deadbeef",
1659 "query-secret",
1660 "digest-secret",
1661 "also-secret",
1662 ] {
1663 assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
1664 }
1665
1666 assert_eq!(
1667 redact_free_text_credentials("token=wrapper-secret"),
1668 "token=[REDACTED]"
1669 );
1670 }
1671
1672 #[test]
1673 fn standalone_bearer_marker_prefix_cannot_bypass_redaction() {
1674 assert_eq!(
1675 redact_free_text_credentials_with("Bearer <redacted>ACTUAL-SECRET", "<redacted>"),
1676 "Bearer <redacted>"
1677 );
1678 assert_eq!(
1679 redact_free_text_credentials("Bearer [REDACTED]ACTUAL-SECRET"),
1680 "Bearer [REDACTED]"
1681 );
1682 assert_eq!(
1683 redact_free_text_credentials("Bearer [REDACTED]"),
1684 "Bearer [REDACTED]"
1685 );
1686 }
1687
1688 #[test]
1689 fn free_text_redaction_handles_namespaced_and_singular_keys() {
1690 let text = concat!(
1691 "githubToken=ghp_private sessionToken: session-private ",
1692 "dbPassword=\"correct horse battery staple\" ",
1693 "openaiApiKey='sk-private' credential=credential-private ",
1694 "passphrase: 'phrase private' ",
1695 "authentication=enabled tokenizer=cl100k passwordless=true"
1696 );
1697 let redacted = redact_free_text_credentials(text);
1698
1699 for secret in [
1700 "ghp_private",
1701 "session-private",
1702 "correct horse battery staple",
1703 "sk-private",
1704 "credential-private",
1705 "phrase private",
1706 ] {
1707 assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
1708 }
1709 assert_eq!(redacted.matches(REDACTED_VALUE).count(), 6);
1710 assert!(redacted.contains("authentication=enabled"));
1711 assert!(redacted.contains("tokenizer=cl100k"));
1712 assert!(redacted.contains("passwordless=true"));
1713 }
1714
1715 #[test]
1716 fn free_text_redaction_handles_uri_userinfo_and_aws_signed_queries() {
1717 let text = concat!(
1718 "GET https://alice:s3cr3t@example.com/private?",
1719 "X-Amz-Credential=AKIA_PRIVATE%2F20260802%2Fus-east-1&",
1720 "X-Amz-Signature=deadbeef&",
1721 "aws_access_key_id=AKIA_OTHER&mode=read"
1722 );
1723 let redacted = redact_free_text_credentials(text);
1724
1725 assert_eq!(
1726 redacted,
1727 concat!(
1728 "GET https://[REDACTED]@example.com/private?",
1729 "X-Amz-Credential=[REDACTED]&",
1730 "X-Amz-Signature=[REDACTED]&",
1731 "aws_access_key_id=[REDACTED]&mode=read"
1732 )
1733 );
1734 for secret in ["alice", "s3cr3t", "AKIA_PRIVATE", "deadbeef", "AKIA_OTHER"] {
1735 assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
1736 }
1737 }
1738
1739 #[test]
1740 fn free_text_redaction_is_unicode_safe_and_bounded_callers_can_truncate() {
1741 let text = format!(
1742 "prefix 🔑 githubToken={} suffix authentication=visible",
1743 "s".repeat(16_384)
1744 );
1745 let redacted = redact_free_text_credentials(&text);
1746 assert!(redacted.contains("🔑 githubToken=[REDACTED]"));
1747 assert!(redacted.contains("authentication=visible"));
1748 assert!(!redacted.contains(&"s".repeat(512)));
1749
1750 let bounded = bounded_redacted_terminal_text(&text, 80);
1751 assert!(bounded.chars().count() <= 80);
1752 }
1753
1754 #[test]
1755 fn test_render_and_convenience_methods_in_rich_mode() {
1756 let (writer, captured) = SharedWriter::new();
1757 let console = FastMcpConsole::with_writer(writer, true);
1758
1759 let mut table = Table::new()
1760 .with_column(Column::new("A"))
1761 .with_column(Column::new("B"));
1762 table.add_row(Row::new(vec![Cell::new("1"), Cell::new("2")]));
1763 let panel = Panel::from_text("Panel body");
1764
1765 console.rule(Some("Section"));
1766 console.rule(None);
1767 console.print_styled("Styled", Style::new().bold());
1768 console.print_table(&table, "table fallback");
1769 console.print_panel(&panel, "panel fallback");
1770 console.render(&Rule::new());
1771
1772 let mut called = false;
1773 console.render_or(
1774 |c| {
1775 called = true;
1776 c.print("render_or rich");
1777 },
1778 "render_or fallback",
1779 );
1780 assert!(called);
1781
1782 let output = String::from_utf8(captured.lock().expect("writer lock poisoned").clone())
1783 .unwrap_or_default();
1784 assert!(output.contains("Section"));
1785 assert!(output.contains("Styled"));
1786 assert!(output.contains("Panel body"));
1787 assert!(output.contains("render_or rich"));
1788 }
1789
1790 #[test]
1795 fn strip_markup_trailing_backslash() {
1796 assert_eq!(strip_markup("path\\"), "path\\");
1798 }
1799
1800 #[test]
1801 fn strip_markup_backslash_non_special() {
1802 assert_eq!(strip_markup("line\\n break"), "line\\n break");
1804 }
1805
1806 #[test]
1807 fn strip_markup_backslash_backslash_escape() {
1808 assert_eq!(strip_markup("a\\\\b"), "a\\b");
1810 }
1811
1812 #[test]
1813 fn strip_markup_unclosed_tag() {
1814 assert_eq!(strip_markup("hello [bold no close"), "hello ");
1816 }
1817
1818 #[test]
1819 fn with_writer_plain_mode() {
1820 let (writer, captured) = SharedWriter::new();
1821 let console = FastMcpConsole::with_writer(writer, false);
1822
1823 assert!(!console.is_rich());
1824 console.print_plain("[literal]");
1825
1826 let output = String::from_utf8(captured.lock().unwrap().clone()).unwrap_or_default();
1827 assert_eq!(output, "[literal]\n");
1828 }
1829
1830 #[test]
1831 fn console_default_impl() {
1832 let console = FastMcpConsole::default();
1834 assert!(console.width() > 0);
1836 assert!(console.height() > 0);
1837 }
1838
1839 #[test]
1840 fn disabled_mode_routes_every_output_path_through_configured_writer() {
1841 let (writer, captured) = SharedWriter::new();
1842 let console = FastMcpConsole::with_writer(writer, false);
1843 let table = Table::new().with_column(Column::new("A"));
1844 let panel = Panel::from_text("panel");
1845
1846 console.print("[bold]Hello[/]");
1847 console.print_plain("plain");
1848 console.render(&Rule::new());
1849 console.rule(Some("Title"));
1850 console.rule(None);
1851 console.newline();
1852 console.print_styled("styled", Style::new());
1853 console.print_table(&table, "table fallback");
1854 console.print_panel(&panel, "panel fallback");
1855
1856 let mut called = false;
1857 console.render_or(
1858 |_| {
1859 called = true;
1860 },
1861 "fallback",
1862 );
1863 assert!(!called);
1864
1865 let output = String::from_utf8(captured.lock().unwrap().clone()).unwrap_or_default();
1866 for expected in [
1867 "Hello",
1868 "plain",
1869 "[Complex Output]",
1870 "--- Title ---",
1871 "styled",
1872 "table fallback",
1873 "panel fallback",
1874 "fallback",
1875 ] {
1876 assert!(
1877 output.contains(expected),
1878 "missing {expected:?} from configured writer output: {output:?}"
1879 );
1880 }
1881 assert!(!output.contains("[bold]"));
1882 }
1883}