1
0
mirror of https://github.com/sharkdp/bat synced 2026-08-10 20:01:45 +00:00

Merge pull request #3729 from curious-rabbit/sanitize

add --sanitize option
This commit is contained in:
Keith Hall
2026-07-01 23:15:58 +03:00
committed by GitHub
15 changed files with 634 additions and 28 deletions
+2
View File
@@ -8,6 +8,7 @@
## Features ## Features
- Add a `--sanitize=<auto|always|never>` flag for safe display of untrusted input. It implies `--strip-ansi` at the same value and additionally substitutes terminal-active control bytes (cursor moves, charset switches, beep, etc.) and Unicode bidi / zero-width formatting characters with the Unicode replacement character (U+FFFD). Mitigates Trojan-Source-style spoofing (CVE-2021-42574). See #3729 (@curious-rabbit)
- Map justfile, Justfile, .justfile, and *.justfile to Makefile syntax highlighting, see #3623 (@zachvalenta) - Map justfile, Justfile, .justfile, and *.justfile to Makefile syntax highlighting, see #3623 (@zachvalenta)
- Preserve `--diff` change markers and snip separators when `--plain` is set. Closes #3630, see #3643 (@mvanhorn) - Preserve `--diff` change markers and snip separators when `--plain` is set. Closes #3630, see #3643 (@mvanhorn)
- Added support for `hidden_file_extensions` from `.sublime-syntax` files, see #3613 (@Matei02355) - Added support for `hidden_file_extensions` from `.sublime-syntax` files, see #3613 (@Matei02355)
@@ -22,6 +23,7 @@
- Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres)
## Bugfixes ## Bugfixes
- `--strip-ansi`: also strip 8-bit C1 introducers (U+0090, U+0098, U+009B, U+009D, U+009E, U+009F) and DCS/SOS/PM/APC sequence bodies, which previously passed through. See #3729 (@curious-rabbit)
- Fix `--ignored-suffix` not falling back to first-line/shebang detection when the ignored suffix is also a registered extension (e.g. `--ignored-suffix .txt` on a shebang script), see #2745 and #3816 (@adnrivera) - Fix `--ignored-suffix` not falling back to first-line/shebang detection when the ignored suffix is also a registered extension (e.g. `--ignored-suffix .txt` on a shebang script), see #2745 and #3816 (@adnrivera)
- Fix `capacity overflow` panic when printing a snip separator at `--terminal-width=1` with multiple line ranges. Closes #3803, see #3804 (@leeewee) - Fix `capacity overflow` panic when printing a snip separator at `--terminal-width=1` with multiple line ranges. Closes #3803, see #3804 (@leeewee)
- Pass `--no-paging` to `bat` invocations inside the bash / zsh / fish / PowerShell shell completion scripts so that shell-level pager wiring (e.g. `LESSOPEN='|-bat -f -pp %s'`) cannot inject ANSI escape sequences into the completion candidates. Closes #3760 (@mvanhorn) - Pass `--no-paging` to `bat` invocations inside the bash / zsh / fish / PowerShell shell completion scripts so that shell-level pager wiring (e.g. `LESSOPEN='|-bat -f -pp %s'`) cannot inject ANSI escape sequences into the completion candidates. Closes #3760 (@mvanhorn)
+4
View File
@@ -843,6 +843,10 @@ If your version of `bat` supports the `--strip-ansi=auto` option, it can be used
before syntax highlighting. Alternatively, you may disable both syntax highlighting and wrapping by before syntax highlighting. Alternatively, you may disable both syntax highlighting and wrapping by
passing the `--color=never --wrap=never` options to `bat`. passing the `--color=never --wrap=never` options to `bat`.
For untrusted input, the `--sanitize=auto|always|never` option additionally replaces terminal-active
control bytes and Unicode bidi / zero-width formatting characters with the Unicode replacement
character. It implies `--strip-ansi` at the same value.
> [!NOTE] > [!NOTE]
> The `auto` option of `--strip-ansi` avoids removing escape sequences when the syntax is plain text. > The `auto` option of `--strip-ansi` avoids removing escape sequences when the syntax is plain text.
+6
View File
@@ -100,6 +100,11 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)} ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)}
break break
} }
'*;--sanitize' {
$ArrayWhen |
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)}
break
}
'*;--strip-ansi' { '*;--strip-ansi' {
$ArrayWhen | $ArrayWhen |
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)} ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)}
@@ -158,6 +163,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
[CompletionResult]::new('--ignored-suffix' , 'ignored-suffix' , [CompletionResultType]::ParameterName, 'Ignore extension. For example: ''bat --ignored-suffix ".dev" my_file.json.dev'' will use JSON syntax, and ignore ''.dev''') [CompletionResult]::new('--ignored-suffix' , 'ignored-suffix' , [CompletionResultType]::ParameterName, 'Ignore extension. For example: ''bat --ignored-suffix ".dev" my_file.json.dev'' will use JSON syntax, and ignore ''.dev''')
[CompletionResult]::new('--squeeze-blank' , 'squeeze-blank' , [CompletionResultType]::ParameterName, 'Squeeze consecutive empty lines into a single empty line.') [CompletionResult]::new('--squeeze-blank' , 'squeeze-blank' , [CompletionResultType]::ParameterName, 'Squeeze consecutive empty lines into a single empty line.')
[CompletionResult]::new('--squeeze-limit' , 'squeeze-limit' , [CompletionResultType]::ParameterName, 'Set the maximum number of consecutive empty lines to be printed.') [CompletionResult]::new('--squeeze-limit' , 'squeeze-limit' , [CompletionResultType]::ParameterName, 'Set the maximum number of consecutive empty lines to be printed.')
[CompletionResult]::new('--sanitize' , 'sanitize' , [CompletionResultType]::ParameterName, 'Specify when to sanitize untrusted input for safe display. Implies --strip-ansi and also replaces terminal-active and bidi / zero-width bytes. (auto, always, *never*).')
[CompletionResult]::new('--strip-ansi' , 'strip-ansi' , [CompletionResultType]::ParameterName, 'Specify when to strip ANSI escape sequences from the input. The automatic mode will remove escape sequences unless the syntax highlighting language is plain text. (auto, always, *never*).') [CompletionResult]::new('--strip-ansi' , 'strip-ansi' , [CompletionResultType]::ParameterName, 'Specify when to strip ANSI escape sequences from the input. The automatic mode will remove escape sequences unless the syntax highlighting language is plain text. (auto, always, *never*).')
# [CompletionResult]::new('-p' , 'p' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').') # [CompletionResult]::new('-p' , 'p' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').')
[CompletionResult]::new('--plain' , 'plain' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').') [CompletionResult]::new('--plain' , 'plain' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').')
+5
View File
@@ -132,6 +132,10 @@ _bat() {
COMPREPLY=($(compgen -W "auto never always" -- "$cur")) COMPREPLY=($(compgen -W "auto never always" -- "$cur"))
return 0 return 0
;; ;;
--sanitize)
COMPREPLY=($(compgen -W "auto never always" -- "$cur"))
return 0
;;
--completion) --completion)
COMPREPLY=($(compgen -W "bash fish zsh ps1" -- "$cur")) COMPREPLY=($(compgen -W "bash fish zsh ps1" -- "$cur"))
return 0 return 0
@@ -221,6 +225,7 @@ _bat() {
--list-themes --list-themes
--squeeze-blank --squeeze-blank
--squeeze-limit --squeeze-limit
--sanitize
--strip-ansi --strip-ansi
--style --style
--line-range --line-range
+1
View File
@@ -221,6 +221,7 @@ complete -c $bat -s s -l squeeze-blank -d "Squeeze consecutive empty lines into
complete -c $bat -l squeeze-limit -x -d "Set the maximum number of consecutive empty lines to be printed" -n __bat_no_excl_args complete -c $bat -l squeeze-limit -x -d "Set the maximum number of consecutive empty lines to be printed" -n __bat_no_excl_args
complete -c $bat -l strip-ansi -x -a "auto never always" -d "Specify when to strip ANSI escape sequences from the input" -n __bat_no_excl_args complete -c $bat -l strip-ansi -x -a "auto never always" -d "Specify when to strip ANSI escape sequences from the input" -n __bat_no_excl_args
complete -c $bat -l sanitize -x -a "auto never always" -d "Specify when to sanitize untrusted input for safe display" -n __bat_no_excl_args
complete -c $bat -s p -l plain -d "Disable decorations" -n __bat_no_excl_args complete -c $bat -s p -l plain -d "Disable decorations" -n __bat_no_excl_args
+1
View File
@@ -54,6 +54,7 @@ _{{PROJECT_EXECUTABLE}}_main() {
--squeeze-blank'[squeeze consecutive empty lines into a single empty line]' --squeeze-blank'[squeeze consecutive empty lines into a single empty line]'
--squeeze-limit='[set the maximum number of consecutive empty lines]:limit:' --squeeze-limit='[set the maximum number of consecutive empty lines]:limit:'
--strip-ansi='[specify when to strip ANSI escape sequences]:when:(auto never always)' --strip-ansi='[specify when to strip ANSI escape sequences]:when:(auto never always)'
--sanitize='[specify when to sanitize untrusted input for safe display]:when:(auto never always)'
--style='[comma-separated list of style elements to display]: : _values "style [default]" --style='[comma-separated list of style elements to display]: : _values "style [default]"
default auto full plain changes header header-filename header-filesize grid rule numbers snip' default auto full plain changes header header-filename header-filesize grid rule numbers snip'
\*{-r+,--line-range=}'[only print the specified line range]:start\:end' \*{-r+,--line-range=}'[only print the specified line range]:start\:end'
+8
View File
@@ -164,6 +164,14 @@ Options:
escape sequences unless the syntax highlighting language is plain text. Possible values: escape sequences unless the syntax highlighting language is plain text. Possible values:
auto, always, *never*. auto, always, *never*.
--sanitize <when>
Specify when to sanitize input bytes for safe terminal display. Implies --strip-ansi to
the same value, and additionally substitutes terminal-active control bytes (cursor moves,
charset switches, beep, etc.) and Unicode bidi / zero-width formatting characters with the
Unicode replacement character (U+FFFD). Tab, LF, FF, and CRLF pass through. Useful for
displaying untrusted file content (e.g. file-manager preview panes). Possible values:
auto, always, *never*.
--style <components> --style <components>
Configure which elements (line numbers, file headers, grid borders, Git modifications, ..) Configure which elements (line numbers, file headers, grid borders, Git modifications, ..)
to display in addition to the file contents. The argument is a comma-separated list of to display in addition to the file contents. The argument is a comma-separated list of
+34 -9
View File
@@ -38,6 +38,15 @@ pub fn env_no_color() -> bool {
env::var_os("NO_COLOR").is_some_and(|x| !x.is_empty()) env::var_os("NO_COLOR").is_some_and(|x| !x.is_empty())
} }
fn parse_strip_ansi_value(raw: Option<&str>, flag_name: &str) -> StripAnsiMode {
match raw {
Some("never") | None => StripAnsiMode::Never,
Some("always") => StripAnsiMode::Always,
Some("auto") => StripAnsiMode::Auto,
_ => unreachable!("other values for {flag_name} are not allowed"),
}
}
enum HelpType { enum HelpType {
Short, Short,
Long, Long,
@@ -458,16 +467,32 @@ impl App {
4 4
}, },
), ),
strip_ansi: match self strip_ansi: {
.matches let sanitize = parse_strip_ansi_value(
.get_one::<String>("strip-ansi") self.matches
.map(|s| s.as_str()) .get_one::<String>("sanitize")
{ .map(|s| s.as_str()),
Some("never") => StripAnsiMode::Never, "--sanitize",
Some("always") => StripAnsiMode::Always, );
Some("auto") => StripAnsiMode::Auto, let strip_ansi = parse_strip_ansi_value(
_ => unreachable!("other values for --strip-ansi are not allowed"), self.matches
.get_one::<String>("strip-ansi")
.map(|s| s.as_str()),
"--strip-ansi",
);
// --sanitize implies --strip-ansi to the same value.
if sanitize != StripAnsiMode::Never {
sanitize
} else {
strip_ansi
}
}, },
sanitize: parse_strip_ansi_value(
self.matches
.get_one::<String>("sanitize")
.map(|s| s.as_str()),
"--sanitize",
),
quiet_empty: self.matches.get_flag("quiet-empty"), quiet_empty: self.matches.get_flag("quiet-empty"),
unbuffered: self.matches.get_flag("unbuffered"), unbuffered: self.matches.get_flag("unbuffered"),
theme: theme(self.theme_options()).to_string(), theme: theme(self.theme_options()).to_string(),
+18
View File
@@ -480,6 +480,24 @@ pub fn build_app(interactive_output: bool) -> Command {
language is plain text. Possible values: auto, always, *never*.") language is plain text. Possible values: auto, always, *never*.")
.hide_short_help(true) .hide_short_help(true)
) )
.arg(
Arg::new("sanitize")
.long("sanitize")
.overrides_with("sanitize")
.value_name("when")
.value_parser(["auto", "always", "never"])
.default_value("never")
.hide_default_value(true)
.help("Sanitize untrusted input for safe display (auto, always, *never*)")
.long_help("Specify when to sanitize input bytes for safe terminal display. \
Implies --strip-ansi to the same value, and additionally substitutes \
terminal-active control bytes (cursor moves, charset switches, beep, etc.) \
and Unicode bidi / zero-width formatting characters with the Unicode \
replacement character (U+FFFD). Tab, LF, FF, and CRLF pass through. Useful \
for displaying untrusted file content (e.g. file-manager preview panes). \
Possible values: auto, always, *never*.")
.hide_short_help(true)
)
.arg( .arg(
Arg::new("style") Arg::new("style")
.long("style") .long("style")
+3
View File
@@ -111,6 +111,9 @@ pub struct Config<'a> {
// Whether or not to strip ANSI escape codes from the input // Whether or not to strip ANSI escape codes from the input
pub strip_ansi: StripAnsiMode, pub strip_ansi: StripAnsiMode,
// Substitute terminal-active and spoofing-relevant bytes; implies strip_ansi.
pub sanitize: StripAnsiMode,
/// Whether or not to produce no output when input is empty /// Whether or not to produce no output when input is empty
pub quiet_empty: bool, pub quiet_empty: bool,
+169 -2
View File
@@ -139,16 +139,104 @@ pub fn replace_nonprintable(
/// Strips ANSI escape sequences from the input. /// Strips ANSI escape sequences from the input.
pub fn strip_ansi(line: &str) -> String { pub fn strip_ansi(line: &str) -> String {
let mut buffer = String::with_capacity(line.len()); let mut buffer = String::with_capacity(line.len());
for seq in EscapeSequenceOffsetsIterator::new(line) { for seq in EscapeSequenceOffsetsIterator::new(line) {
if let EscapeSequenceOffsets::Text { .. } = seq { if let EscapeSequenceOffsets::Text { .. } = seq {
buffer.push_str(&line[seq.index_of_start()..seq.index_past_end()]); buffer.push_str(&line[seq.index_of_start()..seq.index_past_end()]);
} }
} }
buffer buffer
} }
/// Strips ANSI escape sequences and substitutes terminal-active control bytes
/// and visual-spoofing Unicode codepoints (bidi, zero-width) with U+FFFD.
pub fn sanitize(line: &str) -> String {
let stripped = strip_ansi(line);
let mut buffer = String::with_capacity(stripped.len());
let bytes = stripped.as_bytes();
let mut start = 0;
let mut i = 0;
// Skip directly to the next trigger byte instead of testing each one.
while let Some(off) = bytes[i..].iter().position(|&b| is_sanitize_trigger(b)) {
i += off;
let len = sanitize_at(bytes, i, &stripped, &mut buffer, &mut start);
i += len;
}
buffer.push_str(&stripped[start..]);
buffer
}
#[inline]
fn is_sanitize_trigger(b: u8) -> bool {
// C0 controls minus \t \n \f; DEL; UTF-8 leads with dangerous codepoints.
matches!(b, 0x00..=0x08 | 0x0B | 0x0D..=0x1F | 0x7F | 0xC2 | 0xE2 | 0xEF)
}
/// Substitutes the byte/sequence at `bytes[i]` (or passes it through on
/// false-alarm trigger), flushing the prefix from `start`. Returns bytes consumed.
fn sanitize_at(
bytes: &[u8],
i: usize,
full: &str,
buffer: &mut String,
start: &mut usize,
) -> usize {
buffer.push_str(&full[*start..i]);
let consumed = match bytes[i] {
b'\r' if bytes.get(i + 1) == Some(&b'\n') => {
buffer.push_str("\r\n");
2
}
// 0xC2 leads U+0080..U+00FF; filter the C1 range.
0xC2 if matches!(bytes.get(i + 1), Some(0x80..=0x9F)) => {
buffer.push('\u{FFFD}');
2
}
0xE2 if is_dangerous_e2(bytes, i) => {
buffer.push('\u{FFFD}');
3
}
// 0xEF 0xBB 0xBF = U+FEFF (BOM / zero-width no-break space).
0xEF if bytes.get(i + 1) == Some(&0xBB) && bytes.get(i + 2) == Some(&0xBF) => {
buffer.push('\u{FFFD}');
3
}
// False-alarm trigger: pass the full UTF-8 sequence through.
lead @ (0xC2 | 0xE2 | 0xEF) => {
let n = utf8_len_from_lead(lead);
buffer.push_str(&full[i..i + n]);
n
}
_ => {
buffer.push('\u{FFFD}');
1
}
};
*start = i + consumed;
consumed
}
#[inline]
fn is_dangerous_e2(bytes: &[u8], i: usize) -> bool {
// U+200B..D (zero-width), U+202A..E (bidi controls), U+2066..9 (isolates).
matches!(
(bytes.get(i + 1), bytes.get(i + 2)),
(Some(0x80), Some(0x8B..=0x8D | 0xAA..=0xAE)) | (Some(0x81), Some(0xA6..=0xA9))
)
}
#[inline]
fn utf8_len_from_lead(lead: u8) -> usize {
if lead < 0x80 {
1
} else if lead < 0xE0 {
2
} else if lead < 0xF0 {
3
} else {
4
}
}
/// Escape C0, DEL, and C1 control characters so a string from an untrusted /// Escape C0, DEL, and C1 control characters so a string from an untrusted
/// filename or path can be safely written to the terminal. /// filename or path can be safely written to the terminal.
pub fn sanitize_for_terminal(input: &str) -> String { pub fn sanitize_for_terminal(input: &str) -> String {
@@ -270,6 +358,85 @@ fn test_strip_ansi() {
); );
} }
#[test]
fn test_strip_ansi_8bit_c1_introducers() {
assert_eq!(strip_ansi("a\u{9B}31mRED\u{9B}0mb"), "aREDb");
assert_eq!(strip_ansi("a\x1bP1;0|payload\x1b\\b"), "ab");
assert_eq!(strip_ansi("a\u{90}body\u{9C}b"), "ab");
}
#[test]
fn test_strip_ansi_single_char_esc() {
// strip_ansi must consume both bytes of single-byte ESC sequences (RIS, DECSC, keypad, VT52).
assert_eq!(strip_ansi("a\x1bcb"), "ab");
assert_eq!(strip_ansi("a\x1b7b\x1b8c"), "abc");
assert_eq!(strip_ansi("a\x1b=b\x1b>c"), "abc");
assert_eq!(strip_ansi("a\x1bZb"), "ab");
}
#[test]
fn test_strip_ansi_preserves_control_bytes() {
// strip_ansi removes only ANSI escape sequences; control bytes pass through.
assert_eq!(strip_ansi("safe\rEVIL"), "safe\rEVIL");
assert_eq!(strip_ansi("a\x08b\x07c\x0E\x0Fd"), "a\x08b\x07c\x0E\x0Fd");
}
#[test]
fn test_sanitize_substitutes_dangerous_bytes() {
let r = '\u{FFFD}';
assert_eq!(sanitize("safe\rEVIL"), format!("safe{r}EVIL"));
assert_eq!(sanitize("a\x08b"), format!("a{r}b"));
assert_eq!(sanitize("a\x07b"), format!("a{r}b"));
assert_eq!(sanitize("a\x0Bb"), format!("a{r}b"));
assert_eq!(sanitize("a\x0Eb\x0Fc"), format!("a{r}b{r}c"));
assert_eq!(sanitize("a\u{8D}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{85}b"), format!("a{r}b"));
assert_eq!(sanitize("trailing\r"), format!("trailing{r}"));
assert_eq!(sanitize("a\x7Fb"), format!("a{r}b"));
}
#[test]
fn test_sanitize_substitutes_bidi_and_zero_width() {
let r = '\u{FFFD}';
// Trojan-Source bidi formatting: U+202A..U+202E
assert_eq!(sanitize("a\u{202A}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{202E}b"), format!("a{r}b"));
// Bidi isolates: U+2066..U+2069
assert_eq!(sanitize("a\u{2066}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{2069}b"), format!("a{r}b"));
// Zero-width: U+200B..U+200D
assert_eq!(sanitize("a\u{200B}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{200D}b"), format!("a{r}b"));
// BOM in middle of file: U+FEFF
assert_eq!(sanitize("a\u{FEFF}b"), format!("a{r}b"));
}
#[test]
fn test_sanitize_preserves_legitimate_bytes() {
assert_eq!(sanitize("crlf\r\nline\r\n"), "crlf\r\nline\r\n");
assert_eq!(sanitize("a\tb\nc"), "a\tb\nc");
assert_eq!(sanitize("plain ascii"), "plain ascii");
assert_eq!(sanitize("üñíçödé"), "üñíçödé");
// FF (U+000C) passes through; section separator in C source / Emacs Lisp.
assert_eq!(sanitize("section1\x0Csection2"), "section1\x0Csection2");
// Common Unicode that shares a UTF-8 lead byte with dangerous codepoints
// must pass through unchanged.
assert_eq!(sanitize("snowman ☃ moon ☾"), "snowman ☃ moon ☾");
assert_eq!(sanitize("emoji 🎉 ☃"), "emoji 🎉 ☃");
assert_eq!(sanitize("0xC2 lead: ÿ ñ ç"), "0xC2 lead: ÿ ñ ç");
assert_eq!(sanitize("CJK 漢字 emoji 🦀"), "CJK 漢字 emoji 🦀");
}
#[test]
fn test_sanitize_strips_ansi() {
let r = '\u{FFFD}';
// sanitize is a strict superset of strip_ansi.
assert_eq!(sanitize("a\x1B[31mb\x1B[0mc"), "abc");
assert_eq!(sanitize("a\u{9B}31mb\u{9B}0mc"), "abc");
// ANSI then dangerous byte: ANSI gone, byte substituted.
assert_eq!(sanitize("\x1B[31mhello\rEVIL"), format!("hello{r}EVIL"));
}
#[test] #[test]
fn test_strip_overstrike() { fn test_strip_overstrike() {
// Bold: X\x08X (same char repeated) // Bold: X\x08X (same char repeated)
+9
View File
@@ -192,6 +192,15 @@ impl<'a> PrettyPrinter<'a> {
self self
} }
/// Whether to sanitize untrusted input for safe display (default: never)
///
/// Strips ANSI escape sequences and additionally substitutes terminal-active
/// control bytes and bidi / zero-width codepoints with U+FFFD.
pub fn sanitize(&mut self, mode: StripAnsiMode) -> &mut Self {
self.config.sanitize = mode;
self
}
/// Text wrapping mode (default: do not wrap) /// Text wrapping mode (default: do not wrap)
pub fn wrapping_mode(&mut self, mode: WrappingMode) -> &mut Self { pub fn wrapping_mode(&mut self, mode: WrappingMode) -> &mut Self {
self.config.wrapping_mode = mode; self.config.wrapping_mode = mode;
+19 -4
View File
@@ -30,7 +30,8 @@ use crate::input::OpenedInput;
use crate::line_range::{MaxBufferedLineNumber, RangeCheckResult}; use crate::line_range::{MaxBufferedLineNumber, RangeCheckResult};
use crate::output::OutputHandle; use crate::output::OutputHandle;
use crate::preprocessor::{ use crate::preprocessor::{
expand_tabs, replace_nonprintable, sanitize_for_terminal, strip_ansi, strip_overstrike, expand_tabs, replace_nonprintable, sanitize, sanitize_for_terminal, strip_ansi,
strip_overstrike,
}; };
use crate::style::StyleComponent; use crate::style::StyleComponent;
use crate::terminal::{as_terminal_escaped, to_ansi_color}; use crate::terminal::{as_terminal_escaped, to_ansi_color};
@@ -210,6 +211,7 @@ pub(crate) struct InteractivePrinter<'a> {
background_color_highlight: Option<Color>, background_color_highlight: Option<Color>,
consecutive_empty_lines: usize, consecutive_empty_lines: usize,
strip_ansi: bool, strip_ansi: bool,
sanitize: bool,
strip_overstrike: bool, strip_overstrike: bool,
} }
@@ -273,7 +275,9 @@ impl<'a> InteractivePrinter<'a> {
let needs_to_match_syntax = (!is_printing_binary let needs_to_match_syntax = (!is_printing_binary
|| matches!(config.binary, BinaryBehavior::AsText)) || matches!(config.binary, BinaryBehavior::AsText))
&& (config.colored_output || config.strip_ansi == StripAnsiMode::Auto); && (config.colored_output
|| config.strip_ansi == StripAnsiMode::Auto
|| config.sanitize == StripAnsiMode::Auto);
let (is_plain_text, strip_overstrike, highlighter_from_set) = if needs_to_match_syntax { let (is_plain_text, strip_overstrike, highlighter_from_set) = if needs_to_match_syntax {
// Determine the type of syntax for highlighting // Determine the type of syntax for highlighting
@@ -319,6 +323,14 @@ impl<'a> InteractivePrinter<'a> {
_ => false, _ => false,
}; };
let sanitize = match config.sanitize {
_ if config.show_nonprintable => false,
StripAnsiMode::Always => true,
StripAnsiMode::Auto if is_plain_text => false,
StripAnsiMode::Auto => true,
_ => false,
};
Ok(InteractivePrinter { Ok(InteractivePrinter {
panel_width, panel_width,
colors, colors,
@@ -332,6 +344,7 @@ impl<'a> InteractivePrinter<'a> {
background_color_highlight, background_color_highlight,
consecutive_empty_lines: 0, consecutive_empty_lines: 0,
strip_ansi, strip_ansi,
sanitize,
strip_overstrike, strip_overstrike,
}) })
} }
@@ -675,8 +688,10 @@ impl Printer for InteractivePrinter<'_> {
} }
} }
// If ANSI escape sequences are supposed to be stripped, do it before syntax highlighting. // Sanitize is the strict superset; otherwise strip-ansi alone.
if self.strip_ansi { if self.sanitize {
line = sanitize(&line).into()
} else if self.strip_ansi {
line = strip_ansi(&line).into() line = strip_ansi(&line).into()
} }
+203 -13
View File
@@ -386,12 +386,23 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
} }
fn next_text(&mut self) -> Option<EscapeSequenceOffsets> { fn next_text(&mut self) -> Option<EscapeSequenceOffsets> {
self.chars_take_while(|c| c != '\x1B') self.chars_take_while(|c| !is_sequence_introducer(c))
.map(|(start, end)| EscapeSequenceOffsets::Text { start, end }) .map(|(start, end)| EscapeSequenceOffsets::Text { start, end })
} }
fn next_sequence(&mut self) -> Option<EscapeSequenceOffsets> { fn next_sequence(&mut self) -> Option<EscapeSequenceOffsets> {
let (start_sequence, c) = self.chars.next().expect("to not be finished"); let (start_sequence, c) = self.chars.next().expect("to not be finished");
// Handle 8-bit C1 introducers as their 7-bit `ESC <x>` equivalents.
match c {
'\u{9B}' => return self.next_csi_body(start_sequence),
'\u{9D}' => return self.next_osc_body(start_sequence),
'\u{90}' | '\u{98}' | '\u{9E}' | '\u{9F}' => {
return self.next_string_terminated_body(start_sequence)
}
_ => {}
}
match self.chars.peek() { match self.chars.peek() {
None => Some(EscapeSequenceOffsets::Unknown { None => Some(EscapeSequenceOffsets::Unknown {
start: start_sequence, start: start_sequence,
@@ -400,12 +411,24 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
Some((_, ']')) => self.next_osc(start_sequence), Some((_, ']')) => self.next_osc(start_sequence),
Some((_, '[')) => self.next_csi(start_sequence), Some((_, '[')) => self.next_csi(start_sequence),
Some((i, c)) => match c { // 7-bit DCS/SOS/PM/APC: `ESC P/X/^/_` introduces a string body.
Some((_, 'P' | 'X' | '^' | '_')) => {
self.chars.next();
self.next_string_terminated_body(start_sequence)
}
Some(&(i, c)) => match c {
'\x20'..='\x2F' => self.next_nf(start_sequence), '\x20'..='\x2F' => self.next_nf(start_sequence),
c => Some(EscapeSequenceOffsets::Unknown { c => {
start: start_sequence, // Single-byte ESC sequence (RIS, DECSC/DECRC, keypad, VT52 etc.).
end: i + c.len_utf8(), let end = match self.chars.next() {
}), Some((j, fc)) => j + fc.len_utf8(),
None => i + c.len_utf8(),
};
Some(EscapeSequenceOffsets::Unknown {
start: start_sequence,
end,
})
}
}, },
} }
} }
@@ -413,12 +436,27 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
fn next_osc(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> { fn next_osc(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let (osc_open_index, osc_open_char) = self.chars.next().expect("to not be finished"); let (osc_open_index, osc_open_char) = self.chars.next().expect("to not be finished");
debug_assert_eq!(osc_open_char, ']'); debug_assert_eq!(osc_open_char, ']');
let start_command = osc_open_index + osc_open_char.len_utf8();
Some(self.read_osc_body(start_sequence, start_command))
}
/// OSC body parser entered after the 8-bit introducer U+009D was consumed.
fn next_osc_body(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let start_command = start_sequence + '\u{9D}'.len_utf8();
Some(self.read_osc_body(start_sequence, start_command))
}
fn read_osc_body(
&mut self,
start_sequence: usize,
start_command: usize,
) -> EscapeSequenceOffsets {
let mut start_terminator: usize; let mut start_terminator: usize;
let mut end_sequence: usize; let mut end_sequence: usize;
loop { loop {
match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B')) { // ST is BEL, ESC `\\`, or U+009C.
match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B' | '\u{9C}')) {
None => { None => {
start_terminator = self.text.len(); start_terminator = self.text.len();
end_sequence = start_terminator; end_sequence = start_terminator;
@@ -437,6 +475,11 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
break; break;
} }
Some((ti, '\u{9C}')) => {
end_sequence = ti + '\u{9C}'.len_utf8();
break;
}
Some((ti, '\x1B')) => { Some((ti, '\x1B')) => {
match self.chars.next() { match self.chars.next() {
Some((i, '\\')) => { Some((i, '\\')) => {
@@ -466,20 +509,82 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
} }
} }
Some(EscapeSequenceOffsets::OSC { EscapeSequenceOffsets::OSC {
start_sequence, start_sequence,
start_command: osc_open_index + osc_open_char.len_utf8(), start_command,
start_terminator, start_terminator,
end: end_sequence, end: end_sequence,
}
}
/// DCS/SOS/PM/APC body parser. Emitted as `Unknown` so the body is stripped.
fn next_string_terminated_body(
&mut self,
start_sequence: usize,
) -> Option<EscapeSequenceOffsets> {
let mut end_sequence: usize;
loop {
match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B' | '\u{9C}')) {
None => {
end_sequence = self.text.len();
break;
}
Some((_, end)) => {
end_sequence = end;
}
}
match self.chars.next() {
Some((ti, '\x07')) => {
end_sequence = ti + '\x07'.len_utf8();
break;
}
Some((ti, '\u{9C}')) => {
end_sequence = ti + '\u{9C}'.len_utf8();
break;
}
Some((ti, '\x1B')) => match self.chars.next() {
Some((i, '\\')) => {
end_sequence = i + '\\'.len_utf8();
break;
}
None => {
end_sequence = ti + '\x1B'.len_utf8();
break;
}
_ => {}
},
None => break,
Some((_, tc)) => {
panic!("this should not be reached: char {tc:?}")
}
}
}
Some(EscapeSequenceOffsets::Unknown {
start: start_sequence,
end: end_sequence,
}) })
} }
fn next_csi(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> { fn next_csi(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let (csi_open_index, csi_open_char) = self.chars.next().expect("to not be finished"); let (csi_open_index, csi_open_char) = self.chars.next().expect("to not be finished");
debug_assert_eq!(csi_open_char, '['); debug_assert_eq!(csi_open_char, '[');
Some(self.read_csi_body(start_sequence, csi_open_index + csi_open_char.len_utf8()))
}
let start_parameters: usize = csi_open_index + csi_open_char.len_utf8(); /// CSI body parser entered after the 8-bit introducer U+009B was consumed.
fn next_csi_body(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let start_parameters = start_sequence + '\u{9B}'.len_utf8();
Some(self.read_csi_body(start_sequence, start_parameters))
}
fn read_csi_body(
&mut self,
start_sequence: usize,
start_parameters: usize,
) -> EscapeSequenceOffsets {
// Keep iterating while within the range of `0x30-0x3F`. // Keep iterating while within the range of `0x30-0x3F`.
let mut start_intermediates: usize = start_parameters; let mut start_intermediates: usize = start_parameters;
if let Some((_, end)) = self.chars_take_while(|c| matches!(c, '\x30'..='\x3F')) { if let Some((_, end)) = self.chars_take_while(|c| matches!(c, '\x30'..='\x3F')) {
@@ -498,13 +603,13 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
Some((i, c)) => i + c.len_utf8(), Some((i, c)) => i + c.len_utf8(),
}; };
Some(EscapeSequenceOffsets::CSI { EscapeSequenceOffsets::CSI {
start_sequence, start_sequence,
start_parameters, start_parameters,
start_intermediates, start_intermediates,
start_final_byte, start_final_byte,
end: end_of_sequence, end: end_of_sequence,
}) }
} }
fn next_nf(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> { fn next_nf(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
@@ -543,13 +648,25 @@ impl Iterator for EscapeSequenceOffsetsIterator<'_> {
type Item = EscapeSequenceOffsets; type Item = EscapeSequenceOffsets;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
match self.chars.peek() { match self.chars.peek() {
Some((_, '\x1B')) => self.next_sequence(), Some((_, c)) if is_sequence_introducer(*c) => self.next_sequence(),
Some((_, _)) => self.next_text(), Some((_, _)) => self.next_text(),
None => None, None => None,
} }
} }
} }
/// True for ESC and the 8-bit C1 sequence introducers (DCS/SOS/CSI/OSC/PM/APC).
#[inline]
fn is_sequence_introducer(c: char) -> bool {
if (c as u32) < 0x80 {
return c == '\x1B';
}
matches!(
c,
'\u{90}' | '\u{98}' | '\u{9B}' | '\u{9D}' | '\u{9E}' | '\u{9F}'
)
}
/// An iterator over ANSI/VT escape sequences within a string. /// An iterator over ANSI/VT escape sequences within a string.
/// ///
/// ## Example /// ## Example
@@ -717,6 +834,79 @@ mod tests {
); );
} }
#[test]
fn test_escape_sequence_offsets_iterator_parses_8bit_csi() {
let mut iter = EscapeSequenceOffsetsIterator::new("\u{9B}31m");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::CSI {
start_sequence: 0,
start_parameters: 2,
start_intermediates: 4,
start_final_byte: 4,
end: 5,
})
);
assert_eq!(iter.next(), None);
}
#[test]
fn test_escape_sequence_offsets_iterator_parses_8bit_osc_with_8bit_st() {
let mut iter = EscapeSequenceOffsetsIterator::new("\u{9D}0;title\u{9C}");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::OSC {
start_sequence: 0,
start_command: 2,
start_terminator: 9,
end: 11,
})
);
assert_eq!(iter.next(), None);
}
#[test]
fn test_escape_sequence_offsets_iterator_parses_7bit_dcs_consumes_body() {
let mut iter = EscapeSequenceOffsetsIterator::new("\x1BP1;0;|payload\x1B\\rest");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Unknown { start: 0, end: 16 })
);
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Text { start: 16, end: 20 })
);
assert_eq!(iter.next(), None);
}
#[test]
fn test_escape_sequence_offsets_iterator_8bit_dcs_consumes_body() {
let mut iter = EscapeSequenceOffsetsIterator::new("\u{90}body\u{9C}rest");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Unknown { start: 0, end: 8 })
);
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Text { start: 8, end: 12 })
);
}
#[test]
fn test_escape_sequence_offsets_iterator_truncated_dcs_consumes_to_eof() {
// Unterminated DCS must still be consumed, not emitted as `Text`.
let input = "\x1BPno-terminator";
let mut iter = EscapeSequenceOffsetsIterator::new(input);
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Unknown {
start: 0,
end: input.len(),
})
);
assert_eq!(iter.next(), None);
}
#[test] #[test]
fn test_escape_sequence_offsets_iterator_parses_csi() { fn test_escape_sequence_offsets_iterator_parses_csi() {
let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[m"); let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[m");
+152
View File
@@ -3844,6 +3844,158 @@ fn strip_ansi_auto_does_not_strip_ansi_when_plain_text_by_option() {
assert!(output.contains("\x1B[33mYellow")) assert!(output.contains("\x1B[33mYellow"))
} }
#[test]
fn sanitize_implies_strip_ansi() {
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("\x1B[33mYellow\x1B[m")
.assert()
.success()
.stdout("Yellow");
}
#[test]
fn sanitize_strips_osc_clipboard_hijack() {
// OSC 52 sets the system clipboard. A file containing this would silently
// overwrite the user's clipboard if displayed unfiltered.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("safe\x1B]52;c;cm0=\x07payload")
.assert()
.success()
.stdout("safepayload");
}
#[test]
fn sanitize_strips_osc_8_hyperlink_spoof() {
// OSC 8 hyperlinks let displayed text point to an arbitrary URL.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("\x1B]8;;https://evil.example\x07click here\x1B]8;;\x07")
.assert()
.success()
.stdout("click here");
}
#[test]
fn sanitize_strips_window_title_injection() {
// OSC 0/1/2 set the terminal window title.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("hello\x1B]0;evil-title\x07world")
.assert()
.success()
.stdout("helloworld");
}
#[test]
fn sanitize_strips_8bit_csi() {
// 8-bit CSI introducer (U+009B) is the single-codepoint equivalent of ESC [.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("a\u{9B}31mRED\u{9B}0mb")
.assert()
.success()
.stdout("aREDb");
}
#[test]
fn sanitize_substitutes_bare_cr() {
// Bare CR (not part of CRLF) is the line-overwrite forgery vector.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("safe\rEVIL")
.assert()
.success()
.stdout("safe\u{FFFD}EVIL");
}
#[test]
fn sanitize_preserves_crlf() {
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("line1\r\nline2\r\n")
.assert()
.success()
.stdout("line1\r\nline2\r\n");
}
#[test]
fn sanitize_substitutes_bidi_controls() {
// Trojan-Source attack (CVE-2021-42574): U+202E (RLO) reorders display.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("admin\u{202E}check")
.assert()
.success()
.stdout("admin\u{FFFD}check");
}
#[test]
fn sanitize_substitutes_zero_width() {
// Zero-width chars allow invisible content / identifier confusion.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("ad\u{200B}min")
.assert()
.success()
.stdout("ad\u{FFFD}min");
}
#[test]
fn sanitize_preserves_form_feed_in_source() {
// FF (U+000C) is used as a section separator in C source and Emacs Lisp.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("section1\x0Csection2")
.assert()
.success()
.stdout("section1\x0Csection2");
}
#[test]
fn sanitize_preserves_unicode_text() {
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("snowman ☃ CJK 漢字 emoji 🦀")
.assert()
.success()
.stdout("snowman ☃ CJK 漢字 emoji 🦀");
}
// Tests that style components can be removed with `-component`. // Tests that style components can be removed with `-component`.
#[test] #[test]
fn style_components_can_be_removed() { fn style_components_can_be_removed() {