Files
ghostty-rs/crates/ghostty-termio/src/command_windows.rs
T
ddidderr 8f3e206d9f refactor: type escaped OS strings and fix the final audit findings
Escaped config strings (ghostty_os::osbytes) kept reaching OS
boundaries undecoded, and decoding was occasionally applied to plain
text. The root cause was that both were plain `String`s. Now:
- `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only
  via to_os/to_path/to_bytes/to_wide; plain text enters via
  from_plain_text. OS helpers (env, home, xdg, passwd, resources dir,
  path expansion), config::Path, WorkingDirectory, theme/file_load/edit
  paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the
  compiler forces decoding at every boundary. os::open takes &OsStr.
  Config output is unchanged.

Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md):
screen-file copy; regex/OSC 8 URLs no longer decoded (regression);
lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows
resources dir/TERMINFO; color OSC request lists and X11 color lookup no
longer allocate globally (C zero-allocation test extended); macOS Mach
handler resumes all threads before KERN_FAILURE (hang regression) and
SIGABRT minidumps go through the exception thread; plus open-config,
background images, crash dir, ssh cache, exe dir/PATH, fontconfig
filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths.
The typing also surfaced and fixed gtk-custom-css, bell audio, bash
HISTFILE, OSC 52 writes and the C config getters.

Test Plan:
- cargo test --workspace (3376 passed)
- just check-macos (both), just check-windows, just check-wasm
- diff <(ghostty-rs +show-config --default --docs) \
  <(ghostty +show-config --default --docs)
2026-10-12 10:06:07 +02:00

354 lines
13 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("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); decode them to WTF-16 for the Windows API.
let application_w = EncodedOs::from_encoded(&self.path).to_wide_nul();
let cwd_w = self.cwd.as_ref().map(EncodedOs::to_wide_nul);
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).to_wide())
.collect();
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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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));
}
}