1#![forbid(unsafe_code)]
2
3use std::borrow::Cow;
45
46use memchr::memchr;
47
48#[inline]
69pub fn sanitize(input: &str) -> Cow<'_, str> {
70 let bytes = input.as_bytes();
71
72 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 Cow::Owned(sanitize_slow(input))
84}
85
86#[inline]
91fn has_forbidden_c0(bytes: &[u8]) -> bool {
92 bytes.iter().any(|&b| is_forbidden_c0(b))
93}
94
95#[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#[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
116fn 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 0x1B => {
127 i = skip_escape_sequence(bytes, i);
128 }
129 0x09 | 0x0A | 0x0D => {
131 output.push(b as char);
132 i += 1;
133 }
134 0x00..=0x08 | 0x0B..=0x0C | 0x0E..=0x1A | 0x1C..=0x1F => {
136 i += 1;
137 }
138 0x7F => {
140 i += 1;
141 }
142 0x20..=0x7E => {
144 output.push(b as char);
145 i += 1;
146 }
147 0x80..=0xFF => {
149 if let Some((c, len)) = decode_utf8_char(&bytes[i..]) {
150 if !('\u{0080}'..='\u{009F}').contains(&c) {
153 output.push(c);
154 }
155 i += len;
156 } else {
157 i += 1;
159 }
160 }
161 }
162 }
163
164 output
165}
166
167fn skip_escape_sequence(bytes: &[u8], start: usize) -> usize {
177 let mut i = start + 1; if i >= bytes.len() {
179 return i;
180 }
181
182 match bytes[i] {
183 b'[' => {
185 i += 1;
186 while i < bytes.len() {
189 let b = bytes[i];
190 if (0x40..=0x7E).contains(&b) {
191 return i + 1;
192 }
193 if !(0x20..=0x3F).contains(&b) {
195 return i;
198 }
199 i += 1;
200 }
201 }
202 b']' => {
204 i += 1;
205 while i < bytes.len() {
206 let b = bytes[i];
207 if b == 0x07 {
209 return i + 1;
210 }
211 if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
213 return i + 2;
214 }
215 if b == 0xC2 && i + 1 < bytes.len() && bytes[i + 1] == 0x9C {
220 return i + 2;
221 }
222 if b == 0x1B {
225 return i;
226 }
227 if b < 0x20 {
229 return i;
230 }
231 i += 1;
232 }
233 }
234 b'P' | b'^' | b'_' => {
236 i += 1;
237 while i < bytes.len() {
238 let b = bytes[i];
239 if b == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
241 return i + 2;
242 }
243 if b == 0xC2 && i + 1 < bytes.len() && bytes[i + 1] == 0x9C {
245 return i + 2;
246 }
247 if b == 0x1B {
250 return i;
251 }
252 if b < 0x20 {
254 return i;
255 }
256 i += 1;
257 }
258 }
259 0x20..=0x7E => {
261 return i + 1;
262 }
263 _ => {}
265 }
266
267 i
268}
269
270fn 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, };
286
287 if bytes.len() < expected_len {
288 return None;
289 }
290
291 for &b in bytes.iter().take(expected_len).skip(1) {
293 if (b & 0xC0) != 0x80 {
294 return None; }
296 codepoint = (codepoint << 6) | (b & 0x3F) as u32;
297 }
298
299 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 char::from_u32(codepoint).map(|c| (c, expected_len))
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum Text<'a> {
320 Sanitized(Cow<'a, str>),
322
323 Trusted(Cow<'a, str>),
326}
327
328impl<'a> Text<'a> {
329 #[inline]
331 pub fn sanitized(s: &'a str) -> Self {
332 Text::Sanitized(sanitize(s))
333 }
334
335 #[inline]
341 pub fn trusted(s: &'a str) -> Self {
342 Text::Trusted(Cow::Borrowed(s))
343 }
344
345 #[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 #[inline]
356 pub fn trusted_owned(s: String) -> Self {
357 Text::Trusted(Cow::Owned(s))
358 }
359
360 #[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 #[inline]
372 #[must_use]
373 pub fn is_sanitized(&self) -> bool {
374 matches!(self, Text::Sanitized(_))
375 }
376
377 #[inline]
379 #[must_use]
380 pub fn is_trusted(&self) -> bool {
381 matches!(self, Text::Trusted(_))
382 }
383
384 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 #[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 #[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 #[test]
485 fn slow_path_strips_osc_title_bel() {
486 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 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 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 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 #[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 #[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 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 let result = sanitize(input);
627 assert_eq!(result.as_ref(), "Line1\nLine2\tTabbed\rReturn");
628 }
629
630 #[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 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 let input = "Before\x1b!After";
699 let result = sanitize(input);
700 assert_eq!(result.as_ref(), "BeforeAfter");
702 }
703
704 #[test]
707 fn preserves_unicode_characters() {
708 let input = "\u{4e16}\u{754c}"; 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}"; 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 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 #[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 #[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 #[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(); 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(); 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(); 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]; 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]; let result = decode_utf8_char(bytes);
870 assert_eq!(result, None);
871 }
872
873 #[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 #[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 #[test]
968 fn adversarial_osc52_clipboard_set_bel() {
969 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 let input = "\x1b]52;c;?\x07";
986 let result = sanitize(input);
987 assert_eq!(result.as_ref(), "");
988 }
989
990 #[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 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 #[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 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 #[test]
1083 fn adversarial_fake_shell_prompt() {
1084 let input = "\x1b[999;1H\x1b[2K$ sudo rm -rf /\x1b[A";
1086 let result = sanitize(input);
1087 assert!(!result.contains('\x1b'));
1088 assert_eq!(result.as_ref(), "$ sudo rm -rf /");
1090 }
1091
1092 #[test]
1093 fn adversarial_fake_password_prompt() {
1094 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 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 #[test]
1116 fn adversarial_c1_single_byte_csi() {
1117 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 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 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 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 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 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 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 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 let input = "clean\u{0085}text"; 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 #[test]
1207 fn adversarial_nested_osc_in_osc() {
1208 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 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 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 let input = "A\x1b]0;title\x07B\x1b\\C";
1237 let result = sanitize(input);
1238 assert!(!result.contains('\x1b'));
1240 assert!(!result.contains('\x07'));
1241 }
1242
1243 #[test]
1244 fn adversarial_csi_without_final_byte() {
1245 let input = "A\x1b[0;0;0;0;0;0;0;0;0;0B";
1247 let result = sanitize(input);
1248 assert_eq!(result.as_ref(), "A");
1250 }
1251
1252 #[test]
1253 fn adversarial_csi_many_params_then_final() {
1254 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 #[test]
1263 fn adversarial_very_long_csi_params() {
1264 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 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 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 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 let input: String = (0..1000)
1309 .map(|i| if i % 2 == 0 { "\x1b[m" } else { "a" })
1310 .collect();
1311 let result = sanitize(&input);
1312 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 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 #[test]
1334 fn adversarial_combined_title_clear_clipboard() {
1335 let input = concat!(
1337 "\x1b]0;Terminal\x07", "\x1b[2J", "\x1b[H", "\x1b]52;c;cm0gLXJm\x07", "Password: ", );
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 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 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 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 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 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 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 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 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 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 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 fn escape_sequence() -> impl Strategy<Value = String> {
1511 prop_oneof![
1512 (
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 proptest::string::string_regex("[^\x07\x1b]{0,50}")
1527 .unwrap()
1528 .prop_map(|content| format!("\x1b]{content}\x07")),
1529 proptest::string::string_regex("[^\x1b]{0,50}")
1531 .unwrap()
1532 .prop_map(|content| format!("\x1b]{content}\x1b\\")),
1533 proptest::string::string_regex("[^\x1b]{0,50}")
1535 .unwrap()
1536 .prop_map(|content| format!("\x1bP{content}\x1b\\")),
1537 proptest::string::string_regex("[^\x1b]{0,50}")
1539 .unwrap()
1540 .prop_map(|content| format!("\x1b_{content}\x1b\\")),
1541 proptest::string::string_regex("[^\x1b]{0,50}")
1543 .unwrap()
1544 .prop_map(|content| format!("\x1b^{content}\x1b\\")),
1545 (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 proptest::string::string_regex("[a-zA-Z0-9 ]{1,10}").unwrap(),
1555 escape_sequence(),
1557 (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 }
1597 }
1598 }
1599}