1
0
mirror of https://github.com/sharkdp/bat synced 2026-08-04 19:01:44 +00:00

Compare commits

..

34 Commits

Author SHA1 Message Date
auto-merge-dependabot-prs[bot] 2ba8db9c14 Merge pull request #3874 from sharkdp/dependabot/cargo/minus-5.7.2
build(deps): bump minus from 5.7.1 to 5.7.2
2026-08-01 04:01:25 +00:00
dependabot[bot] d344844486 build(deps): bump minus from 5.7.1 to 5.7.2
Bumps [minus](https://github.com/AMythicDev/minus) from 5.7.1 to 5.7.2.
- [Changelog](https://github.com/AMythicDev/minus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AMythicDev/minus/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: minus
  dependency-version: 5.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 03:50:26 +00:00
auto-merge-dependabot-prs[bot] aeb0e4c457 Merge pull request #3869 from sharkdp/dependabot/cargo/gix-0.86.0
build(deps): bump gix from 0.85.0 to 0.86.0
2026-08-01 03:47:33 +00:00
dependabot[bot] 1cf12d49bd build(deps): bump gix from 0.85.0 to 0.86.0
Bumps [gix](https://github.com/GitoxideLabs/gitoxide) from 0.85.0 to 0.86.0.
- [Release notes](https://github.com/GitoxideLabs/gitoxide/releases)
- [Changelog](https://github.com/GitoxideLabs/gitoxide/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GitoxideLabs/gitoxide/compare/gix-v0.85.0...gix-v0.86.0)

---
updated-dependencies:
- dependency-name: gix
  dependency-version: 0.86.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 03:33:08 +00:00
auto-merge-dependabot-prs[bot] 7cb212646c Merge pull request #3871 from sharkdp/dependabot/cargo/terminal-colorsaurus-1.0.3
build(deps): bump terminal-colorsaurus from 1.0.0 to 1.0.3
2026-08-01 03:28:34 +00:00
dependabot[bot] 6573ca9307 build(deps): bump terminal-colorsaurus from 1.0.0 to 1.0.3
Bumps [terminal-colorsaurus](https://github.com/tautropfli/terminal-colorsaurus) from 1.0.0 to 1.0.3.
- [Release notes](https://github.com/tautropfli/terminal-colorsaurus/releases)
- [Changelog](https://github.com/tautropfli/terminal-colorsaurus/blob/main/changelog.md)
- [Commits](https://github.com/tautropfli/terminal-colorsaurus/compare/1.0.0...1.0.3)

---
updated-dependencies:
- dependency-name: terminal-colorsaurus
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 03:09:37 +00:00
auto-merge-dependabot-prs[bot] f04dc80bc7 Merge pull request #3873 from sharkdp/dependabot/cargo/execute-0.3.0
build(deps): bump execute from 0.2.15 to 0.3.0
2026-08-01 03:06:47 +00:00
dependabot[bot] e6ac6ce08d build(deps): bump execute from 0.2.15 to 0.3.0
Bumps [execute](https://github.com/magiclen/execute) from 0.2.15 to 0.3.0.
- [Commits](https://github.com/magiclen/execute/compare/v0.2.15...v0.3.0)

---
updated-dependencies:
- dependency-name: execute
  dependency-version: 0.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 02:38:14 +00:00
auto-merge-dependabot-prs[bot] 949f18e23a Merge pull request #3868 from sharkdp/dependabot/cargo/proc-macro2-1.0.107
build(deps): bump proc-macro2 from 1.0.106 to 1.0.107
2026-08-01 02:19:43 +00:00
dependabot[bot] 969b33dad1 build(deps): bump proc-macro2 from 1.0.106 to 1.0.107
Bumps [proc-macro2](https://github.com/dtolnay/proc-macro2) from 1.0.106 to 1.0.107.
- [Release notes](https://github.com/dtolnay/proc-macro2/releases)
- [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.106...1.0.107)

---
updated-dependencies:
- dependency-name: proc-macro2
  dependency-version: 1.0.107
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 02:03:54 +00:00
Keith Hall 73dc3258be Merge pull request #3862 from lenamonj/fix/sanitize-bidi-control-set
Fix --sanitize passing through the bidi control characters U+200E, U+200F and U+061C
2026-07-30 00:27:20 +03:00
lenamonj df38225653 Add CHANGELOG entry for #3862 2026-07-29 17:02:33 -04:00
lenamonj c2cc79daa7 Fix --sanitize passing through the bidi control characters U+200E, U+200F and U+061C 2026-07-29 17:02:11 -04:00
Keith Hall 0230cba42d Merge pull request #3828 from cyphercodes/fix-list-languages-paging-never
Respect paging mode for --list-languages
2026-07-29 23:49:08 +03:00
Keith Hall 227e0fa00f Merge branch 'master' into fix-list-languages-paging-never 2026-07-29 23:33:51 +03:00
auto-merge-dependabot-prs[bot] 384f2f5d13 Merge pull request #3821 from sharkdp/dependabot/cargo/regex-1.12.4
build(deps): bump regex from 1.12.3 to 1.13.1
2026-07-29 20:23:23 +00:00
dependabot[bot] 060ccb1385 build(deps): bump regex from 1.12.3 to 1.13.1
Bumps [regex](https://github.com/rust-lang/regex) from 1.12.3 to 1.13.1.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.12.3...1.13.1)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.12.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 20:09:58 +00:00
auto-merge-dependabot-prs[bot] feabb25209 Merge pull request #3823 from sharkdp/dependabot/cargo/serde_with-3.21.0
build(deps): bump serde_with from 3.19.0 to 3.21.0
2026-07-29 20:06:40 +00:00
dependabot[bot] 839f2300b7 build(deps): bump serde_with from 3.19.0 to 3.21.0
Bumps [serde_with](https://github.com/jonasbb/serde_with) from 3.19.0 to 3.21.0.
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.19.0...v3.21.0)

---
updated-dependencies:
- dependency-name: serde_with
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 19:46:45 +00:00
auto-merge-dependabot-prs[bot] ea0742babc Merge pull request #3820 from sharkdp/dependabot/cargo/quote-1.0.46
build(deps): bump quote from 1.0.45 to 1.0.47
2026-07-29 19:40:36 +00:00
dependabot[bot] bfa6e59978 build(deps): bump quote from 1.0.45 to 1.0.47
Bumps [quote](https://github.com/dtolnay/quote) from 1.0.45 to 1.0.47.
- [Release notes](https://github.com/dtolnay/quote/releases)
- [Commits](https://github.com/dtolnay/quote/compare/1.0.45...1.0.47)

---
updated-dependencies:
- dependency-name: quote
  dependency-version: 1.0.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 19:17:50 +00:00
auto-merge-dependabot-prs[bot] 9710a231d8 Merge pull request #3819 from sharkdp/dependabot/cargo/bytesize-2.4.0
build(deps): bump bytesize from 2.3.1 to 2.4.2
2026-07-29 19:14:35 +00:00
dependabot[bot] a9b67b2d99 build(deps): bump bytesize from 2.3.1 to 2.4.2
Bumps [bytesize](https://github.com/bytesize-rs/bytesize) from 2.3.1 to 2.4.2.
- [Release notes](https://github.com/bytesize-rs/bytesize/releases)
- [Changelog](https://github.com/bytesize-rs/bytesize/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bytesize-rs/bytesize/compare/bytesize-v2.3.1...bytesize-v2.4.2)

---
updated-dependencies:
- dependency-name: bytesize
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 18:54:55 +00:00
auto-merge-dependabot-prs[bot] b22be63fec Merge pull request #3822 from sharkdp/dependabot/cargo/unicode-segmentation-1.13.3
build(deps): bump unicode-segmentation from 1.13.2 to 1.13.3
2026-07-29 18:50:14 +00:00
Keith Hall 3a7a750969 Merge branch 'master' into fix-list-languages-paging-never 2026-07-29 21:25:10 +03:00
dependabot[bot] be5343c742 build(deps): bump unicode-segmentation from 1.13.2 to 1.13.3
Bumps [unicode-segmentation](https://github.com/unicode-rs/unicode-segmentation) from 1.13.2 to 1.13.3.
- [Commits](https://github.com/unicode-rs/unicode-segmentation/commits)

---
updated-dependencies:
- dependency-name: unicode-segmentation
  dependency-version: 1.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 18:21:51 +00:00
Keith Hall c371044276 Merge pull request #3861 from TyceHerrman/fix/cargo-audit-2026-07
chore(deps): resolve current cargo audit failures
2026-07-29 21:18:39 +03:00
Tyce Herrman d449ce5b7e chore(deps): resolve current cargo audit failures 2026-07-29 12:10:05 -04:00
cyphercodes 218afc30ac Respect paging mode for list languages 2026-07-04 06:31:26 +03:00
Keith Hall 78951393e2 Merge pull request #3729 from curious-rabbit/sanitize
add --sanitize option
2026-07-01 23:15:58 +03:00
curious-rabbit 0a52e4321f improve patch 2026-07-01 07:54:51 +02:00
curious-rabbit d72163160e add sanitize option 2026-07-01 07:09:49 +02:00
auto-merge-dependabot-prs[bot] 971f9679da Merge pull request #3817 from sharkdp/dependabot/submodules/assets/syntaxes/02_Extra/LESS-d076a4d
build(deps): bump assets/syntaxes/02_Extra/LESS from `836b47e` to `d076a4d`
2026-07-01 02:16:46 +00:00
dependabot[bot] 57d436861a build(deps): bump assets/syntaxes/02_Extra/LESS
Bumps [assets/syntaxes/02_Extra/LESS](https://github.com/danro/LESS-sublime) from `836b47e` to `d076a4d`.
- [Release notes](https://github.com/danro/LESS-sublime/releases)
- [Commits](https://github.com/danro/LESS-sublime/compare/836b47ec61a9c6a6445b4007e8353337fe63e2c9...d076a4dd416bcdb155bacf90cda72f7bf1c8abbb)

---
updated-dependencies:
- dependency-name: assets/syntaxes/02_Extra/LESS
  dependency-version: d076a4dd416bcdb155bacf90cda72f7bf1c8abbb
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 02:03:33 +00:00
21 changed files with 885 additions and 251 deletions
+9 -9
View File
@@ -35,7 +35,7 @@ jobs:
name: Extract crate metadata name: Extract crate metadata
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Extract crate information - name: Extract crate information
id: crate_metadata id: crate_metadata
run: | run: |
@@ -58,7 +58,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt,clippy components: rustfmt,clippy
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- run: cargo fmt -- --check - run: cargo fmt -- --check
- run: cargo clippy --locked --all-targets --all-features -- -D warnings - run: cargo clippy --locked --all-targets --all-features -- -D warnings
@@ -68,7 +68,7 @@ jobs:
needs: crate_metadata needs: crate_metadata
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Install rust toolchain (v${{ needs.crate_metadata.outputs.msrv }}) - name: Install rust toolchain (v${{ needs.crate_metadata.outputs.msrv }})
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
@@ -80,7 +80,7 @@ jobs:
name: License checks name: License checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
submodules: true # we especially want to perform license checks on submodules submodules: true # we especially want to perform license checks on submodules
- run: tests/scripts/license-checks.sh - run: tests/scripts/license-checks.sh
@@ -90,7 +90,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Git checkout - name: Git checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
submodules: true # we need all syntax and theme submodules submodules: true # we need all syntax and theme submodules
- name: Install Rust toolchain - name: Install Rust toolchain
@@ -119,7 +119,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Git checkout - name: Git checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Prepare environment variables - name: Prepare environment variables
run: | run: |
echo "BAT_SYSTEM_CONFIG_PREFIX=$GITHUB_WORKSPACE/tests/examples/system_config" >> $GITHUB_ENV echo "BAT_SYSTEM_CONFIG_PREFIX=$GITHUB_WORKSPACE/tests/examples/system_config" >> $GITHUB_ENV
@@ -135,7 +135,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Git checkout - name: Git checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Check documentation - name: Check documentation
@@ -150,7 +150,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: cargo install cargo-audit --locked - run: cargo install cargo-audit --locked
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- run: cargo audit - run: cargo audit
build: build:
@@ -178,7 +178,7 @@ jobs:
BUILD_CMD: cargo BUILD_CMD: cargo
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Install prerequisites - name: Install prerequisites
shell: bash shell: bash
@@ -13,7 +13,7 @@ jobs:
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
PR_BASE: ${{ github.base_ref }} PR_BASE: ${{ github.base_ref }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Fetch PR base - name: Fetch PR base
run: git fetch --no-tags --prune --depth=1 origin run: git fetch --no-tags --prune --depth=1 origin
+5
View File
@@ -3,11 +3,13 @@
## Other ## Other
- Update Cargo dependencies to resolve current RustSec advisories, see #3861 (@TyceHerrman)
- Add instructions for removing fish help abbreviations to README, see #3655 (@claw-explorer). Closes #3536 - Add instructions for removing fish help abbreviations to README, see #3655 (@claw-explorer). Closes #3536
- Add .NET slnx extension, see #3682 (@ltrzesniewski) - Add .NET slnx extension, see #3682 (@ltrzesniewski)
## 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 +24,9 @@
- 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
- Fix `--list-languages` respecting `--paging=never`, see #3828 (@cyphercodes)
- Fix `--sanitize` passing through the bidi control characters U+200E, U+200F and U+061C, see #3862 (@lenamonj)
- `--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)
Generated
+186 -200
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -70,13 +70,13 @@ regex = { version = "1.12.3", optional = true }
walkdir = { version = "2.5", optional = true } walkdir = { version = "2.5", optional = true }
bytesize = { version = "2.3.1" } bytesize = { version = "2.3.1" }
encoding_rs = "0.8.35" encoding_rs = "0.8.35"
execute = { version = "0.2.15", optional = true } execute = { version = "0.3.0", optional = true }
terminal-colorsaurus = "1.0" terminal-colorsaurus = "1.0"
unicode-segmentation = "1.13.2" unicode-segmentation = "1.13.2"
itertools = "0.14.0" itertools = "0.14.0"
[dependencies.gix] [dependencies.gix]
version = "0.85" version = "0.86"
optional = true optional = true
default-features = false default-features = false
features = ["sha1", "blob-diff"] features = ["sha1", "blob-diff"]
+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
+33 -8
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(
self.matches
.get_one::<String>("sanitize")
.map(|s| s.as_str()),
"--sanitize",
);
let strip_ansi = parse_strip_ansi_value(
self.matches
.get_one::<String>("strip-ansi") .get_one::<String>("strip-ansi")
.map(|s| s.as_str()) .map(|s| s.as_str()),
{ "--strip-ansi",
Some("never") => StripAnsiMode::Never, );
Some("always") => StripAnsiMode::Always, // --sanitize implies --strip-ansi to the same value.
Some("auto") => StripAnsiMode::Auto, if sanitize != StripAnsiMode::Never {
_ => unreachable!("other values for --strip-ansi are not allowed"), 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 -5
View File
@@ -427,11 +427,9 @@ fn run() -> Result<bool> {
if app.matches.get_flag("list-languages") { if app.matches.get_flag("list-languages") {
let languages: String = get_languages(&config, cache_dir)?; let languages: String = get_languages(&config, cache_dir)?;
let inputs: Vec<Input> = vec![Input::from_reader(Box::new(languages.as_bytes()))]; let inputs: Vec<Input> = vec![Input::from_reader(Box::new(languages.as_bytes()))];
let plain_config = Config { let mut plain_config = config.clone();
style_components: StyleComponents::new(StyleComponent::Plain.components(false)), plain_config.style_components =
paging_mode: PagingMode::QuitIfOneScreen, StyleComponents::new(StyleComponent::Plain.components(false));
..Default::default()
};
run_controller(inputs, &plain_config, cache_dir) run_controller(inputs, &plain_config, cache_dir)
} else if app.matches.get_flag("list-themes") { } else if app.matches.get_flag("list-themes") {
list_themes(&config, config_dir, cache_dir, app.theme_options())?; list_themes(&config, config_dir, cache_dir, app.theme_options())?;
+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,
+204 -2
View File
@@ -139,16 +139,111 @@ 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 | 0xD8 | 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
}
// 0xD8 0x9C = U+061C (Arabic letter mark, a bidi control). The rest of
// the 0xD8 block is ordinary Arabic text.
0xD8 if bytes.get(i + 1) == Some(&0x9C) => {
buffer.push('\u{FFFD}');
2
}
// False-alarm trigger: pass the full UTF-8 sequence through.
lead @ (0xC2 | 0xD8 | 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+200E..F (LRM/RLM), U+202A..E (bidi embedding
// and override), U+2066..9 (bidi isolates).
matches!(
(bytes.get(i + 1), bytes.get(i + 2)),
(Some(0x80), Some(0x8B..=0x8F | 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 +365,113 @@ 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_substitutes_every_bidi_control() {
// Unicode Bidi_Control is exactly these 12 codepoints. Covering only some
// of them leaves a reordering attack available through the rest.
let r = '\u{FFFD}';
for c in [
'\u{061C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}',
] {
assert_eq!(
sanitize(&format!("a{c}b")),
format!("a{r}b"),
"U+{:04X} was not substituted",
c as u32
);
}
}
#[test]
fn test_sanitize_preserves_arabic_sharing_the_alm_lead_byte() {
// U+061C is reached via lead byte 0xD8, which also leads ordinary Arabic.
assert_eq!(
sanitize("\u{0600}\u{061F}\u{06FF}"),
"\u{0600}\u{061F}\u{06FF}"
);
assert_eq!(sanitize("مرحبا بالعالم"), "مرحبا بالعالم");
}
#[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()
} }
+202 -12
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 => {
// Single-byte ESC sequence (RIS, DECSC/DECRC, keypad, VT52 etc.).
let end = match self.chars.next() {
Some((j, fc)) => j + fc.len_utf8(),
None => i + c.len_utf8(),
};
Some(EscapeSequenceOffsets::Unknown {
start: start_sequence, start: start_sequence,
end: i + c.len_utf8(), 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");
+159 -1
View File
@@ -626,12 +626,18 @@ fn list_themes_to_piped_output() {
} }
#[test] #[test]
#[serial]
fn list_languages() { fn list_languages() {
mocked_pagers::with_mocked_versions_of_more_and_most_in_path(|| {
bat() bat()
.env("PAGER", mocked_pagers::from("echo pager-output"))
.arg("--list-languages") .arg("--list-languages")
.arg("--paging=never")
.assert() .assert()
.success() .success()
.stdout(predicate::str::contains("Rust").normalize()); .stdout(predicate::str::contains("Rust").normalize())
.stdout(predicate::str::contains("pager-output").not());
});
} }
#[test] #[test]
@@ -3844,6 +3850,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() {