Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+968
View File
@@ -0,0 +1,968 @@
mod unescape;
use std::collections::{HashMap, HashSet};
use std::ops::Deref;
use std::path::{Path, PathBuf};
use anyhow::Result;
use channel_versions::overrides::TargetOS;
use enum_iterator::Sequence;
use itertools::Itertools;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use typed_path::{TypedPath, TypedPathBuf, WindowsPath};
use version_compare::{Cmp, Version};
use galaxy_completer::completer::{CommandExitStatus, CommandOutput};
#[cfg(windows)]
use galaxy_core::paths::base_config_dir;
use galaxy_core::platform::SessionPlatform;
use galaxy_util::path::{
convert_msys2_to_windows_native_path, convert_wsl_to_windows_host_path, msys2_exe_to_root,
};
use crate::model::escape_sequences;
use self::unescape::unescape_quotes;
const ZSH_META: u8 = 0x83;
/// These are file extensions of executable files on Windows.
///
/// Commands ending with any of these extensions may be executed with the extension elided, e.g.
/// you can type `git` in a shell instead of `git.exe`.
/// This is the contents of `$env:PATHEXT` on a default Windows 11 installation. See docs:
/// https://renenyffenegger.ch/notes/Windows/development/environment-variables/PATHEXT
/// TODO(CORE-2948) Fetch this dynamically instead.
const PATHEXT: [&str; 12] = [
".COM", ".EXE", ".BAT", ".CMD", ".VBS", ".VBE", ".JS", ".JSE", ".WSF", ".WSH", ".MSC", ".CPL",
];
lazy_static! {
static ref BASH_INPUT_REPORTING_MINIMUM_VERSION: Version<'static> =
Version::from("4.0").expect("version parses successfully");
}
/// Strips the extended history prefix from a zsh history line, if present.
///
/// The zsh history can have two types of output depending on if `extended_history` mode is enabled.
/// * If enabled, the history is of the form: `: <beginning time>:<elapsed seconds>;<command>`.
/// * If not enabled, history is simply the command on each line with no additional metadata.
///
/// We avoid using a regex here in favor of simple string manipulation for better performance in this
/// hot path.
fn strip_zsh_extended_prefix(line: &str) -> &str {
let Some(rest) = line.strip_prefix(": ") else {
return line;
};
let Some(semi_idx) = rest.find(';') else {
return line;
};
let prefix = &rest[..semi_idx];
let Some((timestamp, elapsed)) = prefix.split_once(':') else {
return line;
};
if !timestamp.is_empty()
&& timestamp.bytes().all(|b| b.is_ascii_digit())
&& !elapsed.is_empty()
&& elapsed.bytes().all(|b| b.is_ascii_digit())
{
&rest[semi_idx + 1..]
} else {
line
}
}
/// Represents a shell and its configuration.
#[derive(Clone, Debug)]
pub struct Shell {
shell_type: ShellType,
version: Option<String>,
options: Option<HashSet<String>>,
/// Shell plugins like Powerlevel10k that we autodetect while bootstrapping.
/// This is not at all exhaustive, it's just for common plugins that need
/// special handling (like warning the user that they're incompatible).
plugins: HashSet<String>,
/// The full path to the running shell binary on the host (e.g. "/usr/bin/zsh").
/// Populated from the `Bootstrapped` DCS payload. For local sessions this is
/// redundant with `ShellLaunchData::executable_path`; for SSH sessions this
/// is the authoritative path on the remote host.
shell_path: Option<String>,
}
impl Shell {
pub fn new(
shell_type: ShellType,
version: Option<String>,
options: Option<HashSet<String>>,
plugins: HashSet<String>,
shell_path: Option<String>,
) -> Self {
Self {
shell_type,
version,
options,
plugins,
shell_path,
}
}
pub fn shell_path(&self) -> &Option<String> {
&self.shell_path
}
pub fn shell_type(&self) -> ShellType {
self.shell_type
}
pub fn version(&self) -> &Option<String> {
&self.version
}
pub fn options(&self) -> &Option<HashSet<String>> {
&self.options
}
pub fn plugins(&self) -> &HashSet<String> {
&self.plugins
}
/// Returns true if the shell requires the use of an in-band command executor.
/// This applies to both local and remote sessions.
pub fn force_in_band_command_executor(&self) -> bool {
self.shell_type.force_in_band_command_executor()
}
/// Returns whether the current shell supports native shell completions.
pub fn supports_native_shell_completions(&self) -> bool {
self.shell_type.supports_native_shell_completions()
}
/// Whether the shell supports "autocd" (`cd`ing into a directory without specifying
/// `cd`).
pub fn supports_autocd(&self) -> bool {
match self.shell_type {
ShellType::Zsh | ShellType::Bash => self
.options
.as_ref()
.is_some_and(|map| map.contains("autocd")),
// autocd is always enabled in Fish, see https://fishshell.com/docs/current/cmds/cd.html.
ShellType::Fish => true,
ShellType::PowerShell => false,
}
}
/// If the particular version of this shell supports input reporting, return the byte sequence
/// to trigger input reporting.
///
/// These sequences are bound to Warp shell functions during session bootstrap that print the
/// shell's input buffer, wrapped within the 'InputBuffer' DCS hook when triggered. PowerShell
/// cannot use a binding that contains the letter "i" because it does virtual key code
/// translation based on the current layout, and not all layouts have the letter "i".
pub fn input_reporting_sequence(&self) -> Option<[u8; 2]> {
match self.shell_type {
ShellType::PowerShell => Some([escape_sequences::C0::ESC, b'1']),
ShellType::Fish | ShellType::Zsh => Some([escape_sequences::C0::ESC, b'i']),
ShellType::Bash => self
.version
.as_ref()
.and_then(|version| Version::from(version.as_str()))
.and_then(|version| {
version
.compare_to(&*BASH_INPUT_REPORTING_MINIMUM_VERSION, Cmp::Ge)
.then_some([escape_sequences::C0::ESC, b'i'])
}),
}
}
/// Returns `true` if the given command should be written to history based on the shell's
/// options.
pub fn should_add_command_to_history(&self, command: &str) -> bool {
if command.trim().is_empty() {
return false;
}
match &self.options {
Some(options) => {
if !command.starts_with(' ') {
return true;
}
// If the command starts with a space, check the shell's options to determine if it
// should be added to history.
match self.shell_type {
ShellType::Zsh => !options.contains("histignorespace"),
ShellType::Bash => {
// Look for our fake option that contains the value of the HISTCONTROL
// environment variable.
if let Some(histcontrol) =
options.iter().find(|opt| opt.starts_with("!histcontrol"))
{
// HISTCONTROL can contain a single value or a list of values separated
// by colons. In either case, we want to know whether the "ignorespace"
// or "ignoreboth" (ignorespace+ignoredups) values are present.
!histcontrol.contains("ignorespace")
&& !histcontrol.contains("ignoreboth")
} else {
true
}
}
_ => true,
}
}
None => true,
}
}
}
/// Brief, human-readable description of a shell session.
#[derive(Clone, Debug)]
pub enum ShellName {
/// The description isn't more descriptive than the [`ShellType`], so the [`ShellType`] should
/// take precedent if that is known.
LessDescriptive(String),
/// The description is more descriptive than the [`ShellType`], so it should take precendent
/// over the [`ShellType`] even if it is known.
MoreDescriptive(String),
}
impl Deref for ShellName {
type Target = str;
fn deref(&self) -> &Self::Target {
match self {
Self::MoreDescriptive(name) | Self::LessDescriptive(name) => name,
}
}
}
impl ShellName {
pub fn blank() -> Self {
Self::MoreDescriptive(String::new())
}
}
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, Sequence)]
pub enum ShellType {
Zsh,
Bash,
Fish,
PowerShell,
}
impl From<ShellType> for command_corrections::Shell {
fn from(s: ShellType) -> command_corrections::Shell {
match s {
ShellType::Bash => command_corrections::Shell::Bash,
ShellType::Zsh => command_corrections::Shell::Zsh,
ShellType::Fish => command_corrections::Shell::Fish,
ShellType::PowerShell => command_corrections::Shell::PowerShell,
}
}
}
impl From<ShellType> for galaxy_util::path::ShellFamily {
fn from(value: ShellType) -> Self {
match value {
ShellType::Zsh | ShellType::Bash | ShellType::Fish => Self::Posix,
ShellType::PowerShell => Self::PowerShell,
}
}
}
impl ShellType {
// Returns a shell type from a shell executable name
pub fn from_name(name: &str) -> Option<Self> {
// Support (/usr/bin/zsh /bin/zsh -zsh or zsh)
if name == "bash"
|| name == "-bash"
|| name.ends_with("/bash")
|| name.ends_with("bash.exe")
{
Some(ShellType::Bash)
} else if name == "zsh" || name == "-zsh" || name.ends_with("/zsh") {
Some(ShellType::Zsh)
} else if name == "fish" || name == "-fish" || name.ends_with("/fish") {
Some(ShellType::Fish)
} else if name == "pwsh"
|| name.ends_with("/pwsh")
|| name.ends_with("pwsh.exe")
|| name == "powershell"
|| name.ends_with("/powershell")
|| name.ends_with("powershell.exe")
{
Some(ShellType::PowerShell)
} else {
None
}
}
// Returns a shell type from a markdown code block language specifier
pub fn from_markdown_language_spec(language: &str) -> Option<Self> {
match language {
"bash" | "shell" | "sh" => Some(ShellType::Bash),
"zsh" => Some(ShellType::Zsh),
"fish" => Some(ShellType::Fish),
"powershell" | "pwsh" => Some(ShellType::PowerShell),
_ => None,
}
}
/// Returns locations of history files in order of search precedence
pub fn history_files(self) -> Vec<String> {
match self {
ShellType::Zsh => vec!["~/.zsh_history".to_string(), "~/.zhistory".to_string()],
ShellType::Bash => vec!["~/.bash_history".to_string()],
ShellType::Fish => vec!["~/.local/share/fish/fish_history".to_string()],
#[cfg(not(windows))]
ShellType::PowerShell => {
vec!["~/.local/share/powershell/PSReadLine/ConsoleHost_history.txt".to_string()]
}
#[cfg(windows)]
ShellType::PowerShell => {
vec![base_config_dir()
.join("Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt")
.display()
.to_string()]
}
}
}
/// Returns the potential paths to the RC file relative to the `home` directory.
pub fn rc_file_paths(&self, os: TargetOS) -> Vec<PathBuf> {
let home_dir = Path::new(match os {
TargetOS::Windows => "$HOME",
_ => "~",
});
let relative_paths = match (self, os) {
(ShellType::PowerShell, TargetOS::Windows) => {
vec![Path::new(
".config/powershell/Microsoft.PowerShell_profile.ps1",
)]
}
// We need to make sure this works for either editor of PowerShell (PowerShell Core or
// Windows PowerShell) so just write the file to both.
(ShellType::PowerShell, _) => vec![
Path::new("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
Path::new("Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1"),
],
(_, TargetOS::Windows) => vec![],
(ShellType::Bash, _) => vec![Path::new(".bashrc")],
(ShellType::Zsh, _) => vec![Path::new(".zshrc")],
(ShellType::Fish, _) => vec![Path::new(".config/fish/config.fish")],
};
relative_paths
.iter()
.map(|relative_path| home_dir.join(relative_path))
.collect()
}
/// Returns the syntax to use to run a second command only if the first one succeeds.
/// NOTE: Guarded with `cfg(unix)` b/c PowerShell didn't have the `&&` operator until v7. On
/// Unix, we can safely assume v7, but Windows comes with PowerShell v5 out of the box.
#[cfg(unix)]
pub fn and_combiner(self) -> &'static str {
match self {
ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => " && ",
ShellType::Fish => "; and ",
}
}
/// Returns whether the current shell supports native shell completions.
fn supports_native_shell_completions(&self) -> bool {
matches!(self, ShellType::Zsh)
}
/// Returns the syntax to run a second command regardless if the first one succeeds.
pub fn or_combiner(self) -> &'static str {
match self {
ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => " ; ",
ShellType::Fish => "; or ",
}
}
/// Given the output of the `alias` command, returns a map of alias keys to values.
pub fn aliases(self, alias_output: &str) -> HashMap<SmolStr, String> {
match self {
ShellType::Zsh => alias_output
.lines()
.filter_map(|line| {
// ZSH outputs aliases in a key pair of `name=val`, with both the name and value
// optionally escaped. Aliases that span multiple lines are properly escaped.
line.split_once('=')
.and_then(|(key, value)| unescape_alias_key_value(key, value))
})
.collect(),
ShellType::Bash => {
// Bash outputs aliases in a key pair of `alias name=val`. Alias values that
// span multiple lines are not escaped. For simplicity in parsing this, append a
// newline on the front of the string and then split on "\nalias"
let alias_output = format!("\n{alias_output}");
alias_output
.split("\nalias ")
.filter_map(|line| {
line.split_once('=')
.and_then(|(key, value)| unescape_alias_key_value(key, value))
})
.collect()
}
ShellType::Fish => {
let alias_output = format!("\n{alias_output}");
alias_output
.split("\nalias ")
.filter_map(|line| {
// Fish outputs aliases in the form:
// alias name val
// For simplicity in parsing this, append a newline on the front of
// the string and then split on "\nalias"
// Note: Currently doesn't support alias values that span multiple
// lines due to fish not respecting their specified format for
// outputing alias values with multiple lines.
line.split_once(' ')
.and_then(|(key, value)| unescape_alias_key_value(key, value))
})
.collect()
}
ShellType::PowerShell => alias_output
.lines()
.filter_map(|line| {
// PowerShell outputs aliases in a key pair of `name -> val`, with both the name
// and value optionally escaped. Aliases that span multiple lines are properly
// escaped.
line.split_once(" -> ")
.filter(|(_, value)| !value.trim().is_empty())
.and_then(|(key, value)| unescape_alias_key_value(key, value))
})
.collect(),
}
}
pub fn abbreviations(self, abbr_output: &str) -> HashMap<SmolStr, String> {
match self {
ShellType::Fish => {
abbr_output
.lines()
.filter_map(|line| {
// Fish outputs abbreviations in the form:
// abbr -a -U -- name val # optional comment
// First, strip off the comment at the end if it exists. Then,
// strip off the preamble characters and separate the key and value
// Note: The key cannot have spaces in it (fish won't allow you to define an
// abbreviation with a space in the name)
line.split_once(" #")
.map_or(line, |split_line| split_line.0)
.split_once(" -- ")
.and_then(|(_, abbr)| abbr.split_once(' '))
})
.filter_map(|(key, value)| unescape_alias_key_value(key, value))
.collect()
}
// Abbreviations are currently only supported in fish
_ => HashMap::new(),
}
}
/// Parse the contents of the shell's history file
pub fn parse_history(self, history_file_bytes: &[u8]) -> Vec<String> {
let mut history_lines: Vec<String> = Vec::new();
match self {
ShellType::Zsh => {
let mut current_line = String::new();
let unmetafied_content = zsh_unmetafy(history_file_bytes);
for line in unmetafied_content.lines() {
// Only strip the extended history prefix on the first line of each command.
// Continuation lines are raw command text and should not be stripped.
let command_part = if current_line.is_empty() {
strip_zsh_extended_prefix(line)
} else {
line
};
current_line.push_str(command_part);
// ZSH considers a command to be multi-line if it ends in a backslash, see
// https://github.com/johan/zsh/blob/master/Src/hist.c#L2192-L2220.
if line.ends_with('\\') {
// Replace the last backslash with a new line.
current_line.pop();
current_line.push('\n');
} else if !current_line.is_empty() {
history_lines.push(current_line.clone());
current_line.clear();
}
}
}
ShellType::Bash => {
let history_file_contents = String::from_utf8_lossy(history_file_bytes);
// Bash format if HISTTIMEFORMAT not set
// <command>
// Bash format if HISTTIMEFORMAT set
// #<timestamp>
// <command>
history_lines = history_file_contents
.lines()
.filter_map(|line| {
if line.starts_with('#') || line.is_empty() {
None
} else {
Some(line.to_owned())
}
})
.collect()
}
ShellType::Fish => {
let history_file_contents = String::from_utf8_lossy(history_file_bytes);
// fish has psuedo-yaml.
// The commands start with "- cmd: ".
history_lines = history_file_contents
.lines()
.filter_map(|line| line.strip_prefix("- cmd: "))
.map(fish_unescape_history_yaml)
.collect()
}
ShellType::PowerShell => {
let history_file_contents = String::from_utf8_lossy(history_file_bytes);
history_lines = history_file_contents
.lines()
.filter(|s| !s.is_empty())
.map(|s| s.to_owned())
.collect()
}
}
history_lines
}
/// Bytes used to notify the shell to delete the current buffer.
///
/// In zsh this is implemented by killing the ZLE buffer and must match the `bindkey` call in
/// the boostrap `zsh.sh`. In bash this is implemented via a custom `bind` that calls
/// `kill-whole-line` and must match the `bind` in `bash.sh`. In fish this is a custom `bind`
/// that clears the command line. PowerShell cannot use a binding that contains the letter "p"
/// (DLE maps to ctrl-p) because it does virtual key code translation based on the current
/// layout, and not all layouts have the letter "p".
pub fn kill_buffer_bytes(self) -> &'static [u8] {
const POWERSHELL_BINDING: [u8; 2] = [escape_sequences::C0::ESC, b'2'];
const OTHER_BINDING: [u8; 1] = [escape_sequences::C0::DLE];
match self {
ShellType::PowerShell => POWERSHELL_BINDING.as_slice(),
ShellType::Zsh | ShellType::Bash | ShellType::Fish => OTHER_BINDING.as_slice(),
}
}
/// Bytes used to execute a command, once the command text is sent
pub fn execute_command_bytes(self) -> &'static [u8] {
match self {
ShellType::Bash | ShellType::Zsh => &b"\n"[..],
ShellType::PowerShell => &b"\r"[..],
// For Fish, we send an extra space, immediately followed by backspace, and then
// the newline character. The backspace ensures that any autosuggestions are
// suppressed, so we don't get erroneous ghosted autosuggestion text in the command
// grid.
ShellType::Fish => &b" \x7f\n"[..],
}
}
/// The name of the shell
pub fn name(self) -> &'static str {
match self {
ShellType::Zsh => "zsh",
ShellType::Bash => "bash",
ShellType::Fish => "fish",
ShellType::PowerShell => "pwsh",
}
}
/// If true, Warp will bootstrap the shell if it's the login shell on the remote host.
pub fn is_fully_supported_remotely(&self) -> bool {
match self {
ShellType::Zsh | ShellType::Bash => true,
ShellType::Fish | ShellType::PowerShell => false,
}
}
pub fn shell_command_to_get_executables(&self) -> &'static str {
match self {
ShellType::Bash => {
// Since `compgen -c` returns more than just executables (and the output itself
// doesn't include any info about what the type of the word is), we filter down to
// executable "file"s. Note that we do this at the shell level since if we did this
// as a post-process step, it would require N filesystem calls, which would not scale
// well for remote sessions. Additionally, we invoke `type` once, passing it the full
// list of commands, to avoid a lot of overhead invoking it thousands of times for
// systems with a lot of installed commands.
r#"COMMANDS=($(compgen -c)); TYPES=($(type -t ${COMMANDS[@]})); for i in "${!COMMANDS[@]}"; do if [[ ${TYPES[$i]} == "file" ]]; then echo ${COMMANDS[$i]}; fi; done"#
}
ShellType::Fish => {
// Although `complete -C` returns more than just executables, we don't check the type here
// since the output of `complete` already tells us what is an executable ('command') and what isn't
// (whereas the output of `compgen` doesn't so we need to check the `type` there).
// Instead we post-process the output below. We try to use the `--escape` argument, but
// if we're running a version of fish that doesn't support it, try again without it.
"complete -C --escape '' || complete -C ''"
}
ShellType::Zsh => {
// zsh is cool and has an API for just fetching executables
"builtin print -l -- ${(ok)commands}"
}
ShellType::PowerShell => {
// PowerShell does not deal in strings, but in Objects and Object lists
// Get-Command and Select-Object each return a list of Objects. In the shell,
// this will print one item per line. However, when it is converted to a string,
// it will join the entries together with a space. So to make sure we get one item
// per line, we explicitly join the results with a newline.
"Get-Command -CommandType Application | Select-Object -ExpandProperty Name"
}
}
}
/// Returns a Vec containing executable commands parsed from the given `output`.
///
/// If `output` is `Err(..)`, returns an empty Vec.
pub fn executables_from_shell_command_output(
&self,
output: Result<CommandOutput>,
is_msys2: bool,
) -> Vec<SmolStr> {
match output {
Ok(command_output) if command_output.status == CommandExitStatus::Success => {
let Ok(output_string) = command_output.to_string() else {
return Vec::new();
};
match self {
ShellType::Bash | ShellType::Zsh => {
// For bash and zsh, we wrote the command such that the output is just
// a list of executable files.
if !is_msys2 {
return output_string.lines().map(Into::into).collect();
}
// TODO add this to fish
output_string
.lines()
// Remove all `.dll` files.
.filter(|line| !line.to_lowercase().ends_with("dll"))
.flat_map(|line| {
// Those suffixes are contained in `PATHEXT`.
for ext in PATHEXT {
// If the command ends with one of those suffixes, tell
// Warp about this command as-is and also sans-suffix, e.g.
// "git" and "git.exe".
if line.to_lowercase().ends_with(&ext.to_lowercase()) {
let trimmed = &line[..line.len() - ext.len()];
return Box::<[&str]>::from([trimmed, line]);
}
}
// Otherwise, pass it through unaltered.
Box::<[&str]>::from([line])
})
.map_into()
.collect()
}
ShellType::Fish => {
// This is the post-processing for Fish explained above.
output_string
.lines()
.filter_map(|line| {
line.split_once(char::is_whitespace).and_then(
|(command, command_type)| {
let is_executable = command_type == "command"
|| command_type == "command link"
|| command_type.starts_with("Executable");
is_executable.then_some(command.into())
},
)
})
.collect()
}
ShellType::PowerShell => {
// Windows allows certain suffixes, e.g. "exe", to be elided.
if cfg!(windows) {
output_string
.lines()
.flat_map(|line| {
// Those suffixes are contained in `PATHEXT`.
for ext in PATHEXT {
// If the command ends with one of those suffixes, tell
// Warp about this command as-is and also sans-suffix, e.g.
// "git" and "git.exe".
if line.to_lowercase().ends_with(&ext.to_lowercase()) {
let trimmed = &line[..line.len() - ext.len()];
return Box::<[&str]>::from([trimmed, line]);
}
}
// Otherwise, pass it through unaltered.
Box::<[&str]>::from([line])
})
.map_into()
.collect()
} else {
output_string.lines().map_into().collect()
}
}
}
}
Ok(output) => {
log::warn!("Generator for executable names failed");
if let Ok(output_string) = output.to_string() {
log::warn!("{output_string}");
};
Vec::new()
}
Err(e) => {
log::warn!("Generator for executable names failed: {e:#}");
Vec::new()
}
}
}
pub fn force_in_band_command_executor(&self) -> bool {
// TODO: Remove this function once we have confidence in using a local executor in
// powershell.
false
}
}
/// Provides the necessary info to be able to launch and bootstrap the selected AvailableShell. For
/// executables, this is the path to the executable and the shell type. For WSL, this is the distro
/// name. For Docker sandboxes, this is the `sbx` CLI path plus the base Docker
/// image; the shell inside the container is whatever the image provides.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ShellLaunchData {
Executable {
executable_path: PathBuf,
shell_type: ShellType,
},
/// Windows Subsystem for Linux.
#[cfg_attr(not(windows), allow(dead_code))]
WSL { distro: String },
MSYS2 {
executable_path: PathBuf,
shell_type: ShellType,
},
/// A shell running inside a `sbx`-managed Docker sandbox container.
///
/// A dedicated variant ensures callers can't accidentally execute the
/// sandbox as if it were a regular local shell: the `sbx` binary at
/// `sbx_path` is not a shell, it's the CLI we use to enter the container.
DockerSandbox {
sbx_path: PathBuf,
/// Base Docker image to use when creating the sandbox (passed as
/// `sbx run --template <image>`). `None` means "use sbx's default
/// image".
base_image: Option<String>,
},
}
impl ShellLaunchData {
/// Converts the given path string to a OS-native PathBuf, performing any necessary shell-informed conversions.
pub fn maybe_convert_absolute_path(&self, path_str: &str) -> Option<PathBuf> {
match self {
ShellLaunchData::Executable { .. } => Some(PathBuf::from(path_str)),
ShellLaunchData::WSL { distro } => {
let unix_path = TypedPath::unix(path_str);
convert_wsl_to_windows_host_path(&unix_path, distro).ok()
}
ShellLaunchData::MSYS2 {
executable_path, ..
} => {
let unix_path = TypedPath::unix(path_str);
convert_msys2_to_windows_native_path(
&unix_path,
&msys2_exe_to_root(WindowsPath::new(
executable_path.as_os_str().as_encoded_bytes(),
)),
)
.ok()
}
// Paths inside the sandbox container are plain Unix paths.
ShellLaunchData::DockerSandbox { .. } => Some(PathBuf::from(path_str)),
}
}
/// Converts a shell-encoded [`typed_path::TypedPathBuf`] into an OS-native path.
fn maybe_convert_shell_encoded_path(
&self,
shell_encoded_path: TypedPathBuf,
) -> Option<PathBuf> {
match self {
ShellLaunchData::Executable { .. } | ShellLaunchData::DockerSandbox { .. } => {
PathBuf::try_from(shell_encoded_path).ok()
}
ShellLaunchData::WSL { distro } => {
convert_wsl_to_windows_host_path(&shell_encoded_path.to_path(), distro).ok()
}
ShellLaunchData::MSYS2 {
executable_path, ..
} => convert_msys2_to_windows_native_path(
&shell_encoded_path.to_path(),
&msys2_exe_to_root(WindowsPath::new(
executable_path.as_os_str().as_encoded_bytes(),
)),
)
.ok(),
}
}
/// Naively changes the path string to an OS-native encoding, without performing shell-informed conversions.
fn to_native_path_encoding(&self, path_str: &str) -> Option<PathBuf> {
match self {
ShellLaunchData::Executable { .. } => Some(PathBuf::from(path_str)),
ShellLaunchData::WSL { .. } | ShellLaunchData::MSYS2 { .. } => {
let windows_encoding = TypedPath::unix(path_str).with_windows_encoding();
PathBuf::try_from(windows_encoding).ok()
}
// The container is Unix; Warp runs on the host, so paths are
// already in the host's native encoding. Pass through unchanged.
ShellLaunchData::DockerSandbox { .. } => Some(PathBuf::from(path_str)),
}
}
/// Converts a path string to a shell's encoding.
fn to_shell_encoding<'a>(&self, path_str: &'a str) -> TypedPath<'a> {
match self {
ShellLaunchData::Executable { .. } => {
if cfg!(unix) {
TypedPath::unix(path_str)
} else {
TypedPath::windows(path_str)
}
}
ShellLaunchData::WSL { .. }
| ShellLaunchData::MSYS2 { .. }
| ShellLaunchData::DockerSandbox { .. } => TypedPath::unix(path_str),
}
}
/// Attempts to append the relative path to the base path and convert it into a OS-native PathBuf,
/// performing any necessary shell-informed conversions.
pub fn maybe_convert_relative_path(
&self,
base_path_str: &str,
relative_path_str: &str,
) -> Option<PathBuf> {
let base_path = self.to_shell_encoding(base_path_str);
let rest_of_path = self.to_shell_encoding(relative_path_str);
let absolute_typed_path = base_path.join(rest_of_path);
self.maybe_convert_shell_encoded_path(absolute_typed_path)
}
/// Joins the given path string to the base path, ensuring the given path string is encoded correctly.
pub fn join_to_native_path(&self, base_path: &Path, path_str: &str) -> Option<PathBuf> {
self.to_native_path_encoding(path_str)
.map(|rest_of_path| base_path.join(rest_of_path))
}
/// How to present this data to the user for error messages.
pub fn shell_detail(&self) -> String {
match self {
Self::Executable {
executable_path, ..
}
| Self::MSYS2 {
executable_path, ..
} => executable_path.to_string_lossy().into_owned(),
Self::WSL { distro } => distro.to_owned(),
Self::DockerSandbox { base_image, .. } => match base_image {
Some(image) => format!("Docker sandbox ({image})"),
None => "Docker sandbox".to_owned(),
},
}
}
}
impl From<ShellLaunchData> for SessionPlatform {
fn from(data: ShellLaunchData) -> Self {
match data {
ShellLaunchData::Executable { .. } => SessionPlatform::Native,
ShellLaunchData::WSL { .. } => SessionPlatform::WSL,
ShellLaunchData::MSYS2 { .. } => SessionPlatform::MSYS2,
ShellLaunchData::DockerSandbox { .. } => SessionPlatform::DockerSandbox,
}
}
}
/// Unescape the key and value for an alias, returning None if either fails
fn unescape_alias_key_value(key: &str, value: &str) -> Option<(SmolStr, String)> {
let key = unescape_quotes(key)
.map_err(|e| {
log::error!("Unable to unescape key for alias: {e}");
e
})
.ok()?;
let value = unescape_quotes(value)
.map_err(|e| {
log::error!("Unable to unescape value for alias: {e}");
e
})
.ok()?;
Some((key.into(), value))
}
// fish history replaces newlines with \n, and \ with \\.
// This does the reverse.
fn fish_unescape_history_yaml(line: &str) -> String {
let mut result = String::new();
result.reserve(line.len());
let mut escaped = false;
for ch in line.chars() {
match ch {
'\\' => {
if escaped {
result.push('\\');
}
escaped = !escaped;
}
'n' if escaped => {
result.push('\n');
escaped = false;
}
_ => {
result.push(ch);
escaped = false;
}
}
}
result
}
// Basically a translation of this function into rust:
// http://mika.l3ib.org/code/unmetafy.c
// To unmetafy a string from zsh's internal format, we need to skip each meta symbol
// and XOR the next symbol with 32.
fn zsh_unmetafy(content: &[u8]) -> String {
let mut unmetafied = Vec::new();
match content.last() {
None => "".into(),
Some(byte) => {
let mut following_byte = *byte;
content.iter().rev().skip(1).for_each(|current_byte| {
if *current_byte == ZSH_META {
following_byte ^= 32;
} else {
unmetafied.push(following_byte);
following_byte = *current_byte;
}
});
unmetafied.push(following_byte);
unmetafied.reverse();
String::from_utf8_lossy(&unmetafied).into()
}
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,272 @@
use super::*;
#[test]
fn test_parse_history_bash() {
let history_lines = "cat ~/.bash_history
#1618089175
ls
#1618089176
pwd";
assert_eq!(
ShellType::Bash.parse_history(history_lines.as_bytes()),
vec![
"cat ~/.bash_history".to_string(),
"ls".to_string(),
"pwd".to_string()
]
);
}
#[test]
fn test_strip_zsh_extended_prefix() {
// Extended history format.
assert_eq!(
strip_zsh_extended_prefix(": 1699251735:0;an extended history command"),
"an extended history command"
);
// Non-extended format (no prefix to strip).
assert_eq!(
strip_zsh_extended_prefix("cat ~/.zsh_history"),
"cat ~/.zsh_history"
);
// Edge cases that should NOT be stripped.
assert_eq!(
strip_zsh_extended_prefix(": not_a_timestamp"),
": not_a_timestamp"
);
assert_eq!(strip_zsh_extended_prefix(": 123;"), ": 123;"); // Missing second number.
assert_eq!(strip_zsh_extended_prefix(": :0;cmd"), ": :0;cmd"); // Empty timestamp.
assert_eq!(strip_zsh_extended_prefix(": 123:;cmd"), ": 123:;cmd"); // Empty elapsed.
}
#[test]
fn test_parse_history_zsh() {
let history_lines = "
cat ~/.zsh_history
a multi-line\\
command
: 1699251735:0;an extended history command
: 1699251735:0;a multi-line extended\\
history command
";
assert_eq!(
ShellType::Zsh.parse_history(history_lines.as_bytes()),
vec![
"cat ~/.zsh_history".to_string(),
"a multi-line\ncommand".to_string(),
"an extended history command".to_string(),
"a multi-line extended\nhistory command".to_string(),
]
);
}
#[test]
fn test_parse_history_zsh_continuation_line_looks_like_prefix() {
// Regression test: a continuation line that happens to match the extended history
// prefix pattern should NOT be stripped.
let history_lines = ": 1699251735:0;echo '\\
: 9999:0;fake prefix'";
assert_eq!(
ShellType::Zsh.parse_history(history_lines.as_bytes()),
vec!["echo '\n: 9999:0;fake prefix'".to_string()]
);
}
#[test]
fn test_zsh_unmetafy() {
let test_zsh_history = [
227, 129, 131, 179, 227, 130, 131, 172, 227, 129, 175, 230, 131, 183, 165, 230, 131, 188,
172, 232, 170, 131, 190, 227, 129, 167, 227, 129, 131, 185,
];
let unmetafied_test = zsh_unmetafy(&test_zsh_history);
assert_eq!("これは日本語です", &unmetafied_test);
}
#[test]
fn test_fish_unescape_history_yaml() {
assert_eq!(fish_unescape_history_yaml("foo"), "foo");
assert_eq!(fish_unescape_history_yaml("foo\\nbar"), "foo\nbar");
assert_eq!(fish_unescape_history_yaml("foo\\\\"), "foo\\");
assert_eq!(fish_unescape_history_yaml("foo\\"), "foo"); // trailing escape dropped
}
#[test]
fn test_from_name() {
assert_eq!(Some(ShellType::Bash), ShellType::from_name("bash"));
assert_eq!(Some(ShellType::Bash), ShellType::from_name("-bash"));
assert_eq!(Some(ShellType::Bash), ShellType::from_name("/bin/bash"));
assert_eq!(Some(ShellType::Bash), ShellType::from_name("/usr/bin/bash"));
assert_eq!(Some(ShellType::Zsh), ShellType::from_name("/bin/zsh"));
assert_eq!(None, ShellType::from_name("/bin/zsh/foo"));
assert_eq!(None, ShellType::from_name("/bin/zsh/-bash"));
assert_eq!(None, ShellType::from_name("rezsh"));
assert_eq!(Some(ShellType::Fish), ShellType::from_name("fish"));
assert_eq!(
Some(ShellType::Fish),
ShellType::from_name("/usr/local/bin/fish")
);
assert_eq!(
Some(ShellType::PowerShell),
ShellType::from_name("pwsh.exe")
);
assert_eq!(None, ShellType::from_name("pwsh.bat"));
assert_eq!(
Some(ShellType::PowerShell),
ShellType::from_name("/usr/bin/env/powershell")
);
assert_eq!(
Some(ShellType::PowerShell),
ShellType::from_name("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
);
assert_eq!(None, ShellType::from_name("psh"));
}
#[test]
fn test_from_markdown_language_spec() {
// Standard shell languages
assert_eq!(
Some(ShellType::Bash),
ShellType::from_markdown_language_spec("bash")
);
assert_eq!(
Some(ShellType::Bash),
ShellType::from_markdown_language_spec("shell")
);
assert_eq!(
Some(ShellType::Bash),
ShellType::from_markdown_language_spec("sh")
);
assert_eq!(
Some(ShellType::Zsh),
ShellType::from_markdown_language_spec("zsh")
);
assert_eq!(
Some(ShellType::Fish),
ShellType::from_markdown_language_spec("fish")
);
assert_eq!(
Some(ShellType::PowerShell),
ShellType::from_markdown_language_spec("powershell")
);
assert_eq!(
Some(ShellType::PowerShell),
ShellType::from_markdown_language_spec("pwsh")
);
// Non-shell languages and invalid inputs
assert_eq!(None, ShellType::from_markdown_language_spec("python"));
assert_eq!(None, ShellType::from_markdown_language_spec("rust"));
assert_eq!(None, ShellType::from_markdown_language_spec(""));
// Paths and executable names should not match (use from_name for those)
assert_eq!(None, ShellType::from_markdown_language_spec("/bin/bash"));
assert_eq!(None, ShellType::from_markdown_language_spec("-bash"));
}
#[test]
fn test_fish_parse_abbrs() {
let raw_abbrs = "abbr -a -U -- gco 'git checkout'
abbr -a -g -- gq 'git commit'
abbr -a -U -- ehw 'echo \"Hello, world\"'
abbr -a -- ga 'git add' # imported from a universal variable, see `help abbr`";
let abbrs = ShellType::Fish.abbreviations(raw_abbrs);
assert_eq!(abbrs.len(), 4);
assert_eq!(abbrs.get("gco").unwrap(), "git checkout");
assert_eq!(abbrs.get("gq").unwrap(), "git commit");
assert_eq!(abbrs.get("ehw").unwrap(), r#"echo "Hello, world""#);
assert_eq!(abbrs.get("ga").unwrap(), "git add");
}
#[test]
fn test_fish_parse_aliases() {
let raw_aliases = "alias g git
alias rmi 'rm -i'
alias ehw 'echo \"Hello, world\"'";
let aliases = ShellType::Fish.aliases(raw_aliases);
assert_eq!(aliases.len(), 3);
assert_eq!(aliases.get("g").unwrap(), "git");
assert_eq!(aliases.get("rmi").unwrap(), "rm -i");
assert_eq!(aliases.get("ehw").unwrap(), r#"echo "Hello, world""#);
}
#[test]
fn test_should_add_command_to_history() {
{
// Test zsh's "histignorespace" option.
let options = HashSet::from(["histignorespace".to_string()]);
let shell = Shell::new(
ShellType::Zsh,
None,
Some(options),
Default::default(),
None,
);
assert!(shell.should_add_command_to_history("asdf"));
assert!(!shell.should_add_command_to_history(" asdf"));
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
assert!(shell.should_add_command_to_history("asdf"));
assert!(shell.should_add_command_to_history(" asdf"));
}
// Test our "!histcontrol_" faked option for bash.
{
for variant in [
"!histcontrol_ignorespace",
"!histcontrol_ignoreboth",
"!histcontrol_testing:ignorespace",
"!histcontrol_testing:ignoreboth:testing",
] {
let options = HashSet::from([variant.to_string()]);
let bash_shell = Shell::new(
ShellType::Bash,
None,
Some(options.clone()),
Default::default(),
None,
);
assert!(bash_shell.should_add_command_to_history("asdf"));
assert!(!bash_shell.should_add_command_to_history(" asdf"));
// Make sure that option only takes effect when the shell is bash.
let zsh_shell = Shell::new(
ShellType::Zsh,
None,
Some(options),
Default::default(),
None,
);
assert!(zsh_shell.should_add_command_to_history(" asdf"));
let bash_shell_no_options =
Shell::new(ShellType::Bash, None, None, Default::default(), None);
assert!(bash_shell_no_options.should_add_command_to_history("asdf"));
assert!(bash_shell_no_options.should_add_command_to_history(" asdf"));
}
// Ensure we're not only looking for "!histcontrol".
{
let options = HashSet::from(["!histcontrol_testing".to_string()]);
let bash_shell = Shell::new(
ShellType::Bash,
None,
Some(options),
Default::default(),
None,
);
assert!(bash_shell.should_add_command_to_history(" asdf"));
}
}
// Fish has no shell options that prevent a command from being written to history.
{
let fish_shell = Shell::new(ShellType::Fish, None, None, Default::default(), None);
assert!(fish_shell.should_add_command_to_history("asdf"));
assert!(fish_shell.should_add_command_to_history(" asdf"));
}
}
@@ -0,0 +1,161 @@
use anyhow::{anyhow, Result};
enum CurrentQuoteStrategy {
None,
Single,
Double,
AnsiC,
}
/// Unescape alias outputs in single quoting and ANSI-C quoting format.
/// For single quoting, we should take the literal meaning of all characters within
/// the quoting. For ANSI-C quoting, we need to translate escape sequences to their
/// unicode values.
///
/// Note that we don't unescape double quotes here as they require the knowledge of
/// shell variables' values. E.g. If we have the following string `echo "'$apple'"`,
/// we could unescape it without knowing what the value of $apple.
pub fn unescape_quotes(s: &str) -> Result<String> {
let mut current_quoting = CurrentQuoteStrategy::None;
let mut chars = s.chars().enumerate().peekable();
let mut res = String::with_capacity(s.len());
while let Some((idx, c)) = chars.next() {
match (c, &current_quoting) {
// If in single / Ansi-C quote, end the quote escaping.
('\'', CurrentQuoteStrategy::Single | CurrentQuoteStrategy::AnsiC) => {
current_quoting = CurrentQuoteStrategy::None
}
('\'', CurrentQuoteStrategy::None) => current_quoting = CurrentQuoteStrategy::Single,
('\"', CurrentQuoteStrategy::Double) => current_quoting = CurrentQuoteStrategy::None,
('\"', CurrentQuoteStrategy::None) => current_quoting = CurrentQuoteStrategy::Double,
('\\', CurrentQuoteStrategy::AnsiC) => {
match chars.next() {
None => {
return Err(anyhow!("invalid escape at char {} in string {}", idx, s));
}
Some((_, next_character)) => {
// Referenced from the table here: https://en.wikipedia.org/wiki/Escape_sequences_in_C
res.push(match next_character {
'a' => '\u{07}',
'b' => '\u{08}',
'e' | 'E' => '\u{1B}',
'f' => '\u{0C}',
'n' => '\n',
'r' => '\r',
't' => '\t',
'v' => '\u{0B}',
// TODO(kevin): Need to add escaping to unicode
// characters here. But this should be rare.
next_character => next_character,
});
}
}
}
('\\', CurrentQuoteStrategy::Double) => {
match chars.next() {
None => {
return Err(anyhow!("invalid escape at char {} in string {}", idx, s));
}
Some((_, next_character)) => {
// The backslash retains special meaning when followed by
// $, `, ", \. Otherwise is treated as a literal.
// Referenced from here:
// https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
match next_character {
'$' | '`' | '"' | '\\' => res.push(next_character),
// Newlines are ignored after a backslash, see:
// https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Escape-Character
'\n' => {}
_ => {
res.push('\\');
res.push(next_character);
}
}
}
}
}
('\\', CurrentQuoteStrategy::None) => match chars.next() {
None => {
return Err(anyhow!("invalid escape at char {} in string {}", idx, s));
}
Some((_, next_character)) => match next_character {
// Newlines are ignored after a backslash, see:
// https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Escape-Character
'\n' => {}
_ => res.push(next_character),
},
},
('$', CurrentQuoteStrategy::None) => {
match chars.peek() {
// ANSI-C quoting starts with $'
Some((_, '\'')) => {
current_quoting = CurrentQuoteStrategy::AnsiC;
chars.next();
}
_ => res.push('$'),
}
}
_ => res.push(c),
}
}
Ok(res)
}
#[test]
fn test_unescape_quotes() {
assert_eq!(unescape_quotes("東方").unwrap(), "東方".to_string());
assert_eq!(unescape_quotes(r#"$'\"\"'"#).unwrap(), r#""""#.to_string());
assert_eq!(unescape_quotes(r#"'"'"#).unwrap(), r#"""#.to_string());
assert_eq!(
unescape_quotes(r#"$'foo"barbaz\'quux'"#).unwrap(),
r#"foo"barbaz'quux"#.to_string()
);
// Every escape between ANSI-C quoting.
assert_eq!(
unescape_quotes(r"$'\a\b\v\f\n\r\t\e\E'").unwrap(),
"\u{07}\u{08}\u{0b}\u{0c}\u{0a}\u{0d}\u{09}\u{1b}\u{1b}".to_string()
);
// Failure case when the escape character is at end of string.
assert!(unescape_quotes(r"$'\").is_err());
// Chars following escape chars should be taken literally when not currently
// in a quote strategy.
assert_eq!(
unescape_quotes(r"'echo '\''hello\nworld'\'").unwrap(),
"echo 'hello\\nworld'"
);
}
#[test]
fn test_unescape_double_quotes() {
assert_eq!(unescape_quotes("\"hello world\"").unwrap(), "hello world");
assert_eq!(unescape_quotes(r#""hello\$world""#).unwrap(), "hello$world");
assert_eq!(unescape_quotes(r#""hello\`world""#).unwrap(), "hello`world");
assert_eq!(
unescape_quotes(r#""hello\"world""#).unwrap(),
"hello\"world"
);
assert_eq!(
unescape_quotes(r#""hello\\world""#).unwrap(),
"hello\\world"
);
}
#[test]
fn test_unescape_double_quotes_nonspecial_chars() {
assert_eq!(
unescape_quotes(r#""hello\aworld""#).unwrap(),
r"hello\aworld"
);
}
#[test]
fn test_unescape_backslash_with_newline() {
// With no quoting strategy.
assert_eq!(unescape_quotes("hello\\\nworld").unwrap(), "helloworld");
// With double quotes.
assert_eq!(unescape_quotes("\"hello\\\nworld\"").unwrap(), "helloworld");
}