Files
ghostty-rs/crates/ghostty-termio/src/command_windows.rs
T
ddidderr 4d7ad7cdba fix-deviant(termio): resolve bare program names on PATH on Windows
Intentional deviation from Ghostty 1.3.1 (DEVIANT_FIXES.md row 39).
Zig's Command.startWindows (Command.zig:261,347-358) passes the command
path as CreateProcessW's lpApplicationName, which is taken literally:
a partial name is completed with the current directory only, never the
search path. A bare `cmd.exe` (e.g. `command = direct:cmd.exe` or
`-e cmd.exe`) therefore fails to start in Ghostty on Windows.

A bare program name (no drive or separator) is now looked up on PATH,
appending .exe when there is no extension as CreateProcess does;
anything else or a failed lookup is passed through unchanged. Verified
under Wine by the exec_windows ConPTY tests.

Test Plan:
- cargo test -p ghostty-termio resolve_application_searches_path_for_bare_names
- just test-windows-wine
2026-10-12 17:23:38 +02:00

415 lines
15 KiB
Rust

//! Command launches sub-processes (Windows branch of `Command.zig`:
//! `startWindows` and the Windows `wait`). Uses `CreateProcessW`, attaching
//! the child to a ConPTY pseudo console through a process thread attribute
//! list when one is set.
//!
//! The pure parts (command line quoting, environment block) are in
//! [`crate::windows_args`].
// Win32 FFI takes out-parameters as `*mut`; implicit `&mut` -> pointer coercions
// are the idiomatic spelling at these call sites.
#![allow(clippy::borrow_as_ptr)]
use std::{
io,
os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle, RawHandle},
ptr,
};
use ghostty_os::{
osbytes::EncodedOs,
windows::{
CREATE_UNICODE_ENVIRONMENT,
EXTENDED_STARTUPINFO_PRESENT,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
},
};
use windows_sys::Win32::{
Foundation::{HANDLE, TRUE, WAIT_FAILED},
System::{
Console::HPCON,
Threading::{
CreateProcessW,
DeleteProcThreadAttributeList,
GetExitCodeProcess,
INFINITE,
InitializeProcThreadAttributeList,
LPPROC_THREAD_ATTRIBUTE_LIST,
PROCESS_INFORMATION,
STARTF_USESTDHANDLES,
STARTUPINFOEXW,
TerminateProcess,
UpdateProcThreadAttribute,
WaitForSingleObject,
},
},
};
use crate::windows_args::{self, create_windows_env_block, windows_create_command_line_wide};
/// Errors from [`Command::start`].
#[derive(Debug, thiserror::Error)]
pub enum StartError {
#[error("invalid utf-8 in a path, argument or environment value")]
InvalidUtf8(#[from] ghostty_os::osbytes::InvalidUtf8),
#[error("string or environment is not valid unicode")]
Encode(#[from] windows_args::EncodeError),
#[error("windows error: {0}")]
Os(#[from] io::Error),
}
/// How a process exited. On Windows this is just the exit code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exit {
Exited(u32),
}
/// A command to launch.
#[derive(Default)]
pub struct Command {
/// Path to the command to run (`lpApplicationName`).
pub path: String,
/// Command-line arguments. It is the responsibility of the caller to set
/// args[0] to the command. If empty, no command line is passed.
pub args: Vec<String>,
/// Environment variables for the child process. If this is `None`,
/// inherits the environment variables from this process. These are the
/// exact environment variables to set; these are /not/ merged.
pub env: Option<ghostty_os::EnvMap>,
/// Working directory for the child. If not set, the working directory of
/// the calling process is preserved.
pub cwd: Option<ghostty_os::osbytes::EncodedOs>,
/// The file handles to set for stdin/out/err. If not set the NUL device
/// is used. Ignored if `pseudo_console` is set.
pub stdin: Option<OwnedHandle>,
pub stdout: Option<OwnedHandle>,
pub stderr: Option<OwnedHandle>,
/// If set, then the process will be created attached to this pseudo
/// console. `stdin`, `stdout`, and `stderr` will be ignored if set.
pub pseudo_console: Option<HPCON>,
/// Process handle (Zig stores it in `pid`), set after start is called.
process: Option<OwnedHandle>,
/// Process ID, set after start is called.
pub pid: Option<u32>,
}
impl std::fmt::Debug for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Command")
.field("path", &self.path)
.field("args", &self.args)
.field("cwd", &self.cwd)
.field("pid", &self.pid)
.finish_non_exhaustive()
}
}
impl Command {
pub fn new(path: impl Into<String>, args: &[&str]) -> Self {
Self {
path: path.into(),
args: args.iter().map(|s| (*s).to_owned()).collect(),
..Self::default()
}
}
/// The process handle, which becomes signaled when the process exits.
pub fn process_handle(&self) -> Option<BorrowedHandle<'_>> {
use std::os::windows::io::AsHandle;
self.process.as_ref().map(AsHandle::as_handle)
}
/// Start the subprocess. This returns immediately once the child is
/// started. After this is successful, `pid` and the process handle are
/// available.
#[allow(unsafe_code)]
pub fn start(&mut self) -> Result<(), StartError> {
// `path` and `args` are the config command's argv strings (encoded
// OS strings); validate UTF-8 and convert to UTF-16 for the Windows API.
// Like Zig's fallible `utf8ToUtf16Le*`, malformed input fails the
// launch instead of selecting a U+FFFD-substituted path or argument.
// DEVIANT (see DEVIANT_FIXES.md): `lpApplicationName` is never
// searched on PATH, so a bare `cmd.exe` (a `command = direct:` /
// `-e` argv[0]) would not start in Zig. Resolve it ourselves.
let resolved = resolve_application(&self.path);
let application_w = EncodedOs::from_encoded(&resolved).try_to_utf16_nul()?;
let cwd_w = self
.cwd
.as_ref()
.map(EncodedOs::try_to_utf16_nul)
.transpose()?;
let mut command_line_w = if self.args.is_empty() {
None
} else {
let wide: Vec<Vec<u16>> = self
.args
.iter()
.map(|a| EncodedOs::from_encoded(a).try_to_utf16())
.collect::<Result<_, _>>()?;
let mut line = windows_create_command_line_wide(&wide);
line.push(0);
Some(line)
};
let mut env_w = match &self.env {
Some(map) => Some(create_windows_env_block(map.iter())?),
None => None,
};
// If any stdio handle is missing (and we're not using a pseudo
// console) the child gets the NUL device for it.
let any_null_fd = self.stdin.is_none() || self.stdout.is_none() || self.stderr.is_none();
let null_fd = if any_null_fd && self.pseudo_console.is_none() {
Some(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("NUL")?,
)
} else {
None
};
// TODO (as in Zig): in the case of having handles instead of a pty,
// set up attributes such that the child process only inherits these
// handles, then set bInheritsHandles below.
// The attribute list buffer must outlive CreateProcessW. A u64 buffer
// guarantees pointer alignment.
let mut attribute_buf: Vec<u64> = Vec::new();
let attribute_list: LPPROC_THREAD_ATTRIBUTE_LIST =
if let Some(pseudo_console) = self.pseudo_console {
let mut size: usize = 0;
// SAFETY: querying the required size with a null list is the
// documented use; it is expected to "fail" with the size set.
unsafe { InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut size) };
attribute_buf.resize(size.div_ceil(8), 0);
let list: LPPROC_THREAD_ATTRIBUTE_LIST = attribute_buf.as_mut_ptr().cast();
// SAFETY: the buffer is at least `size` bytes and 8-aligned.
if unsafe { InitializeProcThreadAttributeList(list, 1, 0, &mut size) } == 0 {
return Err(io::Error::last_os_error().into());
}
// SAFETY: the list is initialized; the pseudo console handle is
// passed by value as the attribute value, as the API requires.
if unsafe {
UpdateProcThreadAttribute(
list,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize,
pseudo_console as *const std::ffi::c_void,
std::mem::size_of::<HPCON>(),
ptr::null_mut(),
ptr::null(),
)
} == 0
{
let err = io::Error::last_os_error();
// SAFETY: initialized above.
unsafe { DeleteProcThreadAttributeList(list) };
return Err(err.into());
}
list
} else {
ptr::null_mut()
};
let handle_of = |h: &Option<OwnedHandle>| -> HANDLE {
match (h, &null_fd) {
(Some(h), _) => h.as_raw_handle() as HANDLE,
(None, Some(f)) => f.as_raw_handle() as HANDLE,
(None, None) => ptr::null_mut(),
}
};
let (stdin, stdout, stderr) = if attribute_list.is_null() {
(
handle_of(&self.stdin),
handle_of(&self.stdout),
handle_of(&self.stderr),
)
} else {
(ptr::null_mut(), ptr::null_mut(), ptr::null_mut())
};
// SAFETY: STARTUPINFOEXW is plain data; all-zero is a valid value.
let mut startup_info_ex: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
startup_info_ex.StartupInfo.cb = if attribute_list.is_null() {
std::mem::size_of::<windows_sys::Win32::System::Threading::STARTUPINFOW>() as u32
} else {
std::mem::size_of::<STARTUPINFOEXW>() as u32
};
startup_info_ex.StartupInfo.hStdError = stderr;
startup_info_ex.StartupInfo.hStdOutput = stdout;
startup_info_ex.StartupInfo.hStdInput = stdin;
startup_info_ex.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup_info_ex.lpAttributeList = attribute_list;
let mut flags = CREATE_UNICODE_ENVIRONMENT;
if !attribute_list.is_null() {
flags |= EXTENDED_STARTUPINFO_PRESENT;
}
// SAFETY: PROCESS_INFORMATION is plain data.
let mut process_information: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// SAFETY: every pointer is either null or points at a NUL-terminated
// (or properly terminated environment) buffer that outlives the call;
// the command line buffer is mutable as CreateProcessW requires.
let ok = unsafe {
CreateProcessW(
application_w.as_ptr(),
command_line_w
.as_mut()
.map_or(ptr::null_mut(), Vec::as_mut_ptr),
ptr::null(),
ptr::null(),
TRUE,
flags,
env_w
.as_mut()
.map_or(ptr::null(), |w| w.as_mut_ptr().cast_const().cast()),
cwd_w.as_ref().map_or(ptr::null(), Vec::as_ptr),
ptr::addr_of!(startup_info_ex.StartupInfo),
&mut process_information,
)
};
let err = (ok == 0).then(io::Error::last_os_error);
if !attribute_list.is_null() {
// SAFETY: initialized above; no longer needed after the call.
unsafe { DeleteProcThreadAttributeList(attribute_list) };
}
if let Some(err) = err {
return Err(err.into());
}
// SAFETY: CreateProcessW succeeded, so both handles are valid and
// owned by us. Zig leaks the thread handle; we close it.
let (process, _thread) = unsafe {
(
OwnedHandle::from_raw_handle(process_information.hProcess as RawHandle),
OwnedHandle::from_raw_handle(process_information.hThread as RawHandle),
)
};
self.process = Some(process);
self.pid = Some(process_information.dwProcessId);
Ok(())
}
/// Wait for the command to exit and return information about how it
/// exited. Like Zig this always blocks until the process exits (`block`
/// is ignored on Windows); it returns immediately if the process already
/// exited.
#[allow(unsafe_code)]
pub fn wait(&self, _block: bool) -> io::Result<Exit> {
let handle = self
.process
.as_ref()
.ok_or_else(|| io::Error::other("process not started"))?
.as_raw_handle() as HANDLE;
// SAFETY: the handle is valid while `self` is alive.
if unsafe { WaitForSingleObject(handle, INFINITE) } == WAIT_FAILED {
return Err(io::Error::last_os_error());
}
self.exit_code().map(Exit::Exited)
}
/// The exit code of the (already exited) process (`GetExitCodeProcess`).
#[allow(unsafe_code)]
pub fn exit_code(&self) -> io::Result<u32> {
let handle = self
.process
.as_ref()
.ok_or_else(|| io::Error::other("process not started"))?
.as_raw_handle() as HANDLE;
let mut code: u32 = 0;
// SAFETY: valid handle and out pointer.
if unsafe { GetExitCodeProcess(handle, &mut code) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(code)
}
/// Terminate the process (`TerminateProcess(pid, 0)`).
#[allow(unsafe_code)]
pub fn terminate(&self) -> io::Result<()> {
let Some(process) = &self.process else {
return Ok(());
};
// SAFETY: valid handle.
if unsafe { TerminateProcess(process.as_raw_handle() as HANDLE, 0) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
/// `CreateProcessW` takes `lpApplicationName` literally (a partial name is
/// completed with the current directory only, the search path is *not* used).
/// For a bare program name (no drive or separator) look it up on `PATH`,
/// appending `.exe` when it has no extension as `CreateProcess` would.
/// Anything else, or a failed lookup, is returned unchanged.
fn resolve_application(path: &str) -> String {
if path.is_empty() || path.contains(['\\', '/', ':']) {
return path.to_owned();
}
let name = if std::path::Path::new(path).extension().is_some() {
path.to_owned()
} else {
format!("{path}.exe")
};
match ghostty_os::path::expand_os(std::ffi::OsStr::new(&name)) {
Ok(Some(found)) => found
.to_str()
.map_or_else(|| path.to_owned(), str::to_owned),
_ => path.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_application_searches_path_for_bare_names() {
let found = resolve_application("cmd.exe");
assert!(found.to_ascii_lowercase().ends_with("\\cmd.exe"), "{found}");
assert_ne!(found, "cmd.exe");
// Extension is implied like CreateProcess.
assert!(
resolve_application("cmd")
.to_ascii_lowercase()
.ends_with("\\cmd.exe")
);
// Paths and unknown names are left alone.
assert_eq!(resolve_application(r"C:\x\y.exe"), r"C:\x\y.exe");
assert_eq!(resolve_application(r".\y.exe"), r".\y.exe");
assert_eq!(
resolve_application("no-such-program-zzz"),
"no-such-program-zzz"
);
}
#[test]
fn bare_program_name_starts_via_path_search() {
let mut cmd = Command::new("cmd.exe", &["cmd.exe", "/c", "exit 3"]);
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(3));
}
#[test]
fn run_cmd_and_wait() {
let mut cmd = Command::new(
"C:\\Windows\\System32\\cmd.exe",
&["cmd.exe", "/c", "exit 7"],
);
cmd.start().unwrap();
assert!(cmd.pid.is_some());
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(7));
}
}