377 lines
16 KiB
Rust
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;
|