Skip to main content

fastmcp_console/
console.rs

1//! Console wrapper for rich stderr output.
2//!
3//! `FastMcpConsole` is the core output surface for fastmcp-console. It wraps
4//! a `rich_rust::Console` configured to write to stderr, and it automatically
5//! falls back to plain text when running in agent contexts.
6//!
7//! # Quick Example
8//!
9//! ```rust,ignore
10//! use fastmcp_console::console::FastMcpConsole;
11//!
12//! let console = FastMcpConsole::new();
13//! console.rule(Some("FastMCP Console"));
14//! console.print("Ready.");
15//! ```
16
17use 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/// A bounded, redacted, single-line terminal-safe rendering of untrusted text.
31///
32/// Constructing this type escapes terminal controls, line separators, bidi
33/// controls, and Unicode default-ignorable characters. It also redacts common
34/// credential forms and applies a hard output bound. The original source text
35/// is never retained.
36#[derive(Clone, Debug, Eq, Hash, PartialEq)]
37pub struct UntrustedDisplayText(String);
38
39impl UntrustedDisplayText {
40    /// Sanitize untrusted text using the standard terminal-field budget.
41    #[must_use]
42    pub fn new(text: &str) -> Self {
43        Self::with_max_chars(text, DEFAULT_TERMINAL_FIELD_MAX_CHARS)
44    }
45
46    /// Sanitize untrusted text using a caller-supplied character budget.
47    ///
48    /// The budget is still capped by the crate's internal hard ceiling.
49    #[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    /// Borrow the sanitized terminal-safe representation.
55    #[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
67/// Returns whether a structured field name conventionally carries a secret.
68pub fn is_credential_key(key: &str) -> bool {
69    // Public callers may receive peer-controlled object keys. Bound all
70    // classifier allocations and fail closed when the complete key cannot be
71    // inspected.
72    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    // Case-folding the complete alphanumeric spelling catches hostile mixed
82    // casing such as `AuTh` and `PassWord`, where camel-case word splitting is
83    // intentionally ambiguous. Keep this exact list narrow so ordinary
84    // metadata such as `authentication` and `tokenizer` remains visible.
85    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    // Lowercase or deliberately irregularly-cased names may not expose a
169    // camel-case boundary. Inspect the compact suffix as a fallback, while
170    // retaining the metadata exclusions above and exact benign words below.
171    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
245/// Redacts credential-like assignments in free text.
246pub fn redact_free_text_credentials(text: &str) -> String {
247    redact_free_text_credentials_with(text, REDACTED_VALUE)
248}
249
250/// Redacts credential-like assignments in free text using `replacement`.
251///
252/// This recognizes structured secret assignments, authorization and cookie
253/// headers, standalone bearer tokens, and URI userinfo. The replacement is
254/// copied verbatim and should therefore be a trusted application constant.
255pub fn redact_free_text_credentials_with(text: &str, replacement: &str) -> String {
256    if text.is_empty() {
257        return String::new();
258    }
259
260    // Keep each pass linear and non-recursive. Assignment detection delegates
261    // key classification to `is_credential_key`, so namespaced camelCase and
262    // vendor-specific keys receive the same treatment as structured fields.
263    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            // An attacker-controlled token may deliberately begin with our
581            // marker (for example, `Bearer <redacted>ACTUAL`). Treat only an
582            // exact, delimiter-terminated marker as already redacted.
583            (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            // Frozen Unicode Default_Ignorable_Code_Point profile, plus the
617            // Unicode line and paragraph separators. These formatting
618            // characters can spoof identifiers or forge terminal layout even
619            // when bidi controls and C0/C1 are escaped. Keep the reserved
620            // supplementary ranges: newly assigned characters there remain
621            // unsafe until this table is deliberately reviewed.
622            '\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))]
650/// Produces bounded terminal-safe text escaped for insertion into rich markup.
651///
652/// When a trusted markup tag will be appended immediately after this value,
653/// use [`bounded_rich_fragment`] so trailing backslashes cannot escape the
654/// caller's opening bracket.
655pub(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))]
665/// Produces bounded terminal-safe text for interpolation before trusted markup.
666///
667/// Rich markup parses a backslash immediately before `[` as an escape. Doubling
668/// the trailing markup-source run preserves the same number of visible
669/// backslashes while ensuring a caller-appended trusted tag remains active.
670pub(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
674/// Redacts secrets and produces bounded, single-line terminal-safe text.
675pub(crate) fn bounded_redacted_terminal_text(text: &str, max_chars: usize) -> String {
676    bounded_redacted_text_impl(text, max_chars, false)
677}
678
679/// Redacts secrets and produces bounded text safe for rich markup insertion.
680///
681/// When a trusted markup tag will be appended immediately after this value,
682/// use [`bounded_redacted_rich_fragment`].
683pub(crate) fn bounded_redacted_rich_text(text: &str, max_chars: usize) -> String {
684    bounded_redacted_text_impl(text, max_chars, true)
685}
686
687/// Redacts secrets and prepares a bounded value for interpolation before a
688/// trusted rich-markup tag.
689pub(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    // Limit redaction work to a small multiple of the display budget while
712    // retaining look-ahead for a secret value near the visible edge.
713    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        // For one-to-three-character budgets, preserve the visible prefix we
725        // already produced. Replacing it with dots would make this helper's
726        // semantics differ from `bounded_terminal_text_impl` merely because
727        // the bounded regex scan detected additional source input.
728        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            // Preserve ordinary backslashes verbatim. Immediately before `[`,
763            // rich_rust interprets slash parity, so N source slashes must
764            // become 2N+1 markup-source slashes to render N literal slashes
765            // followed by a literal `[`. Cap look-ahead at the remaining
766            // output budget so a huge slash run cannot force a full scan.
767            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
827/// FastMCP console for rich output to stderr.
828///
829/// This type centralizes rich-vs-plain output behavior and exposes
830/// convenience methods for printing tables, panels, and styled text.
831///
832/// # Example
833///
834/// ```rust,ignore
835/// use fastmcp_console::console::FastMcpConsole;
836/// use rich_rust::prelude::Style;
837///
838/// let console = FastMcpConsole::new();
839/// console.print_styled("Server started", Style::new().bold());
840/// ```
841pub 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    /// Create with automatic detection
855    #[must_use]
856    pub fn new() -> Self {
857        let enabled = crate::detection::should_enable_rich();
858        Self::with_enabled(enabled)
859    }
860
861    /// Create with explicit enable/disable
862    #[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    /// Create with custom writer (for testing)
890    #[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    // ─────────────────────────────────────────────────
916    // State Queries
917    // ─────────────────────────────────────────────────
918
919    /// Check if rich output is enabled.
920    pub fn is_rich(&self) -> bool {
921        self.enabled
922    }
923
924    /// Get the theme used for standard styling.
925    pub fn theme(&self) -> &FastMcpTheme {
926        self.theme
927    }
928
929    /// Get the configured terminal width.
930    pub fn width(&self) -> usize {
931        self.lock_inner().width()
932    }
933
934    /// Get the configured terminal height.
935    pub fn height(&self) -> usize {
936        self.lock_inner().height()
937    }
938
939    // ─────────────────────────────────────────────────
940    // Output Methods
941    // ─────────────────────────────────────────────────
942
943    /// Print trusted styled markup (auto-detects markup).
944    ///
945    /// This method does not sanitize terminal controls or secrets. Use
946    /// [`Self::print_untrusted`] for any peer-, user-, or network-controlled
947    /// value.
948    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    /// Print trusted plain text (no markup processing ever).
958    ///
959    /// Disabling markup does not make terminal control sequences safe. Use
960    /// [`Self::print_untrusted`] for untrusted values.
961    pub fn print_plain(&self, text: &str) {
962        // Use the dependency's markup-disabled path directly. Escaping and
963        // then parsing is both unnecessary and unsafe for attacker-controlled
964        // backslash runs immediately before `[`.
965        self.lock_inner().print_plain(text);
966    }
967
968    /// Sanitize, redact, bound, and print an untrusted value as plain text.
969    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    /// Print a renderable.
975    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            // Plain fallback: caller should provide alternative or we log a placeholder
981            console.print_plain("[Complex Output]");
982        }
983    }
984
985    /// Print a renderable with a trusted plain-text fallback closure.
986    ///
987    /// Use [`Self::render_or_untrusted`] when the fallback is not trusted.
988    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    /// Print a renderable with a sanitized untrusted plain-text fallback.
1001    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    // ─────────────────────────────────────────────────
1015    // Convenience Methods
1016    // ─────────────────────────────────────────────────
1017
1018    /// Print a horizontal rule with an optional trusted title.
1019    ///
1020    /// Use [`Self::rule_untrusted`] for an untrusted title.
1021    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    /// Print a horizontal rule after sanitizing an untrusted title.
1042    pub fn rule_untrusted(&self, title: &str) {
1043        let safe = UntrustedDisplayText::new(title);
1044        self.rule(Some(safe.as_str()));
1045    }
1046
1047    /// Print a blank line.
1048    pub fn newline(&self) {
1049        self.lock_inner().print_plain("");
1050    }
1051
1052    /// Print trusted text with a specific style.
1053    ///
1054    /// Use [`Self::print_untrusted_styled`] for an untrusted value.
1055    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    /// Sanitize an untrusted value before printing it with a trusted style.
1065    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    /// Print a table with a trusted plain fallback.
1071    ///
1072    /// Use [`Self::print_table_untrusted`] when the fallback is untrusted.
1073    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    /// Print a table with a sanitized untrusted plain fallback.
1083    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    /// Print a panel with a trusted plain fallback.
1089    ///
1090    /// Use [`Self::print_panel_untrusted`] when the fallback is untrusted.
1091    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    /// Print a panel with a sanitized untrusted plain fallback.
1101    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
1113// ─────────────────────────────────────────────────────────
1114// Global Console Accessor
1115// ─────────────────────────────────────────────────────────
1116
1117static CONSOLE: OnceLock<FastMcpConsole> = OnceLock::new();
1118
1119/// Get the global FastMCP console instance.
1120///
1121/// # Example
1122///
1123/// ```rust,ignore
1124/// let console = fastmcp_console::console::console();
1125/// console.print("Hello from global console");
1126/// ```
1127#[must_use]
1128pub fn console() -> &'static FastMcpConsole {
1129    CONSOLE.get_or_init(FastMcpConsole::new)
1130}
1131
1132/// Initialize the global console with specific settings.
1133///
1134/// Must be called before any output; returns error if already initialized.
1135///
1136/// # Example
1137///
1138/// ```rust,ignore
1139/// use fastmcp_console::console::init_console;
1140///
1141/// init_console(false).expect("console already initialized");
1142/// ```
1143pub 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// ─────────────────────────────────────────────────────────
1150// Helpers
1151// ─────────────────────────────────────────────────────────
1152
1153/// Strip markup tags from text (for plain output).
1154///
1155/// Handles escaped brackets (`[[` -> `[`, `\\[` -> `[`, `\\]` -> `]`) and strips valid tags (`[...]`).
1156#[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                // Rich uses backslash escaping for literal brackets. Preserve those for plain output.
1165                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                // Check for escaped bracket [[
1178                if let Some('[') = chars.peek() {
1179                    out.push('[');
1180                    chars.next(); // Consume the second [
1181                } else {
1182                    // It's a tag start, skip until ]
1183                    // Note: This is a simple skippper; it doesn't handle nested brackets
1184                    // or quoted strings inside tags, but covers standard style tags.
1185                    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        // Verify theme is accessible
1305        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        // Non-TTY should return defaults
1312        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        // Both print paths keep the redaction marker verbatim; the rule
1533        // truncates its overwide hostile title, which may drop the marker
1534        // along with the secret. The safety assertions above already prove
1535        // the rule leaked no canary and escaped every control sequence.
1536        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    // =========================================================================
1791    // Additional coverage tests (bd-m32k)
1792    // =========================================================================
1793
1794    #[test]
1795    fn strip_markup_trailing_backslash() {
1796        // Backslash at end of string with no following char
1797        assert_eq!(strip_markup("path\\"), "path\\");
1798    }
1799
1800    #[test]
1801    fn strip_markup_backslash_non_special() {
1802        // Backslash followed by a char that is NOT [ ] or \
1803        assert_eq!(strip_markup("line\\n break"), "line\\n break");
1804    }
1805
1806    #[test]
1807    fn strip_markup_backslash_backslash_escape() {
1808        // Double backslash → single backslash
1809        assert_eq!(strip_markup("a\\\\b"), "a\\b");
1810    }
1811
1812    #[test]
1813    fn strip_markup_unclosed_tag() {
1814        // Opening bracket with no closing bracket — consumes rest of string
1815        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        // Default should not panic and should produce a valid console
1833        let console = FastMcpConsole::default();
1834        // Width/height should return sensible values
1835        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}