diff --git a/Cargo.lock b/Cargo.lock index 351ab370c..f551101ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2941,6 +2941,7 @@ dependencies = [ "lazy_static", "libc", "log", + "tempfile", "thiserror 2.0.17", "win32job", "windows 0.62.2", diff --git a/app/src/crash_reporting/linux.rs b/app/src/crash_reporting/linux.rs index 7dca4568d..c3787ec94 100644 --- a/app/src/crash_reporting/linux.rs +++ b/app/src/crash_reporting/linux.rs @@ -16,11 +16,7 @@ pub fn get_virtualized_environment() -> Option { } }; - // Test specifically for WSL based on existence of a particular file under - // /proc. - // - // See: https://superuser.com/questions/1749781/how-can-i-check-if-the-environment-is-wsl-from-a-shell-script - if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() { + if command::wsl::is_wsl() { return Some(VirtualEnvironment { name: "wsl".to_owned(), }); diff --git a/crates/command/Cargo.toml b/crates/command/Cargo.toml index 5ba9a97aa..13dfb6769 100644 --- a/crates/command/Cargo.toml +++ b/crates/command/Cargo.toml @@ -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 diff --git a/crates/command/src/async.rs b/crates/command/src/async.rs index c2c1ebd89..9557cfb06 100644 --- a/crates/command/src/async.rs +++ b/crates/command/src/async.rs @@ -39,6 +39,7 @@ impl Command { /// let mut cmd = Command::new("ls"); /// ``` pub fn new>(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>(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>(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); diff --git a/crates/command/src/blocking.rs b/crates/command/src/blocking.rs index 521a1519c..a04643417 100644 --- a/crates/command/src/blocking.rs +++ b/crates/command/src/blocking.rs @@ -66,6 +66,7 @@ impl Command { /// .expect("sh command failed to start"); /// ``` pub fn new>(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); diff --git a/crates/command/src/lib.rs b/crates/command/src/lib.rs index 16b9a921c..4d67ef36c 100644 --- a/crates/command/src/lib.rs +++ b/crates/command/src/lib.rs @@ -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}; diff --git a/crates/command/src/wsl.rs b/crates/command/src/wsl.rs new file mode 100644 index 000000000..aea1f6ce9 --- /dev/null +++ b/crates/command/src/wsl.rs @@ -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 = 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 = OnceLock::new(); + static GH: OnceLock = 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 { + 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 +} diff --git a/crates/command/src/wsl_tests.rs b/crates/command/src/wsl_tests.rs new file mode 100644 index 000000000..2a8baec91 --- /dev/null +++ b/crates/command/src/wsl_tests.rs @@ -0,0 +1,168 @@ +use std::ffi::{OsStr, OsString}; +use std::fs; +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); +} + +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); +} diff --git a/crates/warpui/src/platform/linux/mod.rs b/crates/warpui/src/platform/linux/mod.rs index c89429989..e55c9c310 100644 --- a/crates/warpui/src/platform/linux/mod.rs +++ b/crates/warpui/src/platform/linux/mod.rs @@ -56,11 +56,7 @@ pub fn user_windowing_system() -> WindowingSystem { } pub fn is_wsl() -> bool { - use std::sync::OnceLock; - static IS_WSL: OnceLock = OnceLock::new(); - IS_WSL - .get_or_init(|| std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists()) - .to_owned() + command::wsl::is_wsl() } pub fn is_wayland_env_var_set() -> bool {