Skip to main content

ftui_render/
sanitize.rs

1#![forbid(unsafe_code)]
2
3//! Sanitization for untrusted terminal output.
4//!
5//! This module implements the sanitize-by-default policy (ADR-006) to protect
6//! against terminal escape injection attacks. Any untrusted bytes displayed
7//! as logs, tool output, or LLM streams must be treated as **data**, not
8//! executed as terminal control sequences.
9//!
10//! # Threat Model
11//!
12//! Malicious content in logs could:
13//! 1. Manipulate cursor position (break inline mode)
14//! 2. Change terminal colors/modes persistently
15//! 3. Hide text or show fake prompts (social engineering)
16//! 4. Trigger terminal queries that exfiltrate data
17//! 5. Set window title to misleading values
18//!
19//! # Performance
20//!
21//! - **Fast path (95%+ of cases)**: Scan for ESC byte using memchr.
22//!   If no ESC found, content is safe - return borrowed slice.
23//!   Zero allocation in common case, < 100ns for typical log line.
24//!
25//! - **Slow path**: Allocate output buffer, strip control sequences,
26//!   return owned String. Linear in input size.
27//!
28//! # Usage
29//!
30//! ```
31//! use ftui_render::sanitize::sanitize;
32//! use std::borrow::Cow;
33//!
34//! // Fast path - no escapes, returns borrowed
35//! let safe = sanitize("Normal log message");
36//! assert!(matches!(safe, Cow::Borrowed(_)));
37//!
38//! // Slow path - escapes stripped, returns owned
39//! let malicious = sanitize("Evil \x1b[31mred\x1b[0m text");
40//! assert!(matches!(malicious, Cow::Owned(_)));
41//! assert_eq!(malicious.as_ref(), "Evil red text");
42//! ```
43
44use std::borrow::Cow;
45
46use memchr::memchr;
47
48/// Sanitize untrusted text for safe terminal display.
49///
50/// # Fast Path
51/// If no ESC (0x1B) found and no forbidden C0 controls, returns borrowed input
52/// with zero allocation.
53///
54/// # Slow Path
55/// Strips all escape sequences and forbidden C0 controls, returns owned String.
56///
57/// # What Gets Stripped
58/// - ESC (0x1B) and all following CSI/OSC/DCS/APC sequences
59/// - C0 controls except: TAB (0x09), LF (0x0A), CR (0x0D)
60/// - C1 controls (U+0080..U+009F) — these are the 8-bit equivalents of
61///   ESC-prefixed sequences and some terminals honor them
62/// - DEL (0x7F)
63///
64/// # What Gets Preserved
65/// - TAB, LF, CR (allowed control characters)
66/// - All printable ASCII (0x20-0x7E)
67/// - All valid UTF-8 sequences above U+009F
68#[inline]
69pub fn sanitize(input: &str) -> Cow<'_, str> {
70    let bytes = input.as_bytes();
71
72    // Fast path: check for any ESC byte, forbidden C0 controls, DEL, or C1 controls.
73    // C1 controls (U+0080..U+009F) are encoded in UTF-8 as \xC2\x80..\xC2\x9F.
74    if memchr(0x1B, bytes).is_none()
75        && memchr(0x7F, bytes).is_none()
76        && !has_forbidden_c0(bytes)
77        && !has_c1_controls(bytes)
78    {
79        return Cow::Borrowed(input);
80    }
81
82    // Slow path: strip escape sequences
83    Cow::Owned(sanitize_slow(input))
84}
85
86/// Check if any forbidden C0 control characters are present.
87///
88/// Forbidden: 0x00-0x08, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F
89/// Allowed: TAB (0x09), LF (0x0A), CR (0x0D)
90#[inline]
91fn has_forbidden_c0(bytes: &[u8]) -> bool {
92    bytes.iter().any(|&b| is_forbidden_c0(b))
93}
94
95/// Check if a single byte is a forbidden C0 control.
96#[inline]
97const fn is_forbidden_c0(b: u8) -> bool {
98    matches!(
99        b,
100        0x00..=0x08 | 0x0B..=0x0C | 0x0E..=0x1A | 0x1C..=0x1F
101    )
102}
103
104/// Check if any C1 control characters (U+0080..U+009F) are present.
105///
106/// In UTF-8, these are encoded as the two-byte sequence \xC2\x80..\xC2\x9F.
107/// C1 controls include CSI (U+009B), OSC (U+009D), DCS (U+0090), APC (U+009F),
108/// etc. — some terminals honor these as equivalent to their ESC-prefixed forms.
109#[inline]
110fn has_c1_controls(bytes: &[u8]) -> bool {
111    bytes
112        .windows(2)
113        .any(|w| w[0] == 0xC2 && (0x80..=0x9F).contains(&w[1]))
114}
115
116/// Slow path: strip escape sequences and forbidden controls.
117fn sanitize_slow(input: &str) -> String {
118    let bytes = input.as_bytes();
119    let mut output = String::with_capacity(input.len());
120    let mut i = 0;
121
122    while i < bytes.len() {
123        let b = bytes[i];
124        match b {
125            // ESC - start of escape sequence
126            0x1B => {
127                i = skip_escape_sequence(bytes, i);
128            }
129            // Allowed C0 controls: TAB, LF, CR
130            0x09 | 0x0A | 0x0D => {
131                output.push(b as char);
132                i += 1;
133            }
134            // Forbidden C0 controls - skip
135            0x00..=0x08 | 0x0B..=0x0C | 0x0E..=0x1A | 0x1C..=0x1F => {
136                i += 1;
137            }
138            // DEL - skip
139            0x7F => {
140                i += 1;
141            }
142            // Printable ASCII
143            0x20..=0x7E => {
144                output.push(b as char);
145                i += 1;
146            }
147            // Start of UTF-8 sequence (high bit set)
148            0x80..=0xFF => {
149                if let Some((c, len)) = decode_utf8_char(&bytes[i..]) {
150                    // Skip C1 controls (U+0080..U+009F) — these are the 8-bit
151                    // equivalents of ESC-prefixed sequences (CSI, OSC, DCS, etc.)
152                    if !('\u{0080}'..='\u{009F}').contains(&c) {
153                        output.push(c);
154                    }
155                    i += len;
156                } else {
157                    // Invalid UTF-8, skip byte
158                    i += 1;
159                }
160            }
161        }
162    }
163
164    output
165}
166
167/// Skip over escape sequence, returning index after it.
168///
169/// Handles:
170/// - CSI: ESC [ ... final_byte (0x40-0x7E)
171/// - OSC: ESC ] ... (BEL or ST)
172/// - DCS: ESC P ... ST
173/// - PM: ESC ^ ... ST
174/// - APC: ESC _ ... ST
175/// - Single-char escapes: ESC char
176fn skip_escape_sequence(bytes: &[u8], start: usize) -> usize {
177    let mut i = start + 1; // Skip ESC
178    if i >= bytes.len() {
179        return i;
180    }
181
182    match bytes[i] {
183        // CSI sequence: ESC [ params... final_byte
184        b'[' => {
185            i += 1;
186            // Consume parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F)
187            // Stop at final byte (0x40-0x7E)
188            while i < bytes.len() {
189                let b = bytes[i];
190                if (0x40..=0x7E).contains(&b) {
191                    return i + 1;
192                }
193                // Valid parameter/intermediate bytes are 0x20-0x3F
194                if !(0x20..=0x3F).contains(&b) {
195                    // Invalid char in CSI (e.g. newline, control char, or high byte)
196                    // Abort sequence processing to prevent eating valid text
197                    return i;
198                }
199                i += 1;
200            }
201        }
202        // OSC sequence: ESC ] ... (BEL or ST)
203        b']' => {
204            i += 1;
205            while i < bytes.len() {
206                let b = bytes[i];
207                // BEL terminates OSC
208                if b == 0x07 {
209                    return i + 1;
210                }
211                // ST (ESC \) terminates OSC
212                if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
213                    return i + 2;
214                }
215                // 8-bit ST (U+009C, UTF-8 C2 9C) also terminates: real
216                // terminals accept it, so a C1-ST-terminated OSC is properly
217                // terminated — treating it as content would swallow all
218                // following legitimate text to end of input.
219                if b == 0xC2 && i + 1 < bytes.len() && bytes[i + 1] == 0x9C {
220                    return i + 2;
221                }
222                // Lone ESC (not followed by \): abort OSC and let the main loop
223                // re-process this ESC as a potential new escape sequence.
224                if b == 0x1B {
225                    return i;
226                }
227                // Abort on other C0 controls (e.g. newline) to prevent swallowing logs
228                if b < 0x20 {
229                    return i;
230                }
231                i += 1;
232            }
233        }
234        // DCS/PM/APC: ESC P/^/_ ... ST
235        b'P' | b'^' | b'_' => {
236            i += 1;
237            while i < bytes.len() {
238                let b = bytes[i];
239                // ST (ESC \) terminates
240                if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
241                    return i + 2;
242                }
243                // 8-bit ST (U+009C) terminates, as in the OSC arm above.
244                if b == 0xC2 && i + 1 < bytes.len() && bytes[i + 1] == 0x9C {
245                    return i + 2;
246                }
247                // Lone ESC (not followed by \): abort and let the main loop
248                // re-process this ESC as a potential new escape sequence.
249                if b == 0x1B {
250                    return i;
251                }
252                // Abort on C0 controls
253                if b < 0x20 {
254                    return i;
255                }
256                i += 1;
257            }
258        }
259        // Single-char escape sequences (ESC followed by 0x20-0x7E)
260        0x20..=0x7E => {
261            return i + 1;
262        }
263        // Unknown or invalid - just skip the ESC
264        _ => {}
265    }
266
267    i
268}
269
270/// Decode a single UTF-8 character from byte slice.
271///
272/// Returns the character and number of bytes consumed, or None if invalid.
273fn decode_utf8_char(bytes: &[u8]) -> Option<(char, usize)> {
274    if bytes.is_empty() {
275        return None;
276    }
277
278    let first = bytes[0];
279    let (expected_len, mut codepoint) = match first {
280        0x00..=0x7F => return Some((first as char, 1)),
281        0xC0..=0xDF => (2, (first & 0x1F) as u32),
282        0xE0..=0xEF => (3, (first & 0x0F) as u32),
283        0xF0..=0xF7 => (4, (first & 0x07) as u32),
284        _ => return None, // Invalid lead byte
285    };
286
287    if bytes.len() < expected_len {
288        return None;
289    }
290
291    // Process continuation bytes
292    for &b in bytes.iter().take(expected_len).skip(1) {
293        if (b & 0xC0) != 0x80 {
294            return None; // Invalid continuation byte
295        }
296        codepoint = (codepoint << 6) | (b & 0x3F) as u32;
297    }
298
299    // Reject overlong encodings (RFC 3629)
300    let min_codepoint = match expected_len {
301        2 => 0x80,
302        3 => 0x800,
303        4 => 0x1_0000,
304        _ => return None,
305    };
306    if codepoint < min_codepoint {
307        return None;
308    }
309
310    // Validate codepoint
311    char::from_u32(codepoint).map(|c| (c, expected_len))
312}
313
314/// Text with trust level annotation.
315///
316/// Use this to explicitly mark whether text has been sanitized or comes
317/// from a trusted source.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum Text<'a> {
320    /// Sanitized text (escape sequences stripped).
321    Sanitized(Cow<'a, str>),
322
323    /// Trusted text (may contain ANSI sequences).
324    /// Only use with content from trusted sources.
325    Trusted(Cow<'a, str>),
326}
327
328impl<'a> Text<'a> {
329    /// Create sanitized text from an untrusted source.
330    #[inline]
331    pub fn sanitized(s: &'a str) -> Self {
332        Text::Sanitized(sanitize(s))
333    }
334
335    /// Create from a trusted source (ANSI sequences allowed).
336    ///
337    /// # Safety
338    /// Only use with content from trusted sources. Untrusted content
339    /// can corrupt terminal state or deceive users.
340    #[inline]
341    pub fn trusted(s: &'a str) -> Self {
342        Text::Trusted(Cow::Borrowed(s))
343    }
344
345    /// Create owned sanitized text.
346    #[inline]
347    pub fn sanitized_owned(s: String) -> Self {
348        match sanitize(&s) {
349            Cow::Borrowed(_) => Text::Sanitized(Cow::Owned(s)),
350            Cow::Owned(owned) => Text::Sanitized(Cow::Owned(owned)),
351        }
352    }
353
354    /// Create owned trusted text.
355    #[inline]
356    pub fn trusted_owned(s: String) -> Self {
357        Text::Trusted(Cow::Owned(s))
358    }
359
360    /// Get the inner string slice.
361    #[inline]
362    #[must_use]
363    pub fn as_str(&self) -> &str {
364        match self {
365            Text::Sanitized(cow) => cow.as_ref(),
366            Text::Trusted(cow) => cow.as_ref(),
367        }
368    }
369
370    /// Check if this text is sanitized.
371    #[inline]
372    #[must_use]
373    pub fn is_sanitized(&self) -> bool {
374        matches!(self, Text::Sanitized(_))
375    }
376
377    /// Check if this text is trusted.
378    #[inline]
379    #[must_use]
380    pub fn is_trusted(&self) -> bool {
381        matches!(self, Text::Trusted(_))
382    }
383
384    /// Convert to owned version.
385    pub fn into_owned(self) -> Text<'static> {
386        match self {
387            Text::Sanitized(cow) => Text::Sanitized(Cow::Owned(cow.into_owned())),
388            Text::Trusted(cow) => Text::Trusted(Cow::Owned(cow.into_owned())),
389        }
390    }
391}
392
393impl AsRef<str> for Text<'_> {
394    fn as_ref(&self) -> &str {
395        self.as_str()
396    }
397}
398
399impl std::fmt::Display for Text<'_> {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        write!(f, "{}", self.as_str())
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    // ============== Fast Path Tests ==============
410
411    #[test]
412    fn fast_path_no_escape() {
413        let input = "Normal log message without escapes";
414        let result = sanitize(input);
415        assert!(matches!(result, Cow::Borrowed(_)));
416        assert_eq!(result.as_ref(), input);
417    }
418
419    #[test]
420    fn fast_path_with_allowed_controls() {
421        let input = "Line1\nLine2\tTabbed\rCarriage";
422        let result = sanitize(input);
423        assert!(matches!(result, Cow::Borrowed(_)));
424        assert_eq!(result.as_ref(), input);
425    }
426
427    #[test]
428    fn fast_path_unicode() {
429        let input = "Hello \u{4e16}\u{754c} \u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}";
430        let result = sanitize(input);
431        assert!(matches!(result, Cow::Borrowed(_)));
432        assert_eq!(result.as_ref(), input);
433    }
434
435    #[test]
436    fn fast_path_empty() {
437        let input = "";
438        let result = sanitize(input);
439        assert!(matches!(result, Cow::Borrowed(_)));
440        assert_eq!(result.as_ref(), "");
441    }
442
443    #[test]
444    fn fast_path_printable_ascii() {
445        let input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()";
446        let result = sanitize(input);
447        assert!(matches!(result, Cow::Borrowed(_)));
448        assert_eq!(result.as_ref(), input);
449    }
450
451    // ============== Slow Path: CSI Sequences ==============
452
453    #[test]
454    fn slow_path_strips_sgr_color() {
455        let input = "Hello \x1b[31mred\x1b[0m world";
456        let result = sanitize(input);
457        assert!(matches!(result, Cow::Owned(_)));
458        assert_eq!(result.as_ref(), "Hello red world");
459    }
460
461    #[test]
462    fn slow_path_strips_cursor_movement() {
463        let input = "Before\x1b[2;5HAfter";
464        let result = sanitize(input);
465        assert_eq!(result.as_ref(), "BeforeAfter");
466    }
467
468    #[test]
469    fn slow_path_strips_erase() {
470        let input = "Text\x1b[2JCleared";
471        let result = sanitize(input);
472        assert_eq!(result.as_ref(), "TextCleared");
473    }
474
475    #[test]
476    fn slow_path_strips_multiple_sequences() {
477        let input = "\x1b[1mBold\x1b[0m \x1b[4mUnderline\x1b[24m \x1b[38;5;196mColor\x1b[0m";
478        let result = sanitize(input);
479        assert_eq!(result.as_ref(), "Bold Underline Color");
480    }
481
482    // ============== Slow Path: OSC Sequences ==============
483
484    #[test]
485    fn slow_path_strips_osc_title_bel() {
486        // OSC 0: set title, terminated by BEL
487        let input = "Text\x1b]0;Evil Title\x07More";
488        let result = sanitize(input);
489        assert_eq!(result.as_ref(), "TextMore");
490    }
491
492    #[test]
493    fn slow_path_strips_osc_title_st() {
494        // OSC 0: set title, terminated by ST
495        let input = "Text\x1b]0;Evil Title\x1b\\More";
496        let result = sanitize(input);
497        assert_eq!(result.as_ref(), "TextMore");
498    }
499
500    #[test]
501    fn slow_path_strips_osc8_hyperlink() {
502        // OSC 8: hyperlink
503        let input = "Click \x1b]8;;https://evil.com\x07here\x1b]8;;\x07 please";
504        let result = sanitize(input);
505        assert_eq!(result.as_ref(), "Click here please");
506    }
507
508    #[test]
509    fn osc_terminated_by_c1_st_does_not_swallow_following_text() {
510        // Regression: the OSC skipper only knew BEL and ESC-\ terminators.
511        // The 8-bit ST (U+009C) — which real terminals accept — was consumed
512        // as content, so everything after a C1-ST-terminated OSC was
513        // swallowed to end of input.
514        let input = "Before\x1b]0;title\u{009C}hello world";
515        let result = sanitize(input);
516        assert_eq!(result.as_ref(), "Beforehello world");
517    }
518
519    #[test]
520    fn dcs_pm_apc_terminated_by_c1_st_do_not_swallow_following_text() {
521        for intro in ['P', '^', '_'] {
522            let input = format!("Before\x1b{intro}payload\u{009C}after");
523            let result = sanitize(&input);
524            assert_eq!(
525                result.as_ref(),
526                "Beforeafter",
527                "introducer {intro:?}: text after C1 ST swallowed"
528            );
529        }
530    }
531
532    // ============== Slow Path: DCS/PM/APC ==============
533
534    #[test]
535    fn slow_path_strips_dcs() {
536        let input = "Before\x1bPdevice control string\x1b\\After";
537        let result = sanitize(input);
538        assert_eq!(result.as_ref(), "BeforeAfter");
539    }
540
541    #[test]
542    fn slow_path_strips_apc() {
543        let input = "Before\x1b_application program command\x1b\\After";
544        let result = sanitize(input);
545        assert_eq!(result.as_ref(), "BeforeAfter");
546    }
547
548    #[test]
549    fn slow_path_strips_pm() {
550        let input = "Before\x1b^privacy message\x1b\\After";
551        let result = sanitize(input);
552        assert_eq!(result.as_ref(), "BeforeAfter");
553    }
554
555    #[test]
556    fn slow_path_strips_osc52_clipboard() {
557        let input = "Before\x1b]52;c;SGVsbG8=\x07After";
558        let result = sanitize(input);
559        assert_eq!(result.as_ref(), "BeforeAfter");
560    }
561
562    #[test]
563    fn slow_path_strips_osc52_clipboard_st() {
564        let input = "Before\x1b]52;c;SGVsbG8=\x1b\\After";
565        let result = sanitize(input);
566        assert_eq!(result.as_ref(), "BeforeAfter");
567    }
568
569    #[test]
570    fn slow_path_strips_private_modes() {
571        let input = "A\x1b[?1049hB\x1b[?1000hC\x1b[?2004hD";
572        let result = sanitize(input);
573        assert_eq!(result.as_ref(), "ABCD");
574    }
575
576    // ============== Slow Path: C0 Controls ==============
577
578    #[test]
579    fn slow_path_strips_nul() {
580        let input = "Hello\x00World";
581        let result = sanitize(input);
582        assert_eq!(result.as_ref(), "HelloWorld");
583    }
584
585    #[test]
586    fn slow_path_strips_bel() {
587        // BEL (0x07) outside of OSC should be stripped
588        let input = "Hello\x07World";
589        let result = sanitize(input);
590        assert_eq!(result.as_ref(), "HelloWorld");
591    }
592
593    #[test]
594    fn slow_path_strips_backspace() {
595        let input = "Hello\x08World";
596        let result = sanitize(input);
597        assert_eq!(result.as_ref(), "HelloWorld");
598    }
599
600    #[test]
601    fn slow_path_strips_form_feed() {
602        let input = "Hello\x0CWorld";
603        let result = sanitize(input);
604        assert_eq!(result.as_ref(), "HelloWorld");
605    }
606
607    #[test]
608    fn slow_path_strips_vertical_tab() {
609        let input = "Hello\x0BWorld";
610        let result = sanitize(input);
611        assert_eq!(result.as_ref(), "HelloWorld");
612    }
613
614    #[test]
615    fn slow_path_strips_del() {
616        let input = "Hello\x7FWorld";
617        let result = sanitize(input);
618        assert_eq!(result.as_ref(), "HelloWorld");
619    }
620
621    #[test]
622    fn slow_path_preserves_tab_lf_cr() {
623        let input = "Line1\nLine2\tTabbed\rReturn";
624        // This should trigger slow path due to needing to scan
625        // but preserve tab/lf/cr
626        let result = sanitize(input);
627        assert_eq!(result.as_ref(), "Line1\nLine2\tTabbed\rReturn");
628    }
629
630    // ============== Edge Cases ==============
631
632    #[test]
633    fn handles_truncated_csi() {
634        let input = "Hello\x1b[";
635        let result = sanitize(input);
636        assert!(!result.contains('\x1b'));
637        assert_eq!(result.as_ref(), "Hello");
638    }
639
640    #[test]
641    fn handles_truncated_dcs() {
642        let input = "Hello\x1bP1;2;3";
643        let result = sanitize(input);
644        assert!(!result.contains('\x1b'));
645        assert_eq!(result.as_ref(), "Hello");
646    }
647
648    #[test]
649    fn handles_truncated_apc() {
650        let input = "Hello\x1b_test";
651        let result = sanitize(input);
652        assert!(!result.contains('\x1b'));
653        assert_eq!(result.as_ref(), "Hello");
654    }
655
656    #[test]
657    fn handles_truncated_pm() {
658        let input = "Hello\x1b^secret";
659        let result = sanitize(input);
660        assert!(!result.contains('\x1b'));
661        assert_eq!(result.as_ref(), "Hello");
662    }
663
664    #[test]
665    fn handles_truncated_osc() {
666        let input = "Hello\x1b]0;Title";
667        let result = sanitize(input);
668        assert!(!result.contains('\x1b'));
669        assert_eq!(result.as_ref(), "Hello");
670    }
671
672    #[test]
673    fn handles_esc_at_end() {
674        let input = "Hello\x1b";
675        let result = sanitize(input);
676        assert_eq!(result.as_ref(), "Hello");
677    }
678
679    #[test]
680    fn handles_lone_esc() {
681        let input = "\x1b";
682        let result = sanitize(input);
683        assert_eq!(result.as_ref(), "");
684    }
685
686    #[test]
687    fn handles_single_char_escape() {
688        // ESC 7 (save cursor) and ESC 8 (restore cursor)
689        let input = "Before\x1b7Middle\x1b8After";
690        let result = sanitize(input);
691        assert_eq!(result.as_ref(), "BeforeMiddleAfter");
692    }
693
694    #[test]
695    fn handles_unknown_escape() {
696        // ESC followed by a byte that's not a valid escape introducer
697        // Using a valid printable byte that's not a known escape char
698        let input = "Before\x1b!After";
699        let result = sanitize(input);
700        // Single-char escape: ESC ! gets stripped
701        assert_eq!(result.as_ref(), "BeforeAfter");
702    }
703
704    // ============== Unicode Tests ==============
705
706    #[test]
707    fn preserves_unicode_characters() {
708        let input = "\u{4e16}\u{754c}"; // Chinese characters
709        let result = sanitize(input);
710        assert_eq!(result.as_ref(), "\u{4e16}\u{754c}");
711    }
712
713    #[test]
714    fn preserves_emoji() {
715        let input = "\u{1f600}\u{1f389}\u{1f680}"; // Emoji
716        let result = sanitize(input);
717        assert_eq!(result.as_ref(), "\u{1f600}\u{1f389}\u{1f680}");
718    }
719
720    #[test]
721    fn preserves_combining_characters() {
722        // e with combining acute accent
723        let input = "e\u{0301}";
724        let result = sanitize(input);
725        assert_eq!(result.as_ref(), "e\u{0301}");
726    }
727
728    #[test]
729    fn mixed_unicode_and_escapes() {
730        let input = "\u{4e16}\x1b[31m\u{754c}\x1b[0m";
731        let result = sanitize(input);
732        assert_eq!(result.as_ref(), "\u{4e16}\u{754c}");
733    }
734
735    // ============== Text Type Tests ==============
736
737    #[test]
738    fn text_sanitized() {
739        let text = Text::sanitized("Hello \x1b[31mWorld\x1b[0m");
740        assert!(text.is_sanitized());
741        assert!(!text.is_trusted());
742        assert_eq!(text.as_str(), "Hello World");
743    }
744
745    #[test]
746    fn text_trusted() {
747        let text = Text::trusted("Hello \x1b[31mWorld\x1b[0m");
748        assert!(!text.is_sanitized());
749        assert!(text.is_trusted());
750        assert_eq!(text.as_str(), "Hello \x1b[31mWorld\x1b[0m");
751    }
752
753    #[test]
754    fn text_into_owned() {
755        let text = Text::sanitized("Hello");
756        let owned = text.into_owned();
757        assert!(owned.is_sanitized());
758        assert_eq!(owned.as_str(), "Hello");
759    }
760
761    #[test]
762    fn text_display() {
763        let text = Text::sanitized("Hello");
764        assert_eq!(format!("{text}"), "Hello");
765    }
766
767    // ============== Property Tests (basic) ==============
768
769    #[test]
770    fn output_never_contains_esc() {
771        let inputs = [
772            "Normal text",
773            "\x1b[31mRed\x1b[0m",
774            "\x1b]0;Title\x07",
775            "\x1bPDCS\x1b\\",
776            "Mixed\x1b[1m\x1b]8;;url\x07text\x1b]8;;\x07\x1b[0m",
777            "",
778            "\x1b",
779            "\x1b[",
780            "\x1b]",
781        ];
782
783        for input in inputs {
784            let result = sanitize(input);
785            assert!(
786                !result.contains('\x1b'),
787                "Output contains ESC for input: {input:?}"
788            );
789        }
790    }
791
792    #[test]
793    fn output_never_contains_forbidden_c0() {
794        let inputs = [
795            "\x00\x01\x02\x03\x04\x05\x06\x07",
796            "\x08\x0B\x0C\x0E\x0F",
797            "\x10\x11\x12\x13\x14\x15\x16\x17",
798            "\x18\x19\x1A\x1C\x1D\x1E\x1F",
799            "Mixed\x00text\x07with\x0Ccontrols",
800        ];
801
802        for input in inputs {
803            let result = sanitize(input);
804            for b in result.as_bytes() {
805                assert!(
806                    !is_forbidden_c0(*b),
807                    "Output contains forbidden C0 0x{b:02X} for input: {input:?}"
808                );
809            }
810        }
811    }
812
813    #[test]
814    fn allowed_controls_preserved_in_output() {
815        let input = "Tab\there\nNewline\rCarriage";
816        let result = sanitize(input);
817        assert!(result.contains('\t'));
818        assert!(result.contains('\n'));
819        assert!(result.contains('\r'));
820    }
821
822    // ============== Decode UTF-8 Tests ==============
823
824    #[test]
825    fn decode_ascii() {
826        let bytes = b"A";
827        let result = decode_utf8_char(bytes);
828        assert_eq!(result, Some(('A', 1)));
829    }
830
831    #[test]
832    fn decode_two_byte() {
833        let bytes = "\u{00E9}".as_bytes(); // é
834        let result = decode_utf8_char(bytes);
835        assert_eq!(result, Some(('\u{00E9}', 2)));
836    }
837
838    #[test]
839    fn decode_three_byte() {
840        let bytes = "\u{4e16}".as_bytes(); // Chinese
841        let result = decode_utf8_char(bytes);
842        assert_eq!(result, Some(('\u{4e16}', 3)));
843    }
844
845    #[test]
846    fn decode_four_byte() {
847        let bytes = "\u{1f600}".as_bytes(); // Emoji
848        let result = decode_utf8_char(bytes);
849        assert_eq!(result, Some(('\u{1f600}', 4)));
850    }
851
852    #[test]
853    fn decode_invalid_lead() {
854        let bytes = &[0xFF];
855        let result = decode_utf8_char(bytes);
856        assert_eq!(result, None);
857    }
858
859    #[test]
860    fn decode_truncated() {
861        let bytes = &[0xC2]; // Incomplete 2-byte sequence
862        let result = decode_utf8_char(bytes);
863        assert_eq!(result, None);
864    }
865
866    #[test]
867    fn decode_invalid_continuation() {
868        let bytes = &[0xC2, 0x00]; // Invalid continuation byte
869        let result = decode_utf8_char(bytes);
870        assert_eq!(result, None);
871    }
872
873    // ================================================================
874    // Adversarial Security Tests (bd-397)
875    //
876    // Tests below exercise the specific threat model from ADR-006:
877    //   1. Log injection / cursor corruption
878    //   2. Title injection (OSC 0)
879    //   3. Clipboard hijacking (OSC 52)
880    //   4. Terminal mode hijacking
881    //   5. Data exfiltration via terminal queries
882    //   6. Social engineering via fake prompts
883    //   7. C1 control code injection
884    //   8. Sequence terminator confusion
885    //   9. DoS via large / deeply nested payloads
886    //  10. Combined / chained attacks
887    // ================================================================
888
889    // ---- 1. Log injection / cursor corruption ----
890
891    #[test]
892    fn adversarial_clear_screen() {
893        let input = "\x1b[2J";
894        let result = sanitize(input);
895        assert_eq!(result.as_ref(), "");
896    }
897
898    #[test]
899    fn adversarial_home_cursor() {
900        let input = "visible\x1b[Hhidden";
901        let result = sanitize(input);
902        assert_eq!(result.as_ref(), "visiblehidden");
903    }
904
905    #[test]
906    fn adversarial_cursor_absolute_position() {
907        let input = "ok\x1b[999;999Hmalicious";
908        let result = sanitize(input);
909        assert_eq!(result.as_ref(), "okmalicious");
910    }
911
912    #[test]
913    fn adversarial_scroll_up() {
914        let input = "text\x1b[5Smore";
915        let result = sanitize(input);
916        assert_eq!(result.as_ref(), "textmore");
917    }
918
919    #[test]
920    fn adversarial_scroll_down() {
921        let input = "text\x1b[5Tmore";
922        let result = sanitize(input);
923        assert_eq!(result.as_ref(), "textmore");
924    }
925
926    #[test]
927    fn adversarial_erase_line() {
928        let input = "secret\x1b[2Koverwrite";
929        let result = sanitize(input);
930        assert_eq!(result.as_ref(), "secretoverwrite");
931    }
932
933    #[test]
934    fn adversarial_insert_delete_lines() {
935        let input = "text\x1b[10Linserted\x1b[5Mdeleted";
936        let result = sanitize(input);
937        assert_eq!(result.as_ref(), "textinserteddeleted");
938    }
939
940    // ---- 2. Title injection (OSC 0, 1, 2) ----
941
942    #[test]
943    fn adversarial_osc0_title_injection() {
944        let input = "\x1b]0;PWNED - Enter Password\x07";
945        let result = sanitize(input);
946        assert_eq!(result.as_ref(), "");
947        assert!(!result.contains('\x1b'));
948        assert!(!result.contains('\x07'));
949    }
950
951    #[test]
952    fn adversarial_osc1_icon_title() {
953        let input = "\x1b]1;evil-icon\x07";
954        let result = sanitize(input);
955        assert_eq!(result.as_ref(), "");
956    }
957
958    #[test]
959    fn adversarial_osc2_window_title() {
960        let input = "\x1b]2;sudo password required\x1b\\";
961        let result = sanitize(input);
962        assert_eq!(result.as_ref(), "");
963    }
964
965    // ---- 3. Clipboard hijacking (OSC 52) ----
966
967    #[test]
968    fn adversarial_osc52_clipboard_set_bel() {
969        // Set clipboard to "rm -rf /" encoded in base64
970        let input = "safe\x1b]52;c;cm0gLXJmIC8=\x07text";
971        let result = sanitize(input);
972        assert_eq!(result.as_ref(), "safetext");
973    }
974
975    #[test]
976    fn adversarial_osc52_clipboard_set_st() {
977        let input = "safe\x1b]52;c;cm0gLXJmIC8=\x1b\\text";
978        let result = sanitize(input);
979        assert_eq!(result.as_ref(), "safetext");
980    }
981
982    #[test]
983    fn adversarial_osc52_clipboard_query() {
984        // Query clipboard (could exfiltrate data)
985        let input = "\x1b]52;c;?\x07";
986        let result = sanitize(input);
987        assert_eq!(result.as_ref(), "");
988    }
989
990    // ---- 4. Terminal mode hijacking ----
991
992    #[test]
993    fn adversarial_alt_screen_enable() {
994        let input = "\x1b[?1049h";
995        let result = sanitize(input);
996        assert_eq!(result.as_ref(), "");
997    }
998
999    #[test]
1000    fn adversarial_alt_screen_disable() {
1001        let input = "\x1b[?1049l";
1002        let result = sanitize(input);
1003        assert_eq!(result.as_ref(), "");
1004    }
1005
1006    #[test]
1007    fn adversarial_mouse_enable() {
1008        let input = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h";
1009        let result = sanitize(input);
1010        assert_eq!(result.as_ref(), "");
1011    }
1012
1013    #[test]
1014    fn adversarial_bracketed_paste_enable() {
1015        let input = "\x1b[?2004h";
1016        let result = sanitize(input);
1017        assert_eq!(result.as_ref(), "");
1018    }
1019
1020    #[test]
1021    fn adversarial_focus_events_enable() {
1022        let input = "\x1b[?1004h";
1023        let result = sanitize(input);
1024        assert_eq!(result.as_ref(), "");
1025    }
1026
1027    #[test]
1028    fn adversarial_raw_mode_sequence() {
1029        // Attempt to set raw mode
1030        let input = "\x1b[?7727h";
1031        let result = sanitize(input);
1032        assert_eq!(result.as_ref(), "");
1033    }
1034
1035    #[test]
1036    fn adversarial_cursor_hide_show() {
1037        let input = "\x1b[?25l\x1b[?25h";
1038        let result = sanitize(input);
1039        assert_eq!(result.as_ref(), "");
1040    }
1041
1042    // ---- 5. Data exfiltration via terminal queries ----
1043
1044    #[test]
1045    fn adversarial_device_attributes_query_da1() {
1046        let input = "\x1b[c";
1047        let result = sanitize(input);
1048        assert_eq!(result.as_ref(), "");
1049    }
1050
1051    #[test]
1052    fn adversarial_device_attributes_query_da2() {
1053        let input = "\x1b[>c";
1054        let result = sanitize(input);
1055        assert_eq!(result.as_ref(), "");
1056    }
1057
1058    #[test]
1059    fn adversarial_device_status_report() {
1060        let input = "\x1b[6n";
1061        let result = sanitize(input);
1062        assert_eq!(result.as_ref(), "");
1063    }
1064
1065    #[test]
1066    fn adversarial_osc_color_query() {
1067        // Query background color (OSC 11)
1068        let input = "\x1b]11;?\x07";
1069        let result = sanitize(input);
1070        assert_eq!(result.as_ref(), "");
1071    }
1072
1073    #[test]
1074    fn adversarial_decrpm_query() {
1075        let input = "\x1b[?2026$p";
1076        let result = sanitize(input);
1077        assert_eq!(result.as_ref(), "");
1078    }
1079
1080    // ---- 6. Social engineering via fake prompts ----
1081
1082    #[test]
1083    fn adversarial_fake_shell_prompt() {
1084        // Try to move cursor to create a fake prompt
1085        let input = "\x1b[999;1H\x1b[2K$ sudo rm -rf /\x1b[A";
1086        let result = sanitize(input);
1087        assert!(!result.contains('\x1b'));
1088        // Only text content should survive
1089        assert_eq!(result.as_ref(), "$ sudo rm -rf /");
1090    }
1091
1092    #[test]
1093    fn adversarial_fake_password_prompt() {
1094        // Combine title set + cursor move + fake prompt
1095        let input = "\x1b]0;Terminal\x07\x1b[2J\x1b[HPassword: ";
1096        let result = sanitize(input);
1097        assert_eq!(result.as_ref(), "Password: ");
1098    }
1099
1100    #[test]
1101    fn adversarial_overwrite_existing_content() {
1102        // Try to use backspaces + CR to overwrite existing output
1103        let input = "safe output\r\x1b[2Kmalicious replacement";
1104        let result = sanitize(input);
1105        assert_eq!(result.as_ref(), "safe output\rmalicious replacement");
1106    }
1107
1108    // ---- 7. C1 control codes (single-byte, 0x80-0x9F) ----
1109    //
1110    // In ISO-8859-1, 0x80-0x9F are C1 control characters.
1111    // In UTF-8, these byte values are continuation bytes and should
1112    // be handled by the UTF-8 decoder (invalid as leading bytes).
1113    // The sanitizer should not let them through as control codes.
1114
1115    #[test]
1116    fn adversarial_c1_single_byte_csi() {
1117        // U+009B is the C1 equivalent of ESC [ (CSI)
1118        // Some terminals treat this as a CSI introducer, so it MUST be stripped.
1119        let input = "text\u{009B}31mmalicious";
1120        let result = sanitize(input);
1121        assert!(!result.contains('\x1b'));
1122        assert!(
1123            !result.contains('\u{009B}'),
1124            "C1 CSI (U+009B) must be stripped"
1125        );
1126    }
1127
1128    #[test]
1129    fn adversarial_c1_osc_byte() {
1130        // U+009D is the C1 equivalent of ESC ] (OSC)
1131        let input = "text\u{009D}0;Evil Title\x07malicious";
1132        let result = sanitize(input);
1133        assert!(!result.contains('\x1b'));
1134        assert!(
1135            !result.contains('\u{009D}'),
1136            "C1 OSC (U+009D) must be stripped"
1137        );
1138    }
1139
1140    #[test]
1141    fn adversarial_c1_dcs_byte() {
1142        // U+0090 (DCS)
1143        let input = "A\u{0090}device control\x1b\\B";
1144        let result = sanitize(input);
1145        assert!(!result.contains('\u{0090}'));
1146    }
1147
1148    #[test]
1149    fn adversarial_c1_apc_byte() {
1150        // U+009F (APC)
1151        let input = "A\u{009F}app command\x1b\\B";
1152        let result = sanitize(input);
1153        assert!(!result.contains('\u{009F}'));
1154    }
1155
1156    #[test]
1157    fn adversarial_c1_pm_byte() {
1158        // U+009E (PM)
1159        let input = "A\u{009E}private msg\x1b\\B";
1160        let result = sanitize(input);
1161        assert!(!result.contains('\u{009E}'));
1162    }
1163
1164    #[test]
1165    fn adversarial_c1_st_byte() {
1166        // U+009C (ST = String Terminator)
1167        let input = "A\u{009C}B";
1168        let result = sanitize(input);
1169        assert!(!result.contains('\u{009C}'));
1170    }
1171
1172    #[test]
1173    fn adversarial_all_c1_controls_stripped() {
1174        // Every C1 control (U+0080..U+009F) must be stripped
1175        for cp in 0x0080..=0x009F_u32 {
1176            let c = char::from_u32(cp).unwrap();
1177            let input = format!("A{c}B");
1178            let result = sanitize(&input);
1179            assert!(
1180                !result
1181                    .chars()
1182                    .any(|ch| ('\u{0080}'..='\u{009F}').contains(&ch)),
1183                "C1 control U+{cp:04X} passed through sanitizer"
1184            );
1185            // The surrounding text must survive
1186            assert!(result.contains('A'), "Text before C1 U+{cp:04X} lost");
1187            assert!(result.contains('B'), "Text after C1 U+{cp:04X} lost");
1188        }
1189    }
1190
1191    #[test]
1192    fn adversarial_c1_fast_path_triggers_slow_path() {
1193        // C1 controls must trigger the slow path even without ESC/DEL/C0
1194        let input = "clean\u{0085}text"; // U+0085 = NEL (Next Line)
1195        let result = sanitize(input);
1196        assert!(
1197            matches!(result, Cow::Owned(_)),
1198            "C1 should trigger slow path"
1199        );
1200        assert!(!result.contains('\u{0085}'));
1201        assert_eq!(result.as_ref(), "cleantext");
1202    }
1203
1204    // ---- 8. Sequence terminator confusion ----
1205
1206    #[test]
1207    fn adversarial_nested_osc_in_osc() {
1208        // OSC within OSC - inner should not terminate outer
1209        let input = "safe\x1b]8;;\x1b]0;evil\x07https://ok.com\x07text";
1210        let result = sanitize(input);
1211        assert!(!result.contains('\x1b'));
1212        assert!(!result.contains('\x07'));
1213    }
1214
1215    #[test]
1216    fn adversarial_st_inside_dcs() {
1217        // DCS with lone ESC (not followed by \) in body: aborts the DCS handler.
1218        // The lone ESC is re-processed by the main loop as ESC d (single-char escape),
1219        // and the remaining "ata" appears as text before ESC \ (another single-char escape).
1220        let input = "A\x1bPsome\x1bdata\x1b\\B";
1221        let result = sanitize(input);
1222        assert_eq!(result.as_ref(), "AataB");
1223    }
1224
1225    #[test]
1226    fn dcs_with_proper_st_fully_consumed() {
1227        // DCS properly terminated by ST (no lone ESC in body)
1228        let input = "A\x1bPsomedata\x1b\\B";
1229        let result = sanitize(input);
1230        assert_eq!(result.as_ref(), "AB");
1231    }
1232
1233    #[test]
1234    fn adversarial_bel_vs_st_terminator() {
1235        // OSC terminated by BEL, then more text, then ST
1236        let input = "A\x1b]0;title\x07B\x1b\\C";
1237        let result = sanitize(input);
1238        // BEL terminates the OSC; "B" is text; ESC \ is a single-char escape
1239        assert!(!result.contains('\x1b'));
1240        assert!(!result.contains('\x07'));
1241    }
1242
1243    #[test]
1244    fn adversarial_csi_without_final_byte() {
1245        // CSI with only parameter bytes, never reaching a final byte
1246        let input = "A\x1b[0;0;0;0;0;0;0;0;0;0B";
1247        let result = sanitize(input);
1248        // The 'B' (0x42) IS a valid CSI final byte, so entire CSI is consumed
1249        assert_eq!(result.as_ref(), "A");
1250    }
1251
1252    #[test]
1253    fn adversarial_csi_many_params_then_final() {
1254        // CSI with many parameters followed by a valid final byte
1255        let input = "X\x1b[1;2;3;4;5;6;7;8;9;10mY";
1256        let result = sanitize(input);
1257        assert_eq!(result.as_ref(), "XY");
1258    }
1259
1260    // ---- 9. DoS-style payloads ----
1261
1262    #[test]
1263    fn adversarial_very_long_csi_params() {
1264        // Very long CSI parameter string
1265        let params: String = std::iter::repeat_n("0;", 10_000).collect();
1266        let input = format!("start\x1b[{params}mend");
1267        let result = sanitize(&input);
1268        assert_eq!(result.as_ref(), "startend");
1269    }
1270
1271    #[test]
1272    fn adversarial_many_short_sequences() {
1273        // Many small CSI sequences back to back
1274        let input: String = (0..10_000).map(|_| "\x1b[0m").collect();
1275        let input = format!("start{input}end");
1276        let result = sanitize(&input);
1277        assert_eq!(result.as_ref(), "startend");
1278    }
1279
1280    #[test]
1281    fn adversarial_very_long_osc_content() {
1282        // Very long OSC payload (could be used to cause memory issues)
1283        let payload: String = std::iter::repeat_n('A', 100_000).collect();
1284        let input = format!("text\x1b]0;{payload}\x07more");
1285        let result = sanitize(&input);
1286        assert_eq!(result.as_ref(), "textmore");
1287    }
1288
1289    #[test]
1290    fn adversarial_very_long_dcs_content() {
1291        let payload: String = std::iter::repeat_n('X', 100_000).collect();
1292        let input = format!("text\x1bP{payload}\x1b\\more");
1293        let result = sanitize(&input);
1294        assert_eq!(result.as_ref(), "textmore");
1295    }
1296
1297    #[test]
1298    fn adversarial_only_escape_bytes() {
1299        // Input composed entirely of ESC bytes
1300        let input: String = std::iter::repeat_n('\x1b', 1000).collect();
1301        let result = sanitize(&input);
1302        assert_eq!(result.as_ref(), "");
1303    }
1304
1305    #[test]
1306    fn adversarial_alternating_esc_and_text() {
1307        // ESC-char-ESC-char pattern
1308        let input: String = (0..1000)
1309            .map(|i| if i % 2 == 0 { "\x1b[m" } else { "a" })
1310            .collect();
1311        let result = sanitize(&input);
1312        // Only the "a" chars survive
1313        let expected: String = std::iter::repeat_n('a', 500).collect();
1314        assert_eq!(result.as_ref(), expected);
1315    }
1316
1317    #[test]
1318    fn adversarial_all_forbidden_c0_in_sequence() {
1319        // Every forbidden C0 byte
1320        let mut input = String::from("start");
1321        for b in 0x00u8..=0x1F {
1322            if b != 0x09 && b != 0x0A && b != 0x0D && b != 0x1B {
1323                input.push(b as char);
1324            }
1325        }
1326        input.push_str("end");
1327        let result = sanitize(&input);
1328        assert_eq!(result.as_ref(), "startend");
1329    }
1330
1331    // ---- 10. Combined / chained attacks ----
1332
1333    #[test]
1334    fn adversarial_combined_title_clear_clipboard() {
1335        // Chain: set title + clear screen + set clipboard + fake prompt
1336        let input = concat!(
1337            "\x1b]0;Terminal\x07",    // set title
1338            "\x1b[2J",                // clear screen
1339            "\x1b[H",                 // home cursor
1340            "\x1b]52;c;cm0gLXJm\x07", // set clipboard
1341            "Password: ",             // fake prompt
1342        );
1343        let result = sanitize(input);
1344        assert_eq!(result.as_ref(), "Password: ");
1345        assert!(!result.contains('\x1b'));
1346        assert!(!result.contains('\x07'));
1347    }
1348
1349    #[test]
1350    fn adversarial_sgr_color_soup() {
1351        // Many SGR sequences interspersed with text to try to leak colors
1352        let input = "\x1b[31m\x1b[1m\x1b[4m\x1b[7m\x1b[38;2;255;0;0mred\x1b[0m";
1353        let result = sanitize(input);
1354        assert_eq!(result.as_ref(), "red");
1355    }
1356
1357    #[test]
1358    fn adversarial_hyperlink_wrapping_attack() {
1359        // Try to create a clickable region that covers existing content
1360        let input = concat!(
1361            "\x1b]8;;https://evil.com\x07",
1362            "Click here for info",
1363            "\x1b]8;;\x07",
1364        );
1365        let result = sanitize(input);
1366        assert_eq!(result.as_ref(), "Click here for info");
1367    }
1368
1369    #[test]
1370    fn adversarial_kitty_graphics_protocol() {
1371        // Kitty graphics protocol uses APC
1372        let input = "img\x1b_Gf=100,s=1,v=1;AAAA\x1b\\text";
1373        let result = sanitize(input);
1374        assert_eq!(result.as_ref(), "imgtext");
1375    }
1376
1377    #[test]
1378    fn adversarial_sixel_data() {
1379        // Sixel graphics data via DCS
1380        let input = "pre\x1bPq#0;2;0;0;0#1;2;100;100;100~-\x1b\\post";
1381        let result = sanitize(input);
1382        assert_eq!(result.as_ref(), "prepost");
1383    }
1384
1385    #[test]
1386    fn adversarial_mixed_valid_utf8_and_escapes() {
1387        // Unicode text interspersed with escape sequences
1388        let input = "\u{1f512}\x1b[31m\u{26a0}\x1b[0m secure\x1b]0;evil\x07\u{2705}";
1389        let result = sanitize(input);
1390        assert_eq!(result.as_ref(), "\u{1f512}\u{26a0} secure\u{2705}");
1391    }
1392
1393    #[test]
1394    fn adversarial_control_char_near_escape() {
1395        // Control chars adjacent to escape sequences
1396        let input = "\x01\x1b[31m\x02text\x03\x1b[0m\x04";
1397        let result = sanitize(input);
1398        assert!(!result.contains('\x1b'));
1399        assert_eq!(result.as_ref(), "text");
1400    }
1401
1402    #[test]
1403    fn adversarial_save_restore_cursor_attack() {
1404        // Save cursor, write fake content, restore cursor to hide it
1405        let input = "\x1b7fake prompt\x1b8real content";
1406        let result = sanitize(input);
1407        assert_eq!(result.as_ref(), "fake promptreal content");
1408    }
1409
1410    #[test]
1411    fn adversarial_dec_set_reset_barrage() {
1412        // Barrage of DEC private mode set/reset sequences
1413        let input = (1..100)
1414            .map(|i| format!("\x1b[?{i}h\x1b[?{i}l"))
1415            .collect::<String>();
1416        let input = format!("A{input}B");
1417        let result = sanitize(&input);
1418        assert_eq!(result.as_ref(), "AB");
1419    }
1420
1421    // ---- Property-based tests via proptest ----
1422
1423    mod proptest_adversarial {
1424        use super::*;
1425        use proptest::prelude::*;
1426
1427        proptest! {
1428            #[test]
1429            fn sanitize_never_panics(input in ".*") {
1430                let _ = sanitize(&input);
1431            }
1432
1433            #[test]
1434            fn sanitize_output_never_contains_esc(input in ".*") {
1435                let result = sanitize(&input);
1436                prop_assert!(
1437                    !result.contains('\x1b'),
1438                    "Output contained ESC for input {:?}", input
1439                );
1440            }
1441
1442            #[test]
1443            fn sanitize_output_never_contains_del(input in ".*") {
1444                let result = sanitize(&input);
1445                prop_assert!(
1446                    !result.contains('\x7f'),
1447                    "Output contained DEL for input {:?}", input
1448                );
1449            }
1450
1451            #[test]
1452            fn sanitize_output_no_forbidden_c0(input in ".*") {
1453                let result = sanitize(&input);
1454                for &b in result.as_bytes() {
1455                    prop_assert!(
1456                        !is_forbidden_c0(b),
1457                        "Output contains forbidden C0 0x{:02X}", b
1458                    );
1459                }
1460            }
1461
1462            #[test]
1463            fn sanitize_preserves_clean_input(input in "[a-zA-Z0-9 .,!?\\n\\t]+") {
1464                let result = sanitize(&input);
1465                prop_assert_eq!(result.as_ref(), input.as_str());
1466            }
1467
1468            #[test]
1469            fn sanitize_idempotent(input in ".*") {
1470                let first = sanitize(&input);
1471                let second = sanitize(first.as_ref());
1472                prop_assert_eq!(
1473                    first.as_ref(),
1474                    second.as_ref(),
1475                    "Sanitize is not idempotent"
1476                );
1477            }
1478
1479            #[test]
1480            fn sanitize_output_len_lte_input(input in ".*") {
1481                let result = sanitize(&input);
1482                prop_assert!(
1483                    result.len() <= input.len(),
1484                    "Output ({}) longer than input ({})", result.len(), input.len()
1485                );
1486            }
1487
1488            #[test]
1489            fn sanitize_output_is_valid_utf8(input in ".*") {
1490                let result = sanitize(&input);
1491                // The return type is Cow<str> so it's guaranteed valid UTF-8,
1492                // but verify the invariant explicitly.
1493                prop_assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1494            }
1495
1496            #[test]
1497            fn sanitize_output_no_c1_controls(input in ".*") {
1498                let result = sanitize(&input);
1499                for c in result.as_ref().chars() {
1500                    prop_assert!(
1501                        !('\u{0080}'..='\u{009F}').contains(&c),
1502                        "Output contains C1 control U+{:04X}", c as u32
1503                    );
1504                }
1505            }
1506        }
1507
1508        // Targeted generators for adversarial byte patterns
1509
1510        fn escape_sequence() -> impl Strategy<Value = String> {
1511            prop_oneof![
1512                // CSI sequences with random params and final bytes
1513                (
1514                    proptest::collection::vec(0x30u8..=0x3F, 0..20),
1515                    0x40u8..=0x7E,
1516                )
1517                    .prop_map(|(params, final_byte)| {
1518                        let mut s = String::from("\x1b[");
1519                        for b in params {
1520                            s.push(b as char);
1521                        }
1522                        s.push(final_byte as char);
1523                        s
1524                    }),
1525                // OSC with BEL terminator
1526                proptest::string::string_regex("[^\x07\x1b]{0,50}")
1527                    .unwrap()
1528                    .prop_map(|content| format!("\x1b]{content}\x07")),
1529                // OSC with ST terminator
1530                proptest::string::string_regex("[^\x1b]{0,50}")
1531                    .unwrap()
1532                    .prop_map(|content| format!("\x1b]{content}\x1b\\")),
1533                // DCS
1534                proptest::string::string_regex("[^\x1b]{0,50}")
1535                    .unwrap()
1536                    .prop_map(|content| format!("\x1bP{content}\x1b\\")),
1537                // APC
1538                proptest::string::string_regex("[^\x1b]{0,50}")
1539                    .unwrap()
1540                    .prop_map(|content| format!("\x1b_{content}\x1b\\")),
1541                // PM
1542                proptest::string::string_regex("[^\x1b]{0,50}")
1543                    .unwrap()
1544                    .prop_map(|content| format!("\x1b^{content}\x1b\\")),
1545                // Single-char escapes
1546                (0x20u8..=0x7E).prop_map(|b| format!("\x1b{}", b as char)),
1547            ]
1548        }
1549
1550        fn mixed_adversarial_input() -> impl Strategy<Value = String> {
1551            proptest::collection::vec(
1552                prop_oneof![
1553                    // Clean text
1554                    proptest::string::string_regex("[a-zA-Z0-9 ]{1,10}").unwrap(),
1555                    // Escape sequences
1556                    escape_sequence(),
1557                    // Forbidden C0 controls
1558                    (0x00u8..=0x1F)
1559                        .prop_filter("not allowed control", |b| {
1560                            *b != 0x09 && *b != 0x0A && *b != 0x0D
1561                        })
1562                        .prop_map(|b| String::from(b as char)),
1563                ],
1564                1..20,
1565            )
1566            .prop_map(|parts| parts.join(""))
1567        }
1568
1569        proptest! {
1570            #[test]
1571            fn adversarial_mixed_input_safe(input in mixed_adversarial_input()) {
1572                let result = sanitize(&input);
1573                prop_assert!(!result.contains('\x1b'));
1574                prop_assert!(!result.contains('\x7f'));
1575                for &b in result.as_bytes() {
1576                    prop_assert!(!is_forbidden_c0(b));
1577                }
1578            }
1579
1580            #[test]
1581            fn escape_sequences_fully_stripped(seq in escape_sequence()) {
1582                let input = format!("before{seq}after");
1583                let result = sanitize(&input);
1584                prop_assert!(
1585                    !result.contains('\x1b'),
1586                    "Output contains ESC for sequence {:?}", seq
1587                );
1588                prop_assert!(
1589                    result.starts_with("before"),
1590                    "Output doesn't start with 'before' for {:?}: got {:?}", seq, result
1591                );
1592                // Note: unterminated DCS/APC/PM/OSC sequences consume to
1593                // end of input, so "after" may be absorbed. This is correct
1594                // security behavior — consuming unterminated sequences is
1595                // safer than letting potential payload through.
1596            }
1597        }
1598    }
1599}