Skip to main content

ai_usagebar/
display.rs

1//! Sanitization for text that crosses an untrusted data boundary into a UI.
2//!
3//! Vendor responses and cached diagnostics are data, not terminal programs.
4//! Keep ordinary Unicode and line breaks, but remove terminal control bytes
5//! before the text is persisted or handed to Pango/ratatui/ANSI renderers.
6
7/// Generous bound for one remote label or diagnostic field. Legitimate values
8/// are normally a few dozen characters; the cap prevents a valid-but-hostile
9/// JSON response from turning one UI cell or cache sidecar into megabytes.
10pub const MAX_UNTRUSTED_FIELD_CHARS: usize = 4 * 1024;
11
12/// Strip terminal control characters while preserving readable line layout.
13///
14/// Newlines are safe and useful in diagnostics. Tabs and carriage returns are
15/// normalized to spaces; every other Unicode control character (including ESC,
16/// BEL, DEL, and C1 controls) is removed. Invisible bidirectional markers and
17/// overrides are also removed so an untrusted label cannot visually reorder
18/// neighboring UI text. The result is capped by character, not byte, so UTF-8
19/// is never split.
20pub fn sanitize_untrusted_field(value: &str) -> String {
21    value
22        .chars()
23        .filter_map(|ch| match ch {
24            '\n' => Some('\n'),
25            '\t' | '\r' => Some(' '),
26            _ if ch.is_control() || is_bidi_control(ch) => None,
27            _ => Some(ch),
28        })
29        .take(MAX_UNTRUSTED_FIELD_CHARS)
30        .collect()
31}
32
33/// One line of untrusted text on its way to a terminal or a log.
34///
35/// [`sanitize_untrusted_field`] keeps newlines, which is right for a multi-line
36/// diagnostic in a UI cell and wrong for anything sharing a line-oriented
37/// stream with output the user is reading: one embedded newline forges a line.
38/// A subprocess's stderr is exactly that case — it is one or more lines on
39/// their way into an error message.
40pub fn sanitize_untrusted_line(value: &str) -> String {
41    sanitize_untrusted_field(value).replace('\n', " ")
42}
43
44/// A filesystem path on its way to the same place.
45///
46/// [`std::path::Display`] escapes nothing, and a path is not always a literal
47/// this program chose — it can carry a component from an account name, a
48/// vendor response, or an archive member. This is what [`crate::error::AppError::Io`]
49/// renders its path through, so an attacker-chosen path cannot carry a terminal
50/// escape out of *any* error site rather than only the ones that remembered.
51pub fn sanitize_untrusted_path(path: &std::path::Path) -> String {
52    sanitize_untrusted_line(&path.to_string_lossy())
53}
54
55/// Remove `secret` from text that is about to be persisted or displayed.
56///
57/// A provider's answer can quote the credential it was sent with — Z.AI
58/// returns the reason for an in-band failure in `msg`, inside an HTTP 200 —
59/// and the first point of writing is the vendor cache, so the key must be
60/// gone before the failure is recorded, not scrubbed after the fact. Exact
61/// occurrences become `[key]`; when the secret is at least eight characters
62/// long and any consecutive eight-character window of it — counted in
63/// `char`, not bytes — still survives in the text, the provider quoted the
64/// key in part, and a partial echo is the key with a missing tail, so the
65/// whole text is replaced with a fixed note. An empty secret leaves the text
66/// alone.
67pub(crate) fn redact_secret(text: &str, secret: &str) -> String {
68    const PARTIAL_ECHO: &str = "The provider's answer quoted the key, so it is not shown.";
69    if secret.is_empty() {
70        return text.to_string();
71    }
72    let redacted = text.replace(secret, "[key]");
73    let chars: Vec<char> = secret.chars().collect();
74    let quoted_partial = chars.len() >= 8
75        && (0..=chars.len() - 8).any(|start| {
76            redacted.contains(chars[start..start + 8].iter().collect::<String>().as_str())
77        });
78    if quoted_partial {
79        PARTIAL_ECHO.to_string()
80    } else {
81        redacted
82    }
83}
84
85fn is_bidi_control(ch: char) -> bool {
86    matches!(
87        ch,
88        '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
89    )
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn strips_terminal_sequences_but_keeps_text_and_newlines() {
98        let input = "before\x1b]52;c;Y2xpcGJvYXJk\x07after\nnext\tcolumn\rreturn\u{202e}spoof";
99        assert_eq!(
100            sanitize_untrusted_field(input),
101            "before]52;c;Y2xpcGJvYXJkafter\nnext column returnspoof"
102        );
103    }
104
105    /// A subprocess's stderr shares a line-oriented stream with the message
106    /// carrying it, so a newline in it forges a line the program never wrote.
107    /// This is the shape `security` and `tar` diagnostics arrive in.
108    #[test]
109    fn collapses_newlines_so_untrusted_text_cannot_forge_a_line() {
110        let stderr = "tar: \x1b[2Kall good\nRESTORED: 0 files";
111        let out = sanitize_untrusted_line(stderr);
112        assert!(!out.contains('\n'), "{out:?}");
113        assert!(!out.contains('\u{1b}'), "{out:?}");
114        assert_eq!(out, "tar: [2Kall good RESTORED: 0 files");
115    }
116
117    #[test]
118    fn a_path_carrying_an_escape_renders_without_it() {
119        let path = std::path::Path::new("/tmp/\x1b[2Kspoofed");
120        assert_eq!(sanitize_untrusted_path(path), "/tmp/[2Kspoofed");
121    }
122
123    #[test]
124    fn caps_untrusted_fields_without_splitting_unicode() {
125        let input = "é".repeat(MAX_UNTRUSTED_FIELD_CHARS + 10);
126        let output = sanitize_untrusted_field(&input);
127        assert_eq!(output.chars().count(), MAX_UNTRUSTED_FIELD_CHARS);
128        assert!(output.chars().all(|ch| ch == 'é'));
129    }
130
131    /// A provider that quotes the key a fetch was sent with must not persist
132    /// it: exact echoes become the marker, and for a key of eight characters
133    /// or more a surviving eight-character window is a partial echo — the key
134    /// with a missing tail — so the whole text is replaced instead.
135    #[test]
136    fn redact_secret_replaces_echoed_keys_and_partial_echoes() {
137        let key = "abcdefghijklmnop";
138        // A verbatim echo, on its own and alongside the provider's wording.
139        assert_eq!(redact_secret(key, key), "[key]");
140        assert_eq!(
141            redact_secret("bad key abcdefghijklmnop", key),
142            "bad key [key]"
143        );
144        // An eight-char window is the key in all but the missing tail.
145        assert_eq!(
146            redact_secret("bad key abcdefgh…", key),
147            "The provider's answer quoted the key, so it is not shown."
148        );
149        // Text without the key passes through unchanged.
150        assert_eq!(redact_secret("HTTP 500", key), "HTTP 500");
151        // A short key has no eight-char window: exact replacement only.
152        assert_eq!(redact_secret("short", "short"), "[key]");
153        // An empty secret leaves the text alone.
154        assert_eq!(redact_secret("nothing to hide", ""), "nothing to hide");
155    }
156}
157
158/// Width of plain (non-markup) text in terminal columns.
159///
160/// Use this for layout arithmetic — column padding, gauge widths, the widest
161/// label in a report. A character count is wrong for any locale with CJK text,
162/// where one glyph occupies two cells, and for combining marks, which occupy
163/// none.
164///
165/// This is deliberately separate from [`crate::pango::visible_width`], which
166/// additionally strips `<span>` markup. Feeding plain text to that function
167/// would treat a literal `<` as the start of a tag and silently undercount the
168/// rest of the line; feeding markup to this one would count the tags.
169///
170/// Character counts remain correct for *limits* (a config field documented as
171/// "1 to 48 characters") and for edit-cursor positions, which move per
172/// character rather than per column. Those are not layout.
173pub fn text_width(s: &str) -> usize {
174    unicode_width::UnicodeWidthStr::width(s)
175}
176
177/// Left-align `s` in a field `width` columns wide, padding with spaces.
178///
179/// `format!("{s:width$}")` cannot do this: Rust's fill/align pads a `str` by
180/// character count, so a label of three CJK ideographs in a field of ten gets
181/// seven trailing spaces and renders thirteen columns wide, pushing the next
182/// column out of line. Padding is computed from [`text_width`] instead.
183///
184/// A string already at or past `width` is returned unpadded rather than
185/// truncated — the callers use this for column alignment, where clipping a
186/// label is worse than a single long row.
187pub fn pad_end(s: &str, width: usize) -> String {
188    let w = text_width(s);
189    if w >= width {
190        return s.to_string();
191    }
192    let mut out = String::with_capacity(s.len() + (width - w));
193    out.push_str(s);
194    out.extend(std::iter::repeat_n(' ', width - w));
195    out
196}
197
198#[cfg(test)]
199mod width_tests {
200    use super::{pad_end, text_width};
201
202    #[test]
203    fn text_width_measures_columns_not_characters() {
204        assert_eq!(text_width("Weekly"), 6);
205        assert_eq!(text_width("日本語"), 6); // 3 chars, 6 columns
206        assert_eq!(text_width("사용량"), 6);
207        assert_eq!(text_width("e\u{301}"), 1); // combining mark adds nothing
208    }
209
210    #[test]
211    fn pad_end_pads_by_columns_not_characters() {
212        // The bug this exists to prevent: `format!("{:10}", "日本語")` appends
213        // seven spaces to a six-column string, yielding thirteen columns.
214        assert_eq!(text_width(&pad_end("日本語", 10)), 10);
215        assert_eq!(text_width(&pad_end("Weekly", 10)), 10);
216        assert_eq!(pad_end("Weekly", 10), "Weekly    ");
217        // Already wide enough: returned unchanged rather than truncated.
218        assert_eq!(pad_end("Weekly", 3), "Weekly");
219        assert_eq!(pad_end("日本語", 6), "日本語");
220    }
221
222    #[test]
223    fn text_width_counts_a_literal_angle_bracket() {
224        // The reason this is not `pango::visible_width`: that function would
225        // read `<` as a tag opening and drop everything after it.
226        assert_eq!(text_width("a<b"), 3);
227        assert_eq!(text_width("Claude & GPT"), 12);
228    }
229}