first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+4 -1
View File
@@ -9,6 +9,10 @@ license.workspace = true
test-util = []
[dependencies]
log.workspace = true
[dev-dependencies]
tempfile = "3.8.0"
[target.'cfg(not(target_family = "wasm"))'.dependencies]
async-process = { workspace = true }
@@ -17,7 +21,6 @@ futures-lite.workspace = true
[target.'cfg(windows)'.dependencies]
anyhow.workspace = true
lazy_static.workspace = true
log.workspace = true
thiserror.workspace = true
win32job = "2.0.2"
windows.workspace = true
+6 -3
View File
@@ -2,13 +2,13 @@
// wrapper implementation that allows us to not import the above type elsewhere in this workspace.
#![allow(clippy::disallowed_types)]
use async_process::Child;
use std::ffi::OsStr;
use std::fmt;
use std::future::Future;
use std::io;
use std::path::Path;
use std::process::{ExitStatus, Output, Stdio};
use std::{fmt, io};
use async_process::Child;
/// Wrapper around a [`async_process::Command`] that ensures any new Command is set with the windows
/// `CREATE_NO_WINDOW` flag to avoid a console window temporarily popping up.
@@ -39,6 +39,7 @@ impl Command {
/// let mut cmd = Command::new("ls");
/// ```
pub fn new<S: AsRef<OsStr>>(program: S) -> Command {
let program = crate::wsl::translate_program_for_spawn(program.as_ref());
let inner = async_process::Command::new(program);
Self::new_internal(inner)
}
@@ -51,6 +52,7 @@ impl Command {
/// See [`setsid(2)`](https://man7.org/linux/man-pages/man2/setsid.2.html).
#[cfg(unix)]
pub fn new_with_session<S: AsRef<OsStr>>(program: S) -> Command {
let program = crate::wsl::translate_program_for_spawn(program.as_ref());
let mut command = std::process::Command::new(program);
// SAFETY: `pre_exec` requires the closure to be async-signal-safe.
@@ -77,6 +79,7 @@ impl Command {
/// This allows for killing any other processes spawned by this process
/// when we kill this process.
pub fn new_with_process_group<S: AsRef<OsStr>>(program: S) -> Command {
let program = crate::wsl::translate_program_for_spawn(program.as_ref());
#[allow(unused_mut)]
let mut command = std::process::Command::new(program);
+1 -1
View File
@@ -66,6 +66,7 @@ impl Command {
/// .expect("sh command failed to start");
/// ```
pub fn new<S: AsRef<OsStr>>(program: S) -> Command {
let program = crate::wsl::translate_program_for_spawn(program.as_ref());
#[cfg_attr(not(windows), expect(unused_mut))]
let mut inner = std::process::Command::new(program);
@@ -97,7 +98,6 @@ impl Command {
///
/// [1]: https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863(v=vs.85).aspx
pub fn creation_flags(&mut self, flags: u32) -> &mut Self {
use std::os::windows::process::CommandExt;
let flags = windows::Win32::System::Threading::CREATE_NO_WINDOW.0 | flags;
self.inner.creation_flags(flags);
self
+1
View File
@@ -13,5 +13,6 @@ pub mod blocking;
pub mod unix;
#[cfg(windows)]
pub mod windows;
pub mod wsl;
pub use std::process::{ExitStatus, Output, Stdio};
+2 -1
View File
@@ -1,9 +1,10 @@
use async_process::unix::CommandExt as _;
use std::ffi::OsStr;
use std::io;
use std::io::Error;
use std::os::unix::process::CommandExt as _;
use async_process::unix::CommandExt as _;
/// Unix-specific extensions to the [`Command`] builder.
///
/// This trait is sealed: it cannot be implemented outside `async-process`.
+128
View File
@@ -0,0 +1,128 @@
//! WSL detection and Linux-side binary resolution for subprocess
//! invocations made through this crate's [`Command`](crate::r#async::Command)
//! and [`Command`](crate::blocking::Command) wrappers.
//!
//! Warp ships as a Linux ELF that users routinely run inside WSL.
//! WSL's default `appendWindowsPath = true` (in `/etc/wsl.conf`)
//! puts directories like `/mnt/c/Program Files/Git/cmd/` on `PATH`,
//! so a bare `Command::new("git")` can resolve to Windows `git.exe`
//! through WSL interop. That path is dramatically slower, can
//! mishandle Linux paths, and breaks Linux-side hooks.
//!
//! [`translate_program_for_spawn`] is invoked from the wrappers'
//! `new` constructors and transparently substitutes the program
//! string when (a) we're inside WSL and (b) the program is a bare
//! name in [`KNOWN_NAMES`]. Path-qualified or unknown programs are
//! passed through unchanged. Resolution is cached for the life of
//! the process — PATH is effectively static for the host process.
//!
//! The same `/mnt/*` filtering precedent is used for `compgen` in
//! `app/src/terminal/model/session/command_executor/wsl_command_executor.rs`.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[cfg(test)]
#[path = "wsl_tests.rs"]
mod tests;
/// Bare program names whose resolution Warp wants to override under
/// WSL. Anything not in this list is passed through unchanged.
const KNOWN_NAMES: &[&str] = &["git", "gh"];
/// True when the current process is running inside a WSL guest.
/// Cached for the life of the process.
pub fn is_wsl() -> bool {
static IS_WSL: OnceLock<bool> = OnceLock::new();
*IS_WSL.get_or_init(|| Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists())
}
/// Translate a `Command::new` program string. On WSL, bare names in
/// [`KNOWN_NAMES`] are resolved to the first executable on `PATH`
/// outside `/mnt/*`; everything else is returned unchanged so the OS
/// performs its normal lookup at spawn time.
pub(crate) fn translate_program_for_spawn(program: &OsStr) -> OsString {
if !is_wsl() {
return program.to_owned();
}
let Some(name) = known_bare_name(program) else {
return program.to_owned();
};
cached_resolve(name).to_owned()
}
/// Returns the program's name when it's a bare entry in
/// [`KNOWN_NAMES`]. Paths and absolute names are filtered out so a
/// caller that already specified `/usr/bin/git` is not touched.
fn known_bare_name(program: &OsStr) -> Option<&'static str> {
let s = program.to_str()?;
if s.contains('/') || s.contains('\\') {
return None;
}
KNOWN_NAMES.iter().copied().find(|&n| n == s)
}
fn cached_resolve(name: &'static str) -> &'static OsString {
static GIT: OnceLock<OsString> = OnceLock::new();
static GH: OnceLock<OsString> = OnceLock::new();
let cell = match name {
"git" => &GIT,
"gh" => &GH,
// `KNOWN_NAMES` is exhaustive over the static cells above; if
// a new name is added there it must also be wired here.
_ => unreachable!("unknown name {name:?}"),
};
cell.get_or_init(|| {
let path_env = std::env::var_os("PATH");
match resolve_binary_in_wsl_safe_path(name, path_env.as_deref(), true) {
Some(p) => p.into_os_string(),
None => {
log::warn!(
"wsl: no Linux-side `{name}` found on PATH (excluding /mnt/*); \
falling back to bare `{name}` which may resolve to a Windows .exe"
);
OsString::from(name)
}
}
})
}
/// Returns the first executable named `name` on `path_env`, skipping
/// any PATH entry under `/mnt/` when `is_wsl` is true. Returns `None`
/// if no acceptable match exists. Pure — exposed for unit testing
/// without depending on a real WSL host.
pub fn resolve_binary_in_wsl_safe_path(
name: &str,
path_env: Option<&OsStr>,
is_wsl: bool,
) -> Option<PathBuf> {
let path_env = path_env?;
for dir in std::env::split_paths(path_env) {
if is_wsl && dir.starts_with("/mnt") {
continue;
}
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt as _;
match std::fs::metadata(path) {
Ok(md) => md.is_file() && (md.permissions().mode() & 0o111 != 0),
Err(_) => false,
}
}
#[cfg(not(unix))]
fn is_executable_file(_path: &Path) -> bool {
// The WSL-safe resolver only runs on Linux. Other targets short-
// circuit through `is_wsl() == false`, so this stub is unreachable
// in practice — present only to keep the crate compiling.
false
}
+173
View File
@@ -0,0 +1,173 @@
use std::ffi::OsStr;
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::fs;
#[cfg(unix)]
use std::path::PathBuf;
use super::{known_bare_name, resolve_binary_in_wsl_safe_path};
#[cfg(unix)]
fn make_executable(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt as _;
let mut perms = fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).unwrap();
}
#[cfg(unix)]
fn write_exec(path: &std::path::Path) {
fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
make_executable(path);
}
#[cfg(unix)]
fn join(parts: &[PathBuf]) -> OsString {
std::env::join_paths(parts).unwrap()
}
#[cfg(unix)]
#[test]
fn picks_first_linux_path_when_wsl() {
let linux_dir = tempfile::tempdir().unwrap();
write_exec(&linux_dir.path().join("git"));
// `/mnt/c/...` paths in the synthetic PATH that don't exist on
// disk simulate the WSL-with-Windows-git layout: the resolver
// should skip them on the prefix and never stat them.
let path_env = join(&[
PathBuf::from("/mnt/c/Program Files/Git/cmd"),
linux_dir.path().to_path_buf(),
]);
let resolved =
resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap();
assert_eq!(resolved, linux_dir.path().join("git"));
assert!(!resolved.starts_with("/mnt"));
}
#[cfg(unix)]
#[test]
fn passes_through_first_match_when_not_wsl() {
// When not WSL, `/mnt/...` is just another directory; the resolver
// should pick the first dir on PATH that contains an exec match.
let mnt_dir = tempfile::tempdir().unwrap();
write_exec(&mnt_dir.path().join("git"));
let other_dir = tempfile::tempdir().unwrap();
write_exec(&other_dir.path().join("git"));
let path_env = join(&[mnt_dir.path().to_path_buf(), other_dir.path().to_path_buf()]);
let resolved =
resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), false).unwrap();
assert_eq!(resolved, mnt_dir.path().join("git"));
}
#[cfg(unix)]
#[test]
fn falls_back_to_none_when_only_mnt_has_git() {
let path_env = join(&[
PathBuf::from("/mnt/c/Program Files/Git/cmd"),
PathBuf::from("/mnt/c/Windows/System32"),
]);
assert!(resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).is_none());
}
#[cfg(unix)]
#[test]
fn picks_user_local_bin() {
let bin_dir = tempfile::tempdir().unwrap();
let empty_dir = bin_dir.path().join("empty");
fs::create_dir_all(&empty_dir).unwrap();
let local_bin = bin_dir.path().join("home/.local/bin");
fs::create_dir_all(&local_bin).unwrap();
write_exec(&local_bin.join("git"));
// PATH order: an empty dir, then a `/mnt/...` candidate that
// should be skipped on WSL, then the directory with the real
// exec. The resolver must walk past the first two and land on
// the third.
let path_env = join(&[
empty_dir,
PathBuf::from("/mnt/c/Program Files/Git/cmd"),
local_bin.clone(),
]);
let resolved =
resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap();
assert_eq!(resolved, local_bin.join("git"));
}
#[cfg(unix)]
#[test]
fn follows_symlinks() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("git-wrapper");
write_exec(&real);
let link = dir.path().join("git");
std::os::unix::fs::symlink(&real, &link).unwrap();
let path_env = join(&[dir.path().to_path_buf()]);
let resolved =
resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap();
assert_eq!(resolved, link);
}
#[cfg(unix)]
#[test]
fn skips_non_executable() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("git"), b"not exec").unwrap();
// Intentionally do NOT chmod +x.
let path_env = join(&[dir.path().to_path_buf()]);
assert!(resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).is_none());
}
#[test]
fn handles_empty_path_env() {
assert!(resolve_binary_in_wsl_safe_path("git", None, true).is_none());
assert!(resolve_binary_in_wsl_safe_path("git", None, false).is_none());
}
#[cfg(unix)]
#[test]
fn handles_non_utf8_path_components() {
use std::os::unix::ffi::OsStringExt as _;
let dir = tempfile::tempdir().unwrap();
write_exec(&dir.path().join("git"));
// Build a PATH whose first component is a non-UTF-8 byte sequence,
// followed by a real directory. The resolver must walk past the
// garbage entry without panicking and find the valid one.
let mut bytes = b"/\xff\xfe/bad:".to_vec();
bytes.extend_from_slice(dir.path().as_os_str().as_encoded_bytes());
let path_env = OsString::from_vec(bytes);
let resolved =
resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap();
assert_eq!(resolved, dir.path().join("git"));
}
#[test]
fn known_bare_name_recognizes_git_and_gh() {
assert_eq!(known_bare_name(OsStr::new("git")), Some("git"));
assert_eq!(known_bare_name(OsStr::new("gh")), Some("gh"));
}
#[test]
fn known_bare_name_skips_paths() {
assert_eq!(known_bare_name(OsStr::new("/usr/bin/git")), None);
assert_eq!(known_bare_name(OsStr::new("./git")), None);
assert_eq!(known_bare_name(OsStr::new("bin/git")), None);
#[cfg(windows)]
assert_eq!(known_bare_name(OsStr::new("C:\\git\\git.exe")), None);
}
#[test]
fn known_bare_name_skips_unknowns() {
assert_eq!(known_bare_name(OsStr::new("ls")), None);
assert_eq!(known_bare_name(OsStr::new("python")), None);
assert_eq!(known_bare_name(OsStr::new("")), None);
}