From 70e6da124d3b72ec8e9a13d115aaca24b2bc3cc4 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Wed, 7 Oct 2026 23:01:55 +0200 Subject: [PATCH] fix: preserve windows shell working directories refs #4472 --- src/pane.rs | 4 +- src/platform/mod.rs | 3 + src/platform/windows.rs | 217 ++++++++++++++++++++++++++++++++++------ 3 files changed, 193 insertions(+), 31 deletions(-) diff --git a/src/pane.rs b/src/pane.rs index 09625ce1f..ef354c769 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3793,14 +3793,14 @@ impl PaneRuntime { } let pid = self.child_pid.load(Ordering::Relaxed); - crate::platform::process_cwd(pid) + crate::platform::pane_process_cwd(pid) } pub fn cwd_for_persistence(&self) -> Option { let pid = self.child_pid.load(Ordering::Acquire); let exited = self.cwd_process_exited.load(Ordering::Acquire); if let Some(cwd) = (!exited) - .then(|| crate::platform::process_cwd(pid)) + .then(|| crate::platform::pane_process_cwd(pid)) .flatten() .filter(|cwd| cwd.is_absolute()) { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index da5c2fd4e..7c0c41287 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -414,6 +414,9 @@ mod windows; #[cfg(target_os = "windows")] pub use windows::*; +#[cfg(not(windows))] +pub(crate) use process_cwd as pane_process_cwd; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] mod fallback; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 7e83392b9..16b2f9916 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -446,6 +446,11 @@ pub(crate) fn set_default_plugin_pane_pwd( ) { } +#[cfg(target_pointer_width = "64")] +use windows_sys::{ + Wdk::System::Threading::ProcessWow64Information, Win32::System::Kernel::STRING32, +}; + use windows_sys::{ Wdk::System::Threading::ProcessCommandLineInformation, Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, @@ -1580,12 +1585,80 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option { pub fn process_cwd(pid: u32) -> Option { let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?; - let process_parameters = read_process_parameters(process.0)?; - read_unicode_string(process.0, process_parameters.current_directory.dos_path) + process_cwd_from_handle(process.0) +} + +pub(crate) fn pane_process_cwd(child_pid: u32) -> Option { + let process = ProcessHandle::open( + child_pid, + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, + )?; + let executable = PathBuf::from(process_executable_path(process.0)?); + if executable.with_extension("shim").is_file() { + // A Scoop shim keeps its launch cwd while the real shell can change directories. + // Never replace the saved shell directory with that launcher directory on exit. + let snapshot = cached_foreground_processes(); + return process_cwd(shim_shell_pid(child_pid, &snapshot)?); + } + process_cwd_from_handle(process.0) +} + +fn process_cwd_from_handle(process: HANDLE) -> Option { + read_unicode_string(process, process_cwd_descriptor(process)?) .map(PathBuf::from) .filter(|path| path.is_absolute()) } +fn process_cwd_descriptor(process: HANDLE) -> Option { + #[cfg(target_pointer_width = "64")] + { + let mut peb32_address = 0_usize; + // SAFETY: the output is a writable ULONG_PTR with its exact buffer size. + let status = unsafe { + NtQueryInformationProcess( + process, + ProcessWow64Information, + (&mut peb32_address as *mut usize).cast(), + size_of::() as u32, + null_mut(), + ) + }; + if status != STATUS_SUCCESS as NTSTATUS { + return None; + } + if peb32_address != 0 { + // WoW64's native PEB can report C:\Windows while the x86 shell uses + // another directory. Read its own pointer-width layout instead. + let peb = read_process_value::(process, peb32_address as *const c_void)?; + let parameters = read_process_value::( + process, + peb.process_parameters as usize as *const c_void, + )?; + let cwd = parameters.current_directory; + return Some(UNICODE_STRING { + Length: cwd.Length, + MaximumLength: cwd.MaximumLength, + Buffer: cwd.Buffer as usize as *mut u16, + }); + } + } + Some(read_process_parameters(process)?.current_directory.dos_path) +} + +fn shim_shell_pid(child_pid: u32, snapshot: &ProcessSnapshot) -> Option { + snapshot.entry(child_pid)?; + let mut shells = snapshot + .children_by_parent + .get(&child_pid)? + .iter() + .filter_map(|&index| { + let entry = &snapshot.entries[index]; + super::is_pane_shell_process_name(&entry.name).then_some(entry.pid) + }); + let shell_pid = shells.next()?; + shells.next().is_none().then_some(shell_pid) +} + fn select_pane_foreground_job_cached(shell_pid: u32) -> Option { let snapshot = cached_foreground_processes(); let (job, retry_with_fresh_snapshot) = @@ -2864,6 +2937,24 @@ struct Peb { process_parameters: *mut RtlUserProcessParameters, } +// Prefixes of the x86 PEB and RTL_USER_PROCESS_PARAMETERS through the fields +// needed for cwd; remote pointers must stay 32-bit on a 64-bit reader. +#[cfg(target_pointer_width = "64")] +#[repr(C)] +#[derive(Clone, Copy)] +struct Peb32 { + reserved: [u32; 4], + process_parameters: u32, +} + +#[cfg(target_pointer_width = "64")] +#[repr(C)] +#[derive(Clone, Copy)] +struct ProcessCwdParameters32 { + reserved: [u32; 9], + current_directory: STRING32, +} + #[repr(C)] #[derive(Clone, Copy)] struct CurDir { @@ -4022,40 +4113,108 @@ mod tests { #[test] fn windows_process_cwd_reads_normalized_child_launch_directory() { + use std::path::PathBuf; + let name = format!("Herdr-Cwd-Case-{}", std::process::id()); let cwd = std::env::temp_dir().join(&name); fs::create_dir_all(&cwd).expect("create cwd fixture"); + let cwd = super::normalize_cwd_for_launch_platform(&cwd); let launch_cwd = cwd.with_file_name(name.to_ascii_lowercase()); - - let shell = - std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()); - let mut child = Command::new(shell) - .args(["/D", "/Q", "/C", "ping -n 11 127.0.0.1 > NUL"]) - .current_dir(super::normalize_cwd_for_launch_platform(&launch_cwd)) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn cmd"); - - let deadline = Instant::now() + Duration::from_secs(5); - let mut observed = None; - while Instant::now() < deadline { - observed = super::process_cwd(child.id()); - if observed.as_ref().and_then(|path| path.file_name()) == Some(name.as_ref()) { - break; - } - thread::sleep(Duration::from_millis(100)); + let changed = cwd.join("Changed"); + fs::create_dir(&changed).expect("create changed cwd"); + let windows = PathBuf::from(std::env::var_os("SystemRoot").expect("Windows SystemRoot")); + let shells = [ + windows.join("System32").join("cmd.exe"), + #[cfg(target_pointer_width = "64")] + windows.join("SysWOW64").join("cmd.exe"), + ]; + let mut observations = Vec::new(); + for shell in shells { + let mut child = Command::new(&shell) + .args(["/D", "/Q", "/K"]) + .current_dir(super::normalize_cwd_for_launch_platform(&launch_cwd)) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cmd"); + let observe = |expected: &PathBuf| { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let observed = super::process_cwd(child.id()); + if observed.as_ref() == Some(expected) || Instant::now() >= deadline { + break observed; + } + thread::sleep(Duration::from_millis(20)); + } + }; + let initial = observe(&cwd); + use std::io::Write; + writeln!( + child.stdin.as_ref().unwrap(), + "cd /d \"{}\"\r", + changed.display() + ) + .expect("change cmd cwd"); + let after_cd = observe(&changed); + let pane_cwd = super::pane_process_cwd(child.id()); + let _ = child.kill(); + let _ = child.wait(); + observations.push((shell, initial, after_cd, pane_cwd)); } - - let _ = child.kill(); - let _ = child.wait(); let _ = fs::remove_dir_all(&cwd); + for (shell, initial, after_cd, pane_cwd) in observations { + assert_eq!(initial, Some(cwd.clone()), "{} launch cwd", shell.display()); + assert_eq!( + after_cd, + Some(changed.clone()), + "{} live cd", + shell.display() + ); + assert_eq!(pane_cwd, Some(changed.clone()), "ordinary shell owns cwd"); + } + } - assert_eq!( - observed.as_ref().and_then(|path| path.file_name()), - Some(name.as_ref()) - ); + #[test] + fn windows_shim_cwd_uses_only_an_unambiguous_direct_shell() { + let cases = [ + ("cmdx.exe", vec![(11, 10, "cmd.exe")], Some(11)), + ("pwsh.exe", vec![(11, 10, "pwsh.exe")], Some(11)), + ( + "cmdx.exe", + vec![ + (11, 10, "cmd.exe"), + (12, 11, "pwsh.exe"), + (13, 11, "node.exe"), + ], + Some(11), + ), + ( + "cmdx.exe", + vec![(11, 10, "node.exe"), (12, 11, "cmd.exe")], + None, + ), + ("cmdx.exe", vec![], None), + ( + "cmdx.exe", + vec![(11, 10, "cmd.exe"), (12, 10, "pwsh.exe")], + None, + ), + ]; + for (root_name, children, expected) in cases { + let mut entries = vec![test_entry(10, 1, root_name, &[root_name])]; + entries.extend( + children + .iter() + .map(|&(pid, parent, name)| test_entry(pid, parent, name, &[name])), + ); + let snapshot = super::ProcessSnapshot::new(entries); + assert_eq!( + super::shim_shell_pid(10, &snapshot), + expected, + "{root_name}: {children:?}" + ); + } } #[test]