aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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)
}