Files
galaxy/app/src/terminal/bootstrap.rs
T

377 lines
16 KiB
Rust

use std::borrow::Cow;
use galaxy_core::session_id::SessionId;
use galaxyui::{AppContext, AssetProvider, SingletonEntity};
use itertools::Itertools;
use lazy_static::lazy_static;
use memo_map::MemoMap;
use rand::Rng;
#[cfg(feature = "local_fs")]
use super::{
model::session::{BootstrapSessionType, SessionInfo},
warpify::settings::{PIPENV_SUBSHELL_COMMAND_REGEX, POETRY_SUBSHELL_COMMAND_REGEX},
};
use crate::env_vars::{EnvVar, EnvVarExt};
use crate::terminal::session_settings::SessionSettings;
use crate::terminal::shell::ShellType;
lazy_static! {
/// A memoized cache of the fully-interpolated bootstrap script for each
/// shell. We store the full version here as an optimization so that we
/// don't have to regenerate it every time we spawn a shell.
static ref BOOTSTRAP_CACHE: MemoMap<ShellType, Vec<u8>> = Default::default();
}
/// This can sometimes appear in the beginning of files. If it gets written into the PTY, it causes
/// errors
const BYTE_ORDER_MARK: &str = "\u{FEFF}";
#[cfg(feature = "local_fs")]
pub fn is_container_subshell(session_info: &SessionInfo) -> bool {
session_info.subshell_info.as_ref().is_some_and(|info| {
let first_token = info
.spawning_command
.split_ascii_whitespace()
.next()
.unwrap_or("");
first_token == "docker" || first_token == "podman"
})
}
/// Returns `true` if Warp should use an RC-file based bootstrap (e.g. dump the bootstrap script to
/// a temp file and `source` it) for a newly spawned session with the given `shell_type`, and
/// associated `session_type` and `subshell_initialization_info`.
///
/// This returns `true` for local Fish/Pwsh shells and local subshells spawned via `poetry shell`.
///
/// We use RC-file based bootstrap for local Fish shells because there is a long-standing bug which
/// causes an explosion of formatting output when a command is longer than a screen height. (See
/// https://github.com/fish-shell/fish-shell/issues/7296 for more) This multiplication of output
/// makes our bootstrap take a long time, as we need to process all of that output (even though
/// most of it is irrelevant). To avoid the impact on bootstrap time, we write the script to a
/// temporary file and then source that file (which avoids writing the long script to the shell
/// itself).
///
/// We use RC-file based bootstrap for PowerShell because chars written to the PTY get randomly
/// ignored. See PLAT-757 in Linear.
///
/// We use RC-file based bootstrap for `poetry shell` subshells because the underlying library used
/// to spawn a subshell by `poetry shell` uses blocking PTY reads and writes, which results in a
/// deadlock when attempting to write the whole bootstrap script to the PTY; RC file-based
/// bootstrap is the only known way to bootstrap such subshells successfully.
///
/// We use RC-file based bootstrap for MSYS2 because it has slow PTY throughput.
#[cfg(feature = "local_fs")]
pub fn should_use_rc_file_bootstrap_method(
shell_type: ShellType,
session_info: &SessionInfo,
) -> bool {
use super::ShellLaunchData;
// Container subshells cannot access host temp files, so the RC-file
// method is never viable for them.
if is_container_subshell(session_info) {
return false;
}
let session_type = &session_info.session_type;
match session_type {
BootstrapSessionType::Local => {
let subshell_initialization_info = session_info.subshell_info.as_ref();
let is_poetry_subshell = subshell_initialization_info
.as_ref()
.map(|info| POETRY_SUBSHELL_COMMAND_REGEX.is_match(info.spawning_command.as_str()))
.unwrap_or(false);
let is_pipenv_subshell = subshell_initialization_info
.as_ref()
.map(|info| PIPENV_SUBSHELL_COMMAND_REGEX.is_match(info.spawning_command.as_str()))
.unwrap_or(false);
let is_msys2 = session_info
.launch_data
.as_ref()
.is_some_and(|data| matches!(data, ShellLaunchData::MSYS2 { .. }));
shell_type == ShellType::Fish
|| shell_type == ShellType::PowerShell
|| is_poetry_subshell
|| ((is_pipenv_subshell
|| (subshell_initialization_info.is_some() && cfg!(windows)))
&& shell_type == ShellType::Zsh)
|| is_msys2
}
BootstrapSessionType::WarpifiedRemote => false,
}
}
/// Returns the bootstrap script that should be used when initializing a shell
/// of the given type.
///
/// This supports a very basic form of interpolation:
///
/// ```shell
/// #include bundled/bootstrap/zsh_body.sh
/// ```
///
/// The directive above instructs this function to replace that line with the
/// contents of the file in our asset cache with the path `bundled/bootstrap/zsh_body.sh`.
///
/// At the moment, this interpolation is only performed for the top-level file,
/// and is not performed recursively, but it would be useful to add such support
/// in the future.
pub fn script_for_shell(shell_type: ShellType, assets: &dyn AssetProvider) -> Cow<'static, [u8]> {
let file = match shell_type {
ShellType::Bash => "bash.sh",
ShellType::Zsh => "zsh.sh",
ShellType::Fish => "fish.sh",
ShellType::PowerShell => "pwsh.ps1",
};
BOOTSTRAP_CACHE
.get_or_insert(&shell_type, || {
let file_path = format!("bundled/bootstrap/{file}");
let bootstrap = assets
.get(&file_path)
.unwrap_or_else(|_| panic!("failed to retrieve {file_path} from assets"));
// Interpret the file as UTF-8. We do this in an unchecked way
// for performance, expecting that any issues here will be caught by
// unit tests.
let bootstrap = unsafe { String::from_utf8_unchecked(bootstrap.to_vec()) };
let additional_files = memo_map::MemoMap::new();
// Parse through the file, looking for any lines which start with
// "#include", and replacing that line with the contents of the file
// located at the path specified.
//
// We trim most leading and all trailing whitespace from lines, and
// drop all empty lines and lines that only contain a comment. We
// keep a single leading space on each line, if one exists, to
// avoid interfering with histignorespace behavior.
//
// This minimizes the number of bytes we send over the pty during the
// bootstrap process.
fn trim_and_borrow_line(mut line: &str) -> Cow<'_, str> {
let len = line.len();
let trimmed_len = line.trim_start().len();
if trimmed_len < len {
let trimmed_chars = len - trimmed_len;
line = &line[trimmed_chars - 1..];
}
Cow::Borrowed(line.trim_end())
}
let mut script = bootstrap
.trim_start_matches(BYTE_ORDER_MARK)
.split('\n')
.map(trim_and_borrow_line)
.flat_map(|line| {
if let Some(path) = line.strip_prefix("#include ") {
additional_files
.get_or_insert(path, || {
let data = assets.get(path).unwrap_or_else(|_| {
panic!("failed to retrieve {path} from assets")
});
let data_string =
unsafe { String::from_utf8_unchecked(data.to_vec()) };
data_string.replace(
"@@USING_CON_PTY_BOOLEAN@@",
&(cfg!(windows).to_string()),
)
})
.split('\n')
.map(trim_and_borrow_line)
.collect_vec()
} else {
vec![line]
}
})
// Filter out empty lines and comments, to minimize the amount
// of data we send over the pty during the bootstrap process.
.filter(|line| {
let line = line.trim_start();
!(line.is_empty()
|| line.starts_with('#')
|| shell_type == ShellType::PowerShell
&& line
.starts_with("[Diagnostics.CodeAnalysis.SuppressMessageAttribute"))
})
.join("\n");
// Make sure there's a newline at the end of the bootstrap script,
// otherwise we'll never submit the final line to the shell.
script.push('\n');
script.into_bytes()
})
.into()
}
/// Generates a cryptographically random session ID for use as both a session
/// identifier and an integrity token for DCS hook validation.
pub fn generate_session_id() -> SessionId {
let mut rng = rand::thread_rng();
loop {
let session_id = rng.gen::<u64>();
if session_id != 0 {
return SessionId::from(session_id);
}
}
}
/// Placeholder in init shell scripts that gets replaced with the client-generated session ID.
pub(crate) const SESSION_ID_PLACEHOLDER: &str = "@@WARP_SESSION_ID@@";
/// Returns the init shell script for the given `shell_type` (e.g. the script that emits the
/// InitShell DCS hook).
///
/// The returned script is one line and, for shells that need it, has escaped single-quotes for the
/// purposes of being passed as a single-quoted argument to 'eval'.
pub fn init_shell_script_for_shell(
shell_type: ShellType,
assets: &dyn AssetProvider,
session_id: SessionId,
) -> String {
let script = match shell_type {
ShellType::Zsh => load_and_escape_script("bundled/bootstrap/zsh_init_shell.sh", assets),
ShellType::Bash => load_and_escape_script("bundled/bootstrap/bash_init_shell.sh", assets),
ShellType::Fish => load_and_escape_script("bundled/bootstrap/fish_init_shell.sh", assets),
ShellType::PowerShell => load_script("bundled/bootstrap/pwsh_init_shell.ps1", assets),
};
script.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string())
}
/// Returns the command to be used to emit the InitShell hook for a new subshell session.
///
/// If `shell_type` is `Some()`, returns a shell type-specific command (e.g. valid command for
/// bash, fish, or zsh). Otherwise, returns a shell type-agnostic command that emits the right
/// `InitShell` hook based on the shell it is evaluated in.
pub fn init_subshell_command(
shell_type: Option<ShellType>,
vars: &[EnvVar],
session_id: SessionId,
ctx: &AppContext,
) -> String {
match shell_type {
Some(shell_type) => {
let subshell_script =
init_subshell_script_for_shell(shell_type, &crate::ASSETS, vars, session_id, ctx);
format!(r#" [ -z $WARP_BOOTSTRAPPED ] && eval '{subshell_script}'"#)
}
None => init_subshell_script_for_unknown_shell(&crate::ASSETS, session_id),
}
}
/// Returns the init subshell script for the given `shell_type` (e.g. the script that emits the
/// subshell version of the InitShell DCS hook).
///
/// The returned script is one line and has escaped single-quotes for the purposes of being passed
/// as a single-quoted argument to 'eval'.
fn init_subshell_script_for_shell(
shell_type: ShellType,
assets: &dyn AssetProvider,
env_vars: &[EnvVar],
session_id: SessionId,
ctx: &AppContext,
) -> String {
let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1;
let honor_ps1_env_var_value = if honor_ps1 { "1" } else { "0" };
// Prepend environment variable settings to the script
let env_setup_script = format!(
"export WARP_HONOR_PS1={}; {}",
honor_ps1_env_var_value,
env_vars
.iter()
.map(|var| var.get_initialization_string(shell_type))
.collect_vec()
.join(" ")
);
// Load and escape the shell-specific init script
let shell_init_script = match shell_type {
ShellType::Zsh => load_and_escape_script("bundled/bootstrap/zsh_init_subshell.sh", assets),
ShellType::Bash => {
load_and_escape_script("bundled/bootstrap/bash_init_subshell.sh", assets)
}
ShellType::Fish => {
load_and_escape_script("bundled/bootstrap/fish_init_subshell.sh", assets)
}
// TODO(PLAT-750)
ShellType::PowerShell => todo!(),
};
let shell_init_script =
shell_init_script.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string());
// Combine the environment setup script with the shell-specific init script
format!("{env_setup_script} {shell_init_script}")
}
/// Returns the init subshell script for an unknown shell which detects the shell type.
///
/// The returned script is one line and has escaped single-quotes for the purposes of being passed
/// as a single-quoted argument to 'eval'.
fn init_subshell_script_for_unknown_shell(
assets: &dyn AssetProvider,
session_id: SessionId,
) -> String {
// Load and escape the shell-specific init script
load_and_escape_script("bundled/bootstrap/unknown_init_subshell.sh", assets)
.replace("HOOK_NAME", "InitSubshell")
.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string())
}
/// Returns the raw init shell script for the given `shell_type`, without
/// single-quote escaping. Suitable for passing as an environment variable
/// where the caller controls the eval context (e.g. Docker sandbox init).
///
/// Gated on `unix` because the sole caller today is the Unix Docker
/// sandbox spawn path (`local_tty::unix::prepare_docker_sandbox`); on
/// Windows/wasm the function is dead code.
#[cfg(unix)]
pub fn raw_init_shell_script_for_shell(
shell_type: ShellType,
assets: &dyn AssetProvider,
session_id: SessionId,
) -> String {
let file = match shell_type {
ShellType::Bash => "bundled/bootstrap/bash_init_shell.sh",
ShellType::Zsh => "bundled/bootstrap/zsh_init_shell.sh",
ShellType::Fish => "bundled/bootstrap/fish_init_shell.sh",
ShellType::PowerShell => "bundled/bootstrap/pwsh_init_shell.ps1",
};
load_script(file, assets)
.replace("@@USING_CON_PTY_BOOLEAN@@", &(cfg!(windows).to_string()))
.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string())
}
/// Returns the script in the file at `file_path` to be passed as a single-quoted argument in the
/// shell (e.g. as a single quoted argument to `eval`).
///
/// The script is transformed in two ways:
/// * Newlines are stripped and replaced with semi-colons
/// * Single quotes are escaped (' is replaced with '"'"')
/// * Lines starting with '#' are removed -- this enables use of comments in scripts. Note,
/// however, that you still cannot use a 'partial line' comment, since this logic only considers
/// whole lines.
fn load_and_escape_script(file_path: &str, assets: &dyn AssetProvider) -> String {
load_script(file_path, assets)
.replace('\'', r#"'"'"'"#)
.replace("@@USING_CON_PTY_BOOLEAN@@", &(cfg!(windows).to_string()))
}
fn load_script(file_path: &str, assets: &dyn AssetProvider) -> String {
let script_bytes = assets
.get(file_path)
.unwrap_or_else(|_| panic!("Failed to retrieve {file_path} from assets"));
std::str::from_utf8(&script_bytes)
.expect("InitShell script should be utf8 encoded.")
.trim_start_matches(BYTE_ORDER_MARK)
.lines()
.filter(|line| !line.trim_start().starts_with('#') && !line.trim().is_empty())
.join(";")
}
#[cfg(test)]
#[path = "bootstrap_tests.rs"]
mod tests;