summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPeter Holloway <holloway.p.r@gmail.com>2024-01-17 08:58:11 +0000
committerGitHub <noreply@github.com>2024-01-17 08:58:11 +0000
commitef38fd0a294b63bb48919ccbc2f8cd16201fa622 (patch)
tree825e8fb1dda3e84eabd7cca451bb442aa8f9335a
parented33f63cce994cbe439f442ea9d4dc8f5771ba10 (diff)
Stop control characters being printed to terminal (#1576)
If a previous command in the history contained a literal control character (eg via Ctrl-v, Ctrl-[), when the command was printed, the control character was printed and whatever control sequence it was part of was interpreted by the terminal. For instance, if a command contained the SGR sequence `^[[31m`, all subsequent output from `atuin history list` would be in red. Slightly less of a problem, control characters would also not appear in the interactive search widget although they would be printed when selected. This meant `echo '^[[31foo'` would appear as `echo '[31foo'`. When the entry was selected, the same problem as before would occur and, for the example above, `echo 'foo'` would be printed with 'foo' in red. When copied, this command would not behave the same as the original as it would be missing the control sequence. This adds an extension trait to add a method to anything that behaves like a string to escape ascii control characters and return a string that can be printed safely. This string can then be copied and run directly without having to add the control characters back.
-rw-r--r--atuin-common/src/utils.rs60
-rw-r--r--atuin/src/command/client/history.rs4
-rw-r--r--atuin/src/command/client/search.rs4
-rw-r--r--atuin/src/command/client/search/history_list.rs3
-rw-r--r--atuin/src/command/client/stats.rs6
5 files changed, 71 insertions, 6 deletions
diff --git a/atuin-common/src/utils.rs b/atuin-common/src/utils.rs
index 59050b96..b6018fbb 100644
--- a/atuin-common/src/utils.rs
+++ b/atuin-common/src/utils.rs
@@ -1,4 +1,6 @@
+use std::borrow::Cow;
use std::env;
+use std::fmt::Write;
use std::path::PathBuf;
use rand::RngCore;
@@ -100,6 +102,34 @@ pub fn is_bash() -> bool {
env::var("ATUIN_SHELL_BASH").is_ok()
}
+/// Extension trait for anything that can behave like a string to make it easy to escape control
+/// characters.
+///
+/// Intended to help prevent control characters being printed and interpreted by the terminal when
+/// printing history as well as to ensure the commands that appear in the interactive search
+/// reflect the actual command run rather than just the printable characters.
+pub trait Escapable: AsRef<str> {
+ fn escape_control(&self) -> Cow<str> {
+ if !self.as_ref().contains(|c: char| c.is_ascii_control()) {
+ self.as_ref().into()
+ } else {
+ let mut remaining = self.as_ref();
+ // Not a perfect way to reserve space but should reduce the allocations
+ let mut buf = String::with_capacity(remaining.as_bytes().len());
+ while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
+ // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char
+ buf.push_str(&remaining[..i]);
+ let _ = write!(&mut buf, "\\x{:02x}", remaining.as_bytes()[i]);
+ remaining = &remaining[i + 1..];
+ }
+ buf.push_str(remaining);
+ buf.into()
+ }
+ }
+}
+
+impl<T: AsRef<str>> Escapable for T {}
+
#[cfg(test)]
mod tests {
use time::Month;
@@ -183,4 +213,34 @@ mod tests {
assert_eq!(uuids.len(), how_many);
}
+
+ #[test]
+ fn escape_control_characters() {
+ use super::Escapable;
+ // CSI colour sequence
+ assert_eq!("\x1b[31mfoo".escape_control(), "\\x1b[31mfoo");
+
+ // Tabs count as control chars
+ assert_eq!("foo\tbar".escape_control(), "foo\\x09bar");
+
+ // space is in control char range but should be excluded
+ assert_eq!("two words".escape_control(), "two words");
+
+ // unicode multi-byte characters
+ let s = "🐢\x1b[32m🦀";
+ assert_eq!(s.escape_control(), s.replace("\x1b", "\\x1b"));
+ }
+
+ #[test]
+ fn escape_no_control_characters() {
+ use super::Escapable as _;
+ assert!(matches!(
+ "no control characters".escape_control(),
+ Cow::Borrowed(_)
+ ));
+ assert!(matches!(
+ "with \x1b[31mcontrol\x1b[0m characters".escape_control(),
+ Cow::Owned(_)
+ ));
+ }
}
diff --git a/atuin/src/command/client/history.rs b/atuin/src/command/client/history.rs
index 10f1feb6..4178180c 100644
--- a/atuin/src/command/client/history.rs
+++ b/atuin/src/command/client/history.rs
@@ -5,7 +5,7 @@ use std::{
time::Duration,
};
-use atuin_common::utils;
+use atuin_common::utils::{self, Escapable as _};
use clap::Subcommand;
use eyre::{Context, Result};
use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
@@ -201,7 +201,7 @@ impl FormatKey for FmtHistory<'_> {
#[allow(clippy::cast_sign_loss)]
fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> {
match key {
- "command" => f.write_str(self.0.command.trim())?,
+ "command" => f.write_str(&self.0.command.trim().escape_control())?,
"directory" => f.write_str(self.0.cwd.trim())?,
"exit" => f.write_str(&self.0.exit.to_string())?,
"duration" => {
diff --git a/atuin/src/command/client/search.rs b/atuin/src/command/client/search.rs
index 726da348..0e8e0205 100644
--- a/atuin/src/command/client/search.rs
+++ b/atuin/src/command/client/search.rs
@@ -1,4 +1,4 @@
-use atuin_common::utils;
+use atuin_common::utils::{self, Escapable as _};
use clap::Parser;
use eyre::Result;
@@ -155,7 +155,7 @@ impl Cmd {
if self.interactive {
let item = interactive::history(&self.query, settings, db).await?;
- eprintln!("{item}");
+ eprintln!("{}", item.escape_control());
} else {
let list_mode = ListMode::from_flags(self.human, self.cmd_only);
diff --git a/atuin/src/command/client/search/history_list.rs b/atuin/src/command/client/search/history_list.rs
index de4b46ce..39c1dc32 100644
--- a/atuin/src/command/client/search/history_list.rs
+++ b/atuin/src/command/client/search/history_list.rs
@@ -1,6 +1,7 @@
use std::time::Duration;
use atuin_client::history::History;
+use atuin_common::utils::Escapable as _;
use ratatui::{
buffer::Buffer,
layout::Rect,
@@ -168,7 +169,7 @@ impl DrawState<'_> {
style = style.fg(Color::Red).add_modifier(Modifier::BOLD);
}
- for section in h.command.split_ascii_whitespace() {
+ for section in h.command.escape_control().split_ascii_whitespace() {
self.x += 1;
if self.x > self.list_area.width {
// Avoid attempting to draw a command section beyond the width
diff --git a/atuin/src/command/client/stats.rs b/atuin/src/command/client/stats.rs
index 55844ce7..e990b70b 100644
--- a/atuin/src/command/client/stats.rs
+++ b/atuin/src/command/client/stats.rs
@@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet};
+use atuin_common::utils::Escapable as _;
use clap::Parser;
use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
use eyre::{bail, Result};
@@ -66,7 +67,10 @@ fn compute_stats(settings: &Settings, history: &[History], count: usize) -> Resu
print!(" ");
}
- println!("{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{command}{ResetColor}");
+ println!(
+ "{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{}{ResetColor}",
+ command.escape_control()
+ );
}
println!("Total commands: {}", history.len());
println!("Unique commands: {unique}");