Skip to main content

strip_ansi/
detect.rs

1//! Terminal capability auto-detection.
2//!
3//! Probes stdout to determine what ANSI escape sequences the output
4//! terminal can handle, returning the appropriate [`TerminalPreset`].
5//!
6//! Detection order:
7//! 1. Not a TTY → [`Dumb`](TerminalPreset::Dumb)
8//! 2. `TERM=dumb` → [`Dumb`](TerminalPreset::Dumb)
9//! 3. `NO_COLOR` set → [`Dumb`](TerminalPreset::Dumb)
10//! 4. Color detected → [`Sanitize`](TerminalPreset::Sanitize)
11//!
12//! Auto-detect always caps at `Sanitize`. The color level from
13//! `supports-color` refines the SGR mask via [`detect_sgr_mask`].
14//! `Xterm` and `Full` require `--unsafe`.
15//!
16//! Gated behind the `terminal-detect` feature flag.
17
18#![forbid(unsafe_code)]
19
20use std::io::IsTerminal;
21
22use crate::classifier::SgrContent;
23use crate::preset::TerminalPreset;
24
25/// Detect the appropriate [`TerminalPreset`] for stdout.
26///
27/// Always caps at [`Sanitize`](TerminalPreset::Sanitize) — never
28/// returns `Xterm` or `Full`. Use [`detect_sgr_mask`] to get the
29/// SGR content mask based on color level.
30#[must_use]
31pub fn detect_preset() -> TerminalPreset {
32    // Not a TTY → strip everything.
33    if !std::io::stdout().is_terminal() {
34        return TerminalPreset::Dumb;
35    }
36
37    // TERM=dumb → strip everything.
38    if is_term_dumb() {
39        return TerminalPreset::Dumb;
40    }
41
42    // NO_COLOR spec compliance (https://no-color.org).
43    if std::env::var_os("NO_COLOR").is_some() {
44        return TerminalPreset::Dumb;
45    }
46
47    // Probe color support.
48    let has_color = supports_color::on_cached(supports_color::Stream::Stdout).is_some();
49    if !has_color {
50        return TerminalPreset::Dumb;
51    }
52
53    // Cap at Sanitize — never return Xterm or Full.
54    TerminalPreset::Sanitize
55}
56
57/// Detect the SGR content mask based on `supports-color` level.
58///
59/// Returns the appropriate [`SgrContent`] mask for the detected
60/// color level. Returns `None` when no color support is detected
61/// (caller should use `FilterConfig::strip_all()`).
62#[must_use]
63pub fn detect_sgr_mask() -> Option<SgrContent> {
64    let level = supports_color::on_cached(supports_color::Stream::Stdout)?;
65
66    let mut mask = SgrContent::BASIC;
67    if level.has_256 {
68        mask = mask.union(SgrContent::EXTENDED);
69    }
70    if level.has_16m {
71        mask = mask.union(SgrContent::TRUECOLOR);
72    }
73    Some(mask)
74}
75
76/// Returns `true` if `TERM` is literally `"dumb"`.
77fn is_term_dumb() -> bool {
78    std::env::var("TERM").is_ok_and(|v| v == "dumb")
79}
80
81/// Shared untrusted-TERM classifier.
82///
83/// Single source of truth for the "is TERM non-empty and non-dumb?"
84/// predicate. Both [`detect_preset_untrusted`] and
85/// [`detect_sgr_mask_untrusted`] route through here so their policies
86/// cannot drift. If one grows stricter (e.g. require a `color` suffix),
87/// the other must move in lockstep.
88///
89/// Returns `true` when `TERM` is set to something other than empty
90/// or `"dumb"`. Trusted signal: TERM is the only env var a caller
91/// can't trivially spoof from an unprivileged context that also
92/// matters for attack-surface decisions.
93fn untrusted_term_has_color() -> bool {
94    matches!(std::env::var("TERM"), Ok(term) if !term.is_empty() && term != "dumb")
95}
96
97/// Detect the appropriate [`TerminalPreset`] for stdout, ignoring
98/// attacker-controllable environment variables.
99///
100/// Like [`detect_preset`], but treats the following env vars as
101/// absent (attacker can set them):
102/// - `FORCE_COLOR`, `FORCE_HYPERLINK`, `COLORTERM`
103/// - `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `VTE_VERSION`
104///
105/// Trusted signals: `isatty(stdout)`, `TERM`, `NO_COLOR`.
106///
107/// Decision is binary:
108/// - not a TTY, `NO_COLOR` set, `TERM=dumb`, or `TERM` unset/empty
109///   → [`Dumb`](TerminalPreset::Dumb)
110/// - otherwise → [`Sanitize`](TerminalPreset::Sanitize)
111///
112/// The finer-grained `BASIC`/`EXTENDED`/`TRUECOLOR` distinctions live
113/// in [`detect_sgr_mask_untrusted`]. Both functions share the same
114/// "TERM is color-capable?" heuristic via [`untrusted_term_has_color`]
115/// so their policies stay aligned.
116#[must_use]
117pub fn detect_preset_untrusted() -> TerminalPreset {
118    // Not a TTY → strip everything.
119    if !std::io::stdout().is_terminal() {
120        return TerminalPreset::Dumb;
121    }
122
123    // TERM=dumb → strip everything.
124    if is_term_dumb() {
125        return TerminalPreset::Dumb;
126    }
127
128    // NO_COLOR spec compliance (https://no-color.org).
129    if std::env::var_os("NO_COLOR").is_some() {
130        return TerminalPreset::Dumb;
131    }
132
133    // Ignore attacker-controllable env vars (FORCE_COLOR, FORCE_HYPERLINK,
134    // COLORTERM, TERM_PROGRAM, TERM_PROGRAM_VERSION, VTE_VERSION). Only
135    // TERM and isatty(stdout) are considered trusted.
136    if !untrusted_term_has_color() {
137        return TerminalPreset::Dumb;
138    }
139
140    // Cap at Sanitize.
141    TerminalPreset::Sanitize
142}
143
144/// Detect the SGR content mask from `TERM` alone (untrusted mode).
145///
146/// Ignores `COLORTERM`, `TERM_PROGRAM`, `VTE_VERSION`, and other
147/// attacker-controllable env vars. Falls back to conservative
148/// heuristics based on the `TERM` value.
149///
150/// Routes through [`untrusted_term_has_color`] for the base
151/// "TERM is color-capable?" predicate, then refines by suffix:
152/// - `"256color"` suffix → `BASIC | EXTENDED`
153/// - `"color"` suffix → `BASIC`
154/// - else (non-empty, non-dumb) → `BASIC` (conservative)
155#[must_use]
156pub fn detect_sgr_mask_untrusted() -> Option<SgrContent> {
157    if !untrusted_term_has_color() {
158        return None;
159    }
160    // Safe to unwrap: `untrusted_term_has_color` guarantees Ok + non-empty.
161    let term = std::env::var("TERM").ok()?;
162
163    if term.ends_with("256color") {
164        Some(SgrContent::BASIC.union(SgrContent::EXTENDED))
165    } else if term.ends_with("color") {
166        Some(SgrContent::BASIC)
167    } else {
168        // Conservative: assume basic color support.
169        Some(SgrContent::BASIC)
170    }
171}
172
173/// Detect whether stdout supports OSC 8 hyperlinks.
174///
175/// Uses the `supports-hyperlinks` crate which checks `TERM_PROGRAM`,
176/// `VTE_VERSION`, and other signals to determine if the terminal
177/// can render clickable hyperlinks.
178///
179/// Returns `false` when stdout is not a TTY or when hyperlink
180/// support cannot be determined.
181///
182/// OSC 8 hyperlinks are not a security concern (no echoback vector),
183/// so this is purely a UX signal: avoid emitting sequences that the
184/// terminal would render as garbage or ignore.
185#[must_use]
186pub fn detect_hyperlinks() -> bool {
187    supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout)
188}
189
190/// Detect hyperlink support from trusted signals only (untrusted mode).
191///
192/// Like [`detect_hyperlinks`], but ignores attacker-controllable env
193/// vars (`FORCE_HYPERLINK`, `TERM_PROGRAM`, `VTE_VERSION`).
194///
195/// Falls back to `false` — conservative default when env cannot be
196/// trusted. Only `isatty(stdout)` and `TERM` are considered trusted.
197///
198/// In untrusted mode, hyperlink support cannot be reliably determined
199/// from `TERM` alone (no standard suffix convention), so this always
200/// returns `false`.
201#[must_use]
202pub fn detect_hyperlinks_untrusted() -> bool {
203    // TERM alone cannot indicate hyperlink support — no convention.
204    // Conservative: assume no hyperlinks in untrusted environments.
205    false
206}