diff --git a/.github/config-schema.json b/.github/config-schema.json index 37144b21..5109d7ba 100644 --- a/.github/config-schema.json +++ b/.github/config-schema.json @@ -6444,6 +6444,10 @@ "ignore_timeout": { "default": false, "type": "boolean" + }, + "unsafe_no_escape": { + "default": false, + "type": "boolean" } }, "additionalProperties": false diff --git a/docs/config/README.md b/docs/config/README.md index ccc3ea15..046680a3 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -4756,11 +4756,12 @@ If you have an interesting example not covered there, feel free to share it ther ::: -::: warning Command output is printed unescaped to the prompt +::: warning If `unsafe_no_escape` is enabled or prior to starship v1.20 command output is printed unescaped to the prompt. Whatever output the command generates is printed unmodified in the prompt. This means if the output -contains special sequences that are interpreted by your shell they will be expanded when displayed. -These special sequences are shell specific, e.g. you can write a command module that writes bash sequences, +contains shell-specific interpretable sequences, they could be interpreted on display. +Depending on the shell, this can mean that e.g. strings enclosed by backticks are executed by the shell. +Such sequences are usually shell specific, e.g. you can write a command module that writes bash sequences, e.g. `\h`, but this module will not work in a fish or zsh shell. Format strings can also contain shell specific prompt sequences, e.g. @@ -4778,6 +4779,7 @@ Format strings can also contain shell specific prompt sequences, e.g. | `require_repo` | `false` | If `true`, the module will only be shown in paths containing a (git) repository. This option alone is not sufficient display condition in absence of other options. | | `shell` | | [See below](#custom-command-shell) | | `description` | `''` | The description of the module that is shown when running `starship explain`. | +| `unsafe_no_escape` | `false` | When set, command output is not escaped of characters that could be interpreted by the shell. | | `detect_files` | `[]` | The files that will be searched in the working directory for a match. | | `detect_folders` | `[]` | The directories that will be searched in the working directory for a match. | | `detect_extensions` | `[]` | The extensions that will be searched in the working directory for a match. | diff --git a/src/configs/custom.rs b/src/configs/custom.rs index e5c13b24..4e287339 100644 --- a/src/configs/custom.rs +++ b/src/configs/custom.rs @@ -30,6 +30,7 @@ pub struct CustomConfig<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub use_stdin: Option, pub ignore_timeout: bool, + pub unsafe_no_escape: bool, } impl<'a> Default for CustomConfig<'a> { @@ -50,6 +51,7 @@ impl<'a> Default for CustomConfig<'a> { os: None, use_stdin: None, ignore_timeout: false, + unsafe_no_escape: false, } } } diff --git a/src/modules/custom.rs b/src/modules/custom.rs index f3dde118..d34dba09 100644 --- a/src/modules/custom.rs +++ b/src/modules/custom.rs @@ -59,8 +59,22 @@ pub fn module<'a>(name: &str, context: &'a Context) -> Option> { } } - let parsed = StringFormatter::new(config.format).and_then(|formatter| { - formatter + let variables_closure = |variable: &str| match variable { + "output" => { + let output = exec_command(config.command, context, &config)?; + let trimmed = output.trim(); + + if trimmed.is_empty() { + None + } else { + Some(Ok(trimmed.to_string())) + } + } + _ => None, + }; + + let parsed = StringFormatter::new(config.format).and_then(|mut formatter| { + formatter = formatter .map_meta(|var, _| match var { "symbol" => Some(config.symbol), _ => None, @@ -68,21 +82,15 @@ pub fn module<'a>(name: &str, context: &'a Context) -> Option> { .map_style(|variable| match variable { "style" => Some(Ok(config.style)), _ => None, - }) - .map_no_escaping(|variable| match variable { - "output" => { - let output = exec_command(config.command, context, &config)?; - let trimmed = output.trim(); + }); - if trimmed.is_empty() { - None - } else { - Some(Ok(trimmed.to_string())) - } - } - _ => None, - }) - .parse(None, Some(context)) + if config.unsafe_no_escape { + formatter = formatter.map_no_escaping(variables_closure) + } else { + formatter = formatter.map(variables_closure) + } + + formatter.parse(None, Some(context)) }); match parsed { @@ -244,6 +252,11 @@ fn exec_when(cmd: &str, config: &CustomConfig, context: &Context) -> bool { fn exec_command(cmd: &str, context: &Context, config: &CustomConfig) -> Option { log::trace!("Running '{cmd}'"); + #[cfg(test)] + if cmd == "__starship_to_be_escaped" { + return Some("`to_be_escaped`".to_string()); + } + if let Some(output) = shell_command(cmd, config, context) { if !output.status.success() { log::trace!("Non-zero exit code '{:?}'", output.status.code()); @@ -298,6 +311,7 @@ fn handle_shell(command: &mut Command, shell: &str, shell_args: &[&str]) -> bool mod tests { use super::*; + use crate::context::Shell; use crate::test::{fixture_repo, FixtureProvider, ModuleRenderer}; use nu_ansi_term::Color; use std::fs::File; @@ -761,4 +775,47 @@ mod tests { assert_eq!(expected, actual); repo_dir.close() } + + #[test] + fn output_is_escaped() -> io::Result<()> { + let dir = tempfile::tempdir()?; + + let actual = ModuleRenderer::new("custom.test") + .path(dir.path()) + .config(toml::toml! { + [custom.test] + format = "$output" + command = "__starship_to_be_escaped" + when = true + ignore_timeout = true + }) + .shell(Shell::Bash) + .collect(); + let expected = Some("\\`to_be_escaped\\`".to_string()); + assert_eq!(expected, actual); + + dir.close() + } + + #[test] + fn unsafe_no_escape() -> io::Result<()> { + let dir = tempfile::tempdir()?; + + let actual = ModuleRenderer::new("custom.test") + .path(dir.path()) + .config(toml::toml! { + [custom.test] + format = "$output" + command = "__starship_to_be_escaped" + when = true + ignore_timeout = true + unsafe_no_escape = true + }) + .shell(Shell::Bash) + .collect(); + let expected = Some("`to_be_escaped`".to_string()); + assert_eq!(expected, actual); + + dir.close() + } }