aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-17 17:45:07 +0200
committerGitHub <noreply@github.com>2026-09-17 17:45:07 +0200
commit101ccc20d3c6483a38ed9e0a1239b2ab908b1704 (patch)
tree96b88b9631d14c05e3ef3e7f77627c6596640b8e
parent5c61d8ec2eeb296bd5e31bdd4e8f14ebadd5b7e1 (diff)
fix(windows): prefer pwsh for the default pane shell (#4297)
* fix(windows): prefer pwsh for the default pane shell Windows panes launched Windows PowerShell 5.1 (powershell.exe) whenever [terminal] default_shell was unset, ignoring PowerShell 7 even when pwsh.exe resolved on PATH. Resolve the unset default against PATH and prefer pwsh.exe, falling back to the inbox powershell.exe. An explicit default_shell still wins. * fix(windows): validate pwsh before using it as the default shell portable-pty resolves the configured shell with Path::exists and passes that path to CreateProcessW, which does not fall through to later PATH entries. Selecting an invalid pwsh.exe would therefore break new panes instead of falling back. Only prefer a pwsh.exe that starts with the PE MZ magic, and return its path so the validated binary is the one launched. * fix(windows): validate the full PE header before preferring pwsh Checking only the DOS MZ signature still accepted truncated images, DLLs, and foreign-architecture binaries. Because portable-pty passes the resolved path straight to CreateProcessW without trying later PATH entries, validate the PE signature, the COFF header (machine, executable-image bit, not a DLL, section count), and the optional header before preferring pwsh.exe. * fix(windows): accept native ARM64 pwsh and validate the section table Herdr ships an x64 Windows build that also runs on Windows ARM64 under x64 emulation, so cfg!(target_arch) cannot tell whether a native ARM64 pwsh.exe is launchable. Accept every executable machine type Windows supports instead of rejecting ARM64 on the x86_64 build. Also bound-check the section table (40 bytes per declared section) after the optional header so a truncated table falls back instead of being selected. * fix(windows): detect the native host machine for pwsh compatibility Machine compatibility was derived from cfg!(target_arch), which reports the emulated x64 process on Windows ARM64, and then briefly accepted ARM64 on every host. Read the native machine with IsWow64Process2 in the platform layer instead: ARM64 Windows accepts ARM64/x64/x86 images, x64 accepts x64/x86, and x86 accepts only x86.
-rw-r--r--Cargo.toml1
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx2
-rw-r--r--docs/next/website/src/data/config-reference.json2
-rw-r--r--src/pane.rs324
-rw-r--r--src/platform/windows.rs33
5 files changed, 354 insertions, 8 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 09ceaf73..8c27a543 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -70,6 +70,7 @@ windows-sys = { version = "0.61.2", features = [
"Win32_System_Memory",
"Win32_System_Ole",
"Win32_System_Pipes",
+ "Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_Input_Ime",
"Win32_UI_Input_KeyboardAndMouse",
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 1975a43a..c54c79a9 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -73,7 +73,7 @@ Set the executable Herdr uses for newly created interactive panes:
default_shell = "nu"
```
-When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix and PowerShell on Windows. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`.
+When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix. On Windows it uses `pwsh.exe` (PowerShell 7) when it resolves on `PATH`, and otherwise the inbox `powershell.exe` (Windows PowerShell 5.1). This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`.
Set how Herdr starts newly created interactive pane shells:
diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json
index 36afe337..cf102f4b 100644
--- a/docs/next/website/src/data/config-reference.json
+++ b/docs/next/website/src/data/config-reference.json
@@ -410,7 +410,7 @@
"key": "terminal.default_shell",
"type": "string",
"default": "\"\"",
- "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh."
+ "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh; on Windows, pwsh.exe when present on PATH, otherwise powershell.exe."
},
{
"key": "terminal.shell_mode",
diff --git a/src/pane.rs b/src/pane.rs
index ec1b8646..29cbad2a 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1596,7 +1596,134 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String
#[cfg(windows)]
fn default_pane_shell() -> String {
- "powershell.exe".into()
+ default_windows_pane_shell(std::env::var_os("PATH"))
+}
+
+/// Windows has no `$SHELL`, so an unset `[terminal] default_shell` has to name
+/// a concrete executable. `powershell.exe` (5.1) is the only one guaranteed to
+/// exist, but `pwsh` (7+) is what every other Windows terminal prefers when it
+/// is installed. Return the first launchable `pwsh.exe` on `PATH` as a full
+/// path so the pane launches exactly the binary that was validated; keep the
+/// inbox shell when none is found. An explicit `default_shell` still wins.
+#[cfg(any(windows, test))]
+fn default_windows_pane_shell(path: Option<std::ffi::OsString>) -> String {
+ path.as_deref()
+ .into_iter()
+ .flat_map(std::env::split_paths)
+ .filter(|dir| dir.is_absolute())
+ .map(|dir| dir.join("pwsh.exe"))
+ .find(|candidate| is_windows_executable_file(candidate))
+ .and_then(|path| path.into_os_string().into_string().ok())
+ .unwrap_or_else(|| "powershell.exe".into())
+}
+
+/// Validate that `path` looks like a Windows executable image the current
+/// machine can launch: a DOS `MZ` header whose `e_lfanew` points at a `PE\0\0`
+/// signature with a non-empty, executable-image COFF header for a compatible
+/// machine and a PE32/PE32+ optional header that fits in the file.
+///
+/// `portable-pty` resolves the configured shell with `Path::exists` and hands
+/// it straight to `CreateProcessW`, which does not fall back to later `PATH`
+/// entries, so a malformed, DLL, or foreign-architecture `pwsh.exe` must be
+/// skipped here instead of selected. Actually launching the candidate is the
+/// only way to prove it loads, and this deliberately does not do that.
+#[cfg(any(windows, test))]
+fn is_windows_executable_file(path: &std::path::Path) -> bool {
+ is_windows_executable_file_for_host(path, windows_host_native_machine())
+}
+
+/// Native processor architecture of the host. Non-Windows builds only reach
+/// this from the cross-platform unit tests, which model an x64 host.
+#[cfg(any(windows, test))]
+fn windows_host_native_machine() -> u16 {
+ #[cfg(windows)]
+ {
+ crate::platform::native_machine_type()
+ }
+ #[cfg(not(windows))]
+ {
+ 0x8664
+ }
+}
+
+#[cfg(any(windows, test))]
+fn is_windows_executable_file_for_host(path: &std::path::Path, native_machine: u16) -> bool {
+ use std::io::{Read, Seek, SeekFrom};
+
+ const PE_OFFSET_FIELD: usize = 0x3c;
+ const DOS_HEADER_LEN: u64 = 0x40;
+ const PE_SIGNATURE: &[u8; 4] = b"PE\0\0";
+ const COFF_HEADER_LEN: usize = 20;
+ const SECTION_HEADER_LEN: u64 = 40;
+ const OPTIONAL_HEADER_MAGIC_LEN: u64 = 2;
+ const IMAGE_FILE_EXECUTABLE_IMAGE: u16 = 0x0002;
+ const IMAGE_FILE_DLL: u16 = 0x2000;
+
+ let Ok(mut file) = std::fs::File::open(path) else {
+ return false;
+ };
+ let Ok(len) = file.metadata().map(|metadata| metadata.len()) else {
+ return false;
+ };
+
+ let mut dos_header = [0u8; DOS_HEADER_LEN as usize];
+ if file.read_exact(&mut dos_header).is_err() || &dos_header[..2] != b"MZ" {
+ return false;
+ }
+ let pe_offset = u32::from_le_bytes([
+ dos_header[PE_OFFSET_FIELD],
+ dos_header[PE_OFFSET_FIELD + 1],
+ dos_header[PE_OFFSET_FIELD + 2],
+ dos_header[PE_OFFSET_FIELD + 3],
+ ]) as u64;
+ let pe_header_len = PE_SIGNATURE.len() as u64 + COFF_HEADER_LEN as u64;
+ if pe_offset < DOS_HEADER_LEN || pe_offset.saturating_add(pe_header_len) > len {
+ return false;
+ }
+ if file.seek(SeekFrom::Start(pe_offset)).is_err() {
+ return false;
+ }
+
+ let mut header = [0u8; 4 + COFF_HEADER_LEN];
+ if file.read_exact(&mut header).is_err() || &header[..4] != PE_SIGNATURE {
+ return false;
+ }
+ let machine = u16::from_le_bytes([header[4], header[5]]);
+ let number_of_sections = u16::from_le_bytes([header[4 + 2], header[4 + 3]]);
+ let optional_header_len = u16::from_le_bytes([header[4 + 16], header[4 + 17]]) as u64;
+ let characteristics = u16::from_le_bytes([header[4 + 18], header[4 + 19]]);
+
+ if !windows_executable_machine_is_compatible(machine, native_machine)
+ || number_of_sections == 0
+ || characteristics & IMAGE_FILE_EXECUTABLE_IMAGE == 0
+ || characteristics & IMAGE_FILE_DLL != 0
+ || optional_header_len < OPTIONAL_HEADER_MAGIC_LEN
+ || pe_offset.saturating_add(
+ pe_header_len + optional_header_len + number_of_sections as u64 * SECTION_HEADER_LEN,
+ ) > len
+ {
+ return false;
+ }
+
+ let mut magic = [0u8; 2];
+ file.read_exact(&mut magic).is_ok() && matches!(u16::from_le_bytes(magic), 0x010b | 0x020b)
+}
+
+/// Whether a Windows host can launch a given `IMAGE_FILE_MACHINE_*` image.
+/// A native host runs its own architecture; ARM64 Windows also runs x64 and
+/// x86 through emulation, and x64 Windows runs x86 through WOW64. An unknown
+/// host (detection failed) is treated as the x64 build Herdr ships.
+#[cfg(any(windows, test))]
+fn windows_executable_machine_is_compatible(machine: u16, native_machine: u16) -> bool {
+ const MACHINE_I386: u16 = 0x014c;
+ const MACHINE_AMD64: u16 = 0x8664;
+ const MACHINE_ARM64: u16 = 0xaa64;
+
+ match native_machine {
+ MACHINE_ARM64 => matches!(machine, MACHINE_I386 | MACHINE_AMD64 | MACHINE_ARM64),
+ MACHINE_I386 => machine == MACHINE_I386,
+ _ => matches!(machine, MACHINE_I386 | MACHINE_AMD64),
+ }
}
#[cfg(not(windows))]
@@ -3802,6 +3929,201 @@ mod tests {
assert_eq!(pane_shell_from("", None), default_pane_shell());
}
+ const TEST_EXECUTABLE_IMAGE: u16 = 0x0002;
+ const TEST_DLL: u16 = 0x2000;
+ const TEST_MACHINE_AMD64: u16 = 0x8664;
+
+ /// Structurally valid PE32+ image, with fields chosen so tests can make it
+ /// malformed one way at a time.
+ fn windows_test_pe(machine: u16, characteristics: u16, number_of_sections: u16) -> Vec<u8> {
+ const PE_OFFSET: u32 = 0x80;
+ const COFF_HEADER_LEN: usize = 20;
+ const OPTIONAL_HEADER_LEN: u16 = 0x70;
+ const SECTION_HEADER_LEN: usize = 40;
+
+ let pe = PE_OFFSET as usize;
+ let mut bytes = vec![
+ 0u8;
+ pe + 4
+ + COFF_HEADER_LEN
+ + OPTIONAL_HEADER_LEN as usize
+ + number_of_sections as usize * SECTION_HEADER_LEN
+ ];
+ bytes[..2].copy_from_slice(b"MZ");
+ bytes[0x3c..0x40].copy_from_slice(&PE_OFFSET.to_le_bytes());
+ bytes[pe..pe + 4].copy_from_slice(b"PE\0\0");
+ bytes[pe + 4..pe + 6].copy_from_slice(&machine.to_le_bytes());
+ bytes[pe + 4 + 2..pe + 4 + 4].copy_from_slice(&number_of_sections.to_le_bytes());
+ bytes[pe + 4 + 16..pe + 4 + 18].copy_from_slice(&OPTIONAL_HEADER_LEN.to_le_bytes());
+ bytes[pe + 4 + 18..pe + 4 + 20].copy_from_slice(&characteristics.to_le_bytes());
+ bytes[pe + 24..pe + 26].copy_from_slice(&0x020bu16.to_le_bytes());
+ bytes
+ }
+
+ #[test]
+ fn windows_default_pane_shell_prefers_pwsh_on_path() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-windows-default-shell-pwsh-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ let pwsh = dir.join("pwsh.exe");
+ let image = windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1);
+ std::fs::write(&pwsh, image).unwrap();
+ let path = std::env::join_paths([&dir]).unwrap();
+
+ let resolved = default_windows_pane_shell(Some(path));
+ let expected = pwsh.into_os_string().into_string().unwrap();
+
+ let _ = std::fs::remove_dir_all(dir);
+ assert_eq!(resolved, expected);
+ }
+
+ #[test]
+ fn windows_default_pane_shell_falls_back_to_inbox_powershell() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-windows-default-shell-fallback-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ // A file that is not a launchable executable must not be selected.
+ std::fs::write(dir.join("pwsh.exe"), b"not a PE image").unwrap();
+ let path = std::env::join_paths([&dir]).unwrap();
+
+ let invalid = default_windows_pane_shell(Some(path));
+
+ let _ = std::fs::remove_dir_all(dir);
+ assert_eq!(invalid, "powershell.exe");
+ assert_eq!(default_windows_pane_shell(None), "powershell.exe");
+ }
+
+ #[test]
+ fn windows_default_pane_shell_rejects_non_executable_pe_images() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-windows-default-shell-invalid-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ let pwsh = dir.join("pwsh.exe");
+ let path = || std::env::join_paths([&dir]).unwrap();
+
+ let truncated_header = {
+ let mut bytes = b"MZ".to_vec();
+ bytes.resize(0x40, 0);
+ bytes[0x3c..0x40].copy_from_slice(&0x50u32.to_le_bytes());
+ bytes
+ };
+ // One declared section, but the file ends before its 40-byte header.
+ let truncated_section_table = {
+ let mut bytes = windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1);
+ bytes.truncate(bytes.len() - 1);
+ bytes
+ };
+ let cases: [(&str, Vec<u8>); 7] = [
+ ("empty", Vec::new()),
+ ("dos signature only", b"MZ".to_vec()),
+ ("truncated pe header", truncated_header),
+ ("truncated section table", truncated_section_table),
+ (
+ "dll",
+ windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE | TEST_DLL, 1),
+ ),
+ (
+ "no executable bit",
+ windows_test_pe(TEST_MACHINE_AMD64, 0, 1),
+ ),
+ (
+ "foreign machine",
+ windows_test_pe(0x0200, TEST_EXECUTABLE_IMAGE, 1),
+ ),
+ ];
+
+ for (label, bytes) in cases {
+ std::fs::write(&pwsh, &bytes).unwrap();
+ assert_eq!(
+ default_windows_pane_shell(Some(path())),
+ "powershell.exe",
+ "case {label:?}"
+ );
+ }
+
+ std::fs::write(
+ &pwsh,
+ windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 0),
+ )
+ .unwrap();
+ assert_eq!(
+ default_windows_pane_shell(Some(path())),
+ "powershell.exe",
+ "case zero sections"
+ );
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn windows_executable_machine_compatibility_tracks_native_host() {
+ const I386: u16 = 0x014c;
+ const AMD64: u16 = 0x8664;
+ const ARM64: u16 = 0xaa64;
+
+ assert!(windows_executable_machine_is_compatible(I386, AMD64));
+ assert!(windows_executable_machine_is_compatible(AMD64, AMD64));
+ assert!(!windows_executable_machine_is_compatible(ARM64, AMD64));
+ assert!(windows_executable_machine_is_compatible(I386, ARM64));
+ assert!(windows_executable_machine_is_compatible(AMD64, ARM64));
+ assert!(windows_executable_machine_is_compatible(ARM64, ARM64));
+ assert!(windows_executable_machine_is_compatible(I386, I386));
+ assert!(!windows_executable_machine_is_compatible(AMD64, I386));
+ assert!(!windows_executable_machine_is_compatible(0x0200, AMD64));
+ }
+
+ #[test]
+ fn windows_executable_file_rejects_arm64_image_on_amd64_host() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-windows-default-shell-arm64-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ let pwsh = dir.join("pwsh.exe");
+ std::fs::write(&pwsh, windows_test_pe(0xaa64, TEST_EXECUTABLE_IMAGE, 1)).unwrap();
+
+ let on_amd64 = is_windows_executable_file_for_host(&pwsh, 0x8664);
+ let on_arm64 = is_windows_executable_file_for_host(&pwsh, 0xaa64);
+ let on_x86 = is_windows_executable_file_for_host(&pwsh, 0x014c);
+
+ let _ = std::fs::remove_dir_all(dir);
+ assert!(!on_amd64, "x64 Windows cannot launch a pure ARM64 image");
+ assert!(on_arm64);
+ assert!(!on_x86);
+ }
+
+ #[test]
+ fn windows_default_pane_shell_skips_invalid_pwsh_candidates() {
+ let base = std::env::temp_dir().join(format!(
+ "herdr-windows-default-shell-skip-{}",
+ std::process::id()
+ ));
+ let invalid_dir = base.join("invalid");
+ let valid_dir = base.join("valid");
+ std::fs::create_dir_all(&invalid_dir).unwrap();
+ std::fs::create_dir_all(&valid_dir).unwrap();
+ std::fs::write(invalid_dir.join("pwsh.exe"), b"MZ").unwrap();
+ let pwsh = valid_dir.join("pwsh.exe");
+ std::fs::write(
+ &pwsh,
+ windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1),
+ )
+ .unwrap();
+ let path = std::env::join_paths([&invalid_dir, &valid_dir]).unwrap();
+
+ let resolved = default_windows_pane_shell(Some(path));
+ let expected = pwsh.into_os_string().into_string().unwrap();
+
+ let _ = std::fs::remove_dir_all(base);
+ assert_eq!(resolved, expected);
+ }
+
#[test]
fn shell_mode_auto_uses_login_shell_only_on_macos() {
assert!(shell_mode_uses_login_shell(
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 9e39a715..cdf4acfc 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -350,11 +350,11 @@ use windows_sys::{
},
Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT},
Threading::{
- GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, OpenThread,
- QueryFullProcessImageNameW, ResumeThread, TerminateProcess, CREATE_NO_WINDOW,
- CREATE_SUSPENDED, DETACHED_PROCESS, PROCESS_BASIC_INFORMATION,
- PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
- THREAD_SUSPEND_RESUME,
+ GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, IsWow64Process2,
+ OpenProcess, OpenThread, QueryFullProcessImageNameW, ResumeThread,
+ TerminateProcess, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS,
+ PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION,
+ PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, THREAD_SUSPEND_RESUME,
},
},
UI::{
@@ -386,6 +386,29 @@ const FOREGROUND_SELECTION_CACHE_CAPACITY: usize = 1_024;
const FOREGROUND_SELECTION_CACHE_RETENTION: Duration = Duration::from_secs(60);
const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID";
+/// Native processor architecture of the Windows host as an
+/// `IMAGE_FILE_MACHINE_*` value. `IsWow64Process2` reports the native machine
+/// even when an x64 Herdr runs under emulation on Windows ARM64, which the
+/// build target alone cannot reveal.
+pub(crate) fn native_machine_type() -> u16 {
+ let mut process_machine = 0u16;
+ let mut native_machine = 0u16;
+ // SAFETY: `GetCurrentProcess` returns a pseudo-handle and both out-pointers
+ // are valid for the duration of the call.
+ let ok = unsafe {
+ IsWow64Process2(
+ GetCurrentProcess(),
+ &mut process_machine,
+ &mut native_machine,
+ )
+ };
+ if ok != 0 && native_machine != 0 {
+ native_machine
+ } else {
+ process_machine
+ }
+}
+
pub(crate) fn terminal_title_for_presentation(title: &str) -> &str {
title.strip_prefix("Administrator: ").unwrap_or(title)
}